Archived
1
0

feat: Добавлен Better Auth

This commit is contained in:
2025-11-19 10:00:57 +03:00
parent a423bc6c11
commit 6fd29f5408
21 changed files with 1217 additions and 104 deletions

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

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

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

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

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

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

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

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1762256138442,
"tag": "0000_organic_namora",
"breakpoints": true
}
]
}

View File

@ -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`)

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

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

View 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

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