Spaces:
Sleeping
Sleeping
File size: 9,426 Bytes
99a44ac ec6b346 99a44ac 830cb4d 3a73292 99a44ac 314a59f 99a44ac 3a73292 830cb4d 99a44ac ed7ee81 ec6b346 d3283b8 ec6b346 3a73292 ec6b346 d3283b8 ec6b346 d3283b8 ec6b346 ed7ee81 ec6b346 ed7ee81 f35d3f6 ec6b346 f35d3f6 ed7ee81 ec6b346 ed7ee81 99a44ac 3a73292 830cb4d 3a73292 6912fd3 830cb4d 6912fd3 99a44ac 8a3f5f2 99a44ac 314a59f 99a44ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | 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)
})
}
|