import { readFile, stat } from 'node:fs/promises' import path from 'node:path' import { Hono, type Context } from 'hono' import { bodyLimit } from 'hono/body-limit' import { deleteCookie, getCookie, setCookie } from 'hono/cookie' import { LoginLimiter, SESSION_COOKIE, isSameOrigin, type SessionManager } from './auth.ts' import type { Collab } from './collab.ts' import type { ServerConfig } from './config.ts' import type { ImageStore } from './images.ts' interface AppDeps { config: ServerConfig sessions: SessionManager collab: Collab images: ImageStore } const EMOJI_MAX = 8 const NAME_MAX = 30 export function createApp({ config, sessions, collab, images }: AppDeps) { const app = new Hono() const limiter = new LoginLimiter() const isHttps = (c: Context) => c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() === 'https' || new URL(c.req.url).protocol === 'https:' const writeSession = (c: Context) => { setCookie(c, SESSION_COOKIE, sessions.issue(), { httpOnly: true, sameSite: 'Lax', secure: isHttps(c), path: '/', maxAge: Math.floor(sessions.maxAgeMs / 1000), }) } app.get('/healthz', (c) => c.text('ok')) // 跨站请求一律拒绝 app.use('/api/*', async (c, next) => { if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { const allowed = isSameOrigin(c.req.header('origin'), [ c.req.header('x-forwarded-host'), c.req.header('host'), ]) if (!allowed) return c.json({ error: '请求来源无效' }, 403) } await next() }) // ---------------------------------------------------------------- 登录 app.post('/api/login', async (c) => { const ip = clientIp(c) if (limiter.isBlocked(ip)) { return c.json({ error: '尝试次数过多,请 15 分钟后再试' }, 429) } const body = await c.req.json().catch(() => ({})) const password = (body as { password?: unknown }).password if (!sessions.checkPassword(password)) { limiter.fail(ip) // 稍作停顿,进一步拖慢暴力猜测 await new Promise((resolve) => setTimeout(resolve, 400)) return c.json({ error: '口令不正确' }, 401) } limiter.reset(ip) writeSession(c) return c.json({ ok: true }) }) app.post('/api/logout', (c) => { deleteCookie(c, SESSION_COOKIE, { path: '/' }) return c.json({ ok: true }) }) app.get('/api/session', (c) => { const session = sessions.verify(getCookie(c, SESSION_COOKIE)) if (session && sessions.shouldRenew(session)) writeSession(c) return c.json({ authenticated: Boolean(session) }) }) // 以下接口都需要登录 app.use('/api/*', async (c, next) => { if (!sessions.verify(getCookie(c, SESSION_COOKIE))) { return c.json({ error: '请先登录' }, 401) } await next() }) // ---------------------------------------------------------------- 空间 app.post('/api/spaces', async (c) => { const input = readSpaceInput(await c.req.json().catch(() => ({}))) if (!input.name) return c.json({ error: '请填写空间名称' }, 400) const space = await collab.createSpace(input.name, input.emoji ?? '✦') return c.json(space, 201) }) app.patch('/api/spaces/:id', async (c) => { const input = readSpaceInput(await c.req.json().catch(() => ({}))) if (input.name === '') return c.json({ error: '请填写空间名称' }, 400) const space = await collab.updateSpace(c.req.param('id'), input) return space ? c.json(space) : c.json({ error: '空间不存在' }, 404) }) app.delete('/api/spaces/:id', async (c) => { if (collab.listSpaces().length <= 1) return c.json({ error: '至少保留一个空间' }, 400) const deleted = await collab.deleteSpace(c.req.param('id')) return deleted ? c.json({ ok: true }) : c.json({ error: '空间不存在' }, 404) }) // ---------------------------------------------------------------- 历史版本 app.get('/api/spaces/:id/versions', (c) => { const id = c.req.param('id') if (!collab.spaceExists(id)) return c.json({ error: '空间不存在' }, 404) return c.json(collab.listSnapshots(id)) }) app.post('/api/spaces/:id/versions', (c) => { const id = c.req.param('id') if (!collab.spaceExists(id)) return c.json({ error: '空间不存在' }, 404) const snapshotId = collab.saveSnapshot(id, 'manual') return snapshotId === null ? c.json({ error: '空间还没有内容' }, 400) : c.json({ id: snapshotId }) }) app.post('/api/spaces/:id/versions/:versionId/restore', async (c) => { const id = c.req.param('id') const versionId = Number(c.req.param('versionId')) if (!collab.spaceExists(id) || !Number.isInteger(versionId)) { return c.json({ error: '版本不存在' }, 404) } const restored = await collab.restoreSnapshot(id, versionId) return restored ? c.json({ ok: true }) : c.json({ error: '版本不存在' }, 404) }) // ---------------------------------------------------------------- 搜索 app.get('/api/search', (c) => { const query = (c.req.query('q') ?? '').slice(0, 100) return c.json(collab.search(query)) }) // ---------------------------------------------------------------- 图片 app.post( '/api/images', bodyLimit({ maxSize: config.maxImageBytes, onError: (c) => c.json({ error: `图片不能超过 ${Math.round(config.maxImageBytes / 1048576)}MB` }, 413), }), async (c) => { const bytes = new Uint8Array(await c.req.arrayBuffer()) const name = await images.save(bytes) if (!name) return c.json({ error: '不支持的图片格式' }, 415) return c.json({ url: `/api/images/${name}` }, 201) }, ) app.get('/api/images/:name', async (c) => { const image = await images.read(c.req.param('name')) if (!image) return c.json({ error: '图片不存在' }, 404) return c.body(new Uint8Array(image.data), 200, { 'Content-Type': image.type, 'Cache-Control': 'private, max-age=31536000, immutable', // SVG 里可能带脚本,直接打开时也只当作图片 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", 'X-Content-Type-Options': 'nosniff', }) }) app.all('/api/*', (c) => c.json({ error: '接口不存在' }, 404)) // ---------------------------------------------------------------- 前端页面 if (config.staticDir) { const root = config.staticDir app.get('*', async (c) => { const pathname = decodeURIComponent(new URL(c.req.url).pathname) const file = await resolveStatic(root, pathname) if (file) { return c.body(new Uint8Array(await readFile(file)), 200, { 'Content-Type': contentType(file), 'Cache-Control': cacheControl(pathname), }) } // 前端路由:未知路径都返回 index.html if (path.extname(pathname)) return c.notFound() return c.body(new Uint8Array(await readFile(path.join(root, 'index.html'))), 200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache', }) }) } return app } function readSpaceInput(body: unknown): { name?: string; emoji?: string } { const input = (body ?? {}) as { name?: unknown; emoji?: unknown } return { name: typeof input.name === 'string' ? input.name.trim().slice(0, NAME_MAX) : undefined, emoji: typeof input.emoji === 'string' && input.emoji.trim() ? [...input.emoji.trim()].slice(0, EMOJI_MAX).join('') : undefined, } } function clientIp(c: Context) { return ( c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || c.req.header('x-real-ip') || 'local' ) } async function resolveStatic(root: string, pathname: string) { const file = path.resolve(root, `.${pathname}`) if (file !== root && !file.startsWith(root + path.sep)) return null // 目录(例如 /painterly/)返回其中的 index.html for (const candidate of [file, path.join(file, 'index.html')]) { try { if ((await stat(candidate)).isFile()) return candidate } catch { // 继续尝试下一个 } } return null } const MIME: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', } function contentType(file: string) { return MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream' } function cacheControl(pathname: string) { if (pathname.startsWith('/assets/')) return 'public, max-age=31536000, immutable' if (pathname.startsWith('/painterly/vendor/')) return 'public, max-age=2592000' if (pathname.startsWith('/painterly/')) return 'public, max-age=86400' return 'no-cache' }