feat: import auth package from monorepo
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/auth-config.ts
Normal file
89
src/auth-config.ts
Normal file
@@ -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<typeof createAuth>
|
||||||
910
src/avatar/avatar.ts
Normal file
910
src/avatar/avatar.ts
Normal file
@@ -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<void> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/avatar/openapi.ts
Normal file
51
src/avatar/openapi.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { errorSchema, okSchema } from './schemas.js'
|
||||||
|
|
||||||
|
function jsonContent(schema: Record<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: jsonContent(schema),
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/avatar/schemas.ts
Normal file
59
src/avatar/schemas.ts
Normal file
@@ -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'],
|
||||||
|
}
|
||||||
313
src/client.ts
Normal file
313
src/client.ts
Normal file
@@ -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 extends object> = T & { ok: true }
|
||||||
|
|
||||||
|
export interface WebRelayApiClient {
|
||||||
|
avatars: {
|
||||||
|
list: () => Promise<OkResponse<{ avatars: AvatarRecord[] }>>
|
||||||
|
get: (avatarId: string) => Promise<OkResponse<AvatarDetailResponse>>
|
||||||
|
create: (input: { name: string; description?: string }) => Promise<OkResponse<{ avatar: AvatarRecord }>>
|
||||||
|
update: (
|
||||||
|
avatarId: string,
|
||||||
|
input: { name: string; description?: string },
|
||||||
|
) => Promise<OkResponse<{ avatar: AvatarRecord }>>
|
||||||
|
delete: (avatarId: string) => Promise<OkResponse<{ ok: boolean }>>
|
||||||
|
}
|
||||||
|
emojis: {
|
||||||
|
create: (
|
||||||
|
avatarId: string,
|
||||||
|
input: {
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
description?: string
|
||||||
|
trigger_type?: string
|
||||||
|
trigger_result?: 'success' | 'failure'
|
||||||
|
sort_order?: number
|
||||||
|
},
|
||||||
|
) => Promise<OkResponse<{ emoji: EmojiRecord }>>
|
||||||
|
update: (
|
||||||
|
emojiId: string,
|
||||||
|
input: {
|
||||||
|
label?: string
|
||||||
|
url?: string
|
||||||
|
description?: string
|
||||||
|
trigger_type?: string
|
||||||
|
trigger_result?: 'success' | 'failure' | null
|
||||||
|
sort_order?: number
|
||||||
|
},
|
||||||
|
) => Promise<OkResponse<{ ok: boolean }>>
|
||||||
|
delete: (emojiId: string) => Promise<OkResponse<{ ok: boolean }>>
|
||||||
|
}
|
||||||
|
apiKeyAvatars: {
|
||||||
|
get: (apiKeyId: string) => Promise<OkResponse<ApiKeyAvatarResponse>>
|
||||||
|
bind: (
|
||||||
|
apiKeyId: string,
|
||||||
|
avatarId: string | null,
|
||||||
|
) => Promise<OkResponse<{ apikey_id: string; avatar_id: string | null }>>
|
||||||
|
}
|
||||||
|
files: {
|
||||||
|
uploadRaw: (
|
||||||
|
file: File,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
) => Promise<{ success: true; data: UploadedFileRecord }>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authRequest<T extends object>(
|
||||||
|
baseURL: string,
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<OkResponse<T>> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<AvatarDetailResponse>(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<ApiKeyAvatarResponse>(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<typeof createWebAuthClient>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/index.ts
Normal file
11
src/index.ts
Normal file
@@ -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'
|
||||||
716
src/s3/index.ts
Normal file
716
src/s3/index.ts
Normal file
@@ -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<string, DBFieldAttribute>) {
|
||||||
|
const zodSchema: Record<string, ZodType> = {}
|
||||||
|
|
||||||
|
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<string, DBFieldAttribute>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<string> {
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>,
|
||||||
|
modelName = 'userFile',
|
||||||
|
): Promise<S3FileRecord> {
|
||||||
|
validator.validateFile(file)
|
||||||
|
|
||||||
|
let validatedMetadata: Record<string, unknown> | undefined
|
||||||
|
if (customMetadata) {
|
||||||
|
validatedMetadata = validator.validateMetadata(customMetadata) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<typeof createS3Storage> | 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<string, unknown> = {}
|
||||||
|
if (metadataHeader) {
|
||||||
|
try {
|
||||||
|
additionalFields = JSON.parse(metadataHeader) as Record<string, unknown>
|
||||||
|
} 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<typeof createS3Storage> | 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<string, unknown>
|
||||||
76
src/s3/schema.ts
Normal file
76
src/s3/schema.ts
Normal file
@@ -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<string, DBFieldAttribute>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
64
src/s3/types.ts
Normal file
64
src/s3/types.ts
Normal file
@@ -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<string, DBFieldAttribute>
|
||||||
|
maxFileSize?: number
|
||||||
|
allowedTypes?: string[]
|
||||||
|
hooks?: {
|
||||||
|
upload?: {
|
||||||
|
before?: (
|
||||||
|
file: File & {
|
||||||
|
userId: string
|
||||||
|
s3Key: string
|
||||||
|
metadata: S3FileMetadata & Record<string, unknown>
|
||||||
|
},
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => void | null | Promise<void | null | undefined>
|
||||||
|
after?: (
|
||||||
|
file: S3FileMetadata & Record<string, unknown>,
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => void | Promise<void>
|
||||||
|
}
|
||||||
|
delete?: {
|
||||||
|
before?: (
|
||||||
|
file: S3FileMetadata & Record<string, unknown>,
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => void | null | Promise<void | null | undefined>
|
||||||
|
after?: (
|
||||||
|
file: S3FileMetadata & Record<string, unknown>,
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => void | Promise<void>
|
||||||
|
}
|
||||||
|
list?: {
|
||||||
|
before?: (
|
||||||
|
userId: string,
|
||||||
|
ctx: AuthContext,
|
||||||
|
) => void | null | Promise<void | null | undefined>
|
||||||
|
after?: (userId: string, files: unknown[], ctx: AuthContext) => void | Promise<void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/schema.ts
Normal file
128
src/schema.ts
Normal file
@@ -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(),
|
||||||
|
})
|
||||||
14
tsconfig.build.json
Normal file
14
tsconfig.build.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user