cowrite-dev / server /index.js
lvwerra's picture
lvwerra HF Staff
Close four holes in the share link, from review
830cb4d
Raw
History Blame Contribute Delete
9.43 kB
import express from 'express'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { WebSocketServer } from 'ws'
import { initStore, DATA_DIR, UPLOADS_DIR, getRegistry, canAccessDoc, docByShareToken, uploadProject } from './store.js'
import { registerAuthRoutes, resolveUser, resolveShare, shareCookie, OAUTH_ENABLED, HOST, bootPayload } from './auth.js'
import { apiRouter } from './api.js'
import { hocuspocus } from './collab.js'
import { stopDefaultAgents } from './default-agent.js'
initStore()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const PUBLIC_DIR = path.join(__dirname, '..', 'public')
const PORT = Number(process.env.PORT || 3000)
const app = express()
app.set('trust proxy', true)
app.use(resolveUser())
app.use(resolveShare)
// A share link is a bearer credential and documents link outwards; never let a
// URL from this app travel in a Referer header to another origin.
app.use((req, res, next) => {
res.set('Referrer-Policy', 'no-referrer')
next()
})
app.get('/healthz', (req, res) => res.json({ ok: true, oauth: OAUTH_ENABLED }))
registerAuthRoutes(app)
app.use('/api', apiRouter())
// The HTML shell must never be cached: it carries the ?v=<buildId> asset URLs,
// and a stale shell means a stale bundle, which the collab build-check then
// rejects — a tab that looks fine but silently saves nothing. The bundles
// themselves are versioned, so they can be cached hard.
// The shells are tiny; keep them in memory but re-read when a build replaces
// them, so a rebuild without a restart cannot serve yesterday's asset URLs.
const shells = new Map()
const shell = name => {
const file = path.join(PUBLIC_DIR, name)
const stamp = fs.statSync(file).mtimeMs
const hit = shells.get(name)
if (hit?.stamp === stamp) return hit.body
const body = fs.readFileSync(file, 'utf8')
shells.set(name, { stamp, body })
return body
}
const escapeHtml = s =>
String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
// The header would otherwise show "…" for the whole load. The title is already
// in the registry, so the shell can carry it — but only to someone who is
// allowed to see the project, or the placeholder leaks private titles.
// header = project name; tab = "page · project" (matching the client's painter)
const titleFor = (req, id, slug) => {
if (!id) return null
if (!canAccessDoc(id, req.user?.username) && req.share?.docId !== id) return null
const meta = getRegistry()[id]
if (!meta) return null
const project = meta.title || null
const page = slug === '_structure' ? 'Structure' : slug && slug !== 'home' ? meta.pages?.[slug]?.title || null : null
if (!project && !page) return null
return { header: project || page, tab: page && project ? `${page} · ${project}` : page || project }
}
const sendShell = name => (req, res) => {
const json = JSON.stringify(bootPayload(req)).replace(/</g, '\\u003c')
let body = shell(name).replace('<!--boot-->', `<script>window.__BOOT=${json}</script>`)
const title = titleFor(req, req.params.id, req.params.slug)
if (title) {
body = body
.replace('<span id="doc-title">…</span>', `<span id="doc-title">${escapeHtml(title.header)}</span>`)
.replace('<title>Document</title>', `<title>${escapeHtml(title.tab)}</title>`)
}
res.set('Cache-Control', 'no-cache, must-revalidate')
res.type('html').send(body)
}
const sendDoc = sendShell('doc.html')
// No index page: / means "the document I was last in". A signed-out visitor gets
// the shell and its sign-in overlay; a signed-in one is sent to their most recent
// document, and only sees the shell bare if they have none to send them to. The
// redirect keeps GET / free of side effects — creating a document is a POST the
// switcher makes, not something a page load does behind you.
app.get('/', (req, res, next) => {
if (!req.user) return sendDoc(req, res, next)
const registry = getRegistry()
const mine = Object.entries(registry)
.filter(([id]) => canAccessDoc(id, req.user.username))
.sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
if (mine.length) return res.redirect(302, `/d/${mine[0][0]}`)
sendDoc(req, res, next)
})
// Bundles are immutable per build id, so a client that asks for ?v=<id> can keep
// the answer forever — that is what makes a second visit cost no bytes at all.
app.use((req, res, next) => {
if (req.query.v) res.set('Cache-Control', 'public, max-age=31536000, immutable')
next()
})
// Serve the build's pre-compressed twin when the client takes it. Saves ~480KB
// on a cold editor load; without it express.static ships the raw bundle.
const ENCODINGS = [
['br', '.br'],
['gzip', '.gz'],
]
app.get(/\.(?:js|css)$/, (req, res, next) => {
const name = path.basename(req.path)
if (name !== req.path.slice(1)) return next() // only flat asset names, no traversal
const accepted = req.headers['accept-encoding'] || ''
for (const [token, ext] of ENCODINGS) {
if (!new RegExp(`\\b${token}\\b`).test(accepted)) continue
const file = path.join(PUBLIC_DIR, name + ext)
if (!fs.existsSync(file)) continue
res.set('Content-Encoding', token)
res.set('Vary', 'Accept-Encoding')
res.type(path.extname(name))
return res.sendFile(file)
}
next()
})
app.use(express.static(PUBLIC_DIR, { index: false, setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) res.set('Cache-Control', 'no-cache, must-revalidate')
// the webfont files are content-addressed by name; a new face gets a new name
else if (filePath.includes(`${path.sep}fonts${path.sep}`)) res.set('Cache-Control', 'public, max-age=31536000, immutable')
} }))
app.get('/d/:id', sendDoc)
app.get('/d/:id/:slug', sendDoc)
// Public share links. The token in the path is the whole credential, so it is
// exchanged for a cookie on arrival: nothing else in the app has to carry it,
// and the socket handshake and <img> requests are covered by the same grant.
// An unknown or revoked token is a 404 — it must not confirm that a project
// with that id exists.
const sendShared = (req, res) => {
const docId = docByShareToken(req.params.token)
if (!docId) return res.status(404).type('html').send(shell('doc.html'))
res.setHeader('set-cookie', shareCookie(req.params.token))
// Redirect rather than render here. The token IS the credential, and a
// rendered page leaves it in the address bar, in history, and — because a
// document may carry external images and links — in the Referer header sent
// to whatever host those point at. The cookie carries the grant from here on,
// so /d/<id> works for this visitor and for nobody else.
const slug = req.params.slug ? `/${encodeURIComponent(req.params.slug)}` : ''
res.redirect(302, `/d/${docId}${slug}`)
}
app.get('/p/:token', sendShared)
app.get('/p/:token/:slug', sendShared)
// uploaded images (auth required — same session cookie the <img> tags send)
app.get('/files/:name', (req, res) => {
if (!/^[a-f0-9]{16}\.(png|jpg|gif|webp|svg)$/.test(req.params.name)) return res.status(400).end()
// A signed-in user may fetch any upload by name, as they always could. A
// public-link visitor is confined to the project their link opens: uploads
// are global on disk, so without this the cookie would be a key to every
// figure on the instance for anyone holding any link.
if (!req.user) {
if (!req.share) return res.status(401).end()
if (uploadProject(req.params.name) !== req.share.docId) return res.status(404).end()
}
// dotfiles:'allow' — DATA_DIR may itself be a dot-directory (e.g. ./.browser-data in tests)
res.sendFile(path.join(UPLOADS_DIR, req.params.name), { maxAge: '365d', immutable: true, dotfiles: 'allow' }, err => {
if (err) res.status(404).end()
})
})
const server = http.createServer(app)
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (request, socket, head) => {
if (!request.url?.startsWith('/collab')) {
socket.destroy()
return
}
wss.handleUpgrade(request, socket, head, ws => {
// hocuspocus v4: the caller owns the socket events and feeds them in
const connection = hocuspocus.handleConnection(ws, request)
ws.on('message', data => connection.handleMessage(new Uint8Array(data)))
ws.on('close', (code, reason) => connection.handleClose({ code, reason: reason?.toString() }))
ws.on('error', () => connection.handleClose({ code: 1011, reason: 'socket error' }))
})
})
server.listen(PORT, () => {
console.log(`interactive-editor listening on :${PORT} (host: ${HOST}, oauth: ${OAUTH_ENABLED}, data: ${DATA_DIR})`)
})
// heartbeat: visible in Space logs — if it stops ticking while the app is
// unreachable, the event loop is blocked (e.g. a hung sync write on the
// bucket mount); lag > ~1s means something synchronous is hogging the loop
let hbLast = Date.now()
setInterval(() => {
const lag = Date.now() - hbLast - 60000
hbLast = Date.now()
const mem = Math.round(process.memoryUsage().rss / 1e6)
console.log(`[hb] rss=${mem}MB docs=${hocuspocus.documents?.size ?? '?'} lag=${lag}ms`)
}, 60000)
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, () => {
stopDefaultAgents()
try { hocuspocus.flushPendingStores() } catch {}
setTimeout(() => process.exit(0), 500)
})
}