Compare commits
10 Commits
47c29ad10e
...
16b5aba457
| Author | SHA1 | Date |
|---|---|---|
|
|
16b5aba457 | |
|
|
976cd3edb9 | |
|
|
9c3f103758 | |
|
|
c4ee8fc372 | |
|
|
6566bb4403 | |
|
|
db44350bc9 | |
|
|
2feb97ddfe | |
|
|
8de9738d85 | |
|
|
1d6ca4abd8 | |
|
|
8c03efbfc0 |
|
|
@ -2,8 +2,13 @@
|
|||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Error {
|
||||
message: string
|
||||
cause?: number,
|
||||
}
|
||||
interface Locals {
|
||||
user?: User
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/global.css" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import type { Handle } from "@sveltejs/kit"
|
||||
|
||||
import { error, redirect, json, fail } from "@sveltejs/kit"
|
||||
|
||||
import { Error401Cause } from "$lib/errors"
|
||||
|
||||
import Config from "$lib/server/config"
|
||||
import UserMgmt from "$lib/server/usermgmt"
|
||||
|
||||
function action_fail<T = undefined>(status: number, data: T) {
|
||||
return json(fail(status, data))
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
const token = event.cookies.get("session")
|
||||
let session = await UserMgmt.session_login(token ?? "")
|
||||
|
||||
if (session && event.route.id !== "/api/refresh") {
|
||||
if (session.expires.getTime() < Date.now() + Config.session_refresh_grace) {
|
||||
|
||||
const response = await event.fetch("/api/refresh", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: token
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const new_session = await response.json()
|
||||
|
||||
if (new_session && new_session.token !== null && new_session.expires !== null) {
|
||||
event.cookies.set("session", new_session.token, {
|
||||
expires: new Date(new_session.expires),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
path: "/"
|
||||
})
|
||||
// For mobile
|
||||
/*event.setHeaders({
|
||||
"X-Authorization-Refresh": new_session.token
|
||||
})*/
|
||||
|
||||
session = await UserMgmt.session_login(new_session.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.locals.user = session?.user
|
||||
|
||||
if (!event.route.id) {
|
||||
return error(404, { message: "Diese Seite existiert nicht" })
|
||||
}
|
||||
|
||||
if (event.route.id.startsWith("/api")) {
|
||||
if (event.route.id.startsWith("/api/users") && !event.locals.user) {
|
||||
return error(401, { cause: Error401Cause.NotLoggedIn, message: "Please log in" })
|
||||
}
|
||||
} else {
|
||||
if (!event.locals.user && !event.route.id.startsWith("/login")) {
|
||||
if (event.request.method === "POST") {
|
||||
if (event.request.headers.get("x-sveltekit-action")) {
|
||||
return action_fail(401, { cause: Error401Cause.NotLoggedIn, message: "Bitte melden Sie sich an." })
|
||||
}
|
||||
return error(401, { cause: Error401Cause.NotLoggedIn, message: "Bitte melden Sie sich an." })
|
||||
}
|
||||
return redirect(307, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
return await resolve(event)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
|
||||
export class ArgumentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateError extends Error {
|
||||
fields: Array<string>
|
||||
|
||||
constructor(fields: Array<string>, message: string) {
|
||||
super(message)
|
||||
this.fields = fields
|
||||
}
|
||||
}
|
||||
|
||||
export const enum Error401Cause {
|
||||
NotLoggedIn
|
||||
}
|
||||
|
||||
export const enum RegisterResponseCause {
|
||||
Server = 1,
|
||||
MalformedRequest,
|
||||
EmailLength,
|
||||
EmailFormat,
|
||||
EmailDuplicate,
|
||||
PasswordLength,
|
||||
DisplayNameLength
|
||||
}
|
||||
|
||||
export const enum LoginResponseCause {
|
||||
Server = 1,
|
||||
MalformedRequest,
|
||||
EmailLength,
|
||||
PasswordLength,
|
||||
NotFound,
|
||||
Timeout
|
||||
}
|
||||
|
||||
export const enum RefreshResponseCause {
|
||||
Server = 1,
|
||||
MalformedRequest,
|
||||
InvalidToken,
|
||||
InvalidSession
|
||||
}
|
||||
|
|
@ -37,6 +37,9 @@ class Config {
|
|||
readonly is_debug: boolean = process.env.NODE_ENV != "production"
|
||||
readonly is_production: boolean = process.env.NODE_ENV == "production"
|
||||
|
||||
private _session_timeout: number = 15 * 60 * 1000
|
||||
private _session_refresh_grace: number = 5 * 60 * 1000 // time until expiration
|
||||
|
||||
get log_dir(): string {
|
||||
return this._log_dir
|
||||
}
|
||||
|
|
@ -44,6 +47,9 @@ class Config {
|
|||
return this._log_to_file_when_debug
|
||||
}
|
||||
|
||||
get session_timeout(): number { return this._session_timeout }
|
||||
get session_refresh_grace(): number { return this._session_refresh_grace }
|
||||
|
||||
constructor() {
|
||||
this._log_dir = resolve_env_to_path(process.env.APP_LOG_DIR, "./data/logs")
|
||||
this._log_to_file_when_debug = resolve_env_to_boolean(process.env.LOG_TO_FILE_WHEN_DEBUG, false)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export default prisma
|
||||
|
|
@ -10,7 +10,8 @@ export enum LogSeverity {
|
|||
|
||||
export enum LogModule {
|
||||
PROCESS,
|
||||
USER
|
||||
USER,
|
||||
DATABASE
|
||||
}
|
||||
|
||||
abstract class Logger {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import Bun from "bun"
|
||||
import Crypto from "node:crypto"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
import { ArgumentError, DuplicateError } from "$lib/errors"
|
||||
|
||||
import Config from "$lib/server/config"
|
||||
import Log from "$lib/server/log"
|
||||
import db from "$lib/server/database"
|
||||
|
||||
export type User = Prisma.UserGetPayload<Prisma.UserDefaultArgs>
|
||||
|
||||
export interface SessionData {
|
||||
user: User
|
||||
token: string
|
||||
issued: Date
|
||||
expires: Date
|
||||
}
|
||||
|
||||
|
||||
interface CacheUserInfo {
|
||||
user: User
|
||||
last_update: Date
|
||||
}
|
||||
|
||||
interface CacheSessionInfo {
|
||||
user_id: number
|
||||
issued: Date
|
||||
expires: Date
|
||||
}
|
||||
|
||||
class Cache {
|
||||
private _id_map: Map<number, CacheUserInfo> = new Map()
|
||||
private _session_map: Map<string, CacheSessionInfo> = new Map()
|
||||
|
||||
add(user: User, token: string, issued: Date, expires: Date): SessionData {
|
||||
this._id_map.set(user.id, {
|
||||
user: user,
|
||||
last_update: new Date()
|
||||
})
|
||||
this._session_map.set(token, {
|
||||
user_id: user.id,
|
||||
issued: issued,
|
||||
expires: expires
|
||||
})
|
||||
|
||||
return {
|
||||
user: user,
|
||||
token: token,
|
||||
issued: issued,
|
||||
expires: expires
|
||||
}
|
||||
}
|
||||
|
||||
invalidate_session(session: SessionData) {
|
||||
this._session_map.delete(session.token)
|
||||
}
|
||||
|
||||
get_session(token: string): SessionData|null {
|
||||
const session_info = this._session_map.get(token)
|
||||
if (!session_info) return null
|
||||
|
||||
const user_info = this._id_map.get(session_info.user_id)
|
||||
if (!user_info) return null
|
||||
|
||||
return {
|
||||
user: user_info.user,
|
||||
token: token,
|
||||
issued: session_info.issued,
|
||||
expires: session_info.expires
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class UserMgmt {
|
||||
_cache: Cache = new Cache()
|
||||
|
||||
async register(email: string, password: string, display_name: string): Promise<User|null> {
|
||||
if (email.length == 0 || password.length == 0 || display_name.length == 0) {
|
||||
throw new ArgumentError("No field may be empty")
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
password_hash: await Bun.password.hash(password, "argon2id"),
|
||||
display_name: display_name
|
||||
}
|
||||
})
|
||||
|
||||
Log.info(Log.module.USER, `Created user with id ${user.id}`)
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code == "P2002") {
|
||||
const duplicates = Array.isArray(e.meta?.target) ? e.meta.target as Array<string> : ["unknown"]
|
||||
throw new DuplicateError(duplicates, "Already exists")
|
||||
}
|
||||
|
||||
Log.error(Log.module.DATABASE, `Failed to create User in database! Error: ${JSON.stringify(e)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<SessionData|null> {
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
email: email
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// throw of timing attacks
|
||||
await Bun.password.verify("a", "$argon2id$v=19$m=16,t=2,p=1$ZHB6Zjd4NXV6RXZBZk9wRg$QaYjeAGLon+x3c5I1KB7UQ")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!await Bun.password.verify(password, user.password_hash)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session_info = this._generate_session_for_user(user)
|
||||
|
||||
return session_info
|
||||
}
|
||||
|
||||
async session_login(token: string): Promise<SessionData|null> {
|
||||
const session = this._cache.get_session(token)
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
if (session?.expires.getTime() < Date.now()) {
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
async refresh_session(token: string): Promise<SessionData|null> {
|
||||
const session = await this.session_login(token)
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const new_session = this._generate_session_for_user(session.user)
|
||||
this._cache.invalidate_session(session)
|
||||
|
||||
return new_session
|
||||
}
|
||||
|
||||
private _generate_session_for_user(user: User): SessionData {
|
||||
const token = Crypto.randomBytes(32).toBase64()
|
||||
const session_info = this._cache.add(user, token, new Date(), new Date(Date.now() + Config.session_timeout))
|
||||
|
||||
return session_info
|
||||
}
|
||||
}
|
||||
|
||||
const _manager = new UserMgmt()
|
||||
|
||||
export default _manager
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
function check_email_format(email: string) {
|
||||
const trimmed = email.trim()
|
||||
|
||||
if (trimmed.length > 254) return false
|
||||
|
||||
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:.[a-zA-Z0-9-]+)*$/;
|
||||
return emailRegex.test(trimmed)
|
||||
}
|
||||
|
||||
export default {
|
||||
check_email_format,
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { RequestHandler } from "./$types"
|
||||
|
||||
import { error, json, text } from "@sveltejs/kit"
|
||||
|
||||
import UserMgmt from "$lib/server/usermgmt"
|
||||
|
||||
import { LoginResponseCause } from "$lib/errors"
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
const data = await request.formData()
|
||||
|
||||
const email = data.get("email")
|
||||
const password = data.get("password")
|
||||
|
||||
if (data.keys.length > 2 || !(typeof email === "string" && typeof password === "string")) {
|
||||
return error(400, { cause: LoginResponseCause.MalformedRequest, message: "Invalid request" })
|
||||
}
|
||||
|
||||
if (email.length == 0) {
|
||||
return error(400, { cause: LoginResponseCause.EmailLength, message: "Email must be provided" })
|
||||
}
|
||||
if (password.length == 0) {
|
||||
return error(400, { cause: LoginResponseCause.PasswordLength, message: "Password must be provided" })
|
||||
}
|
||||
|
||||
const session = await UserMgmt.login(email, password)
|
||||
if (!session) {
|
||||
return error(401, { message: "Invalid username or password" })
|
||||
}
|
||||
|
||||
return json({ token: session.token, expires: session.expires })
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { RequestHandler } from "./$types"
|
||||
|
||||
import { error, json } from "@sveltejs/kit"
|
||||
|
||||
import UserMgmt from "$lib/server/usermgmt"
|
||||
import { RefreshResponseCause } from "$lib/errors"
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
const data = await request.json()
|
||||
|
||||
const token = data["token"]
|
||||
if (!token || typeof token !== "string") {
|
||||
return error(400, { cause: RefreshResponseCause.MalformedRequest, message: "token must be provided as string." })
|
||||
}
|
||||
|
||||
const new_session = await UserMgmt.refresh_session(token)
|
||||
|
||||
if (!new_session) {
|
||||
return error(401, { cause: RefreshResponseCause.InvalidSession, message: "No session for token" })
|
||||
}
|
||||
|
||||
return json({
|
||||
token: new_session.token,
|
||||
expires: new_session.expires
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { RequestHandler } from "./$types"
|
||||
|
||||
import { json, error } from "@sveltejs/kit"
|
||||
|
||||
import UserMgmt from "$lib/server/usermgmt"
|
||||
|
||||
import Util from "$lib/util"
|
||||
import { DuplicateError, RegisterResponseCause } from "$lib/errors"
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
|
||||
const data = await request.formData()
|
||||
|
||||
const email = data.get("email")
|
||||
const password = data.get("password")
|
||||
const display_name = data.get("display_name")
|
||||
|
||||
if (data.keys.length > 3 || !(typeof email === "string" && typeof password === "string" && typeof display_name === "string")) {
|
||||
return error(400, { cause: RegisterResponseCause.MalformedRequest, message: "Invalid field arguments" })
|
||||
}
|
||||
if (email.length == 0) {
|
||||
return error(400, { cause: RegisterResponseCause.EmailLength, message: "email must not be empty" })
|
||||
}
|
||||
if (password.length == 0) {
|
||||
return error(400, { cause: RegisterResponseCause.PasswordLength, message: "password must not be empty" })
|
||||
}
|
||||
if (display_name.length == 0) {
|
||||
return error(400, { cause: RegisterResponseCause.DisplayNameLength, message: "Display name must not be empty" })
|
||||
}
|
||||
|
||||
if (!Util.check_email_format(email)) {
|
||||
return error(400, { cause: RegisterResponseCause.EmailFormat, message: "invalid email format" })
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await UserMgmt.register(email, password, display_name)
|
||||
} catch (e) {
|
||||
if (e instanceof DuplicateError) {
|
||||
if (e.fields.includes("email")) {
|
||||
return error(409, { cause: RegisterResponseCause.EmailDuplicate, message: "email already in use" })
|
||||
}
|
||||
}
|
||||
|
||||
return error(500, { cause: RegisterResponseCause.Server, message: "Server failed to create user"})
|
||||
}
|
||||
|
||||
return json({})
|
||||
}
|
||||
|
|
@ -1,10 +1,46 @@
|
|||
import type { Actions } from "./$types"
|
||||
|
||||
import { fail } from "@sveltejs/kit"
|
||||
|
||||
import Log from "$lib/server/log"
|
||||
|
||||
export const actions = {
|
||||
login: ({ }) => {
|
||||
login: async ({ request, fetch, cookies }) => {
|
||||
|
||||
console.log("login")
|
||||
|
||||
return {}
|
||||
const formData = await request.formData()
|
||||
|
||||
const result = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return fail(401, { email: formData.get("email") ?? "", message: "Benutzername oder Passwort ist falsch." })
|
||||
}
|
||||
|
||||
const body = await (async () => {
|
||||
try {
|
||||
return await result.json()
|
||||
} catch (e) {
|
||||
Log.error(Log.module.PROCESS, `Failed to parse body of login response from endpoint`)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (!body || !body.token || !body.expires) {
|
||||
return fail(502, "Invalid response from login endpoint")
|
||||
}
|
||||
|
||||
cookies.set("session", body.token, {
|
||||
expires: new Date(body.expires),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
path: "/"
|
||||
})
|
||||
|
||||
return { message: "Erfolg" }
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<script lang="ts">
|
||||
|
||||
import type { PageProps } from "./$types"
|
||||
|
||||
const { form }: PageProps = $props()
|
||||
|
||||
</script>
|
||||
|
||||
<form action="?/login" method="POST" id="form_login"></form>
|
||||
|
|
@ -8,6 +12,7 @@
|
|||
|
||||
<div class="grid">
|
||||
<h1>Login</h1>
|
||||
<p class="error_msg">{form?.message}</p>
|
||||
<label for="username">E-Mail: </label><input type="text" id="username" form="form_login" name="email" />
|
||||
<label for="password">Passwort: </label><input type="password" id="password" form="form_login" name="password" />
|
||||
<button type="submit" form="form_login">-></button>
|
||||
|
|
@ -24,6 +29,8 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding-top: 100px;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
|
@ -42,6 +49,11 @@
|
|||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.grid > p {
|
||||
width: 100%;
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.grid > button {
|
||||
width: 50%;
|
||||
margin-top: 10px;
|
||||
|
|
@ -49,4 +61,8 @@
|
|||
grid-column: 2;
|
||||
}
|
||||
|
||||
.error_msg {
|
||||
color: red;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,38 @@
|
|||
import { RegisterResponseCause } from "$lib/errors";
|
||||
import type { Actions } from "./$types";
|
||||
|
||||
import { fail } from "@sveltejs/kit"
|
||||
|
||||
export const actions = {
|
||||
register: async ({ request }) => {
|
||||
register: async ({ request, fetch }) => {
|
||||
|
||||
console.log("register")
|
||||
|
||||
const result = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
body: await request.formData()
|
||||
})
|
||||
|
||||
const result_body = await result.json()
|
||||
|
||||
if (!result.ok) {
|
||||
if (!result_body.cause) {
|
||||
return fail(500, { message: "Interner Fehler, versuche es erneut." })
|
||||
}
|
||||
|
||||
switch (result_body.cause) {
|
||||
case RegisterResponseCause.MalformedRequest: return fail(400, { message: "Bitte versuche es erneut." })
|
||||
case RegisterResponseCause.EmailLength: return fail(400, { message: "Bitte gib eine Email ein." })
|
||||
case RegisterResponseCause.EmailFormat: return fail(400, { message: "Das Format der Email ist nicht gültig." })
|
||||
case RegisterResponseCause.EmailDuplicate: return fail(409, { message: "Email wird bereits verwendet." })
|
||||
case RegisterResponseCause.DisplayNameLength: return fail(400, { message: "Bitt gib einen Display Namen ein" })
|
||||
case RegisterResponseCause.PasswordLength: return fail(400, { message: "Bitte gib ein Passwort ein." })
|
||||
default:
|
||||
return fail(500, { message: "Ein interner Fehler ist aufgetreten. Bitte versuche es erneut."})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { message: "Erfolg" }
|
||||
}
|
||||
} satisfies Actions
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<script lang="ts">
|
||||
|
||||
import type { PageProps } from "./$types"
|
||||
|
||||
const { form }: PageProps = $props()
|
||||
|
||||
</script>
|
||||
|
||||
|
|
@ -8,6 +12,7 @@
|
|||
|
||||
<div class="grid">
|
||||
<h1>Login</h1>
|
||||
<p class="error_msg">{form?.message}</p>
|
||||
<label for="username">E-Mail: </label><input type="text" id="username" form="form_register" name="email" />
|
||||
<label for="password">Passwort: </label><input type="password" id="password" form="form_register" name="password" />
|
||||
<label for="display_name">Anzeigename: </label><input type="text" id="display_name" form="form_register" name="display_name" />
|
||||
|
|
@ -25,6 +30,8 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding-top: 100px;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
|
@ -42,6 +49,11 @@
|
|||
text-align: center;
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.grid > p {
|
||||
width: 100%;
|
||||
grid-column: 1 / span 2;
|
||||
}
|
||||
|
||||
.grid > button {
|
||||
width: 50%;
|
||||
|
|
@ -50,4 +62,8 @@
|
|||
grid-column: 2;
|
||||
}
|
||||
|
||||
.error_msg {
|
||||
color: red;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
body {
|
||||
width: 100%;
|
||||
}
|
||||
Loading…
Reference in New Issue