Spaces:
Sleeping
Sleeping
| import fs from 'node:fs' | |
| import path from 'node:path' | |
| import crypto from 'node:crypto' | |
| import { atomicWriteFile, readJSON, writeJSON, projectIdOf, pageSlugOf } from './util.js' | |
| export const DATA_DIR = process.env.DATA_DIR || path.resolve('./data-dev') | |
| const DOCS_DIR = path.join(DATA_DIR, 'docs') | |
| const SNAP_DIR = path.join(DATA_DIR, 'snapshots') | |
| const REGISTRY = path.join(DATA_DIR, 'registry.json') | |
| const UPLOAD_OWNERS = path.join(DATA_DIR, 'uploads.json') | |
| const AGENTS = path.join(DATA_DIR, 'agents.json') | |
| const MENTIONS = path.join(DATA_DIR, 'mentions.json') | |
| const SECRET_FILE = path.join(DATA_DIR, 'session-secret') | |
| export function initStore() { | |
| fs.mkdirSync(DOCS_DIR, { recursive: true }) | |
| fs.mkdirSync(SNAP_DIR, { recursive: true }) | |
| } | |
| export function sessionSecret() { | |
| try { | |
| const s = fs.readFileSync(SECRET_FILE, 'utf8').trim() | |
| if (s.length >= 32) return s | |
| } catch {} | |
| const s = crypto.randomBytes(32).toString('hex') | |
| atomicWriteFile(SECRET_FILE, s) | |
| return s | |
| } | |
| // --- doc binary state --- | |
| // Unified projects use `${id}.yjs`. The page-derived path remains here so the | |
| // migration and whole-project deletion can find pre-unification page files. | |
| export function docPath(docName) { | |
| const id = projectIdOf(docName) | |
| const slug = pageSlugOf(docName) | |
| return path.join(DOCS_DIR, slug === 'home' ? `${id}.yjs` : `${id}__${slug}.yjs`) | |
| } | |
| function mdPath(docName) { | |
| return docPath(docName).replace(/\.yjs$/, '.md') | |
| } | |
| function snapDirFor(docName) { | |
| return path.join(SNAP_DIR, docName.replace('::', '__')) | |
| } | |
| export function loadDocState(id) { | |
| try { | |
| return new Uint8Array(fs.readFileSync(docPath(id))) | |
| } catch { | |
| return null | |
| } | |
| } | |
| // Read a pre-unified page file during the one-time project migration. These | |
| // files are deliberately left in place afterwards as rollback material. | |
| export function loadLegacyPageState(projectId, slug) { | |
| if (!slug || slug === 'home') return null | |
| try { | |
| return new Uint8Array(fs.readFileSync(path.join(DOCS_DIR, `${projectId}__${slug}.yjs`))) | |
| } catch { | |
| return null | |
| } | |
| } | |
| export function saveDocState(docName, update, markdown) { | |
| atomicWriteFile(docPath(docName), Buffer.from(update)) | |
| if (markdown != null) atomicWriteFile(mdPath(docName), markdown) | |
| maybeSnapshot(docName, update) | |
| } | |
| const SNAPSHOT_EVERY_MS = 10 * 60 * 1000 | |
| const SNAPSHOT_KEEP = 6 | |
| function maybeSnapshot(docName, update) { | |
| const dir = snapDirFor(docName) | |
| fs.mkdirSync(dir, { recursive: true }) | |
| let files = [] | |
| try { | |
| files = fs.readdirSync(dir).filter(f => f.endsWith('.yjs')).sort() | |
| } catch {} | |
| const last = files.length ? parseInt(files[files.length - 1], 10) : 0 | |
| if (Date.now() - last < SNAPSHOT_EVERY_MS) return | |
| atomicWriteFile(path.join(dir, `${Date.now()}.yjs`), Buffer.from(update)) | |
| for (const f of files.slice(0, Math.max(0, files.length + 1 - SNAPSHOT_KEEP))) { | |
| try { fs.unlinkSync(path.join(dir, f)) } catch {} | |
| } | |
| } | |
| export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') | |
| const MIME_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg' } | |
| export function saveUpload(buffer, mime, projectId = null) { | |
| const ext = MIME_EXT[mime] | |
| if (!ext) return null | |
| fs.mkdirSync(UPLOADS_DIR, { recursive: true }) | |
| const name = `${crypto.randomBytes(8).toString('hex')}.${ext}` | |
| atomicWriteFile(path.join(UPLOADS_DIR, name), buffer) | |
| if (projectId) { | |
| const owners = readJSON(UPLOAD_OWNERS, {}) | |
| owners[name] = projectId | |
| writeJSON(UPLOAD_OWNERS, owners) | |
| } | |
| return name | |
| } | |
| // Which project an upload was made in. A signed-in user can fetch any upload by | |
| // name, as they always could; a public-link visitor is confined to the project | |
| // their link opens, so an unguessable filename is not the only thing standing | |
| // between a stranger and someone else's figures. | |
| export function uploadProject(name) { | |
| return readJSON(UPLOAD_OWNERS, {})[name] || null | |
| } | |
| // Remove one page's files (keeps the registry entry) | |
| export function deletePageFiles(docName) { | |
| for (const f of [docPath(docName), mdPath(docName)]) { | |
| try { fs.unlinkSync(f) } catch {} | |
| } | |
| try { fs.rmSync(snapDirFor(docName), { recursive: true, force: true }) } catch {} | |
| } | |
| // Remove a whole project: every page file + the registry entry | |
| export function deleteDocFiles(id) { | |
| const reg = getRegistry() | |
| const pages = Object.keys(reg[id]?.pages || {}) | |
| for (const slug of [...new Set(['home', '_structure', ...pages])]) { | |
| deletePageFiles(slug === 'home' ? id : `${id}::${slug}`) | |
| } | |
| delete reg[id] | |
| writeJSON(REGISTRY, reg) | |
| } | |
| // --- registry (doc list) --- | |
| export function getRegistry() { | |
| return readJSON(REGISTRY, {}) | |
| } | |
| export function upsertRegistry(id, patch) { | |
| const reg = getRegistry() | |
| reg[id] = { ...(reg[id] || {}), ...patch } | |
| writeJSON(REGISTRY, reg) | |
| return reg[id] | |
| } | |
| // --- public share links ----------------------------------------------------- | |
| // A link is a bearer credential: whoever holds it reads the project. 24 random | |
| // bytes (192 bits) is far past guessable, and the compare is constant-time so a | |
| // scan of the registry cannot be turned into a character-by-character oracle. | |
| export function newShareToken() { | |
| return crypto.randomBytes(24).toString('base64url') | |
| } | |
| export function docByShareToken(token) { | |
| if (typeof token !== 'string' || token.length < 32 || token.length > 64) return null | |
| const given = Buffer.from(token) | |
| for (const [id, meta] of Object.entries(getRegistry())) { | |
| if (!meta.shareToken || meta.shareToken.length !== token.length) continue | |
| if (crypto.timingSafeEqual(Buffer.from(meta.shareToken), given)) return id | |
| } | |
| return null | |
| } | |
| // --- doc access control --- | |
| export function canAccessDoc(docName, username) { | |
| const meta = getRegistry()[projectIdOf(docName)] | |
| if (!meta || !username) return false | |
| if (!meta.createdBy) return true // legacy docs created before ACLs | |
| return meta.createdBy === username || (meta.sharedWith || []).includes(username) | |
| } | |
| // One combined registry touch per doc store, and only when something actually | |
| // changed — registry.json lives on bucket-mounted storage where every sync | |
| // write is expensive (and a hung write blocks the event loop). | |
| export function touchProjectOnStore(projectId, slug, title, subtitle) { | |
| const reg = getRegistry() | |
| const meta = reg[projectId] | |
| if (!meta) return | |
| const now = Date.now() | |
| let dirty = now - (meta.updatedAt || 0) > 30000 | |
| const next = { ...meta, updatedAt: now } | |
| if (slug === 'home') { | |
| if ((title || 'Untitled') !== meta.title) { | |
| next.title = title || 'Untitled' | |
| dirty = true | |
| } | |
| // the project's one-line description, from home's opening paragraph. Absent | |
| // (the paragraph was emptied or never written) drops the key rather than | |
| // storing a null — a write either way, but only when it really changed. | |
| const sub = subtitle || null | |
| if (sub !== (meta.subtitle || null)) { | |
| if (sub) next.subtitle = sub | |
| else delete next.subtitle | |
| dirty = true | |
| } | |
| } else { | |
| const pageTitle = slug === '_structure' ? 'Structure' : title || slug | |
| if (meta.pages?.[slug]?.title !== pageTitle) { | |
| next.pages = { ...(meta.pages || {}), [slug]: { ...(meta.pages?.[slug] || {}), title: pageTitle } } | |
| dirty = true | |
| } | |
| } | |
| if (dirty) { | |
| reg[projectId] = next | |
| writeJSON(REGISTRY, reg) | |
| } | |
| } | |
| // --- pages metadata (inside the project's registry entry) --- | |
| export function upsertPageMeta(projectId, slug, patch) { | |
| const reg = getRegistry() | |
| if (!reg[projectId]) return null | |
| const pages = { ...(reg[projectId].pages || {}) } | |
| pages[slug] = { ...(pages[slug] || {}), ...patch } | |
| reg[projectId] = { ...reg[projectId], pages } | |
| writeJSON(REGISTRY, reg) | |
| return pages[slug] | |
| } | |
| export function removePageMeta(projectId, slug) { | |
| const reg = getRegistry() | |
| if (!reg[projectId]?.pages?.[slug]) return | |
| const pages = { ...reg[projectId].pages } | |
| delete pages[slug] | |
| reg[projectId] = { ...reg[projectId], pages } | |
| writeJSON(REGISTRY, reg) | |
| } | |
| // Every project has at least the home page; older entries predate `pages`. | |
| export function pagesOf(projectId) { | |
| const meta = getRegistry()[projectId] | |
| if (!meta) return null | |
| return { home: { title: meta.title || 'Untitled' }, ...(meta.pages || {}) } | |
| } | |
| // --- agent handles + app-issued keys --- | |
| // agents.json: { handle: { owner, createdAt, keyHash } } — only the sha256 of | |
| // the key is stored; the plaintext is shown once at registration/rotation. | |
| export function getAgents() { | |
| return readJSON(AGENTS, {}) | |
| } | |
| export function saveAgents(agents) { | |
| writeJSON(AGENTS, agents) | |
| } | |
| export function hashAgentKey(key) { | |
| return crypto.createHash('sha256').update(key).digest('hex') | |
| } | |
| export function newAgentKey() { | |
| return 'ak_' + crypto.randomBytes(24).toString('hex') | |
| } | |
| export function agentByKey(key) { | |
| if (!key?.startsWith('ak_')) return null | |
| const hash = hashAgentKey(key) | |
| for (const [handle, info] of Object.entries(getAgents())) { | |
| if (info.keyHash && info.keyHash === hash) return { handle, owner: info.owner } | |
| } | |
| return null | |
| } | |
| // --- mention tasks --- | |
| // shape: { processed: { [messageId]: true }, tasks: { [mentionId]: task } } | |
| export function getMentionState() { | |
| return readJSON(MENTIONS, { processed: {}, tasks: {} }) | |
| } | |
| export function saveMentionState(state) { | |
| writeJSON(MENTIONS, state) | |
| } | |