自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import { LoginLimiter, SessionManager, isSameOrigin, readCookie } from '../auth.ts'
|
||||
import { Storage } from '../db.ts'
|
||||
import { detectImageType } from '../images.ts'
|
||||
|
||||
describe('SessionManager', () => {
|
||||
const sessions = new SessionManager('secret', 'password', 30)
|
||||
|
||||
it('签发的令牌可以通过校验', () => {
|
||||
expect(sessions.verify(sessions.issue())).not.toBeNull()
|
||||
})
|
||||
|
||||
it('拒绝被篡改或过期的令牌', () => {
|
||||
const token = sessions.issue()
|
||||
const [body, signature] = token.split('.')
|
||||
expect(sessions.verify(`${body}x.${signature}`)).toBeNull()
|
||||
expect(sessions.verify(`${body}.${signature}x`)).toBeNull()
|
||||
expect(sessions.verify('garbage')).toBeNull()
|
||||
expect(sessions.verify(sessions.issue(Date.now() - 31 * 86_400_000))).toBeNull()
|
||||
})
|
||||
|
||||
it('修改口令后旧令牌失效', () => {
|
||||
const token = sessions.issue()
|
||||
expect(new SessionManager('secret', 'new-password', 30).verify(token)).toBeNull()
|
||||
})
|
||||
|
||||
it('校验口令', () => {
|
||||
expect(sessions.checkPassword('password')).toBe(true)
|
||||
expect(sessions.checkPassword('Password')).toBe(false)
|
||||
expect(sessions.checkPassword(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('有效期过半时提示续期', () => {
|
||||
const now = Date.now()
|
||||
expect(sessions.shouldRenew(sessions.verify(sessions.issue(now))!, now)).toBe(false)
|
||||
const old = sessions.verify(sessions.issue(now - 20 * 86_400_000), now)!
|
||||
expect(sessions.shouldRenew(old, now)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求辅助函数', () => {
|
||||
it('解析 Cookie', () => {
|
||||
expect(readCookie('a=1; boundless_session=abc%3D; b=2', 'boundless_session')).toBe('abc=')
|
||||
expect(readCookie(undefined, 'x')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('只接受同源请求,并识别反向代理转发的 Host', () => {
|
||||
expect(isSameOrigin(undefined, ['example.com'])).toBe(true)
|
||||
expect(isSameOrigin('https://example.com', ['example.com'])).toBe(true)
|
||||
expect(isSameOrigin('https://evil.com', ['example.com'])).toBe(false)
|
||||
expect(isSameOrigin('https://board.me', ['board.me', 'localhost:3000'])).toBe(true)
|
||||
})
|
||||
|
||||
it('登录失败过多时暂时锁定', () => {
|
||||
const limiter = new LoginLimiter(3, 1000)
|
||||
limiter.fail('ip', 0)
|
||||
limiter.fail('ip', 10)
|
||||
expect(limiter.isBlocked('ip', 20)).toBe(false)
|
||||
limiter.fail('ip', 30)
|
||||
expect(limiter.isBlocked('ip', 40)).toBe(true)
|
||||
expect(limiter.isBlocked('ip', 2000)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectImageType', () => {
|
||||
const bytes = (...values: Array<number | string>) =>
|
||||
new Uint8Array(
|
||||
values.flatMap((value) =>
|
||||
typeof value === 'string' ? [...value].map((char) => char.charCodeAt(0)) : [value],
|
||||
),
|
||||
)
|
||||
|
||||
it('根据文件头识别格式', () => {
|
||||
expect(detectImageType(bytes(0x89, 'PNG\r\n', 0x1a, '\n', 0, 0, 0, 0))).toBe('png')
|
||||
expect(detectImageType(bytes(0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0))).toBe('jpg')
|
||||
expect(detectImageType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))).toBe('webp')
|
||||
expect(detectImageType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe('svg')
|
||||
})
|
||||
|
||||
it('拒绝非图片文件', () => {
|
||||
expect(detectImageType(bytes('<!doctype html><script>'))).toBeNull()
|
||||
expect(detectImageType(bytes('MZ', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage', () => {
|
||||
let dir = ''
|
||||
let storage: Storage | null = null
|
||||
|
||||
afterEach(() => {
|
||||
storage?.close()
|
||||
storage = null
|
||||
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function open() {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'boundless-'))
|
||||
storage = new Storage(path.join(dir, 'test.sqlite'))
|
||||
return storage
|
||||
}
|
||||
|
||||
it('保存与读取文档', () => {
|
||||
const db = open()
|
||||
const doc = new Y.Doc()
|
||||
doc.getMap('cards').set('a', 1)
|
||||
db.saveDocument('board:x', Y.encodeStateAsUpdate(doc))
|
||||
const loaded = new Y.Doc()
|
||||
Y.applyUpdate(loaded, db.loadDocument('board:x')!)
|
||||
expect(loaded.getMap('cards').get('a')).toBe(1)
|
||||
db.deleteDocument('board:x')
|
||||
expect(db.loadDocument('board:x')).toBeNull()
|
||||
})
|
||||
|
||||
it('历史版本按时间倒序列出', () => {
|
||||
const db = open()
|
||||
const input = {
|
||||
spaceId: 's',
|
||||
data: new Uint8Array([1]),
|
||||
stateVector: new Uint8Array([2]),
|
||||
cardCount: 1,
|
||||
preview: '预览',
|
||||
}
|
||||
const first = db.addSnapshot({ ...input, reason: 'auto' })
|
||||
const second = db.addSnapshot({ ...input, reason: 'manual' })
|
||||
expect(db.listSnapshots('s').map((item) => item.id)).toEqual([second, first])
|
||||
expect(db.loadSnapshot('s', first)).toEqual(new Uint8Array([1]))
|
||||
expect(db.loadSnapshot('other', first)).toBeNull()
|
||||
db.deleteSnapshots('s')
|
||||
expect(db.listSnapshots('s')).toEqual([])
|
||||
})
|
||||
})
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
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'
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
export const SESSION_COOKIE = 'boundless_session'
|
||||
|
||||
interface SessionPayload {
|
||||
/** 签发时间(毫秒) */
|
||||
iat: number
|
||||
/** 过期时间(毫秒) */
|
||||
exp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话令牌:`base64url(payload).签名`。
|
||||
* 签名密钥由服务端密钥和登录口令共同派生,所以修改口令后所有设备都会退出登录。
|
||||
*/
|
||||
export class SessionManager {
|
||||
private readonly key: Buffer
|
||||
private readonly passwordHash: Buffer
|
||||
readonly maxAgeMs: number
|
||||
|
||||
constructor(secret: string, password: string, sessionDays: number) {
|
||||
this.key = createHmac('sha256', secret).update(`session:${password}`).digest()
|
||||
this.passwordHash = sha256(password)
|
||||
this.maxAgeMs = sessionDays * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
checkPassword(candidate: unknown) {
|
||||
if (typeof candidate !== 'string' || candidate.length > 1024) return false
|
||||
return timingSafeEqual(sha256(candidate), this.passwordHash)
|
||||
}
|
||||
|
||||
issue(now = Date.now()) {
|
||||
const payload: SessionPayload = { iat: now, exp: now + this.maxAgeMs }
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${body}.${this.sign(body)}`
|
||||
}
|
||||
|
||||
verify(token: string | undefined | null, now = Date.now()): SessionPayload | null {
|
||||
if (!token) return null
|
||||
const [body, signature, extra] = token.split('.')
|
||||
if (!body || !signature || extra !== undefined) return null
|
||||
|
||||
const expected = Buffer.from(this.sign(body))
|
||||
const actual = Buffer.from(signature)
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
||||
if (typeof payload.exp !== 'number' || payload.exp <= now) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 已用过一半有效期的令牌需要续期,常用设备就不会突然被登出。 */
|
||||
shouldRenew(payload: SessionPayload, now = Date.now()) {
|
||||
return payload.exp - now < this.maxAgeMs / 2
|
||||
}
|
||||
|
||||
private sign(body: string) {
|
||||
return createHmac('sha256', this.key).update(body).digest('base64url')
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(value: string) {
|
||||
return createHash('sha256').update(value).digest()
|
||||
}
|
||||
|
||||
export function readCookie(header: string | null | undefined, name: string) {
|
||||
if (!header) return undefined
|
||||
for (const part of header.split(';')) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) continue
|
||||
if (part.slice(0, index).trim() === name) {
|
||||
try {
|
||||
return decodeURIComponent(part.slice(index + 1).trim())
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录失败限流:同一来源 15 分钟内最多失败 10 次。
|
||||
* 只有一个人使用,内存记录就够了。
|
||||
*/
|
||||
export class LoginLimiter {
|
||||
private readonly failures = new Map<string, number[]>()
|
||||
private readonly maxFailures: number
|
||||
private readonly windowMs: number
|
||||
|
||||
constructor(maxFailures = 10, windowMs = 15 * 60 * 1000) {
|
||||
this.maxFailures = maxFailures
|
||||
this.windowMs = windowMs
|
||||
}
|
||||
|
||||
isBlocked(key: string, now = Date.now()) {
|
||||
return this.recent(key, now).length >= this.maxFailures
|
||||
}
|
||||
|
||||
fail(key: string, now = Date.now()) {
|
||||
const list = this.recent(key, now)
|
||||
list.push(now)
|
||||
this.failures.set(key, list)
|
||||
}
|
||||
|
||||
reset(key: string) {
|
||||
this.failures.delete(key)
|
||||
}
|
||||
|
||||
private recent(key: string, now: number) {
|
||||
const list = (this.failures.get(key) ?? []).filter((time) => now - time < this.windowMs)
|
||||
if (list.length === 0) this.failures.delete(key)
|
||||
else this.failures.set(key, list)
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求来源与当前站点一致,阻止其他网站借用登录状态发起请求或建立 WebSocket。
|
||||
* 没有 Origin 头(例如服务端脚本、curl)时放行,因为它们本来就带不上浏览器 Cookie。
|
||||
* 反向代理可能改写 Host,所以同时接受 X-Forwarded-Host。
|
||||
*/
|
||||
export function isSameOrigin(
|
||||
origin: string | null | undefined,
|
||||
hosts: Array<string | null | undefined>,
|
||||
) {
|
||||
if (!origin) return true
|
||||
let originHost: string
|
||||
try {
|
||||
originHost = new URL(origin).host.toLowerCase()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return hosts.some((host) =>
|
||||
host
|
||||
?.split(',')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.includes(originHost),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { Database } from '@hocuspocus/extension-database'
|
||||
import { Hocuspocus } from '@hocuspocus/server'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import {
|
||||
META_DOC,
|
||||
boardCards,
|
||||
boardDocName,
|
||||
createCardMap,
|
||||
docFromUpdate,
|
||||
metaSpaces,
|
||||
readCards,
|
||||
readSpace,
|
||||
readSpaces,
|
||||
replaceBoardContent,
|
||||
spaceIdFromDocName,
|
||||
type CardFields,
|
||||
type SpaceInfo,
|
||||
} from '../shared/board.ts'
|
||||
import { SESSION_COOKIE, readCookie, type SessionManager } from './auth.ts'
|
||||
import type { SnapshotReason, Storage } from './db.ts'
|
||||
|
||||
/** 自动历史版本的最小间隔 */
|
||||
const AUTO_SNAPSHOT_INTERVAL = 10 * 60 * 1000
|
||||
|
||||
export interface SearchHit {
|
||||
spaceId: string
|
||||
cardId: string
|
||||
title: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
type DirectConnection = Awaited<ReturnType<Hocuspocus['openDirectConnection']>>
|
||||
|
||||
export class Collab {
|
||||
readonly hocuspocus: Hocuspocus
|
||||
private readonly storage: Storage
|
||||
private readonly sessions: SessionManager
|
||||
private meta: DirectConnection | null = null
|
||||
|
||||
constructor(storage: Storage, sessions: SessionManager) {
|
||||
this.storage = storage
|
||||
this.sessions = sessions
|
||||
this.hocuspocus = new Hocuspocus({
|
||||
name: 'boundless',
|
||||
quiet: true,
|
||||
debounce: 1000,
|
||||
maxDebounce: 5000,
|
||||
extensions: [
|
||||
new Database({
|
||||
fetch: async ({ documentName }) => this.storage.loadDocument(documentName),
|
||||
store: async ({ documentName, state, document }) => {
|
||||
this.persist(documentName, state, document)
|
||||
},
|
||||
}),
|
||||
],
|
||||
onAuthenticate: async ({ requestHeaders, documentName, connectionConfig }) => {
|
||||
const token = readCookie(requestHeaders.get('cookie'), SESSION_COOKIE)
|
||||
if (!this.sessions.verify(token)) throw rejection('unauthorized')
|
||||
|
||||
if (documentName === META_DOC) {
|
||||
// 空间列表只能由服务端修改
|
||||
connectionConfig.readOnly = true
|
||||
return
|
||||
}
|
||||
const spaceId = spaceIdFromDocName(documentName)
|
||||
if (!spaceId || !this.spaceExists(spaceId)) throw rejection('not-found')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.meta = await this.hocuspocus.openDirectConnection(META_DOC, { system: true })
|
||||
if (metaSpaces(this.metaDoc).size === 0) await this.seed()
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.meta?.disconnect()
|
||||
this.meta = null
|
||||
}
|
||||
|
||||
private get metaDoc(): Y.Doc {
|
||||
const doc = this.meta?.document
|
||||
if (!doc) throw new Error('collab not started')
|
||||
return doc
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 空间
|
||||
|
||||
spaceExists(spaceId: string) {
|
||||
return metaSpaces(this.metaDoc).has(spaceId)
|
||||
}
|
||||
|
||||
listSpaces(): SpaceInfo[] {
|
||||
return readSpaces(this.metaDoc)
|
||||
}
|
||||
|
||||
async createSpace(name: string, emoji: string): Promise<SpaceInfo> {
|
||||
const id = randomUUID()
|
||||
const now = Date.now()
|
||||
await this.meta!.transact((doc) => {
|
||||
const map = new Y.Map<unknown>()
|
||||
map.set('name', name)
|
||||
map.set('emoji', emoji)
|
||||
map.set('createdAt', now)
|
||||
map.set('updatedAt', now)
|
||||
map.set('cardCount', 0)
|
||||
metaSpaces(doc).set(id, map)
|
||||
})
|
||||
return readSpace(id, metaSpaces(this.metaDoc).get(id)!)
|
||||
}
|
||||
|
||||
async updateSpace(spaceId: string, patch: { name?: string; emoji?: string }) {
|
||||
const map = metaSpaces(this.metaDoc).get(spaceId)
|
||||
if (!map) return null
|
||||
await this.meta!.transact(() => {
|
||||
if (patch.name !== undefined) map.set('name', patch.name)
|
||||
if (patch.emoji !== undefined) map.set('emoji', patch.emoji)
|
||||
map.set('updatedAt', Date.now())
|
||||
})
|
||||
return readSpace(spaceId, map)
|
||||
}
|
||||
|
||||
async deleteSpace(spaceId: string) {
|
||||
if (!this.spaceExists(spaceId)) return false
|
||||
await this.meta!.transact((doc) => {
|
||||
metaSpaces(doc).delete(spaceId)
|
||||
})
|
||||
const name = boardDocName(spaceId)
|
||||
// 断开仍打开这个空间的设备;persist() 会忽略已删除空间的延迟写入
|
||||
this.hocuspocus.closeConnections(name)
|
||||
this.storage.deleteDocument(name)
|
||||
this.storage.deleteSnapshots(spaceId)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 持久化
|
||||
|
||||
private persist(documentName: string, state: Uint8Array, document: Y.Doc) {
|
||||
const spaceId = spaceIdFromDocName(documentName)
|
||||
if (spaceId) {
|
||||
if (!this.spaceExists(spaceId)) return
|
||||
this.autoSnapshot(spaceId, documentName)
|
||||
}
|
||||
this.storage.saveDocument(documentName, state)
|
||||
if (spaceId) this.updateCardCount(spaceId, boardCards(document).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入新状态前,把上一次保存的状态存成历史版本。
|
||||
* 保存的是“改动之前”的样子,误删一大片之后也能找回来。
|
||||
*/
|
||||
private autoSnapshot(spaceId: string, documentName: string) {
|
||||
const latest = this.storage.latestSnapshot(spaceId)
|
||||
if (latest && Date.now() - latest.createdAt < AUTO_SNAPSHOT_INTERVAL) return
|
||||
const previous = this.storage.loadDocument(documentName)
|
||||
if (!previous) return
|
||||
this.snapshotFromDoc(spaceId, docFromUpdate(previous), 'auto', latest?.stateVector)
|
||||
}
|
||||
|
||||
private snapshotFromDoc(
|
||||
spaceId: string,
|
||||
doc: Y.Doc,
|
||||
reason: SnapshotReason,
|
||||
skipIfStateVector?: Uint8Array,
|
||||
) {
|
||||
const stateVector = Y.encodeStateVector(doc)
|
||||
if (skipIfStateVector && sameBytes(stateVector, skipIfStateVector)) return null
|
||||
const cards = readCards(doc)
|
||||
const preview = cards
|
||||
.map((card) => card.title.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join('、')
|
||||
return this.storage.addSnapshot({
|
||||
spaceId,
|
||||
data: Y.encodeStateAsUpdate(doc),
|
||||
stateVector,
|
||||
cardCount: cards.length,
|
||||
preview,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
private updateCardCount(spaceId: string, count: number) {
|
||||
const map = metaSpaces(this.metaDoc).get(spaceId)
|
||||
if (!map || map.get('cardCount') === count) return
|
||||
void this.meta!.transact(() => {
|
||||
map.set('cardCount', count)
|
||||
})
|
||||
}
|
||||
|
||||
/** 读取空间画布:已在内存中的用实时文档,否则从数据库加载一份只读副本。 */
|
||||
private boardDoc(spaceId: string): Y.Doc | null {
|
||||
const name = boardDocName(spaceId)
|
||||
const live = this.hocuspocus.documents.get(name)
|
||||
if (live) return live
|
||||
const saved = this.storage.loadDocument(name)
|
||||
return saved ? docFromUpdate(saved) : null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 历史版本
|
||||
|
||||
listSnapshots(spaceId: string) {
|
||||
return this.storage.listSnapshots(spaceId)
|
||||
}
|
||||
|
||||
saveSnapshot(spaceId: string, reason: SnapshotReason = 'manual') {
|
||||
const doc = this.boardDoc(spaceId)
|
||||
if (!doc) return null
|
||||
return this.snapshotFromDoc(spaceId, doc, reason)
|
||||
}
|
||||
|
||||
async restoreSnapshot(spaceId: string, snapshotId: number) {
|
||||
const data = this.storage.loadSnapshot(spaceId, snapshotId)
|
||||
if (!data) return false
|
||||
// 先备份当前状态,恢复操作本身也可以撤回
|
||||
this.saveSnapshot(spaceId, 'restore')
|
||||
const connection = await this.hocuspocus.openDirectConnection(boardDocName(spaceId), {
|
||||
system: true,
|
||||
})
|
||||
try {
|
||||
await connection.transact((doc) => replaceBoardContent(doc, docFromUpdate(data)))
|
||||
} finally {
|
||||
await connection.disconnect()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 搜索
|
||||
|
||||
search(query: string, limit = 60): SearchHit[] {
|
||||
const needle = query.trim().toLocaleLowerCase()
|
||||
if (!needle) return []
|
||||
const hits: SearchHit[] = []
|
||||
for (const space of this.listSpaces()) {
|
||||
const doc = this.boardDoc(space.id)
|
||||
if (!doc) continue
|
||||
for (const card of readCards(doc)) {
|
||||
const haystack = `${card.title}\n${card.content}`
|
||||
const index = haystack.toLocaleLowerCase().indexOf(needle)
|
||||
if (index === -1) continue
|
||||
hits.push({
|
||||
spaceId: space.id,
|
||||
cardId: card.id,
|
||||
title: card.title,
|
||||
snippet: snippetAround(haystack, index, needle.length),
|
||||
})
|
||||
if (hits.length >= limit) return hits
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 初始内容
|
||||
|
||||
private async seed() {
|
||||
const space = await this.createSpace('我的宇宙', '✦')
|
||||
const now = Date.now()
|
||||
const cards: Array<Omit<CardFields, 'createdAt' | 'updatedAt' | 'z'>> = [
|
||||
{
|
||||
kind: 'note',
|
||||
title: '欢迎来到无界笺',
|
||||
content:
|
||||
'双击空白处,马上记下一件事。\n\n这里的内容保存在你的服务器上,在任何一台设备上打开都是同一张画布。',
|
||||
color: 'paper',
|
||||
x: -180,
|
||||
y: -80,
|
||||
width: 330,
|
||||
height: 240,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
title: '今天想做的事',
|
||||
content: '□ 收集灵感\n□ 整理一个想法\n□ 留一点时间发呆',
|
||||
color: 'sun',
|
||||
x: 220,
|
||||
y: -140,
|
||||
width: 290,
|
||||
height: 210,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
title: '一个小提示',
|
||||
content:
|
||||
'拖入或粘贴图片,卡片右侧圆点可以拉出连线。Shift+拖拽批量框选,右上角可以找回历史版本。',
|
||||
color: 'mint',
|
||||
x: 120,
|
||||
y: 170,
|
||||
width: 300,
|
||||
height: 200,
|
||||
},
|
||||
]
|
||||
const connection = await this.hocuspocus.openDirectConnection(boardDocName(space.id), {
|
||||
system: true,
|
||||
})
|
||||
try {
|
||||
await connection.transact((doc) => {
|
||||
const map = boardCards(doc)
|
||||
cards.forEach((card, index) => {
|
||||
map.set(
|
||||
randomUUID(),
|
||||
createCardMap({ ...card, z: index, createdAt: now + index, updatedAt: now + index }),
|
||||
)
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hocuspocus 会把 error.reason 原样发给客户端,客户端据此区分“未登录”和“空间不存在” */
|
||||
function rejection(reason: 'unauthorized' | 'not-found') {
|
||||
return Object.assign(new Error(reason), { reason })
|
||||
}
|
||||
|
||||
function sameBytes(a: Uint8Array, b: Uint8Array) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function snippetAround(text: string, index: number, length: number) {
|
||||
const start = Math.max(0, index - 24)
|
||||
const end = Math.min(text.length, index + length + 48)
|
||||
const body = text.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}`
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export interface ServerConfig {
|
||||
port: number
|
||||
host: string
|
||||
dataDir: string
|
||||
imagesDir: string
|
||||
databaseFile: string
|
||||
/** 构建后的前端目录;为空表示不托管静态文件(开发时由 Vite 负责) */
|
||||
staticDir: string | null
|
||||
password: string
|
||||
secret: string
|
||||
sessionDays: number
|
||||
maxImageBytes: number
|
||||
production: boolean
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
|
||||
const production = env.NODE_ENV === 'production'
|
||||
const dataDir = path.resolve(env.DATA_DIR || './data')
|
||||
const imagesDir = path.join(dataDir, 'images')
|
||||
mkdirSync(imagesDir, { recursive: true })
|
||||
|
||||
let password = env.BOUNDLESS_PASSWORD ?? ''
|
||||
if (!password) {
|
||||
if (production) {
|
||||
throw new Error('请通过环境变量 BOUNDLESS_PASSWORD 设置登录口令')
|
||||
}
|
||||
password = 'boundless'
|
||||
console.warn('[boundless] 未设置 BOUNDLESS_PASSWORD,开发模式下使用默认口令 "boundless"')
|
||||
}
|
||||
|
||||
// 默认托管构建好的 dist/;开发时页面由 Vite 提供,这里有没有都不影响
|
||||
const staticDir = path.resolve(env.STATIC_DIR || 'dist')
|
||||
|
||||
return {
|
||||
port: Number(env.PORT) || 3000,
|
||||
host: env.HOST || '0.0.0.0',
|
||||
dataDir,
|
||||
imagesDir,
|
||||
databaseFile: path.join(dataDir, 'boundless.sqlite'),
|
||||
staticDir: staticDir && existsSync(staticDir) ? staticDir : null,
|
||||
password,
|
||||
secret: env.BOUNDLESS_SECRET || loadOrCreateSecret(dataDir),
|
||||
sessionDays: Number(env.SESSION_DAYS) || 90,
|
||||
maxImageBytes: (Number(env.MAX_IMAGE_MB) || 20) * 1024 * 1024,
|
||||
production,
|
||||
}
|
||||
}
|
||||
|
||||
/** 未显式提供密钥时,生成一份并保存在数据目录,重启后登录状态仍然有效。 */
|
||||
function loadOrCreateSecret(dataDir: string) {
|
||||
const file = path.join(dataDir, 'session.secret')
|
||||
if (existsSync(file)) {
|
||||
const saved = readFileSync(file, 'utf8').trim()
|
||||
if (saved) return saved
|
||||
}
|
||||
const secret = randomBytes(32).toString('base64url')
|
||||
writeFileSync(file, secret, { mode: 0o600 })
|
||||
return secret
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
export interface SnapshotRow {
|
||||
id: number
|
||||
spaceId: string
|
||||
createdAt: number
|
||||
cardCount: number
|
||||
preview: string
|
||||
reason: SnapshotReason
|
||||
}
|
||||
|
||||
/** auto:编辑时定时保存;manual:手动保存;restore:恢复前自动备份当前状态 */
|
||||
export type SnapshotReason = 'auto' | 'manual' | 'restore'
|
||||
|
||||
/** 每个空间最多保留的历史版本数 */
|
||||
const MAX_SNAPSHOTS_PER_SPACE = 120
|
||||
|
||||
/**
|
||||
* SQLite 存储,使用 Node 内置的 node:sqlite,不依赖原生编译模块,部署更省心。
|
||||
* - documents:Yjs 文档的二进制状态
|
||||
* - snapshots:空间的历史版本
|
||||
*/
|
||||
export class Storage {
|
||||
private readonly db: DatabaseSync
|
||||
|
||||
constructor(file: string) {
|
||||
this.db = new DatabaseSync(file)
|
||||
this.db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
name TEXT PRIMARY KEY,
|
||||
data BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
space_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
state_vector BLOB NOT NULL,
|
||||
card_count INTEGER NOT NULL,
|
||||
preview TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS snapshots_space ON snapshots (space_id, created_at);
|
||||
`)
|
||||
}
|
||||
|
||||
loadDocument(name: string): Uint8Array | null {
|
||||
const row = this.db.prepare('SELECT data FROM documents WHERE name = ?').get(name) as
|
||||
| { data: Uint8Array }
|
||||
| undefined
|
||||
return row ? new Uint8Array(row.data) : null
|
||||
}
|
||||
|
||||
saveDocument(name: string, data: Uint8Array) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO documents (name, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(name, data, Date.now())
|
||||
}
|
||||
|
||||
deleteDocument(name: string) {
|
||||
this.db.prepare('DELETE FROM documents WHERE name = ?').run(name)
|
||||
}
|
||||
|
||||
latestSnapshot(spaceId: string): { createdAt: number; stateVector: Uint8Array } | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT created_at, state_vector FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT 1',
|
||||
)
|
||||
.get(spaceId) as { created_at: number; state_vector: Uint8Array } | undefined
|
||||
return row ? { createdAt: row.created_at, stateVector: new Uint8Array(row.state_vector) } : null
|
||||
}
|
||||
|
||||
addSnapshot(input: {
|
||||
spaceId: string
|
||||
data: Uint8Array
|
||||
stateVector: Uint8Array
|
||||
cardCount: number
|
||||
preview: string
|
||||
reason: SnapshotReason
|
||||
}) {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO snapshots (space_id, created_at, data, state_vector, card_count, preview, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
input.spaceId,
|
||||
Date.now(),
|
||||
input.data,
|
||||
input.stateVector,
|
||||
input.cardCount,
|
||||
input.preview,
|
||||
input.reason,
|
||||
)
|
||||
this.db
|
||||
.prepare(
|
||||
`DELETE FROM snapshots WHERE space_id = ? AND id NOT IN (
|
||||
SELECT id FROM snapshots WHERE space_id = ? ORDER BY id DESC LIMIT ?
|
||||
)`,
|
||||
)
|
||||
.run(input.spaceId, input.spaceId, MAX_SNAPSHOTS_PER_SPACE)
|
||||
return Number(result.lastInsertRowid)
|
||||
}
|
||||
|
||||
listSnapshots(spaceId: string): SnapshotRow[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, space_id, created_at, card_count, preview, reason
|
||||
FROM snapshots WHERE space_id = ? ORDER BY id DESC`,
|
||||
)
|
||||
.all(spaceId) as Array<{
|
||||
id: number
|
||||
space_id: string
|
||||
created_at: number
|
||||
card_count: number
|
||||
preview: string
|
||||
reason: SnapshotReason
|
||||
}>
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
spaceId: row.space_id,
|
||||
createdAt: row.created_at,
|
||||
cardCount: row.card_count,
|
||||
preview: row.preview,
|
||||
reason: row.reason,
|
||||
}))
|
||||
}
|
||||
|
||||
loadSnapshot(spaceId: string, id: number): Uint8Array | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT data FROM snapshots WHERE space_id = ? AND id = ?')
|
||||
.get(spaceId, id) as { data: Uint8Array } | undefined
|
||||
return row ? new Uint8Array(row.data) : null
|
||||
}
|
||||
|
||||
deleteSnapshots(spaceId: string) {
|
||||
this.db.prepare('DELETE FROM snapshots WHERE space_id = ?').run(spaceId)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const TYPES = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
} as const
|
||||
|
||||
type ImageExtension = keyof typeof TYPES
|
||||
|
||||
const FILE_NAME = /^[a-f0-9]{40}\.(png|jpg|gif|webp|avif|bmp|svg)$/
|
||||
|
||||
/** 根据文件头判断真实格式,不信任浏览器给的 Content-Type。 */
|
||||
export function detectImageType(bytes: Uint8Array): ImageExtension | null {
|
||||
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end))
|
||||
if (bytes.length < 12) return null
|
||||
if (bytes[0] === 0x89 && ascii(1, 4) === 'PNG') return 'png'
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpg'
|
||||
if (ascii(0, 4) === 'GIF8') return 'gif'
|
||||
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'webp'
|
||||
if (ascii(4, 8) === 'ftyp' && ['avif', 'avis'].includes(ascii(8, 12))) return 'avif'
|
||||
if (ascii(0, 2) === 'BM') return 'bmp'
|
||||
const head = new TextDecoder().decode(bytes.subarray(0, 1024)).trimStart().toLowerCase()
|
||||
if ((head.startsWith('<svg') || head.startsWith('<?xml')) && head.includes('<svg')) return 'svg'
|
||||
return null
|
||||
}
|
||||
|
||||
export class ImageStore {
|
||||
private readonly dir: string
|
||||
|
||||
constructor(dir: string) {
|
||||
this.dir = dir
|
||||
}
|
||||
|
||||
/** 按内容哈希命名,同一张图片重复上传只存一份。 */
|
||||
async save(bytes: Uint8Array) {
|
||||
const ext = detectImageType(bytes)
|
||||
if (!ext) return null
|
||||
const hash = createHash('sha1').update(bytes).digest('hex')
|
||||
const name = `${hash}.${ext}`
|
||||
const file = path.join(this.dir, name)
|
||||
if (!existsSync(file)) {
|
||||
const temp = `${file}.${process.pid}.tmp`
|
||||
await writeFile(temp, bytes)
|
||||
await rename(temp, file)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
async read(name: string) {
|
||||
if (!FILE_NAME.test(name)) return null
|
||||
try {
|
||||
const data = await readFile(path.join(this.dir, name))
|
||||
const ext = name.slice(name.lastIndexOf('.') + 1) as ImageExtension
|
||||
return { data, type: TYPES[ext] }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
|
||||
import { getRequestListener } from '@hono/node-server'
|
||||
import type { WebSocketLike } from '@hocuspocus/server'
|
||||
import crossws from 'crossws/adapters/node'
|
||||
|
||||
import { createApp } from './app.ts'
|
||||
import { SESSION_COOKIE, SessionManager, isSameOrigin, readCookie } from './auth.ts'
|
||||
import { Collab } from './collab.ts'
|
||||
import { loadConfig } from './config.ts'
|
||||
import { Storage } from './db.ts'
|
||||
import { ImageStore } from './images.ts'
|
||||
|
||||
type ClientConnection = ReturnType<Collab['hocuspocus']['handleConnection']>
|
||||
|
||||
const config = loadConfig()
|
||||
const storage = new Storage(config.databaseFile)
|
||||
const sessions = new SessionManager(config.secret, config.password, config.sessionDays)
|
||||
const collab = new Collab(storage, sessions)
|
||||
await collab.start()
|
||||
|
||||
const app = createApp({ config, sessions, collab, images: new ImageStore(config.imagesDir) })
|
||||
const server = createServer(getRequestListener(app.fetch))
|
||||
|
||||
const connections = new WeakMap<object, ClientConnection>()
|
||||
const ws = crossws({
|
||||
hooks: {
|
||||
open(peer) {
|
||||
const connection = collab.hocuspocus.handleConnection(
|
||||
peer.websocket as unknown as WebSocketLike,
|
||||
peer.request as Request,
|
||||
)
|
||||
connections.set(peer, connection)
|
||||
},
|
||||
message(peer, message) {
|
||||
connections.get(peer)?.handleMessage(message.uint8Array())
|
||||
},
|
||||
close(peer, event) {
|
||||
connections.get(peer)?.handleClose({ code: event.code ?? 1000, reason: event.reason ?? '' })
|
||||
connections.delete(peer)
|
||||
},
|
||||
error(peer, error) {
|
||||
console.error('[boundless] websocket error', error)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
server.on('upgrade', (request, socket: Duplex, head) => {
|
||||
const url = new URL(request.url ?? '/', 'http://localhost')
|
||||
const forwardedHost = request.headers['x-forwarded-host']
|
||||
const allowed =
|
||||
url.pathname === '/collab' &&
|
||||
isSameOrigin(request.headers.origin, [
|
||||
Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost,
|
||||
request.headers.host,
|
||||
]) &&
|
||||
sessions.verify(readCookie(request.headers.cookie, SESSION_COOKIE))
|
||||
|
||||
if (!allowed) {
|
||||
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
|
||||
return
|
||||
}
|
||||
void ws.handleUpgrade(request, socket, head)
|
||||
})
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`[boundless] 已启动 http://${config.host}:${config.port}`)
|
||||
console.log(`[boundless] 数据目录 ${config.dataDir}`)
|
||||
if (!config.staticDir) console.log('[boundless] 未托管前端页面(开发模式请访问 Vite 地址)')
|
||||
})
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown() {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
server.close()
|
||||
// 先把还没写盘的修改落盘,再关闭数据库
|
||||
await collab.stop()
|
||||
await collab.hocuspocus.closeConnections()
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
storage.close()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void shutdown())
|
||||
process.on('SIGTERM', () => void shutdown())
|
||||
Reference in New Issue
Block a user