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