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