cowrite-dev / server /store.js
lvwerra's picture
lvwerra HF Staff
Close four holes in the share link, from review
830cb4d
Raw
History Blame Contribute Delete
9.34 kB
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)
}