From d847c9319265c213344742957ebef75550c2ebcb Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 19 Mar 2026 18:26:18 +0800 Subject: [PATCH] feat: import auth package from monorepo --- .gitignore | 4 + package.json | 28 ++ src/auth-config.ts | 89 +++++ src/avatar/avatar.ts | 910 ++++++++++++++++++++++++++++++++++++++++++ src/avatar/openapi.ts | 51 +++ src/avatar/schemas.ts | 59 +++ src/client.ts | 313 +++++++++++++++ src/index.ts | 11 + src/s3/index.ts | 716 +++++++++++++++++++++++++++++++++ src/s3/schema.ts | 76 ++++ src/s3/types.ts | 64 +++ src/schema.ts | 128 ++++++ tsconfig.build.json | 14 + tsconfig.json | 18 + 14 files changed, 2481 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/auth-config.ts create mode 100644 src/avatar/avatar.ts create mode 100644 src/avatar/openapi.ts create mode 100644 src/avatar/schemas.ts create mode 100644 src/client.ts create mode 100644 src/index.ts create mode 100644 src/s3/index.ts create mode 100644 src/s3/schema.ts create mode 100644 src/s3/types.ts create mode 100644 src/schema.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14959f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.turbo/ +*.tsbuildinfo diff --git a/package.json b/package.json new file mode 100644 index 0000000..04c8f48 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bowong/auth", + "version": "1.0.5", + "type": "module", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client.ts" + }, + "scripts": { + "build": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\" && bun x tsc -p tsconfig.build.json", + "typecheck": "bun x tsc --noEmit", + "clean": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\"" + }, + "dependencies": { + "@better-auth/api-key": "^1.2.0", + "better-auth": "^1.2.0", + "drizzle-orm": "^0.44.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250312.0", + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/src/auth-config.ts b/src/auth-config.ts new file mode 100644 index 0000000..1861f3b --- /dev/null +++ b/src/auth-config.ts @@ -0,0 +1,89 @@ +/** + * Better Auth server-side configuration factory. + * Used by web-chat (Next.js on Cloudflare Pages) to run the full auth instance. + */ + +import { betterAuth } from 'better-auth' +import { bearer, openAPI, username } from 'better-auth/plugins' +import { apiKey } from '@better-auth/api-key' +import { drizzle } from 'drizzle-orm/d1' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import * as schema from './schema.js' +import { avatarPlugin } from './avatar/avatar.js' +import { s3, type S3Config } from './s3/index.js' + +export interface CreateAuthOptions { + /** Raw Cloudflare D1 binding */ + db: unknown + baseURL?: string + secret?: string + trustedOrigins?: string[] + s3?: S3Config +} + +export function createAuth(options: CreateAuthOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const drizzleDb = drizzle(options.db as any, { schema }) + + return betterAuth({ + baseURL: options.baseURL, + secret: options.secret, + // 禁用全局限流 + rateLimit: { + enabled: false, + }, + advanced: { + crossSubDomainCookies: { + enabled: false, // 禁用跨子域 cookies,避免 baseURL 要求 + }, + ipAddress: { + ipAddressHeaders: ["cf-connecting-ip", "x-real-ip"], + disableIpTracking: false, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 7, + updateAge: 60 * 60 * 24, + freshAge: 0, + storeSessionInDatabase: true, // 启用数据库存储以保存地理位置元数据 + }, + trustedOrigins: (request) => { + if (!request) return ["*"]; + const origin = request!.headers.get('origin') || request!.headers.get('expo-origin'); + + const allowedOrigins = options.trustedOrigins || ['*']; + if (!origin) return allowedOrigins; + + const isAllowed = allowedOrigins.some(allowed => + origin === allowed || origin.startsWith(allowed) + ); + + return isAllowed ? [origin] : allowedOrigins; + }, + database: drizzleAdapter(drizzleDb, { + provider: 'sqlite', + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + apikey: schema.apikey, + userFile: schema.userFile, + user_file: schema.userFile, + }, + }), + emailAndPassword: { + enabled: true, + }, + plugins: [ + openAPI(), + bearer(), + username(), + apiKey({ rateLimit: { enabled: false } }), + avatarPlugin({ db: options.db as any }), + ...(options.s3 ? [s3(options.s3)] : []), + ] + }) +} + +export type Auth = ReturnType diff --git a/src/avatar/avatar.ts b/src/avatar/avatar.ts new file mode 100644 index 0000000..943149a --- /dev/null +++ b/src/avatar/avatar.ts @@ -0,0 +1,910 @@ +/** + * Avatar Management Plugin for Better Auth + * 提供形象和表情包管理功能 + */ + +import type { BetterAuthPlugin } from 'better-auth' +import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api' +import * as z from 'zod' +import { + APIKEY_AVATAR_TAG, + AVATAR_TAG, + EMOJI_TAG, + apikeyAvatarSchema, + avatarSchema, + emojiSchema, + errorSchema, +} from './schemas.js' +import { + badRequestResponse, + databaseErrorResponse, + jsonResponse, + notFoundResponse, + okResponse, + unauthorizedResponse, +} from './openapi.js' + +export interface AvatarPluginOptions { + enabled?: boolean + db?: any // D1Database type from @cloudflare/workers-types +} + +function generateId(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}` +} + +function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +function getDbFromContext(ctx: { context?: unknown }): any | null { + const context = ctx.context as + | { + db?: any + context?: { db?: any } + options?: { db?: any } + } + | undefined + + return context?.db ?? context?.context?.db ?? context?.options?.db ?? null +} + +function getDb(ctx: { context?: unknown }, fallbackDb?: any): any { + const db = getDbFromContext(ctx) ?? fallbackDb + if (!db) { + throw new Error('Avatar plugin database binding is missing') + } + return db +} + +async function demoteOtherDefaultEmojis( + db: any, + avatarId: string, + keepEmojiId?: string, +): Promise { + const query = keepEmojiId + ? 'UPDATE emojis SET trigger_type = ? WHERE avatar_id = ? AND trigger_type = ? AND id != ?' + : 'UPDATE emojis SET trigger_type = ? WHERE avatar_id = ? AND trigger_type = ?' + + const statement = keepEmojiId + ? db.prepare(query).bind('message', avatarId, 'default', keepEmojiId) + : db.prepare(query).bind('message', avatarId, 'default') + + await statement.run() +} + +const avatarBodySchema = z.object({ + name: z.string(), + description: z.string().optional(), +}) + +const emojiCreateBodySchema = z.object({ + label: z.string(), + url: z.string(), + description: z.string().optional(), + trigger_type: z.string().optional(), + trigger_result: z.enum(['success', 'failure']).optional(), + sort_order: z.number().optional(), +}) + +const emojiUpdateBodySchema = z.object({ + label: z.string().optional(), + url: z.string().optional(), + description: z.string().optional(), + trigger_type: z.string().optional(), + trigger_result: z.enum(['success', 'failure']).nullable().optional(), + sort_order: z.number().optional(), +}) + +const bindAvatarBodySchema = z.object({ + avatar_id: z.string().nullable().optional(), +}) + +export const avatarPlugin = (options?: AvatarPluginOptions): BetterAuthPlugin => { + if (options?.enabled === false) { + return { + id: 'avatar', + endpoints: {}, + } + } + + return { + id: 'avatar', + endpoints: { + getAvatars: createAuthEndpoint( + '/avatars', + { + method: 'GET', + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + operationId: 'getAvatars', + description: 'Get the current user avatar list', + tags: [AVATAR_TAG], + responses: { + '200': jsonResponse('Avatar list', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + avatars: { + type: 'array', + items: avatarSchema, + }, + }, + required: ['ok', 'avatars'], + }), + '401': unauthorizedResponse(), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + try { + const db = getDb(ctx, options?.db) + const result = await db.prepare( + 'SELECT id, name, description, created_at, updated_at FROM avatars ORDER BY created_at DESC' + ).all() + + return ctx.json({ + ok: true, + avatars: result.results || [], + }) + } catch (err) { + console.error('[avatar-plugin] getAvatars error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + createAvatar: createAuthEndpoint( + '/avatars', + { + method: 'POST', + body: avatarBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + requireRequest: true, + metadata: { + openapi: { + operationId: 'createAvatar', + description: 'Create a new avatar', + tags: [AVATAR_TAG], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['name'], + }, + }, + }, + }, + responses: { + '201': jsonResponse('Avatar created', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + avatar: avatarSchema, + }, + required: ['ok', 'avatar'], + }), + '400': badRequestResponse('Invalid body'), + '401': unauthorizedResponse(), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + const body = ctx.body + if (!body || typeof body.name !== 'string' || body.name.trim() === '') { + return ctx.json({ ok: false, error: 'Name is required' }, { status: 400 }) + } + + const name = body.name.trim() + const id = generateId() + const now = Date.now() + + try { + const db = getDb(ctx, options?.db) + await db.prepare( + 'INSERT INTO avatars (id, user_id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).bind( + id, + ctx.context.session.user.id, + name, + body.description || null, + now, + now, + ).run() + + return ctx.json({ + ok: true, + avatar: { + id, + user_id: ctx.context.session.user.id, + name, + description: body.description || null, + created_at: now, + updated_at: now, + }, + }, { status: 201 }) + } catch (err) { + console.error('[avatar-plugin] createAvatar error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + getAvatar: createAuthEndpoint( + '/avatars/:id', + { + method: 'GET', + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + operationId: 'getAvatar', + description: 'Get avatar detail with emojis', + tags: [AVATAR_TAG], + responses: { + '200': jsonResponse('Avatar detail', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + avatar: avatarSchema, + emojis: { + type: 'array', + items: emojiSchema, + }, + }, + required: ['ok', 'avatar', 'emojis'], + }), + '401': unauthorizedResponse(), + '404': notFoundResponse('Avatar not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + try { + const db = getDb(ctx, options?.db) + const avatarResult = await db.prepare( + 'SELECT id, user_id, name, description, created_at, updated_at FROM avatars WHERE id = ? AND user_id = ?' + ).bind(ctx.params.id, ctx.context.session.user.id).first() + + if (!avatarResult) { + return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 }) + } + + const emojisResult = await db.prepare( + 'SELECT id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at FROM emojis WHERE avatar_id = ? ORDER BY sort_order ASC, created_at ASC' + ).bind(ctx.params.id).all() + + return ctx.json({ + ok: true, + avatar: avatarResult, + emojis: emojisResult.results || [], + }) + } catch (err) { + console.error('[avatar-plugin] getAvatar error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + updateAvatar: createAuthEndpoint( + '/avatars/:id', + { + method: 'PUT', + body: avatarBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + requireRequest: true, + metadata: { + openapi: { + operationId: 'updateAvatar', + description: 'Update avatar info', + tags: [AVATAR_TAG], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + }, + required: ['name'], + }, + }, + }, + }, + responses: { + '200': jsonResponse('Avatar updated', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + avatar: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + updated_at: { type: 'number' }, + }, + required: ['id', 'name', 'description', 'updated_at'], + }, + }, + required: ['ok', 'avatar'], + }), + '400': badRequestResponse('Invalid body'), + '401': unauthorizedResponse(), + '404': notFoundResponse('Avatar not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + const body = ctx.body + if (!body || typeof body.name !== 'string' || body.name.trim() === '') { + return ctx.json({ ok: false, error: 'Name is required' }, { status: 400 }) + } + + const name = body.name.trim() + const now = Date.now() + + try { + const db = getDb(ctx, options?.db) + const result = await db.prepare( + 'UPDATE avatars SET name = ?, description = ?, updated_at = ? WHERE id = ? AND user_id = ?' + ).bind( + name, + body.description || null, + now, + ctx.params.id, + ctx.context.session.user.id, + ).run() + + if (result.meta.changes === 0) { + return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 }) + } + + return ctx.json({ + ok: true, + avatar: { + id: ctx.params.id, + name, + description: body.description || null, + updated_at: now, + }, + }) + } catch (err) { + console.error('[avatar-plugin] updateAvatar error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + deleteAvatar: createAuthEndpoint( + '/avatars/:id', + { + method: 'DELETE', + disableBody: true, + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + operationId: 'deleteAvatar', + description: 'Delete an avatar', + tags: [AVATAR_TAG], + responses: { + '200': okResponse('Avatar deleted'), + '401': unauthorizedResponse(), + '404': notFoundResponse('Avatar not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + try { + const db = getDb(ctx, options?.db) + const result = await db.prepare( + 'DELETE FROM avatars WHERE id = ? AND user_id = ?' + ).bind(ctx.params.id, ctx.context.session.user.id).run() + + if (result.meta.changes === 0) { + return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 }) + } + + return ctx.json({ ok: true }) + } catch (err) { + console.error('[avatar-plugin] deleteAvatar error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + createEmoji: createAuthEndpoint( + '/avatars/:avatarId/emojis', + { + method: 'POST', + body: emojiCreateBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + requireRequest: true, + metadata: { + openapi: { + operationId: 'createEmoji', + description: 'Create a new emoji for an avatar', + tags: [EMOJI_TAG], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + label: { type: 'string' }, + url: { type: 'string' }, + description: { type: 'string' }, + trigger_type: { type: 'string' }, + trigger_result: { type: 'string', nullable: true }, + sort_order: { type: 'number' }, + }, + required: ['label', 'url'], + }, + }, + }, + }, + responses: { + '201': jsonResponse('Emoji created', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + emoji: emojiSchema, + }, + required: ['ok', 'emoji'], + }), + '400': badRequestResponse('Invalid body'), + '401': unauthorizedResponse(), + '404': notFoundResponse('Avatar not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + const body = ctx.body + + if (!body || typeof body.label !== 'string' || body.label.trim() === '') { + return ctx.json({ ok: false, error: 'Label is required' }, { status: 400 }) + } + if (typeof body.url !== 'string' || !isValidHttpUrl(body.url)) { + return ctx.json({ ok: false, error: 'Valid URL is required' }, { status: 400 }) + } + + const db = getDb(ctx, options?.db) + const ownerCheck = await db.prepare( + 'SELECT id FROM avatars WHERE id = ? AND user_id = ?' + ).bind(ctx.params.avatarId, ctx.context.session.user.id).first() + + if (!ownerCheck) { + return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 }) + } + + const id = generateId() + const now = Date.now() + const sortOrder = body.sort_order ?? 0 + const triggerType = body.trigger_type?.trim() || 'message' + const triggerResult = body.trigger_result ?? null + + try { + if (triggerType === 'default') { + await demoteOtherDefaultEmojis(db, ctx.params.avatarId) + } + + await db.prepare( + 'INSERT INTO emojis (id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).bind( + id, + ctx.params.avatarId, + body.label.trim(), + body.url, + body.description || null, + triggerType, + triggerResult, + sortOrder, + now, + ).run() + + return ctx.json({ + ok: true, + emoji: { + id, + avatar_id: ctx.params.avatarId, + label: body.label.trim(), + url: body.url, + description: body.description || null, + trigger_type: triggerType, + trigger_result: triggerResult, + sort_order: sortOrder, + created_at: now, + }, + }, { status: 201 }) + } catch (err) { + console.error('[avatar-plugin] createEmoji error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + updateEmoji: createAuthEndpoint( + '/emojis/:id', + { + method: 'PUT', + body: emojiUpdateBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + requireRequest: true, + metadata: { + openapi: { + operationId: 'updateEmoji', + description: 'Update an emoji', + tags: [EMOJI_TAG], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + label: { type: 'string' }, + url: { type: 'string' }, + description: { type: 'string' }, + trigger_type: { type: 'string' }, + trigger_result: { type: 'string', nullable: true }, + sort_order: { type: 'number' }, + }, + }, + }, + }, + }, + responses: { + '200': okResponse('Emoji updated'), + '400': badRequestResponse('Invalid body'), + '401': unauthorizedResponse(), + '404': notFoundResponse('Emoji not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + const body = ctx.body + if (!body) { + return ctx.json({ ok: false, error: 'Invalid JSON' }, { status: 400 }) + } + + const db = getDb(ctx, options?.db) + const emojiResult = await db.prepare( + 'SELECT e.id, e.avatar_id FROM emojis e JOIN avatars a ON e.avatar_id = a.id WHERE e.id = ? AND a.user_id = ?' + ).bind(ctx.params.id, ctx.context.session.user.id).first() + + if (!emojiResult) { + return ctx.json({ ok: false, error: 'Emoji not found' }, { status: 404 }) + } + + const updates: string[] = [] + const bindings: (string | number | null)[] = [] + + if (body.label !== undefined) { + const label = body.label.trim() + if (label === '') { + return ctx.json({ ok: false, error: 'Label cannot be empty' }, { status: 400 }) + } + updates.push('label = ?') + bindings.push(label) + } + + if (body.url !== undefined) { + if (!isValidHttpUrl(body.url)) { + return ctx.json({ ok: false, error: 'Invalid URL' }, { status: 400 }) + } + updates.push('url = ?') + bindings.push(body.url) + } + + if (body.description !== undefined) { + updates.push('description = ?') + bindings.push(body.description || null) + } + + if (body.trigger_type !== undefined) { + const triggerType = body.trigger_type.trim() + if (triggerType === '') { + return ctx.json({ ok: false, error: 'Trigger type cannot be empty' }, { status: 400 }) + } + + if (triggerType === 'default') { + await demoteOtherDefaultEmojis(db, emojiResult.avatar_id, ctx.params.id) + } + + updates.push('trigger_type = ?') + bindings.push(triggerType) + } + + if (body.trigger_result !== undefined) { + updates.push('trigger_result = ?') + bindings.push(body.trigger_result) + } + + if (body.sort_order !== undefined) { + updates.push('sort_order = ?') + bindings.push(body.sort_order) + } + + if (updates.length === 0) { + return ctx.json({ ok: false, error: 'No fields to update' }, { status: 400 }) + } + + bindings.push(ctx.params.id) + + try { + await db.prepare( + `UPDATE emojis SET ${updates.join(', ')} WHERE id = ?` + ).bind(...bindings).run() + + return ctx.json({ ok: true }) + } catch (err) { + console.error('[avatar-plugin] updateEmoji error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + deleteEmoji: createAuthEndpoint( + '/emojis/:id', + { + method: 'DELETE', + disableBody: true, + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + operationId: 'deleteEmoji', + description: 'Delete an emoji', + tags: [EMOJI_TAG], + responses: { + '200': okResponse('Emoji deleted'), + '401': unauthorizedResponse(), + '404': notFoundResponse('Emoji not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + try { + const db = getDb(ctx, options?.db) + const result = await db.prepare( + 'DELETE FROM emojis WHERE id = ? AND avatar_id IN (SELECT id FROM avatars WHERE user_id = ?)' + ).bind(ctx.params.id, ctx.context.session.user.id).run() + + if (result.meta.changes === 0) { + return ctx.json({ ok: false, error: 'Emoji not found' }, { status: 404 }) + } + + return ctx.json({ ok: true }) + } catch (err) { + console.error('[avatar-plugin] deleteEmoji error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + bindAvatarToApiKey: createAuthEndpoint( + '/apikeys/:id/avatar', + { + method: 'PUT', + body: bindAvatarBodySchema, + use: [sessionMiddleware], + requireHeaders: true, + requireRequest: true, + metadata: { + openapi: { + operationId: 'bindAvatarToApiKey', + description: 'Bind or unbind an avatar to an API key', + tags: [APIKEY_AVATAR_TAG], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + avatar_id: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + responses: { + '200': jsonResponse('Avatar binding updated', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + apikey_id: { type: 'string' }, + avatar_id: { type: 'string', nullable: true }, + }, + required: ['ok', 'apikey_id', 'avatar_id'], + }), + '400': badRequestResponse('Invalid body'), + '401': unauthorizedResponse(), + '404': notFoundResponse('API key or avatar not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + const body = ctx.body + if (!body) { + return ctx.json({ ok: false, error: 'Invalid JSON' }, { status: 400 }) + } + + const db = getDb(ctx, options?.db) + const avatarId = body.avatar_id ?? null + + if (avatarId) { + const avatarResult = await db.prepare( + 'SELECT id FROM avatars WHERE id = ?' + ).bind(avatarId).first() + + if (!avatarResult) { + return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 }) + } + } + + const now = Date.now() + + try { + const result = await db.prepare( + 'UPDATE apikey SET avatar_id = ?, updated_at = ? WHERE id = ? AND reference_id = ?' + ).bind( + avatarId, + now, + ctx.params.id, + ctx.context.session.user.id, + ).run() + + if (result.meta.changes === 0) { + return ctx.json({ ok: false, error: 'API key not found' }, { status: 404 }) + } + + return ctx.json({ + ok: true, + apikey_id: ctx.params.id, + avatar_id: avatarId, + }) + } catch (err) { + console.error('[avatar-plugin] bindAvatarToApiKey error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + + getApiKeyAvatar: createAuthEndpoint( + '/apikeys/:id/avatar', + { + method: 'GET', + use: [sessionMiddleware], + requireHeaders: true, + metadata: { + openapi: { + operationId: 'getApiKeyAvatar', + description: 'Get the avatar and emoji list bound to an API key', + tags: [APIKEY_AVATAR_TAG], + responses: { + '200': jsonResponse('API key avatar detail', { + type: 'object', + properties: { + ok: { type: 'boolean' }, + apikey: apikeyAvatarSchema, + avatar: { + ...avatarSchema, + nullable: true, + }, + emojis: { + type: 'array', + items: emojiSchema, + }, + }, + required: ['ok', 'apikey', 'avatar', 'emojis'], + }), + '401': unauthorizedResponse(), + '404': notFoundResponse('API key not found'), + '500': databaseErrorResponse(), + }, + }, + }, + }, + async (ctx) => { + try { + const db = getDb(ctx, options?.db) + const apikeyResult = await db.prepare( + 'SELECT id, name, avatar_id FROM apikey WHERE id = ?' + ).bind(ctx.params.id).first() as { + id: string + name: string | null + avatar_id: string | null + } | null + + if (!apikeyResult) { + return ctx.json({ ok: false, error: 'API key not found' }, { status: 404 }) + } + + if (!apikeyResult.avatar_id) { + return ctx.json({ + ok: true, + apikey: { + id: apikeyResult.id, + name: apikeyResult.name, + avatar_id: null, + }, + avatar: null, + emojis: [], + }) + } + + const avatarResult = await db.prepare( + 'SELECT id, name, description, created_at, updated_at FROM avatars WHERE id = ?' + ).bind(apikeyResult.avatar_id).first() + + if (!avatarResult) { + return ctx.json({ + ok: true, + apikey: { + id: apikeyResult.id, + name: apikeyResult.name, + avatar_id: apikeyResult.avatar_id, + }, + avatar: null, + emojis: [], + }) + } + + const emojisResult = await db.prepare( + 'SELECT id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at FROM emojis WHERE avatar_id = ? ORDER BY sort_order ASC, created_at ASC' + ).bind(apikeyResult.avatar_id).all() + + return ctx.json({ + ok: true, + apikey: { + id: apikeyResult.id, + name: apikeyResult.name, + avatar_id: apikeyResult.avatar_id, + }, + avatar: avatarResult, + emojis: emojisResult.results || [], + }) + } catch (err) { + console.error('[avatar-plugin] getApiKeyAvatar error:', err) + return ctx.json({ ok: false, error: 'Database error' }, { status: 500 }) + } + }, + ), + }, + } +} diff --git a/src/avatar/openapi.ts b/src/avatar/openapi.ts new file mode 100644 index 0000000..a07352a --- /dev/null +++ b/src/avatar/openapi.ts @@ -0,0 +1,51 @@ +import { errorSchema, okSchema } from './schemas.js' + +function jsonContent(schema: Record) { + return { + 'application/json': { + schema, + }, + } +} + +export function unauthorizedResponse() { + return { + description: 'Unauthorized', + content: jsonContent(errorSchema), + } +} + +export function badRequestResponse(description = 'Invalid request') { + return { + description, + content: jsonContent(errorSchema), + } +} + +export function notFoundResponse(description = 'Not found') { + return { + description, + content: jsonContent(errorSchema), + } +} + +export function databaseErrorResponse() { + return { + description: 'Database error', + content: jsonContent(errorSchema), + } +} + +export function okResponse(description: string) { + return { + description, + content: jsonContent(okSchema), + } +} + +export function jsonResponse(description: string, schema: Record) { + return { + description, + content: jsonContent(schema), + } +} diff --git a/src/avatar/schemas.ts b/src/avatar/schemas.ts new file mode 100644 index 0000000..f193689 --- /dev/null +++ b/src/avatar/schemas.ts @@ -0,0 +1,59 @@ +export const AVATAR_TAG = 'Avatar' +export const EMOJI_TAG = 'Emoji' +export const APIKEY_AVATAR_TAG = 'API Key Avatar' + +export const avatarSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' }, + user_id: { type: 'string' }, + name: { type: 'string' }, + description: { type: 'string', nullable: true }, + created_at: { type: 'number' }, + updated_at: { type: 'number' }, + }, + required: ['id', 'user_id', 'name', 'description', 'created_at', 'updated_at'], +} + +export const emojiSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' }, + avatar_id: { type: 'string' }, + label: { type: 'string' }, + url: { type: 'string' }, + description: { type: 'string', nullable: true }, + trigger_type: { type: 'string' }, + trigger_result: { type: 'string', nullable: true }, + sort_order: { type: 'number' }, + created_at: { type: 'number' }, + }, + required: ['id', 'avatar_id', 'label', 'url', 'description', 'trigger_type', 'trigger_result', 'sort_order', 'created_at'], +} + +export const apikeyAvatarSchema = { + type: 'object' as const, + properties: { + id: { type: 'string' }, + name: { type: 'string', nullable: true }, + avatar_id: { type: 'string', nullable: true }, + }, + required: ['id', 'name', 'avatar_id'], +} + +export const okSchema = { + type: 'object' as const, + properties: { + ok: { type: 'boolean' }, + }, + required: ['ok'], +} + +export const errorSchema = { + type: 'object' as const, + properties: { + ok: { type: 'boolean' }, + error: { type: 'string' }, + }, + required: ['ok', 'error'], +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..15da997 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,313 @@ +/** + * Better Auth browser-side client factory. + * Used by web-chat frontend to interact with the auth API. + */ + +import { createAuthClient } from 'better-auth/client' +import { jwtClient, usernameClient } from 'better-auth/client/plugins' +import { apiKeyClient } from '@better-auth/api-key/client' + +const TOKEN_KEY = 'better_auth_token' + +export interface AvatarRecord { + id: string + user_id: string + name: string + description: string | null + created_at: number + updated_at: number +} + +export interface EmojiRecord { + id: string + avatar_id: string + label: string + url: string + description: string | null + trigger_type: string + trigger_result: 'success' | 'failure' | null + sort_order: number + created_at: number +} + +export interface AvatarDetailResponse { + ok: boolean + avatar: AvatarRecord + emojis: EmojiRecord[] +} + +export interface ApiKeyAvatarResponse { + ok: boolean + apikey: { + id: string + name: string | null + avatar_id: string | null + } + avatar: AvatarRecord | null + emojis: EmojiRecord[] +} + +export interface UploadedFileRecord { + id: string + userId: string + filename: string + originalName: string + contentType: string + size: number + s3Key: string + url: string + uploadedAt: string | Date +} + +interface ErrorResponse { + ok: false + error?: string +} + +type OkResponse = T & { ok: true } + +export interface WebRelayApiClient { + avatars: { + list: () => Promise> + get: (avatarId: string) => Promise> + create: (input: { name: string; description?: string }) => Promise> + update: ( + avatarId: string, + input: { name: string; description?: string }, + ) => Promise> + delete: (avatarId: string) => Promise> + } + emojis: { + create: ( + avatarId: string, + input: { + label: string + url: string + description?: string + trigger_type?: string + trigger_result?: 'success' | 'failure' + sort_order?: number + }, + ) => Promise> + update: ( + emojiId: string, + input: { + label?: string + url?: string + description?: string + trigger_type?: string + trigger_result?: 'success' | 'failure' | null + sort_order?: number + }, + ) => Promise> + delete: (emojiId: string) => Promise> + } + apiKeyAvatars: { + get: (apiKeyId: string) => Promise> + bind: ( + apiKeyId: string, + avatarId: string | null, + ) => Promise> + } + files: { + uploadRaw: ( + file: File, + metadata?: Record, + ) => Promise<{ success: true; data: UploadedFileRecord }> + } +} + +async function authRequest( + baseURL: string, + path: string, + init?: RequestInit, +): Promise> { + const headers = new Headers(init?.headers) + const token = getStoredToken() + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + if (init?.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(`${baseURL}${path}`, { + ...init, + headers, + credentials: 'include', + }) + + const data = await response.json() as (T & { ok?: boolean }) | ErrorResponse + + if (!response.ok || !('ok' in data) || !data.ok) { + throw new Error(('error' in data && data.error) || 'Request failed') + } + + return data as OkResponse +} + +export function createWebAuthClient(baseURL: string) { + return createAuthClient({ + baseURL, + plugins: [jwtClient(), usernameClient(), apiKeyClient()], + fetchOptions: { + credentials: 'include', + auth: { + type: 'Bearer', + token: () => getStoredToken() || ``, + }, + onSuccess: ctx => { + const authToken = ctx.response.headers.get('set-auth-token') + if (authToken) { + localStorage.setItem(TOKEN_KEY, authToken) + } + }, + onRequest: ctx => { + const token = getStoredToken() + if (token) { + ctx.headers.set('Authorization', `Bearer ${token}`) + } + }, + }, + }) +} + +export function createWebRelayApiClient(baseURL: string): WebRelayApiClient { + const encodeHeaderValue = (value: string): string => { + return encodeURIComponent(value) + } + + const uploadRaw = async ( + file: File, + metadata?: Record, + ): Promise<{ success: true; data: UploadedFileRecord }> => { + const headers = new Headers() + const token = getStoredToken() + + headers.set('x-filename', encodeHeaderValue(file.name)) + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + if (metadata && Object.keys(metadata).length > 0) { + headers.set('x-file-metadata', JSON.stringify(metadata)) + } + if (file.type) { + headers.set('Content-Type', file.type) + } + + const response = await fetch(`${baseURL}/api/auth/files/upload-raw`, { + method: 'POST', + headers, + body: file, + credentials: 'include', + }) + + const data = await response.json() as + | { success: true; data: UploadedFileRecord } + | { error?: string; message?: string } + + if (!response.ok || !('success' in data) || !data.success) { + const message = + 'error' in data + ? data.error || data.message || 'Upload failed' + : 'Upload failed' + throw new Error(message) + } + + return data + } + + return { + avatars: { + list: () => authRequest<{ avatars: AvatarRecord[] }>(baseURL, '/api/auth/avatars'), + get: (avatarId: string) => + authRequest(baseURL, `/api/auth/avatars/${avatarId}`), + create: (input: { name: string; description?: string }) => + authRequest<{ avatar: AvatarRecord }>(baseURL, '/api/auth/avatars', { + method: 'POST', + body: JSON.stringify(input), + }), + update: (avatarId: string, input: { name: string; description?: string }) => + authRequest<{ avatar: AvatarRecord }>(baseURL, `/api/auth/avatars/${avatarId}`, { + method: 'PUT', + body: JSON.stringify(input), + }), + delete: (avatarId: string) => + authRequest<{ ok: boolean }>(baseURL, `/api/auth/avatars/${avatarId}`, { + method: 'DELETE', + }), + }, + emojis: { + create: ( + avatarId: string, + input: { + label: string + url: string + description?: string + trigger_type?: string + trigger_result?: 'success' | 'failure' + sort_order?: number + }, + ) => + authRequest<{ emoji: EmojiRecord }>(baseURL, `/api/auth/avatars/${avatarId}/emojis`, { + method: 'POST', + body: JSON.stringify(input), + }), + update: ( + emojiId: string, + input: { + label?: string + url?: string + description?: string + trigger_type?: string + trigger_result?: 'success' | 'failure' | null + sort_order?: number + }, + ) => + authRequest<{ ok: boolean }>(baseURL, `/api/auth/emojis/${emojiId}`, { + method: 'PUT', + body: JSON.stringify(input), + }), + delete: (emojiId: string) => + authRequest<{ ok: boolean }>(baseURL, `/api/auth/emojis/${emojiId}`, { + method: 'DELETE', + }), + }, + apiKeyAvatars: { + get: (apiKeyId: string) => + authRequest(baseURL, `/api/auth/apikeys/${apiKeyId}/avatar`), + bind: (apiKeyId: string, avatarId: string | null) => + authRequest<{ apikey_id: string; avatar_id: string | null }>( + baseURL, + `/api/auth/apikeys/${apiKeyId}/avatar`, + { + method: 'PUT', + body: JSON.stringify({ avatar_id: avatarId }), + }, + ), + }, + files: { + uploadRaw, + }, + } +} + +export type WebAuthClient = ReturnType + +export function getStoredToken(): string | null { + try { + return localStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +export function clearStoredToken(): void { + try { + localStorage.removeItem(TOKEN_KEY) + } catch { + // ignore + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7a54e8e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +/** + * @bowong/auth — shared auth package + * + * Server-side: createAuth() factory for Better Auth instance + * Client-side: import from "@bowong/auth/client" + */ + +export { createAuth, type CreateAuthOptions, type Auth } from './auth-config.js' +export * as schema from './schema.js' +export { avatarPlugin, type AvatarPluginOptions } from './avatar/avatar.js' +export { s3, type S3Config, type S3Credentials, type S3FileMetadata } from './s3/index.js' diff --git a/src/s3/index.ts b/src/s3/index.ts new file mode 100644 index 0000000..639eb8a --- /dev/null +++ b/src/s3/index.ts @@ -0,0 +1,716 @@ +import type { AuthContext } from 'better-auth' +import type { BetterAuthPlugin } from 'better-auth' +import { createAuthEndpoint, getSessionFromCtx, sessionMiddleware } from 'better-auth/api' +import type { DBFieldAttribute } from 'better-auth/db' +import { z, type ZodType } from 'zod' +import { schema } from './schema.js' +import type { S3Config, S3Credentials, S3FileMetadata } from './types.js' + +const S3_ERROR_CODES = { + FILE_TOO_LARGE: 'File is too large. Please choose a smaller file', + INVALID_FILE_TYPE: 'File type not supported. Please choose a different file', + NO_FILE_PROVIDED: 'Please select a file to upload', + STORAGE_NOT_CONFIGURED: 'File storage is temporarily unavailable. Please try again later', + UPLOAD_FAILED: 'Upload failed. Please check your connection and try again', + FILE_ID_REQUIRED: 'File ID is required', + LIST_FILES_FAILED: 'Unable to load your files. Please refresh the page', + INVALID_METADATA: 'Invalid file information. Please try uploading again', + INVALID_FILE_RECORD: 'File information is corrupted. Please contact support', + DB_OPERATION_FAILED: 'Service temporarily unavailable. Please try again later', +} as const + +const fileIdSchema = z.object({ + fileId: z.string().min(1, 'File ID is required'), +}) + +function convertFieldAttributesToZodSchema(additionalFields: Record) { + const zodSchema: Record = {} + + for (const [key, value] of Object.entries(additionalFields)) { + let fieldSchema: ZodType + + if (value.type === 'string') { + fieldSchema = z.string() + } else if (value.type === 'number') { + fieldSchema = z.number() + } else if (value.type === 'boolean') { + fieldSchema = z.boolean() + } else if (value.type === 'date') { + fieldSchema = z.date() + } else if (value.type === 'string[]') { + fieldSchema = z.array(z.string()) + } else if (value.type === 'number[]') { + fieldSchema = z.array(z.number()) + } else { + throw new Error(`Unsupported field type: ${value.type} for field ${key}`) + } + + if (!value.required) { + fieldSchema = fieldSchema.optional() + } + + zodSchema[key] = fieldSchema + } + + return z.object(zodSchema) +} + +function createFileMetadataSchema(additionalFields?: Record) { + if (!additionalFields || Object.keys(additionalFields).length === 0) { + return z.record(z.string(), z.any()).optional() + } + return convertFieldAttributesToZodSchema(additionalFields).optional() +} + +function sanitizeFilename(filename: string): string { + return filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 255) +} + +function decodeHeaderFilename(rawFilename: string): string { + try { + return decodeURIComponent(rawFilename) + } catch { + return rawFilename + } +} + +function getFileExtension(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.') + return lastDotIndex === -1 ? '' : filename.slice(lastDotIndex + 1).toLowerCase() +} + +function getContentType(file: File): string { + if (file.type) { + return file.type + } + + const ext = getFileExtension(file.name) + switch (ext) { + case 'webm': + return 'video/webm' + case 'mp4': + return 'video/mp4' + case 'mov': + return 'video/quicktime' + case 'png': + return 'image/png' + case 'jpg': + case 'jpeg': + return 'image/jpeg' + case 'gif': + return 'image/gif' + default: + return 'application/octet-stream' + } +} + +function getMimeTypeFromExtension(extension: string): string | null { + switch (extension.toLowerCase().replace(/^\./, '')) { + case 'webm': + return 'video/webm' + case 'mp4': + return 'video/mp4' + case 'mov': + return 'video/quicktime' + case 'png': + return 'image/png' + case 'jpg': + case 'jpeg': + return 'image/jpeg' + case 'gif': + return 'image/gif' + default: + return null + } +} + +function createFileValidator(config: S3Config) { + const { + maxFileSize = 10 * 1024 * 1024, + allowedTypes, + } = config + + return { + validateFile(file: File) { + if (file.size > maxFileSize) { + const maxSizeMB = Math.round(maxFileSize / (1024 * 1024)) + throw new Error(`${S3_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`) + } + + if (allowedTypes && allowedTypes.length > 0) { + const extension = getFileExtension(file.name) + const normalizedAllowedTypes = allowedTypes.map((type: string) => + type.startsWith('.') ? type.slice(1).toLowerCase() : type.toLowerCase(), + ) + + if (!extension || !normalizedAllowedTypes.includes(extension)) { + const allowed = allowedTypes + .map((type: string) => (type.startsWith('.') ? type : `.${type}`)) + .join(', ') + throw new Error(`${S3_ERROR_CODES.INVALID_FILE_TYPE}. Supported formats: ${allowed}`) + } + } + }, + validateMetadata(metadata: Record) { + const result = createFileMetadataSchema(config.additionalFields).safeParse(metadata) + if (!result.success) { + const messages = result.error.issues + .map(issue => { + const path = issue.path.length > 0 ? `${issue.path.join('.')}: ` : '' + return `${path}${issue.message}` + }) + .join(', ') + throw new Error(`${S3_ERROR_CODES.INVALID_METADATA}: ${messages}`) + } + return result.data + }, + } +} + +function toAmzDate(date: Date): string { + return date.toISOString().replace(/[:-]|\.\d{3}/g, '') +} + +function toDateStamp(date: Date): string { + return date.toISOString().slice(0, 10).replace(/-/g, '') +} + +async function sha256Hex(data: string | ArrayBuffer): Promise { + const input = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data) + const digest = await crypto.subtle.digest('SHA-256', input) + return Array.from(new Uint8Array(digest)) + .map(byte => byte.toString(16).padStart(2, '0')) + .join('') +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return buffer +} + +async function hmacSha256(key: ArrayBuffer | Uint8Array | string, data: string): Promise { + const rawKeyBytes = + typeof key === 'string' + ? new TextEncoder().encode(key) + : key instanceof Uint8Array + ? key + : new Uint8Array(key) + const rawKey = toArrayBuffer(rawKeyBytes) + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ) + return crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)) +} + +function encodeS3PathSegment(segment: string): string { + return encodeURIComponent(segment).replace(/%2F/g, '/') +} + +function resolveEndpoint(credentials: S3Credentials): URL { + if (credentials.endpoint) { + return new URL(credentials.endpoint) + } + + return new URL(`https://${credentials.bucketName}.s3.${credentials.region}.amazonaws.com`) +} + +function resolvePublicUrl(credentials: S3Credentials, key: string): string { + const encodedKey = key.split('/').map(encodeS3PathSegment).join('/') + + if (credentials.publicBaseUrl) { + return `${credentials.publicBaseUrl.replace(/\/$/, '')}/${encodedKey}` + } + + const endpoint = resolveEndpoint(credentials) + return `${endpoint.origin}/${encodedKey}` +} + +async function createAuthorizationHeaders( + credentials: S3Credentials, + method: 'PUT' | 'DELETE', + key: string, + payload: ArrayBuffer | null, + contentType?: string, +) { + const endpoint = resolveEndpoint(credentials) + const now = new Date() + const amzDate = toAmzDate(now) + const dateStamp = toDateStamp(now) + const encodedKey = key.split('/').map(encodeS3PathSegment).join('/') + const pathname = `/${encodedKey}` + const host = endpoint.host + const payloadHash = payload ? await sha256Hex(payload) : await sha256Hex('') + + const canonicalHeaders = [ + `host:${host}`, + `x-amz-content-sha256:${payloadHash}`, + `x-amz-date:${amzDate}`, + ] + const signedHeadersParts = ['host', 'x-amz-content-sha256', 'x-amz-date'] + + if (contentType) { + canonicalHeaders.push(`content-type:${contentType}`) + signedHeadersParts.push('content-type') + } + + canonicalHeaders.sort() + signedHeadersParts.sort() + + const canonicalRequest = [ + method, + pathname, + '', + `${canonicalHeaders.join('\n')}\n`, + signedHeadersParts.join(';'), + payloadHash, + ].join('\n') + + const credentialScope = `${dateStamp}/${credentials.region}/s3/aws4_request` + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + await sha256Hex(canonicalRequest), + ].join('\n') + + const kDate = await hmacSha256(`AWS4${credentials.secretAccessKey}`, dateStamp) + const kRegion = await hmacSha256(kDate, credentials.region) + const kService = await hmacSha256(kRegion, 's3') + const kSigning = await hmacSha256(kService, 'aws4_request') + const signature = Array.from(new Uint8Array(await hmacSha256(kSigning, stringToSign))) + .map(byte => byte.toString(16).padStart(2, '0')) + .join('') + + const authorization = [ + 'AWS4-HMAC-SHA256 Credential=', + `${credentials.accessKeyId}/${credentialScope}, `, + `SignedHeaders=${signedHeadersParts.join(';')}, `, + `Signature=${signature}`, + ].join('') + + const headers = new Headers({ + Authorization: authorization, + Host: host, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + }) + + if (contentType) { + headers.set('Content-Type', contentType) + } + + return { + url: new URL(pathname, endpoint.origin), + headers, + } +} + +function validateFileMetadata(record: unknown): record is S3FileMetadata { + if (!record || typeof record !== 'object') { + return false + } + + const candidate = record as Record + return ( + typeof candidate.id === 'string' && + typeof candidate.userId === 'string' && + typeof candidate.filename === 'string' && + typeof candidate.originalName === 'string' && + typeof candidate.contentType === 'string' && + typeof candidate.s3Key === 'string' && + typeof candidate.url === 'string' && + typeof candidate.size === 'number' && + (candidate.uploadedAt instanceof Date || typeof candidate.uploadedAt === 'string') + ) +} + +function deriveStoredFileId(filename: string): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}-${sanitizeFilename(filename)}` +} + +function validateAuthContext(ctx: AuthContext): void { + if (!ctx) { + throw new Error('Auth context is not available') + } + if (!ctx.adapter) { + throw new Error('Database adapter is not properly configured') + } +} + +function createS3Storage( + config: S3Config, + generateId: (options: { model: string; size?: number }) => string | false, +) { + const validator = createFileValidator(config) + + return { + async uploadFile( + file: File, + originalName: string, + userId: string, + ctx: AuthContext, + customMetadata?: Record, + modelName = 'userFile', + ): Promise { + validator.validateFile(file) + + let validatedMetadata: Record | undefined + if (customMetadata) { + validatedMetadata = validator.validateMetadata(customMetadata) as Record + } + + const fileId = generateId({ model: modelName }) || deriveStoredFileId(originalName) + + const filename = `${fileId}-${sanitizeFilename(originalName)}` + const keyPrefix = config.credentials.keyPrefix?.replace(/^\/+|\/+$/g, '') + const s3Key = [keyPrefix, userId, filename].filter(Boolean).join('/') + const contentType = getContentType(file) + const metadata: S3FileRecord = { + id: fileId, + userId, + filename, + originalName, + contentType, + size: file.size, + s3Key, + url: resolvePublicUrl(config.credentials, s3Key), + uploadedAt: new Date(), + ...(validatedMetadata ?? {}), + } + + if (config.hooks?.upload?.before) { + const hookFile = Object.assign(file, { + userId, + s3Key, + metadata, + }) + const result = await config.hooks.upload.before(hookFile, ctx) + if (result === null) { + throw new Error('Upload prevented by beforeUpload hook') + } + } + + const body = await file.arrayBuffer() + const { url, headers } = await createAuthorizationHeaders( + config.credentials, + 'PUT', + s3Key, + body, + contentType, + ) + const response = await fetch(url, { + method: 'PUT', + headers, + body, + }) + + if (!response.ok) { + const detail = await response.text() + ctx.logger?.error?.('[S3]: Upload failed', detail) + throw new Error(`${S3_ERROR_CODES.UPLOAD_FAILED}: ${detail || response.status}`) + } + + if (config.hooks?.upload?.after) { + await config.hooks.upload.after(metadata, ctx) + } + + return metadata + }, + + async deleteFile(fileMetadata: S3FileRecord, ctx: AuthContext) { + if (config.hooks?.delete?.before) { + const result = await config.hooks.delete.before(fileMetadata, ctx) + if (result === null) { + throw new Error('Delete prevented by beforeDelete hook') + } + } + + const { url, headers } = await createAuthorizationHeaders( + config.credentials, + 'DELETE', + fileMetadata.s3Key, + null, + ) + const response = await fetch(url, { + method: 'DELETE', + headers, + }) + + if (!response.ok && response.status !== 204) { + const detail = await response.text() + throw new Error(`Failed to delete S3 object: ${detail || response.status}`) + } + + if (config.hooks?.delete?.after) { + await config.hooks.delete.after(fileMetadata, ctx) + } + }, + } +} + +function createS3Endpoints( + getS3Storage: () => ReturnType | null, + s3Config?: S3Config, +) { + const allowedMediaTypes = s3Config?.allowedTypes + ?.map((type: string) => getMimeTypeFromExtension(type)) + .filter((value): value is string => Boolean(value)) + + return { + upload: createAuthEndpoint( + '/files/upload-raw', + { + method: 'POST', + metadata: { + allowedMediaTypes, + }, + }, + async ctx => { + const session = await getSessionFromCtx(ctx) + if (!session) { + throw ctx.error('UNAUTHORIZED', { message: 'Please sign in to upload files' }) + } + + try { + validateAuthContext(ctx.context) + } catch (error) { + ctx.context.logger?.error?.('[S3]: Auth context validation failed', error) + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: S3_ERROR_CODES.DB_OPERATION_FAILED, + }) + } + + const s3Storage = getS3Storage() + if (!s3Storage || !s3Config) { + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: S3_ERROR_CODES.STORAGE_NOT_CONFIGURED, + }) + } + + const contentLength = ctx.request?.headers?.get('content-length') + const maxFileSize = s3Config.maxFileSize || 10 * 1024 * 1024 + if (contentLength && Number.parseInt(contentLength, 10) > maxFileSize) { + const maxSizeMB = Math.round(maxFileSize / (1024 * 1024)) + throw ctx.error('BAD_REQUEST', { + message: `${S3_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`, + }) + } + + const rawFilename = ctx.request?.headers?.get('x-filename') + if (!rawFilename) { + throw ctx.error('BAD_REQUEST', { message: 'x-filename header is required' }) + } + + const metadataHeader = ctx.request?.headers?.get('x-file-metadata') + let additionalFields: Record = {} + if (metadataHeader) { + try { + additionalFields = JSON.parse(metadataHeader) as Record + } catch { + throw ctx.error('BAD_REQUEST', { message: 'Invalid JSON in x-file-metadata header' }) + } + } + + const body = ctx.body + if (!body) { + throw ctx.error('BAD_REQUEST', { message: S3_ERROR_CODES.NO_FILE_PROVIDED }) + } + + const filename = decodeHeaderFilename(rawFilename) + + const file = + body instanceof File + ? body + : new File([body], filename, { + type: body.type || 'application/octet-stream', + }) + + try { + const fileMetadata = await s3Storage.uploadFile( + file, + filename, + session.session.userId, + ctx.context, + additionalFields, + ) + + await ctx.context.adapter.create({ + model: 'userFile', + data: { + userId: fileMetadata.userId, + filename: fileMetadata.filename, + originalName: fileMetadata.originalName, + contentType: fileMetadata.contentType, + size: fileMetadata.size, + s3Key: fileMetadata.s3Key, + url: fileMetadata.url, + uploadedAt: fileMetadata.uploadedAt, + ...additionalFields, + }, + }) + + return ctx.json({ + success: true, + data: fileMetadata, + }) + } catch (error) { + ctx.context.logger?.error?.('[S3]: Upload failed', error) + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: error instanceof Error ? error.message : S3_ERROR_CODES.UPLOAD_FAILED, + }) + } + }, + ), + + list: createAuthEndpoint( + '/files/list', + { + method: 'GET', + use: [sessionMiddleware], + }, + async ctx => { + const session = ctx.context.session + const limit = ctx.query?.limit ? Number.parseInt(ctx.query.limit as string, 10) : 50 + const cursor = ctx.query?.cursor as string | undefined + + if (s3Config?.hooks?.list?.before) { + const result = await s3Config.hooks.list.before(session.session.userId, ctx.context) + if (result === null) { + throw ctx.error('FORBIDDEN', { message: 'List prevented by beforeList hook' }) + } + } + + try { + const records = await ctx.context.adapter.findMany({ + model: 'userFile', + where: [{ field: 'userId', value: session.session.userId }], + limit: Math.min(limit, 100) + 1, + sortBy: { field: 'uploadedAt', direction: 'desc' }, + }) + + let fileRecords = records.filter(validateFileMetadata) + if (cursor) { + const cursorIndex = fileRecords.findIndex(file => file.id === cursor) + if (cursorIndex !== -1) { + fileRecords = fileRecords.slice(cursorIndex + 1) + } + } + + const hasMore = fileRecords.length > limit + const files = hasMore ? fileRecords.slice(0, -1) : fileRecords + const nextCursor = hasMore ? files[files.length - 1]?.id ?? null : null + + if (s3Config?.hooks?.list?.after) { + await s3Config.hooks.list.after(session.session.userId, files, ctx.context) + } + + return ctx.json({ files, nextCursor, hasMore }) + } catch (error) { + ctx.context.logger?.error?.('[S3]: Failed to list files', error) + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: S3_ERROR_CODES.LIST_FILES_FAILED, + }) + } + }, + ), + + get: createAuthEndpoint( + '/files/get', + { + method: 'POST', + use: [sessionMiddleware], + body: fileIdSchema, + }, + async ctx => { + const session = ctx.context.session + const fileRecord = await ctx.context.adapter.findOne({ + model: 'userFile', + where: [ + { field: 'id', value: ctx.body.fileId }, + { field: 'userId', value: session.session.userId }, + ], + }) + + if (!fileRecord) { + throw ctx.error('NOT_FOUND', { + message: 'File not found. It may have been deleted or you do not have permission to access it', + }) + } + + return ctx.json({ data: fileRecord }) + }, + ), + + delete: createAuthEndpoint( + '/files/delete', + { + method: 'POST', + use: [sessionMiddleware], + body: fileIdSchema, + }, + async ctx => { + const session = ctx.context.session + const s3Storage = getS3Storage() + if (!s3Storage) { + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: S3_ERROR_CODES.STORAGE_NOT_CONFIGURED, + }) + } + + const fileRecord = await ctx.context.adapter.findOne({ + model: 'userFile', + where: [ + { field: 'id', value: ctx.body.fileId }, + { field: 'userId', value: session.session.userId }, + ], + }) + + if (!fileRecord || !validateFileMetadata(fileRecord)) { + throw ctx.error('NOT_FOUND', { + message: 'File not found. It may have been deleted or you do not have permission to delete it', + }) + } + + try { + await s3Storage.deleteFile(fileRecord as S3FileRecord, ctx.context) + await ctx.context.adapter.delete({ + model: 'userFile', + where: [{ field: 'id', value: ctx.body.fileId }], + }) + + return ctx.json({ + message: 'File deleted successfully', + fileId: ctx.body.fileId, + }) + } catch (error) { + ctx.context.logger?.error?.('[S3]: Delete failed', error) + throw ctx.error('INTERNAL_SERVER_ERROR', { + message: error instanceof Error ? error.message : S3_ERROR_CODES.UPLOAD_FAILED, + }) + } + }, + ), + } +} + +export function s3(config: S3Config): BetterAuthPlugin { + let s3Storage: ReturnType | null = null + + return { + id: 's3', + schema: schema(config), + endpoints: createS3Endpoints(() => s3Storage, config), + init(initCtx) { + s3Storage = createS3Storage(config, initCtx.generateId) + return {} + }, + } +} + +export type { S3Config, S3Credentials, S3FileMetadata } from './types.js' +type S3FileRecord = S3FileMetadata & Record diff --git a/src/s3/schema.ts b/src/s3/schema.ts new file mode 100644 index 0000000..dd28304 --- /dev/null +++ b/src/s3/schema.ts @@ -0,0 +1,76 @@ +import type { BetterAuthPluginDBSchema, DBFieldAttribute } from 'better-auth/db' +import type { S3Config } from './types.js' + +const coreFileFields = { + userId: { + type: 'string', + required: true, + input: false, + references: { + model: 'user', + field: 'id', + }, + } as DBFieldAttribute, + filename: { + type: 'string', + required: true, + input: false, + } as DBFieldAttribute, + originalName: { + type: 'string', + required: true, + input: false, + } as DBFieldAttribute, + contentType: { + type: 'string', + required: true, + input: false, + } as DBFieldAttribute, + size: { + type: 'number', + required: true, + input: false, + } as DBFieldAttribute, + s3Key: { + type: 'string', + required: true, + input: false, + } as DBFieldAttribute, + url: { + type: 'string', + required: true, + input: false, + } as DBFieldAttribute, + uploadedAt: { + type: 'date', + required: true, + input: false, + } as DBFieldAttribute, +} + +function generateFileFields(additionalFields?: Record) { + const fields = { ...coreFileFields } + + if (!additionalFields) { + return fields + } + + for (const [fieldName, fieldConfig] of Object.entries(additionalFields)) { + fields[fieldName as keyof typeof fields] = fieldConfig + } + + return fields +} + +export const schema = (config?: S3Config): BetterAuthPluginDBSchema => { + const authSchema: BetterAuthPluginDBSchema = {} + + if (config) { + authSchema.userFile = { + modelName: 'user_file', + fields: generateFileFields(config.additionalFields), + } + } + + return authSchema +} diff --git a/src/s3/types.ts b/src/s3/types.ts new file mode 100644 index 0000000..5607cbf --- /dev/null +++ b/src/s3/types.ts @@ -0,0 +1,64 @@ +import type { AuthContext } from 'better-auth' +import type { DBFieldAttribute } from 'better-auth/db' + +export interface S3Credentials { + accessKeyId: string + secretAccessKey: string + bucketName: string + region: string + endpoint?: string + publicBaseUrl?: string + keyPrefix?: string +} + +export interface S3FileMetadata { + id: string + userId: string + filename: string + originalName: string + contentType: string + size: number + s3Key: string + url: string + uploadedAt: Date +} + +export interface S3Config { + credentials: S3Credentials + additionalFields?: Record + maxFileSize?: number + allowedTypes?: string[] + hooks?: { + upload?: { + before?: ( + file: File & { + userId: string + s3Key: string + metadata: S3FileMetadata & Record + }, + ctx: AuthContext, + ) => void | null | Promise + after?: ( + file: S3FileMetadata & Record, + ctx: AuthContext, + ) => void | Promise + } + delete?: { + before?: ( + file: S3FileMetadata & Record, + ctx: AuthContext, + ) => void | null | Promise + after?: ( + file: S3FileMetadata & Record, + ctx: AuthContext, + ) => void | Promise + } + list?: { + before?: ( + userId: string, + ctx: AuthContext, + ) => void | null | Promise + after?: (userId: string, files: unknown[], ctx: AuthContext) => void | Promise + } + } +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..1e20a4f --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,128 @@ +/** + * Drizzle SQLite schema for Better Auth tables. + * These tables are created/managed by Better Auth but defined here + * for type-safe queries (e.g. relay D1 validation). + */ + +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' + +export const user = sqliteTable('user', { + id: text('id').primaryKey(), + name: text('name'), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }) + .notNull() + .default(false), + image: text('image'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const avatars = sqliteTable('avatars', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const emojis = sqliteTable('emojis', { + id: text('id').primaryKey(), + avatarId: text('avatar_id') + .notNull() + .references(() => avatars.id, { onDelete: 'cascade' }), + label: text('label').notNull(), + url: text('url').notNull(), + description: text('description'), + sortOrder: integer('sort_order').default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), +}) + +export const session = sqliteTable('session', { + id: text('id').primaryKey(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + token: text('token').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id), +}) + +export const account = sqliteTable('account', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { + mode: 'timestamp', + }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { + mode: 'timestamp', + }), + scope: text('scope'), + password: text('password'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const verification = sqliteTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }), + updatedAt: integer('updated_at', { mode: 'timestamp' }), +}) + +export const apikey = sqliteTable('apikey', { + id: text('id').primaryKey(), + configId: text('config_id'), + name: text('name'), + start: text('start'), + prefix: text('prefix'), + key: text('key').notNull(), + referenceId: text('reference_id'), + enabled: integer('enabled', { mode: 'boolean' }).default(true), + rateLimitEnabled: integer('rate_limit_enabled', { mode: 'boolean' }).default(true), + rateLimitTimeWindow: integer('rate_limit_time_window'), + rateLimitMax: integer('rate_limit_max'), + requestCount: integer('request_count').default(0), + remaining: integer('remaining'), + refillInterval: integer('refill_interval'), + refillAmount: integer('refill_amount'), + lastRefillAt: integer('last_refill_at', { mode: 'timestamp' }), + lastRequest: integer('last_request', { mode: 'timestamp' }), + permissions: text('permissions'), + metadata: text('metadata'), + expiresAt: integer('expires_at', { mode: 'timestamp' }), + avatarId: text('avatar_id').references(() => avatars.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}) + +export const userFile = sqliteTable('user_file', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + filename: text('filename').notNull(), + originalName: text('original_name').notNull(), + contentType: text('content_type').notNull(), + size: integer('size').notNull(), + s3Key: text('s3_key').notNull(), + url: text('url').notNull(), + uploadedAt: integer('uploaded_at', { mode: 'timestamp' }).notNull(), +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9ef36ee --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4efdd53 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/client.ts"] +}