自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user