Files
boundless/server/app.ts
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

262 lines
8.9 KiB
TypeScript

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<string, string> = {
'.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'
}