feat: Добавлен Better Auth
This commit is contained in:
18
apps/backend/.env.example
Normal file
18
apps/backend/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/p1ctos4ve
|
||||
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
S3_ENDPOINT=http://localhost:8333
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=p1ctos4ve
|
||||
S3_ACCESS_KEY_ID=your_access_key
|
||||
S3_SECRET_ACCESS_KEY=your_secret_key
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
BETTER_AUTH_SECRET=your_secret_key_here_min_32_chars
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
BASE_URL=http://localhost:3000
|
||||
@ -1,60 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"elysia": "^1.4.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
|
||||
|
||||
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"elysia": ["elysia@1.4.12", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-wbd0BkrobsjWSloIfYeF3f7G7rR0UWMa6tuLUhf6ZvwjiCEX3FVfhDsM+KaqqRRxkZpPDw42q4yIZlBLyE32ww=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
|
||||
|
||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
|
||||
|
||||
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
}
|
||||
}
|
||||
12
apps/backend/drizzle.config.ts
Normal file
12
apps/backend/drizzle.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
import { env } from './src/config/env';
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
@ -1,13 +1,28 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"name": "@p1ctos4ve/backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir ./dist --target bun",
|
||||
"start": "bun run dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "bun run src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.4.0",
|
||||
"elysia": "^1.4.12"
|
||||
"@elysiajs/cors": "^1.1.1",
|
||||
"@elysiajs/eden": "^1.4.4",
|
||||
"@elysiajs/openapi": "^1.4.11",
|
||||
"better-auth": "^1.1.6",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"elysia": "^1.1.23",
|
||||
"nanoid": "^5.0.9",
|
||||
"postgres": "^3.4.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
42
apps/backend/src/config/env.ts
Normal file
42
apps/backend/src/config/env.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('3000'),
|
||||
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
|
||||
REDIS_URL: z.string().min(1, 'REDIS_URL is required'),
|
||||
|
||||
S3_ENDPOINT: z.string().min(1, 'S3_ENDPOINT is required'),
|
||||
S3_REGION: z.string().default('us-east-1'),
|
||||
S3_BUCKET: z.string().min(1, 'S3_BUCKET is required'),
|
||||
S3_ACCESS_KEY_ID: z.string().min(1, 'S3_ACCESS_KEY_ID is required'),
|
||||
S3_SECRET_ACCESS_KEY: z.string().min(1, 'S3_SECRET_ACCESS_KEY is required'),
|
||||
S3_FORCE_PATH_STYLE: z.string().default('true'),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string().min(32, 'BETTER_AUTH_SECRET must be at least 32 characters'),
|
||||
BETTER_AUTH_URL: z.string().url().default('http://localhost:3000'),
|
||||
|
||||
BASE_URL: z.string().url().default('http://localhost:3000'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function validateEnv(): Env {
|
||||
try {
|
||||
return envSchema.parse(process.env);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error('❌ Invalid environment variables:');
|
||||
error.issues.forEach((err) => {
|
||||
console.error(` ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
|
||||
15
apps/backend/src/db/index.ts
Normal file
15
apps/backend/src/db/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { env } from '@/config/env';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const connection = postgres(env.DATABASE_URL, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
export const db = drizzle(connection, { schema });
|
||||
|
||||
export * from './schema';
|
||||
|
||||
19
apps/backend/src/db/migrate.ts
Normal file
19
apps/backend/src/db/migrate.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { db, connection } from './index';
|
||||
|
||||
async function runMigrations() {
|
||||
console.log('Running migrations...');
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
console.log('Migrations completed!');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
|
||||
86
apps/backend/src/db/schema.ts
Normal file
86
apps/backend/src/db/schema.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { pgTable, serial, text, timestamp, varchar, integer, boolean, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const user = pgTable('user', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('email_verified').notNull().default(false),
|
||||
image: text('image'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const session = pgTable('session', {
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const account = pgTable('account', {
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const verification = pgTable('verification', {
|
||||
id: text('id').primaryKey(),
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export const save = pgTable(
|
||||
'save',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
type: varchar('type', { length: 50 }).notNull(),
|
||||
tags: text('tags').array().notNull().default([]),
|
||||
visibility: varchar('visibility', { length: 10 }).notNull().default('link'),
|
||||
shareUrl: text('share_url').unique(),
|
||||
s3Key: text('s3_key').notNull(),
|
||||
url: text('url').notNull(),
|
||||
fileSize: integer('file_size').notNull(),
|
||||
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('user_id_idx').on(table.userId),
|
||||
visibilityIdx: index('visibility_idx').on(table.visibility),
|
||||
shareUrlIdx: index('share_url_idx').on(table.shareUrl),
|
||||
tagsIdx: index('tags_idx').on(table.tags),
|
||||
})
|
||||
);
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type NewUser = typeof user.$inferInsert;
|
||||
export type Session = typeof session.$inferSelect;
|
||||
export type Save = typeof save.$inferSelect;
|
||||
export type NewSave = typeof save.$inferInsert;
|
||||
|
||||
12
apps/backend/src/drizzle.config.ts
Normal file
12
apps/backend/src/drizzle.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { Config } from 'drizzle-kit';
|
||||
import { env } from './src/config/env';
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
88
apps/backend/src/drizzle/0000_organic_namora.sql
Normal file
88
apps/backend/src/drizzle/0000_organic_namora.sql
Normal file
@ -0,0 +1,88 @@
|
||||
CREATE TABLE IF NOT EXISTS "account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "save" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"type" varchar(50) NOT NULL,
|
||||
"tags" text[] DEFAULT '{}' NOT NULL,
|
||||
"visibility" varchar(10) DEFAULT 'link' NOT NULL,
|
||||
"share_url" text,
|
||||
"s3_key" text NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"mime_type" varchar(100) NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "save_share_url_unique" UNIQUE("share_url")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "save" ADD CONSTRAINT "save_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_id_idx" ON "save" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "visibility_idx" ON "save" USING btree ("visibility");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "share_url_idx" ON "save" USING btree ("share_url");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tags_idx" ON "save" USING btree ("tags");
|
||||
513
apps/backend/src/drizzle/meta/0000_snapshot.json
Normal file
513
apps/backend/src/drizzle/meta/0000_snapshot.json
Normal file
@ -0,0 +1,513 @@
|
||||
{
|
||||
"id": "0b6d70a9-35a6-40bc-80cd-e296dc5758bd",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.save": {
|
||||
"name": "save",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'"
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'link'"
|
||||
},
|
||||
"share_url": {
|
||||
"name": "share_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"s3_key": {
|
||||
"name": "s3_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_id_idx": {
|
||||
"name": "user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"visibility_idx": {
|
||||
"name": "visibility_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "visibility",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"share_url_idx": {
|
||||
"name": "share_url_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "share_url",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tags_idx": {
|
||||
"name": "tags_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "tags",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"save_user_id_user_id_fk": {
|
||||
"name": "save_user_id_user_id_fk",
|
||||
"tableFrom": "save",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"save_share_url_unique": {
|
||||
"name": "save_share_url_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"share_url"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
apps/backend/src/drizzle/meta/_journal.json
Normal file
13
apps/backend/src/drizzle/meta/_journal.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1762256138442,
|
||||
"tag": "0000_organic_namora",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,14 +1,32 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import { Elysia } from "elysia";
|
||||
import { openapi } from '@elysiajs/openapi'
|
||||
import { auth } from "@/lib/auth";
|
||||
import { env } from "./config/env";
|
||||
import { AuthOpenAPI } from "./lib/auth/openapi";
|
||||
import { purple } from "./lib/term/color";
|
||||
|
||||
const app = new Elysia()
|
||||
.onAfterHandle(({ request, set }) => {
|
||||
const origin = request.headers.get("origin");
|
||||
.use(openapi({
|
||||
documentation: {
|
||||
components: await AuthOpenAPI.components,
|
||||
paths: await AuthOpenAPI.getPaths()
|
||||
}
|
||||
}))
|
||||
.mount('/auth', auth.handler)
|
||||
.listen(env.PORT);
|
||||
|
||||
if (origin === "http://localhost:8081") {
|
||||
set.headers["Access-Control-Allow-Origin"] = origin;
|
||||
}
|
||||
})
|
||||
.get('/', () => 'index')
|
||||
const hostname = app.server?.hostname
|
||||
const port = env.PORT
|
||||
|
||||
export default app
|
||||
console.log(purple`
|
||||
___ __ __ __
|
||||
____ < /____/ /_____ _____/ // /_ _____
|
||||
/ __ \\/ / ___/ __/ __ \\/ ___/ // /| | / / _ \\
|
||||
/ /_/ / / /__/ /_/ /_/ (__ )__ __/ |/ / __/
|
||||
/ .___/_/\\___/\\__/\\____/____/ /_/ |___/\\___/
|
||||
/_/
|
||||
`)
|
||||
|
||||
console.log(` ${purple`started server`} @ ${hostname}:${port}`);
|
||||
console.log(` ${purple`visit scalar`} @ ${hostname}:${port}/openapi`)
|
||||
|
||||
|
||||
27
apps/backend/src/lib/auth/index.ts
Normal file
27
apps/backend/src/lib/auth/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { db } from '@/db';
|
||||
import { env } from '@/config/env';
|
||||
import { openAPI } from 'better-auth/plugins';
|
||||
import * as schema from '@/db/schema'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
trustedOrigins: [env.BASE_URL],
|
||||
plugins: [openAPI()],
|
||||
advanced: {
|
||||
disableOriginCheck: true
|
||||
},
|
||||
basePath: '/api'
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session.session;
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
22
apps/backend/src/lib/auth/middleware.ts
Normal file
22
apps/backend/src/lib/auth/middleware.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import Elysia from "elysia";
|
||||
import { auth } from ".";
|
||||
|
||||
export const betterAuthMiddleware = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
.macro({
|
||||
auth: {
|
||||
async resolve({ status, request: { headers } }) {
|
||||
const session = await auth.api.getSession({
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!session) return status(401);
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
25
apps/backend/src/lib/auth/openapi.ts
Normal file
25
apps/backend/src/lib/auth/openapi.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { auth } from "."
|
||||
|
||||
let _schema: ReturnType<typeof auth.api.generateOpenAPISchema>
|
||||
const getSchema = async () => (_schema ??= auth.api.generateOpenAPISchema())
|
||||
|
||||
export const AuthOpenAPI = {
|
||||
getPaths: (prefix = '/auth/api') =>
|
||||
getSchema().then(({ paths }) => {
|
||||
const reference: typeof paths = Object.create(null)
|
||||
|
||||
for (const path of Object.keys(paths)) {
|
||||
const key = prefix + path
|
||||
reference[key] = paths[path]
|
||||
|
||||
for (const method of Object.keys(paths[path])) {
|
||||
const operation = (reference[key] as any)[method]
|
||||
|
||||
operation.tags = ['Better Auth']
|
||||
}
|
||||
}
|
||||
|
||||
return reference
|
||||
}) as Promise<any>,
|
||||
components: getSchema().then(({ components }) => components) as Promise<any>
|
||||
} as const
|
||||
14
apps/backend/src/lib/term/color.ts
Normal file
14
apps/backend/src/lib/term/color.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export const purple = (
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): string => {
|
||||
const purpleCode = "\x1b[35m";
|
||||
const resetCode = "\x1b[0m";
|
||||
|
||||
let result = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
result += String(values[i]) + strings[i + 1];
|
||||
}
|
||||
|
||||
return purpleCode + result + resetCode;
|
||||
};
|
||||
@ -1,28 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": [
|
||||
"bun-types"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user