all in a days work

This commit is contained in:
Evert Prants 2024-05-16 23:17:06 +03:00
parent 64b9c16eed
commit 5e178a6a19
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
35 changed files with 3953 additions and 4 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
devdocker

16
drizzle.config.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View File

@ -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
View 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
})

View 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>

View File

@ -0,0 +1,3 @@
<form action="/account?/logout" method="POST">
<button type="submit" class="btn btn-link">Log out</button>
</form>

View 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>

View File

@ -0,0 +1,4 @@
<div class="form-section">
<slot />
</div>

View File

@ -0,0 +1,10 @@
<div class="form-wrapper">
<slot />
</div>
<style>
:global(.form-control) {
margin-bottom: 16px;
}
</style>

View 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."
}
}

View 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&nbsp;<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
View 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);

View 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;
}
}

View 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;
}, {});
}
}

View 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));
}
}

View 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';

View 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`);
*/

View 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": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "6",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1715871691221,
"tag": "0000_initial",
"breakpoints": true
}
]
}

View 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]
})
}));

View 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()
});

View 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';

View 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;
}
}

View File

@ -0,0 +1,6 @@
export interface UserSession {
uid: number;
uuid: string;
name: string;
username: string;
}

4
src/lib/validators.ts Normal file
View 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
View 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
View 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 {};
}

View 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
};
}

View 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 />

View 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;

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB