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) }