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 => ({ '&': '&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)
  })
}