all in a days work
This commit is contained in:
parent
64b9c16eed
commit
5e178a6a19
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ node_modules
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
devdocker
|
||||
|
16
drizzle.config.ts
Normal file
16
drizzle.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'mysql',
|
||||
schema: './src/lib/server/drizzle/schema.ts',
|
||||
out: './src/lib/server/drizzle/migrations',
|
||||
dbCredentials: {
|
||||
host: process.env.DATABASE_HOST as string,
|
||||
port: Number(process.env.DATABASE_PORT) || 3306,
|
||||
database: process.env.DATABASE_DB as string,
|
||||
user: process.env.DATABASE_USER as string,
|
||||
password: process.env.DATABASE_PASS as string
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
})
|
1564
package-lock.json
generated
1564
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -15,9 +15,13 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/eslint": "^8.56.0",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"drizzle-kit": "^0.21.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
@ -31,6 +35,13 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.1"
|
||||
"@sveltejs/adapter-node": "^5.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"drizzle-orm": "^0.30.10",
|
||||
"mysql2": "^3.9.7",
|
||||
"otplib": "^12.0.1",
|
||||
"svelte-kit-cookie-session": "^4.0.0",
|
||||
"sveltekit-i18n": "^2.4.2",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
78
src/app.css
Normal file
78
src/app.css
Normal file
@ -0,0 +1,78 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--in-text-color: #fff;
|
||||
--in-link-color: #fff;
|
||||
--in-outline-color: #00aaff;
|
||||
--in-normalized-background: #000;
|
||||
--in-input-background: #fff;
|
||||
--in-input-color: #000;
|
||||
--in-input-border-color: #ddd;
|
||||
|
||||
--in-focus-outline: 3px solid var(--in-outline-color);
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
color: var(--in-text-color);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--in-normalized-background);
|
||||
background-image: url('/background.jpg');
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--in-link-color);
|
||||
|
||||
&:visited {
|
||||
color: var(--in-link-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
}
|
||||
|
||||
a[target='_blank']::after {
|
||||
content: '';
|
||||
background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%23ffffff%27 d=%27M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z%27 /%3E%3C/svg%3E');
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
display: inline-block;
|
||||
margin-left: 2px;
|
||||
vertical-align: top;
|
||||
}
|
18
src/app.d.ts
vendored
18
src/app.d.ts
vendored
@ -1,10 +1,24 @@
|
||||
import type { UserSession } from '$lib/server/users/types';
|
||||
import type { Session } from 'svelte-kit-cookie-session';
|
||||
|
||||
type SessionData = {
|
||||
user?: UserSession;
|
||||
}
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
|
||||
interface Locals {
|
||||
session: Session<SessionData>;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
session: SessionData;
|
||||
}
|
||||
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
7
src/hooks.server.ts
Normal file
7
src/hooks.server.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { SESSION_SECRET } from '$env/static/private';
|
||||
import '$lib/server/drizzle';
|
||||
import { handleSession } from 'svelte-kit-cookie-session';
|
||||
|
||||
export const handle = handleSession({
|
||||
secret: SESSION_SECRET
|
||||
})
|
36
src/lib/components/Button.svelte
Normal file
36
src/lib/components/Button.svelte
Normal file
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
export let type: 'button' | 'submit' = 'button';
|
||||
export let variant: 'default' | 'primary' | 'link' = 'default';
|
||||
</script>
|
||||
|
||||
<button {type} class="btn btn-{variant}"><slot /></button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--in-text-color);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-default,
|
||||
.btn-primary {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #ddd;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
3
src/lib/components/LogoutButton.svelte
Normal file
3
src/lib/components/LogoutButton.svelte
Normal file
@ -0,0 +1,3 @@
|
||||
<form action="/account?/logout" method="POST">
|
||||
<button type="submit" class="btn btn-link">Log out</button>
|
||||
</form>
|
33
src/lib/components/form/FormControl.svelte
Normal file
33
src/lib/components/form/FormControl.svelte
Normal file
@ -0,0 +1,33 @@
|
||||
|
||||
<div class="form-control">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(input) {
|
||||
&:not([type]),
|
||||
&[type=text],
|
||||
&[type=password],
|
||||
&[type=email] {
|
||||
padding: 8px;
|
||||
font-size: 1rem;
|
||||
background-color: var(--in-input-background);
|
||||
color: var(--in-input-color);
|
||||
border: 2px solid var(--in-input-border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(label) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
4
src/lib/components/form/FormSection.svelte
Normal file
4
src/lib/components/form/FormSection.svelte
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
<div class="form-section">
|
||||
<slot />
|
||||
</div>
|
10
src/lib/components/form/FormWrapper.svelte
Normal file
10
src/lib/components/form/FormWrapper.svelte
Normal file
@ -0,0 +1,10 @@
|
||||
|
||||
<div class="form-wrapper">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.form-control) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
28
src/lib/i18n/en/account.json
Normal file
28
src/lib/i18n/en/account.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"username": "Username",
|
||||
"displayName": "Display Name",
|
||||
"changeEmail": "Change email address",
|
||||
"currentEmail": "Current email address",
|
||||
"newEmail": "New email address",
|
||||
"changePassword": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"repeatPassword": "Repeat new password",
|
||||
"submit": "Submit",
|
||||
"login": {
|
||||
"title": "Log in",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"submit": "Log in"
|
||||
},
|
||||
"errors": {
|
||||
"invalidLogin": "Invalid email or password!",
|
||||
"invalidRequest": "Invalid request! Please try again.",
|
||||
"emailRequired": "Email address is required.",
|
||||
"invalidEmail": "The email address is invalid.",
|
||||
"passwordRequired": "The password is required.",
|
||||
"passwordMismatch": "The passwords do not match!",
|
||||
"invalidPassword": "The provided password is invalid.",
|
||||
"invalidDisplayName": "The provided display name is invalid."
|
||||
}
|
||||
}
|
5
src/lib/i18n/en/common.json
Normal file
5
src/lib/i18n/en/common.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"siteName": "Icy Network",
|
||||
"description": "Icy Network is a Single-Sign-On service used by other applications.",
|
||||
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is <a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone."
|
||||
}
|
18
src/lib/i18n/index.ts
Normal file
18
src/lib/i18n/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import i18n from 'sveltekit-i18n';
|
||||
|
||||
const config = {
|
||||
loaders: [
|
||||
{
|
||||
locale: 'en',
|
||||
key: 'common',
|
||||
loader: async () => await import('./en/common.json')
|
||||
},
|
||||
{
|
||||
locale: 'en',
|
||||
key: 'account',
|
||||
loader: async () => await import('./en/account.json')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const { t, locale, locales, loading, loadTranslations } = new i18n(config);
|
79
src/lib/server/challenge.ts
Normal file
79
src/lib/server/challenge.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Changesets } from './changesets';
|
||||
import { CryptoUtils } from './crypto-utils';
|
||||
import type { User } from './drizzle';
|
||||
import { TimeOTP } from './users/totp';
|
||||
|
||||
export interface ChallengeBody<T> {
|
||||
aud: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class Challenge {
|
||||
static async issueChallenge<TChallenge>(
|
||||
challenge: TChallenge,
|
||||
recipient: string
|
||||
): Promise<string> {
|
||||
const body = <ChallengeBody<TChallenge>>{
|
||||
aud: recipient,
|
||||
data: challenge
|
||||
};
|
||||
return CryptoUtils.encryptChallenge(body);
|
||||
}
|
||||
|
||||
static async verifyChallenge<TRes>(
|
||||
challenge: string,
|
||||
recipient: string,
|
||||
code: string,
|
||||
secret: string
|
||||
) {
|
||||
const { aud, data }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
|
||||
if (aud !== recipient) {
|
||||
throw new Error('Invalid challenge');
|
||||
}
|
||||
|
||||
if (!TimeOTP.validate(secret, code)) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async challengeFromBody<TRes>(
|
||||
body: FormData,
|
||||
recipient: string,
|
||||
secret: string
|
||||
): Promise<TRes | undefined> {
|
||||
const { challenge, otpCode } = Changesets.take<{ challenge?: string; otpCode?: string }>(
|
||||
['challenge', 'otpCode'],
|
||||
body
|
||||
);
|
||||
|
||||
if (!challenge || !otpCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Challenge.verifyChallenge<TRes>(challenge, recipient, otpCode, secret);
|
||||
}
|
||||
|
||||
static async authorizedChanges<TRes>(
|
||||
fields: (keyof TRes)[],
|
||||
body: FormData,
|
||||
subject: User
|
||||
): Promise<Partial<TRes>> {
|
||||
if (!body.has('challenge')) {
|
||||
return Changesets.take<TRes>(fields, body);
|
||||
}
|
||||
|
||||
const userOtp = await TimeOTP.getUserOtp(subject);
|
||||
if (!userOtp) {
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
|
||||
const data = await Challenge.challengeFromBody<TRes>(body, subject.uuid, userOtp.token);
|
||||
if (!data) {
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
14
src/lib/server/changesets.ts
Normal file
14
src/lib/server/changesets.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export class Changesets {
|
||||
static take<TRes>(
|
||||
fields: (keyof TRes)[],
|
||||
body: FormData,
|
||||
challenge?: Partial<TRes>
|
||||
): Partial<TRes> {
|
||||
return fields.reduce<Partial<TRes>>((accum, field) => {
|
||||
accum[field] = challenge
|
||||
? challenge[field]
|
||||
: ((body.get(field as string) as string)?.trim() as TRes[typeof field]);
|
||||
return accum;
|
||||
}, {});
|
||||
}
|
||||
}
|
63
src/lib/server/crypto-utils.ts
Normal file
63
src/lib/server/crypto-utils.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { CHALLENGE_SECRET } from '$env/static/private';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
export class CryptoUtils {
|
||||
public static generateString(length: number): string {
|
||||
return crypto.randomBytes(length).toString('hex').slice(0, length);
|
||||
}
|
||||
|
||||
public static generateSecret(): string {
|
||||
return crypto.randomBytes(256 / 8).toString('hex');
|
||||
}
|
||||
|
||||
public static insecureHash(input: string): string {
|
||||
return crypto.createHash('md5').update(input).digest('hex');
|
||||
}
|
||||
|
||||
public static createUUID(): string {
|
||||
return v4();
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/q/52212430
|
||||
/**
|
||||
* Symmetric encryption function
|
||||
* @param text String to encrypt
|
||||
* @param key Encryption key
|
||||
* @returns Encrypted text
|
||||
*/
|
||||
public static encrypt(text: string, key: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric decryption function
|
||||
* @param text Encrypted string
|
||||
* @param key Decryption key
|
||||
* @returns Decrypted text
|
||||
*/
|
||||
public static decrypt(text: string, key: string): string {
|
||||
const [iv, encryptedText] = text.split(':').map((part) => Buffer.from(part, 'hex'));
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(key, 'hex'), iv);
|
||||
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
public static async encryptChallenge<T>(challenge: T): Promise<string> {
|
||||
return this.encrypt(JSON.stringify(challenge), CHALLENGE_SECRET);
|
||||
}
|
||||
|
||||
public static async decryptChallenge<T>(challenge: string): Promise<T> {
|
||||
return JSON.parse(this.decrypt(challenge, CHALLENGE_SECRET));
|
||||
}
|
||||
}
|
14
src/lib/server/drizzle/index.ts
Normal file
14
src/lib/server/drizzle/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { DATABASE_DB, DATABASE_HOST, DATABASE_PASS } from '$env/static/private';
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import * as schema from './schema';
|
||||
|
||||
const connection = await mysql.createConnection({
|
||||
host: DATABASE_HOST,
|
||||
user: DATABASE_PASS,
|
||||
password: DATABASE_PASS,
|
||||
database: DATABASE_DB
|
||||
});
|
||||
|
||||
export const db = drizzle(connection, { schema, mode: 'default' });
|
||||
export * from './schema';
|
137
src/lib/server/drizzle/migrations/0000_initial.sql
Normal file
137
src/lib/server/drizzle/migrations/0000_initial.sql
Normal file
@ -0,0 +1,137 @@
|
||||
-- Current sql file was generated after introspecting the database
|
||||
-- If you want to run this migration please uncomment this code before executing migrations
|
||||
/*
|
||||
CREATE TABLE `audit_log` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`content` text DEFAULT 'NULL',
|
||||
`actor_ip` text DEFAULT 'NULL',
|
||||
`actor_ua` text DEFAULT 'NULL',
|
||||
`flagged` tinyint NOT NULL DEFAULT 0,
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`actorId` int(11) DEFAULT 'NULL'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `document` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`body` text NOT NULL,
|
||||
`authorId` int(11) DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `o_auth2_client` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`client_id` varchar(36) NOT NULL,
|
||||
`client_secret` text NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT 'NULL',
|
||||
`scope` text DEFAULT 'NULL',
|
||||
`grants` text NOT NULL DEFAULT ''authorization_code'',
|
||||
`activated` tinyint NOT NULL DEFAULT 0,
|
||||
`verified` tinyint NOT NULL DEFAULT 0,
|
||||
`pictureId` int(11) DEFAULT 'NULL',
|
||||
`ownerId` int(11) DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
CONSTRAINT `IDX_e9d16c213910ad57bd05e97b42` UNIQUE(`client_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `o_auth2_client_authorization` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`scope` text DEFAULT 'NULL',
|
||||
`expires_at` timestamp NOT NULL DEFAULT 'current_timestamp()',
|
||||
`clientId` int(11) DEFAULT 'NULL',
|
||||
`userId` int(11) DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `o_auth2_client_url` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`url` varchar(255) NOT NULL,
|
||||
`type` enum('redirect_uri','terms','privacy','website') NOT NULL,
|
||||
`created_at` timestamp(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` timestamp(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`clientId` int(11) DEFAULT 'NULL'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `o_auth2_token` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`type` enum('code','access_token','refresh_token') NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`scope` text DEFAULT 'NULL',
|
||||
`expires_at` timestamp NOT NULL DEFAULT 'current_timestamp()',
|
||||
`userId` int(11) DEFAULT 'NULL',
|
||||
`clientId` int(11) DEFAULT 'NULL',
|
||||
`nonce` text DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`pcke` text DEFAULT 'NULL'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `privilege` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `upload` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`original_name` varchar(255) NOT NULL,
|
||||
`mimetype` varchar(255) NOT NULL,
|
||||
`file` varchar(255) NOT NULL,
|
||||
`uploaderId` int(11) DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`uuid` varchar(36) NOT NULL,
|
||||
`username` varchar(26) NOT NULL,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`display_name` varchar(32) NOT NULL,
|
||||
`password` text DEFAULT 'NULL',
|
||||
`activated` tinyint NOT NULL DEFAULT 0,
|
||||
`activity_at` timestamp NOT NULL DEFAULT 'current_timestamp()',
|
||||
`pictureId` int(11) DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)',
|
||||
CONSTRAINT `IDX_a95e949168be7b7ece1a2382fe` UNIQUE(`uuid`),
|
||||
CONSTRAINT `IDX_78a916df40e02a9deb1c4b75ed` UNIQUE(`username`),
|
||||
CONSTRAINT `IDX_e12875dfb3b1d92d7d7c5377e2` UNIQUE(`email`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_privileges_privilege` (
|
||||
`userId` int(11) NOT NULL,
|
||||
`privilegeId` int(11) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_token` (
|
||||
`id` int(11) AUTO_INCREMENT NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`type` enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','recovery') NOT NULL,
|
||||
`expires_at` timestamp DEFAULT 'NULL',
|
||||
`userId` int(11) DEFAULT 'NULL',
|
||||
`nonce` text DEFAULT 'NULL',
|
||||
`created_at` datetime(6) NOT NULL DEFAULT 'current_timestamp(6)'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `audit_log` ADD CONSTRAINT `FK_cb6aa6f6fd56f08eafb60316225` FOREIGN KEY (`actorId`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `document` ADD CONSTRAINT `FK_6a2eb13cadfc503989cbe367572` FOREIGN KEY (`authorId`) REFERENCES `user`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_client` ADD CONSTRAINT `FK_4a6c878506b872e85b3d07f6252` FOREIGN KEY (`ownerId`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_client` ADD CONSTRAINT `FK_e8d65b1eec13474e493420517d7` FOREIGN KEY (`pictureId`) REFERENCES `upload`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_client_authorization` ADD CONSTRAINT `FK_8227110f58510b7233f3db90cfb` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_client_authorization` ADD CONSTRAINT `FK_9ca9ebb654e7ce71954d5fdb281` FOREIGN KEY (`clientId`) REFERENCES `o_auth2_client`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_client_url` ADD CONSTRAINT `FK_aca59c7bdd65987487eea98d00f` FOREIGN KEY (`clientId`) REFERENCES `o_auth2_client`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_token` ADD CONSTRAINT `FK_3ecb760b321ef9bbab635f05b45` FOREIGN KEY (`clientId`) REFERENCES `o_auth2_client`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `o_auth2_token` ADD CONSTRAINT `FK_81ffb9b8d672cf3af1af9e789f3` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `upload` ADD CONSTRAINT `FK_7b8d52838a953b188255682597b` FOREIGN KEY (`uploaderId`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD CONSTRAINT `FK_7478a15985dbfa32ed5fc77a7a1` FOREIGN KEY (`pictureId`) REFERENCES `upload`(`id`) ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE `user_privileges_privilege` ADD CONSTRAINT `FK_0664a7ff494a1859a09014c0f17` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE `user_privileges_privilege` ADD CONSTRAINT `FK_e71171f4ed20bc8564a1819d0b7` FOREIGN KEY (`privilegeId`) REFERENCES `privilege`(`id`) ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
ALTER TABLE `user_token` ADD CONSTRAINT `FK_d37db50eecdf9b8ce4eedd2f918` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `IDX_0664a7ff494a1859a09014c0f1` ON `user_privileges_privilege` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `IDX_e71171f4ed20bc8564a1819d0b` ON `user_privileges_privilege` (`privilegeId`);
|
||||
*/
|
985
src/lib/server/drizzle/migrations/meta/0000_snapshot.json
Normal file
985
src/lib/server/drizzle/migrations/meta/0000_snapshot.json
Normal file
@ -0,0 +1,985 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"prevId": "",
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"tables": {
|
||||
"audit_log": {
|
||||
"name": "audit_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action": {
|
||||
"autoincrement": false,
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"actor_ip": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "actor_ip",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"actor_ua": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "actor_ua",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"flagged": {
|
||||
"default": 0,
|
||||
"autoincrement": false,
|
||||
"name": "flagged",
|
||||
"type": "tinyint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actorId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "actorId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_cb6aa6f6fd56f08eafb60316225": {
|
||||
"name": "FK_cb6aa6f6fd56f08eafb60316225",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"actorId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"document": {
|
||||
"name": "document",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"autoincrement": false,
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"autoincrement": false,
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"autoincrement": false,
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"authorId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "authorId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_6a2eb13cadfc503989cbe367572": {
|
||||
"name": "FK_6a2eb13cadfc503989cbe367572",
|
||||
"tableFrom": "document",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"authorId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"migrations": {
|
||||
"name": "migrations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"timestamp": {
|
||||
"autoincrement": false,
|
||||
"name": "timestamp",
|
||||
"type": "bigint(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"autoincrement": false,
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"o_auth2_client": {
|
||||
"name": "o_auth2_client",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_id": {
|
||||
"autoincrement": false,
|
||||
"name": "client_id",
|
||||
"type": "varchar(36)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_secret": {
|
||||
"autoincrement": false,
|
||||
"name": "client_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"autoincrement": false,
|
||||
"name": "title",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grants": {
|
||||
"default": "''authorization_code''",
|
||||
"autoincrement": false,
|
||||
"name": "grants",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"activated": {
|
||||
"default": 0,
|
||||
"autoincrement": false,
|
||||
"name": "activated",
|
||||
"type": "tinyint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"verified": {
|
||||
"default": 0,
|
||||
"autoincrement": false,
|
||||
"name": "verified",
|
||||
"type": "tinyint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pictureId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "pictureId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ownerId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "ownerId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_4a6c878506b872e85b3d07f6252": {
|
||||
"name": "FK_4a6c878506b872e85b3d07f6252",
|
||||
"tableFrom": "o_auth2_client",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"ownerId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"FK_e8d65b1eec13474e493420517d7": {
|
||||
"name": "FK_e8d65b1eec13474e493420517d7",
|
||||
"tableFrom": "o_auth2_client",
|
||||
"tableTo": "upload",
|
||||
"columnsFrom": [
|
||||
"pictureId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"IDX_e9d16c213910ad57bd05e97b42": {
|
||||
"name": "IDX_e9d16c213910ad57bd05e97b42",
|
||||
"columns": [
|
||||
"client_id"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"o_auth2_client_authorization": {
|
||||
"name": "o_auth2_client_authorization",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"default": "'current_timestamp()'",
|
||||
"autoincrement": false,
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"clientId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "clientId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "userId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_8227110f58510b7233f3db90cfb": {
|
||||
"name": "FK_8227110f58510b7233f3db90cfb",
|
||||
"tableFrom": "o_auth2_client_authorization",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"FK_9ca9ebb654e7ce71954d5fdb281": {
|
||||
"name": "FK_9ca9ebb654e7ce71954d5fdb281",
|
||||
"tableFrom": "o_auth2_client_authorization",
|
||||
"tableTo": "o_auth2_client",
|
||||
"columnsFrom": [
|
||||
"clientId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"o_auth2_client_url": {
|
||||
"name": "o_auth2_client_url",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"autoincrement": false,
|
||||
"name": "url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"autoincrement": false,
|
||||
"name": "type",
|
||||
"type": "enum('redirect_uri','terms','privacy','website')",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "timestamp(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "timestamp(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"clientId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "clientId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_aca59c7bdd65987487eea98d00f": {
|
||||
"name": "FK_aca59c7bdd65987487eea98d00f",
|
||||
"tableFrom": "o_auth2_client_url",
|
||||
"tableTo": "o_auth2_client",
|
||||
"columnsFrom": [
|
||||
"clientId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"o_auth2_token": {
|
||||
"name": "o_auth2_token",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"autoincrement": false,
|
||||
"name": "type",
|
||||
"type": "enum('code','access_token','refresh_token')",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"autoincrement": false,
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"default": "'current_timestamp()'",
|
||||
"autoincrement": false,
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "userId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"clientId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "clientId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"nonce": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "nonce",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pcke": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "pcke",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_3ecb760b321ef9bbab635f05b45": {
|
||||
"name": "FK_3ecb760b321ef9bbab635f05b45",
|
||||
"tableFrom": "o_auth2_token",
|
||||
"tableTo": "o_auth2_client",
|
||||
"columnsFrom": [
|
||||
"clientId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"FK_81ffb9b8d672cf3af1af9e789f3": {
|
||||
"name": "FK_81ffb9b8d672cf3af1af9e789f3",
|
||||
"tableFrom": "o_auth2_token",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"privilege": {
|
||||
"name": "privilege",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"autoincrement": false,
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"upload": {
|
||||
"name": "upload",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"original_name": {
|
||||
"autoincrement": false,
|
||||
"name": "original_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mimetype": {
|
||||
"autoincrement": false,
|
||||
"name": "mimetype",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file": {
|
||||
"autoincrement": false,
|
||||
"name": "file",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uploaderId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "uploaderId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_7b8d52838a953b188255682597b": {
|
||||
"name": "FK_7b8d52838a953b188255682597b",
|
||||
"tableFrom": "upload",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"uploaderId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"uuid": {
|
||||
"autoincrement": false,
|
||||
"name": "uuid",
|
||||
"type": "varchar(36)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"autoincrement": false,
|
||||
"name": "username",
|
||||
"type": "varchar(26)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"autoincrement": false,
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"autoincrement": false,
|
||||
"name": "display_name",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"activated": {
|
||||
"default": 0,
|
||||
"autoincrement": false,
|
||||
"name": "activated",
|
||||
"type": "tinyint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"activity_at": {
|
||||
"default": "'current_timestamp()'",
|
||||
"autoincrement": false,
|
||||
"name": "activity_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pictureId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "pictureId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "updated_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_7478a15985dbfa32ed5fc77a7a1": {
|
||||
"name": "FK_7478a15985dbfa32ed5fc77a7a1",
|
||||
"tableFrom": "user",
|
||||
"tableTo": "upload",
|
||||
"columnsFrom": [
|
||||
"pictureId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"IDX_a95e949168be7b7ece1a2382fe": {
|
||||
"name": "IDX_a95e949168be7b7ece1a2382fe",
|
||||
"columns": [
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"IDX_78a916df40e02a9deb1c4b75ed": {
|
||||
"name": "IDX_78a916df40e02a9deb1c4b75ed",
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
},
|
||||
"IDX_e12875dfb3b1d92d7d7c5377e2": {
|
||||
"name": "IDX_e12875dfb3b1d92d7d7c5377e2",
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_privileges_privilege": {
|
||||
"name": "user_privileges_privilege",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"autoincrement": false,
|
||||
"name": "userId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"privilegeId": {
|
||||
"autoincrement": false,
|
||||
"name": "privilegeId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {
|
||||
"IDX_0664a7ff494a1859a09014c0f1": {
|
||||
"name": "IDX_0664a7ff494a1859a09014c0f1",
|
||||
"columns": [
|
||||
"userId"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"IDX_e71171f4ed20bc8564a1819d0b": {
|
||||
"name": "IDX_e71171f4ed20bc8564a1819d0b",
|
||||
"columns": [
|
||||
"privilegeId"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"FK_0664a7ff494a1859a09014c0f17": {
|
||||
"name": "FK_0664a7ff494a1859a09014c0f17",
|
||||
"tableFrom": "user_privileges_privilege",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
},
|
||||
"FK_e71171f4ed20bc8564a1819d0b7": {
|
||||
"name": "FK_e71171f4ed20bc8564a1819d0b7",
|
||||
"tableFrom": "user_privileges_privilege",
|
||||
"tableTo": "privilege",
|
||||
"columnsFrom": [
|
||||
"privilegeId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "cascade"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user_token": {
|
||||
"name": "user_token",
|
||||
"columns": {
|
||||
"id": {
|
||||
"autoincrement": true,
|
||||
"name": "id",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"autoincrement": false,
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"autoincrement": false,
|
||||
"name": "type",
|
||||
"type": "enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','recovery')",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "userId",
|
||||
"type": "int(11)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"nonce": {
|
||||
"default": "'NULL'",
|
||||
"autoincrement": false,
|
||||
"name": "nonce",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"default": "'current_timestamp(6)'",
|
||||
"autoincrement": false,
|
||||
"name": "created_at",
|
||||
"type": "datetime(6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"FK_d37db50eecdf9b8ce4eedd2f918": {
|
||||
"name": "FK_d37db50eecdf9b8ce4eedd2f918",
|
||||
"tableFrom": "user_token",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {}
|
||||
}
|
||||
}
|
13
src/lib/server/drizzle/migrations/meta/_journal.json
Normal file
13
src/lib/server/drizzle/migrations/meta/_journal.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1715871691221,
|
||||
"tag": "0000_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
129
src/lib/server/drizzle/migrations/relations.ts
Normal file
129
src/lib/server/drizzle/migrations/relations.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { relations } from 'drizzle-orm/relations';
|
||||
import {
|
||||
user,
|
||||
auditLog,
|
||||
document,
|
||||
oauth2Client,
|
||||
upload,
|
||||
oauth2ClientAuthorization,
|
||||
oauth2ClientUrl,
|
||||
oauth2Token,
|
||||
userPrivilegesPrivilege,
|
||||
privilege,
|
||||
userToken
|
||||
} from '../schema';
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [auditLog.actorId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const userRelations = relations(user, ({ one, many }) => ({
|
||||
audit_logs: many(auditLog),
|
||||
documents: many(document),
|
||||
o_auth2_clients: many(oauth2Client),
|
||||
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
|
||||
o_auth2_tokens: many(oauth2Token),
|
||||
uploads: many(upload, {
|
||||
relationName: 'upload_uploaderId_user_id'
|
||||
}),
|
||||
upload: one(upload, {
|
||||
fields: [user.pictureId],
|
||||
references: [upload.id],
|
||||
relationName: 'user_pictureId_upload_id'
|
||||
}),
|
||||
user_privileges_privileges: many(userPrivilegesPrivilege),
|
||||
user_tokens: many(userToken)
|
||||
}));
|
||||
|
||||
export const documentRelations = relations(document, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [document.authorId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const oauth2ClientRelations = relations(oauth2Client, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [oauth2Client.ownerId],
|
||||
references: [user.id]
|
||||
}),
|
||||
upload: one(upload, {
|
||||
fields: [oauth2Client.pictureId],
|
||||
references: [upload.id]
|
||||
}),
|
||||
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
|
||||
o_auth2_client_urls: many(oauth2ClientUrl),
|
||||
o_auth2_tokens: many(oauth2Token)
|
||||
}));
|
||||
|
||||
export const uploadRelations = relations(upload, ({ one, many }) => ({
|
||||
o_auth2_clients: many(oauth2Client),
|
||||
user: one(user, {
|
||||
fields: [upload.uploaderId],
|
||||
references: [user.id],
|
||||
relationName: 'upload_uploaderId_user_id'
|
||||
}),
|
||||
users: many(user, {
|
||||
relationName: 'user_pictureId_upload_id'
|
||||
})
|
||||
}));
|
||||
|
||||
export const oauth2ClientAuthorizationRelations = relations(
|
||||
oauth2ClientAuthorization,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [oauth2ClientAuthorization.userId],
|
||||
references: [user.id]
|
||||
}),
|
||||
o_auth2_client: one(oauth2Client, {
|
||||
fields: [oauth2ClientAuthorization.clientId],
|
||||
references: [oauth2Client.id]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
export const oauth2ClientUrlRelations = relations(oauth2ClientUrl, ({ one }) => ({
|
||||
o_auth2_client: one(oauth2Client, {
|
||||
fields: [oauth2ClientUrl.clientId],
|
||||
references: [oauth2Client.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const oauth2TokenRelations = relations(oauth2Token, ({ one }) => ({
|
||||
o_auth2_client: one(oauth2Client, {
|
||||
fields: [oauth2Token.clientId],
|
||||
references: [oauth2Client.id]
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [oauth2Token.userId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const userPrivilegesPrivilegeRelations = relations(
|
||||
userPrivilegesPrivilege,
|
||||
({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [userPrivilegesPrivilege.userId],
|
||||
references: [user.id]
|
||||
}),
|
||||
privilege: one(privilege, {
|
||||
fields: [userPrivilegesPrivilege.privilegeId],
|
||||
references: [privilege.id]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
export const privilegeRelations = relations(privilege, ({ many }) => ({
|
||||
user_privileges_privileges: many(userPrivilegesPrivilege)
|
||||
}));
|
||||
|
||||
export const userTokenRelations = relations(userToken, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [userToken.userId],
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
208
src/lib/server/drizzle/schema.ts
Normal file
208
src/lib/server/drizzle/schema.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import {
|
||||
mysqlTable,
|
||||
int,
|
||||
text,
|
||||
tinyint,
|
||||
datetime,
|
||||
varchar,
|
||||
unique,
|
||||
timestamp,
|
||||
mysqlEnum,
|
||||
index,
|
||||
type AnyMySqlColumn,
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
|
||||
export const auditLog = mysqlTable('audit_log', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
action: text('action').notNull(),
|
||||
content: text('content'),
|
||||
actor_ip: text('actor_ip'),
|
||||
actor_ua: text('actor_ua'),
|
||||
flagged: tinyint('flagged').default(0).notNull(),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
|
||||
});
|
||||
|
||||
export const document = mysqlTable('document', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
body: text('body').notNull(),
|
||||
authorId: int('authorId').references(() => user.id),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const oauth2Client = mysqlTable(
|
||||
'o_auth2_client',
|
||||
{
|
||||
id: int('id').autoincrement().notNull(),
|
||||
client_id: varchar('client_id', { length: 36 }).notNull(),
|
||||
client_secret: text('client_secret').notNull(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
scope: text('scope'),
|
||||
grants: text('grants').default('authorization_code').notNull(),
|
||||
activated: tinyint('activated').default(0).notNull(),
|
||||
verified: tinyint('verified').default(0).notNull(),
|
||||
pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }),
|
||||
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
IDX_e9d16c213910ad57bd05e97b42: unique('IDX_e9d16c213910ad57bd05e97b42').on(table.client_id)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const oauth2ClientAuthorization = mysqlTable('o_auth2_client_authorization', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
scope: text('scope'),
|
||||
expires_at: timestamp('expires_at', { mode: 'string' }).default('current_timestamp()').notNull(),
|
||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const oauth2ClientUrl = mysqlTable('o_auth2_client_url', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
url: varchar('url', { length: 255 }).notNull(),
|
||||
type: mysqlEnum('type', ['redirect_uri', 'terms', 'privacy', 'website']).notNull(),
|
||||
created_at: timestamp('created_at', { fsp: 6, mode: 'string' })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: timestamp('updated_at', { fsp: 6, mode: 'string' })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' })
|
||||
});
|
||||
|
||||
export const oauth2Token = mysqlTable('o_auth2_token', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(),
|
||||
token: text('token').notNull(),
|
||||
scope: text('scope'),
|
||||
expires_at: timestamp('expires_at', { mode: 'string' }).default('current_timestamp()').notNull(),
|
||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
||||
nonce: text('nonce'),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
pcke: text('pcke')
|
||||
});
|
||||
|
||||
export const privilege = mysqlTable('privilege', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
name: text('name').notNull()
|
||||
});
|
||||
|
||||
export const upload = mysqlTable('upload', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
original_name: varchar('original_name', { length: 255 }).notNull(),
|
||||
mimetype: varchar('mimetype', { length: 255 }).notNull(),
|
||||
file: varchar('file', { length: 255 }).notNull(),
|
||||
uploaderId: int('uploaderId').references((): AnyMySqlColumn => user.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const user = mysqlTable(
|
||||
'user',
|
||||
{
|
||||
id: int('id').autoincrement().notNull(),
|
||||
uuid: varchar('uuid', { length: 36 }).notNull(),
|
||||
username: varchar('username', { length: 26 }).notNull(),
|
||||
email: varchar('email', { length: 255 }).notNull(),
|
||||
display_name: varchar('display_name', { length: 32 }).notNull(),
|
||||
password: text('password'),
|
||||
activated: tinyint('activated').default(0).notNull(),
|
||||
activity_at: timestamp('activity_at', { mode: 'string' })
|
||||
.default('current_timestamp()')
|
||||
.notNull(),
|
||||
pictureId: int('pictureId').references((): AnyMySqlColumn => upload.id, {
|
||||
onDelete: 'set null',
|
||||
onUpdate: 'cascade'
|
||||
}),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
IDX_a95e949168be7b7ece1a2382fe: unique('IDX_a95e949168be7b7ece1a2382fe').on(table.uuid),
|
||||
IDX_78a916df40e02a9deb1c4b75ed: unique('IDX_78a916df40e02a9deb1c4b75ed').on(table.username),
|
||||
IDX_e12875dfb3b1d92d7d7c5377e2: unique('IDX_e12875dfb3b1d92d7d7c5377e2').on(table.email)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type NewUser = typeof user.$inferInsert;
|
||||
|
||||
export const userPrivilegesPrivilege = mysqlTable(
|
||||
'user_privileges_privilege',
|
||||
{
|
||||
userId: int('userId')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||
privilegeId: int('privilegeId')
|
||||
.notNull()
|
||||
.references(() => privilege.id, { onDelete: 'cascade', onUpdate: 'cascade' })
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
IDX_0664a7ff494a1859a09014c0f1: index('IDX_0664a7ff494a1859a09014c0f1').on(table.userId),
|
||||
IDX_e71171f4ed20bc8564a1819d0b: index('IDX_e71171f4ed20bc8564a1819d0b').on(table.privilegeId)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const userToken = mysqlTable('user_token', {
|
||||
id: int('id').autoincrement().notNull(),
|
||||
token: text('token').notNull(),
|
||||
type: mysqlEnum('type', [
|
||||
'generic',
|
||||
'activation',
|
||||
'deactivation',
|
||||
'password',
|
||||
'login',
|
||||
'gdpr',
|
||||
'totp',
|
||||
'public_key',
|
||||
'recovery'
|
||||
]).notNull(),
|
||||
expires_at: timestamp('expires_at', { mode: 'string' }),
|
||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||
nonce: text('nonce'),
|
||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
||||
.default('current_timestamp(6)')
|
||||
.notNull()
|
||||
});
|
48
src/lib/server/users/index.ts
Normal file
48
src/lib/server/users/index.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
import { db, user, type User } from '../drizzle';
|
||||
import type { UserSession } from './types';
|
||||
|
||||
export class Users {
|
||||
static async getByLogin(login: string): Promise<User | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(and(or(eq(user.email, login), eq(user.username, login)), eq(user.activated, 1)))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getBySession(session?: UserSession): Promise<User | undefined> {
|
||||
if (!session) return undefined;
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(and(eq(user.id, session.uid), eq(user.activated, 1)))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
static async update(subject: User, fields: Partial<User>) {
|
||||
return db.update(user).set(fields).where(eq(user.id, subject.id));
|
||||
}
|
||||
|
||||
static async validatePassword(user: User, password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, user.password as string);
|
||||
}
|
||||
|
||||
static async hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
static async toSession(user: User): Promise<UserSession> {
|
||||
return {
|
||||
uid: user.id,
|
||||
uuid: user.uuid,
|
||||
name: user.display_name,
|
||||
username: user.username
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export * from './types';
|
50
src/lib/server/users/totp.ts
Normal file
50
src/lib/server/users/totp.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { authenticator as totp } from 'otplib';
|
||||
import { db, userToken, type User } from '../drizzle';
|
||||
import { and, eq, gt, isNull, or } from 'drizzle-orm';
|
||||
|
||||
totp.options = {
|
||||
window: 2
|
||||
};
|
||||
|
||||
export class TimeOTP {
|
||||
public static validate(secret: string, token: string): boolean {
|
||||
return totp.verify({ token, secret });
|
||||
}
|
||||
|
||||
public static getUri(secret: string, username: string): string {
|
||||
return totp.keyuri(username, 'Icy Network', secret);
|
||||
}
|
||||
|
||||
public static createSecret(): string {
|
||||
return totp.generateSecret();
|
||||
}
|
||||
|
||||
public static async isUserOtp(subject: User) {
|
||||
const tokens = await db
|
||||
.select({ id: userToken.id })
|
||||
.from(userToken)
|
||||
.where(
|
||||
and(
|
||||
eq(userToken.type, 'totp'),
|
||||
eq(userToken.userId, subject.id),
|
||||
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date().toISOString()))
|
||||
)
|
||||
);
|
||||
return tokens?.length;
|
||||
}
|
||||
|
||||
public static async getUserOtp(subject: User) {
|
||||
const [token] = await db
|
||||
.select({ id: userToken.id, token: userToken.token })
|
||||
.from(userToken)
|
||||
.where(
|
||||
and(
|
||||
eq(userToken.type, 'totp'),
|
||||
eq(userToken.userId, subject.id),
|
||||
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date().toISOString()))
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return token;
|
||||
}
|
||||
}
|
6
src/lib/server/users/types.ts
Normal file
6
src/lib/server/users/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface UserSession {
|
||||
uid: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
4
src/lib/validators.ts
Normal file
4
src/lib/validators.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const emailRegex =
|
||||
/^[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
|
||||
export const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,}$/;
|
||||
export const usernameRegex = /^[a-zA-Z0-9_\-.]{3,26}$/;
|
16
src/routes/+layout.svelte
Normal file
16
src/routes/+layout.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import '../app.css'
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
11
src/routes/+layout.ts
Normal file
11
src/routes/+layout.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { loadTranslations } from '$lib/i18n';
|
||||
|
||||
export const load = async ({ url }) => {
|
||||
const { pathname } = url;
|
||||
|
||||
const initLocale = 'en'; // get from cookie, user session, ...
|
||||
|
||||
await loadTranslations(initLocale, pathname);
|
||||
|
||||
return {};
|
||||
}
|
156
src/routes/account/+page.server.ts
Normal file
156
src/routes/account/+page.server.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { Challenge } from '$lib/server/challenge.js';
|
||||
import type { User } from '$lib/server/drizzle';
|
||||
import { Users, type UserSession } from '$lib/server/users/index.js';
|
||||
import { TimeOTP } from '$lib/server/users/totp.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface AccountUpdate {
|
||||
displayName: string;
|
||||
|
||||
currentEmail: string;
|
||||
newEmail: string;
|
||||
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
logout: async ({ locals }) => {
|
||||
await locals.session.destroy();
|
||||
return redirect(303, '/');
|
||||
},
|
||||
update: async ({ request, locals }) => {
|
||||
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||
if (!currentUser) {
|
||||
await locals.session.destroy();
|
||||
return redirect(301, '/login');
|
||||
}
|
||||
|
||||
const body = await request.formData();
|
||||
let data: Partial<AccountUpdate>;
|
||||
try {
|
||||
data = await Challenge.authorizedChanges<AccountUpdate>(
|
||||
['displayName', 'currentEmail', 'newEmail', 'currentPassword', 'newPassword'],
|
||||
body,
|
||||
currentUser
|
||||
);
|
||||
} catch {
|
||||
return fail(400, { errors: ['invalidRequest'] });
|
||||
}
|
||||
|
||||
// No changes
|
||||
if (
|
||||
(!data.displayName || data.displayName === currentUser.display_name) &&
|
||||
!data.newEmail &&
|
||||
!data.newPassword
|
||||
) {
|
||||
return { success: true, errors: <string[]>[], fields: <string[]>[] };
|
||||
}
|
||||
|
||||
// Current email is not provided
|
||||
if (data.newEmail && !data.currentEmail) {
|
||||
return fail(400, {
|
||||
displayName: data.displayName,
|
||||
errors: ['emailRequired'],
|
||||
fields: ['currentEmail']
|
||||
});
|
||||
}
|
||||
|
||||
// Current email is invalid
|
||||
if (data.currentEmail && data.currentEmail !== currentUser.email) {
|
||||
return fail(400, {
|
||||
displayName: data.displayName,
|
||||
errors: ['invalidEmail'],
|
||||
fields: ['currentEmail']
|
||||
});
|
||||
}
|
||||
|
||||
// Current password is not provided
|
||||
if (data.newPassword && !data.currentPassword) {
|
||||
return fail(400, {
|
||||
displayName: data.displayName,
|
||||
errors: ['passwordRequired'],
|
||||
fields: ['currentPassword']
|
||||
});
|
||||
}
|
||||
|
||||
// Password does not match current password
|
||||
if (
|
||||
data.currentPassword &&
|
||||
!(await Users.validatePassword(currentUser, data.currentPassword))
|
||||
) {
|
||||
return fail(400, {
|
||||
displayName: data.displayName,
|
||||
errors: ['invalidPassword'],
|
||||
fields: ['currentPassword']
|
||||
});
|
||||
}
|
||||
|
||||
// Invalid display name
|
||||
if (data.displayName && (data.displayName.length < 3 || data.displayName.length > 32)) {
|
||||
return fail(400, {
|
||||
displayName: data.displayName,
|
||||
errors: ['invalidDisplayName'],
|
||||
fields: ['displayName']
|
||||
});
|
||||
}
|
||||
|
||||
// When updating email or password, we check if OTP has been enabled.
|
||||
// If it is, we need to ask for the OTP code.
|
||||
if (data.newEmail || data.newPassword) {
|
||||
const isOtp = await TimeOTP.isUserOtp(currentUser);
|
||||
if (isOtp) {
|
||||
const challenge = await Challenge.issueChallenge(data, currentUser.uuid);
|
||||
return { otpRequired: challenge };
|
||||
}
|
||||
}
|
||||
|
||||
// Update the user table
|
||||
const updates: Partial<User> = {};
|
||||
if (data.displayName) {
|
||||
updates.display_name = data.displayName;
|
||||
}
|
||||
|
||||
if (data.newEmail) {
|
||||
updates.email = data.newEmail;
|
||||
}
|
||||
|
||||
if (data.newPassword) {
|
||||
updates.password = await Users.hashPassword(data.newPassword);
|
||||
}
|
||||
|
||||
await Users.update(currentUser, updates);
|
||||
|
||||
// Update session display name
|
||||
if (data.displayName) {
|
||||
await locals.session.update(({ user }) => ({
|
||||
user: { ...user, name: data.displayName as string } as UserSession
|
||||
}));
|
||||
}
|
||||
|
||||
// TODO: audit log
|
||||
|
||||
return {
|
||||
success: true,
|
||||
errors: <string[]>[],
|
||||
fields: <string[]>[],
|
||||
displayName: data.displayName || currentUser.display_name
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export async function load({ locals, url }) {
|
||||
const userInfo = locals.session.data?.user;
|
||||
const currentUser = await Users.getBySession(userInfo);
|
||||
if (!userInfo || !currentUser) {
|
||||
await locals.session.destroy();
|
||||
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
|
||||
}
|
||||
|
||||
const otpEnabled = await TimeOTP.isUserOtp(currentUser);
|
||||
|
||||
return {
|
||||
user: userInfo,
|
||||
otpEnabled
|
||||
};
|
||||
}
|
76
src/routes/account/+page.svelte
Normal file
76
src/routes/account/+page.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import LogoutButton from '$lib/components/LogoutButton.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
</script>
|
||||
|
||||
<span>{data.user.name}</span>
|
||||
|
||||
<form action="?/update" method="POST">
|
||||
<div class="form-control">
|
||||
<label for="form-username">{$t('account.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={data.user.username}
|
||||
id="form-username"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-displayName">{$t('account.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="displayName"
|
||||
value={form?.displayName || data.user.name}
|
||||
id="form-displayName"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-subtitle">{$t('account.changeEmail')}</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
||||
<input type="email" name="currentEmail" id="form-currentEmail" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
||||
<input type="email" name="newEmail" id="form-newEmail" />
|
||||
</div>
|
||||
|
||||
<div class="form-subtitle">{$t('account.changePassword')}</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
name="currentPassword"
|
||||
id="form-currentPassword"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-newPassword">{$t('account.newPassword')}</label>
|
||||
<input type="password" autocomplete="new-password" name="newPassword" id="form-newPassword" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-repeatPassword">{$t('account.repeatPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="repeatPassword"
|
||||
id="form-repeatPassword"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="btn">{$t('account.submit')}</button>
|
||||
</form>
|
||||
|
||||
<LogoutButton />
|
40
src/routes/login/+page.server.ts
Normal file
40
src/routes/login/+page.server.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Users } from '$lib/server/users/index.js';
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, locals, url }) => {
|
||||
// Redirect
|
||||
const redirectUrl = url.searchParams.has('redirectTo')
|
||||
? (url.searchParams.get('redirectTo') as string)
|
||||
: '/';
|
||||
|
||||
// Already logged in
|
||||
if (locals.session.data?.user) {
|
||||
return redirect(303, redirectUrl);
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
const email = data.get('email') as string;
|
||||
const password = data.get('password') as string;
|
||||
|
||||
if (!email?.trim() || !password?.trim()) {
|
||||
return fail(400, { incorrect: true });
|
||||
}
|
||||
|
||||
// Find existing active user
|
||||
const loginUser = await Users.getByLogin(email);
|
||||
|
||||
// Compare user password
|
||||
if (!loginUser || !(await Users.validatePassword(loginUser, password))) {
|
||||
return fail(400, { email, incorrect: true });
|
||||
}
|
||||
|
||||
// TODO: check two-factor
|
||||
|
||||
// Create session data for user
|
||||
const sessionUser = await Users.toSession(loginUser);
|
||||
await locals.session.set({ user: sessionUser });
|
||||
|
||||
return redirect(303, redirectUrl);
|
||||
}
|
||||
} as Actions;
|
74
src/routes/login/+page.svelte
Normal file
74
src/routes/login/+page.svelte
Normal file
@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
export let form: ActionData;
|
||||
</script>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div class="login-inner">
|
||||
<h1>{$t('common.siteName')}</h1>
|
||||
|
||||
<h2>{$t('account.login.title')}</h2>
|
||||
|
||||
<form action="" method="POST">
|
||||
<FormWrapper>
|
||||
{#if form?.incorrect}<p class="error">{$t('account.errors.invalidLogin')}</p>{/if}
|
||||
<FormControl>
|
||||
<label for="login-email">{$t('account.login.email')}</label>
|
||||
<input id="login-email" name="email" value={form?.email ?? ''} autocomplete="username" />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="login-password">{$t('account.login.password')}</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
|
||||
<div class="welcome">
|
||||
<p class="text-bold">{$t('common.description')}</p>
|
||||
<p>{@html $t('common.cookieDisclaimer')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.login-wrapper,
|
||||
.login-inner {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
margin-top: auto;
|
||||
|
||||
& .text-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
</style>
|
BIN
static/background.jpg
Normal file
BIN
static/background.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 316 KiB |
Loading…
Reference in New Issue
Block a user