Spaces:
Sleeping
Sleeping
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) | |
| }) | |
| } | |