margins 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,325 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const fsp = require('node:fs/promises');
5
+ const http = require('node:http');
6
+ const path = require('node:path');
7
+
8
+ const { ForbiddenPathError, NotFoundError, normaliseRel, resolveExisting, resolveNew, toRel } = require('./lib/paths');
9
+ const { ConflictError, createNew, imageType, kindOf, readForView, versionOf, writeChecked } = require('./lib/files');
10
+ const { listDir, walkFiles } = require('./lib/tree');
11
+ const { findBacklinks, searchFiles } = require('./lib/search');
12
+ const { RAW_CONTENT_SECURITY_POLICY, baseHeaders, hostIsAllowed, originIsAllowed } = require('./lib/security');
13
+ const { version } = require('./package.json');
14
+
15
+ /**
16
+ * The margins server: one folder, served to one browser on this machine.
17
+ *
18
+ * No framework. It answers a dozen routes, and every dependency a local tool
19
+ * ships is one more thing in someone's supply chain -- the page's two
20
+ * libraries are the only packages margins installs, and the server uses
21
+ * neither.
22
+ */
23
+
24
+ const DEFAULT_PORT = 4600;
25
+ const DEFAULT_HOST = '127.0.0.1';
26
+ const MAX_BODY_BYTES = 3 * 1024 * 1024;
27
+ const PUBLIC = path.join(__dirname, 'public');
28
+
29
+ /**
30
+ * The root of an installed package, from its entry point. The browser builds
31
+ * the page needs are not in either package's export map, so they cannot be
32
+ * required by subpath; the package root can be found from the file that can.
33
+ */
34
+ function packageRoot(name) {
35
+ let dir = path.dirname(require.resolve(name));
36
+ while (!fs.existsSync(path.join(dir, 'package.json'))) {
37
+ const parent = path.dirname(dir);
38
+ if (parent === dir) throw new Error(`Cannot find the ${name} package.`);
39
+ dir = parent;
40
+ }
41
+ return dir;
42
+ }
43
+
44
+ const STATIC = {
45
+ '/': { file: path.join(PUBLIC, 'index.html'), type: 'text/html; charset=utf-8' },
46
+ '/app.js': { file: path.join(PUBLIC, 'app.js'), type: 'text/javascript; charset=utf-8' },
47
+ '/style.css': { file: path.join(PUBLIC, 'style.css'), type: 'text/css; charset=utf-8' },
48
+ '/favicon.svg': { file: path.join(PUBLIC, 'favicon.svg'), type: 'image/svg+xml' },
49
+ '/links.js': { file: path.join(__dirname, 'lib', 'links.js'), type: 'text/javascript; charset=utf-8' },
50
+ '/vendor/marked.js': { file: path.join(packageRoot('marked'), 'lib', 'marked.umd.js'), type: 'text/javascript; charset=utf-8' },
51
+ '/vendor/purify.js': { file: path.join(packageRoot('dompurify'), 'dist', 'purify.min.js'), type: 'text/javascript; charset=utf-8' }
52
+ };
53
+
54
+ class BadRequestError extends Error {
55
+ constructor(message, status = 400) {
56
+ super(message);
57
+ this.status = status;
58
+ }
59
+ }
60
+
61
+ function send(res, status, body, headers = {}) {
62
+ res.writeHead(status, { ...baseHeaders(), ...headers });
63
+ res.end(body);
64
+ }
65
+
66
+ function sendJson(res, status, value) {
67
+ send(res, status, JSON.stringify(value), {
68
+ 'Content-Type': 'application/json; charset=utf-8',
69
+ 'Cache-Control': 'no-store'
70
+ });
71
+ }
72
+
73
+ function fail(res, error) {
74
+ if (res.headersSent) {
75
+ res.destroy();
76
+ return;
77
+ }
78
+ const status = Number.isInteger(error?.status) ? error.status : 500;
79
+ if (status === 500) console.error('margins:', error);
80
+ const body = { error: status === 500 ? 'Something went wrong reading that.' : error.message };
81
+ // A conflict carries what is on disk now, so the page can show it rather
82
+ // than only refuse.
83
+ if (error instanceof ConflictError && error.current) body.current = error.current;
84
+ sendJson(res, status, body);
85
+ }
86
+
87
+ async function readJsonBody(req) {
88
+ const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
89
+ // Only JSON. An HTML form can post text/plain or form-encoded bodies
90
+ // without any script, and requiring a type no form can send closes that
91
+ // route to the write API on top of the Origin check.
92
+ if (type !== 'application/json') throw new BadRequestError('Send JSON.', 415);
93
+
94
+ const chunks = [];
95
+ let size = 0;
96
+ for await (const chunk of req) {
97
+ size += chunk.length;
98
+ if (size > MAX_BODY_BYTES) throw new BadRequestError('That is too large to save here.', 413);
99
+ chunks.push(chunk);
100
+ }
101
+ try {
102
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
103
+ } catch {
104
+ throw new BadRequestError('That was not valid JSON.');
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Build the server for one folder. It is not listening yet; see startServer.
110
+ *
111
+ * @param {object} options
112
+ * @param {string} options.root the folder to serve
113
+ * @param {boolean} [options.hidden] list files and folders whose names start with a dot
114
+ * @param {string|null} [options.initial] a file, relative to the root, to open first
115
+ * @param {() => void} [options.onIdle] called once the last tab has been gone for idleGraceMs
116
+ * @param {number} [options.idleGraceMs]
117
+ * @returns {Promise<http.Server>}
118
+ */
119
+ async function createServer({ root, hidden = false, initial = null, onIdle = null, idleGraceMs = 5000 } = {}) {
120
+ // Resolved once. Every confinement check compares against the real path,
121
+ // so a root reached through a symlink is still a root.
122
+ const realRoot = await fsp.realpath(root);
123
+ const displayRoot = displayPath(realRoot);
124
+
125
+ // --- the tab-lifecycle bookkeeping, as in reviewer -------------------------
126
+ let watching = 0;
127
+ let idleTimer = null;
128
+
129
+ const server = http.createServer((req, res) => {
130
+ handle(req, res).catch(error => fail(res, error));
131
+ });
132
+
133
+ const port = () => server.address()?.port;
134
+
135
+ async function handle(req, res) {
136
+ // (3) in lib/security.js: nothing is answered for a name margins does not
137
+ // listen on -- the DNS-rebinding defence.
138
+ if (!hostIsAllowed(req, port())) {
139
+ return send(res, 421, 'Misdirected request', { 'Content-Type': 'text/plain; charset=utf-8' });
140
+ }
141
+
142
+ const url = new URL(req.url, `http://${req.headers.host}`);
143
+ const route = url.pathname;
144
+
145
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
146
+ // (2) in lib/security.js: writes only from margins' own page.
147
+ if (!originIsAllowed(req, port())) {
148
+ return sendJson(res, 403, { error: 'Changes are only accepted from the margins page itself.' });
149
+ }
150
+ }
151
+
152
+ if ((req.method === 'GET' || req.method === 'HEAD') && STATIC[route]) {
153
+ return serveStatic(res, STATIC[route]);
154
+ }
155
+ if (req.method === 'GET' && route.startsWith('/raw/')) return serveRaw(res, route.slice('/raw/'.length));
156
+
157
+ const query = name => url.searchParams.get(name) ?? '';
158
+
159
+ switch (`${req.method} ${route}`) {
160
+ case 'GET /api/info':
161
+ return sendJson(res, 200, {
162
+ name: path.basename(realRoot),
163
+ root: displayRoot,
164
+ hidden,
165
+ initial,
166
+ version
167
+ });
168
+
169
+ case 'GET /api/tree': {
170
+ const rel = normaliseRel(query('path'));
171
+ const abs = await resolveExisting(realRoot, rel);
172
+ return sendJson(res, 200, { path: rel, entries: await listDir(realRoot, abs, { hidden }) });
173
+ }
174
+
175
+ case 'GET /api/files':
176
+ return sendJson(res, 200, await walkFiles(realRoot, { hidden }));
177
+
178
+ case 'GET /api/file': {
179
+ const rel = normaliseRel(query('path'));
180
+ const abs = await resolveExisting(realRoot, rel);
181
+ return sendJson(res, 200, { path: rel, ...(await readForView(abs)) });
182
+ }
183
+
184
+ case 'GET /api/version': {
185
+ // What the page polls to notice a file changing under it -- edited in
186
+ // another editor, or by an agent. The content hash, not mtime, for
187
+ // the same reason saving uses it.
188
+ const rel = normaliseRel(query('path'));
189
+ const abs = await resolveExisting(realRoot, rel);
190
+ const view = await readForView(abs);
191
+ return sendJson(res, 200, { path: rel, version: view.version ?? null });
192
+ }
193
+
194
+ case 'PUT /api/file': {
195
+ const body = await readJsonBody(req);
196
+ const rel = normaliseRel(body.path);
197
+ if (typeof body.version !== 'string') throw new BadRequestError('Say which version you are replacing.');
198
+ const abs = await resolveExisting(realRoot, rel);
199
+ return sendJson(res, 200, { path: rel, ...(await writeChecked(abs, body.content, body.version)) });
200
+ }
201
+
202
+ case 'POST /api/file': {
203
+ const body = await readJsonBody(req);
204
+ const rel = normaliseRel(body.path);
205
+ if (!rel) throw new BadRequestError('A new file needs a name.');
206
+ const abs = await resolveNew(realRoot, rel);
207
+ const created = await createNew(abs, typeof body.content === 'string' ? body.content : '');
208
+ return sendJson(res, 201, { path: rel, kind: kindOf(rel), ...created });
209
+ }
210
+
211
+ case 'GET /api/search': {
212
+ const q = query('q').trim();
213
+ if (q.length < 2) return sendJson(res, 200, { query: q, results: [], truncated: false, filesSearched: 0 });
214
+ const { files } = await walkFiles(realRoot, { hidden });
215
+ return sendJson(res, 200, { query: q, ...(await searchFiles(realRoot, files, q)) });
216
+ }
217
+
218
+ case 'GET /api/backlinks': {
219
+ const rel = normaliseRel(query('path'));
220
+ const { files } = await walkFiles(realRoot, { hidden });
221
+ return sendJson(res, 200, { path: rel, backlinks: await findBacklinks(realRoot, files, rel) });
222
+ }
223
+
224
+ case 'GET /api/alive':
225
+ return holdAlive(req, res);
226
+
227
+ default:
228
+ return sendJson(res, 404, { error: 'No such route.' });
229
+ }
230
+ }
231
+
232
+ async function serveStatic(res, { file, type }) {
233
+ const body = await fsp.readFile(file);
234
+ send(res, 200, body, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
235
+ }
236
+
237
+ /**
238
+ * An image from the folder, for markdown that shows one. Images only: an
239
+ * HTML file served raw would be a page in margins' origin, and serving it
240
+ * is not what a markdown browser is for.
241
+ */
242
+ async function serveRaw(res, encoded) {
243
+ let rel;
244
+ try {
245
+ rel = normaliseRel(decodeURIComponent(encoded));
246
+ } catch (error) {
247
+ if (error instanceof ForbiddenPathError) throw error;
248
+ throw new BadRequestError('That path is not valid.');
249
+ }
250
+ const type = imageType(rel);
251
+ if (!type) throw new NotFoundError(rel);
252
+
253
+ const abs = await resolveExisting(realRoot, rel);
254
+ const body = await fsp.readFile(abs);
255
+ send(res, 200, body, {
256
+ 'Content-Type': type,
257
+ 'Content-Security-Policy': RAW_CONTENT_SECURITY_POLICY,
258
+ 'Cache-Control': 'no-cache'
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Held open for as long as a tab is watching; the connection closing is
264
+ * the signal. See reviewer's /api/alive for the reasoning, which applies
265
+ * unchanged: a reload closes and reopens within milliseconds, so the last
266
+ * one leaving starts a timer the next arrival cancels, and a server no tab
267
+ * ever opened is not idle.
268
+ */
269
+ function holdAlive(req, res) {
270
+ if (idleTimer) {
271
+ clearTimeout(idleTimer);
272
+ idleTimer = null;
273
+ }
274
+ watching += 1;
275
+
276
+ res.writeHead(200, {
277
+ ...baseHeaders(),
278
+ 'Content-Type': 'text/event-stream',
279
+ 'Cache-Control': 'no-cache',
280
+ Connection: 'keep-alive'
281
+ });
282
+ res.write(': watching\n\n');
283
+
284
+ res.on('close', () => {
285
+ watching -= 1;
286
+ if (watching > 0 || !onIdle) return;
287
+ idleTimer = setTimeout(() => {
288
+ idleTimer = null;
289
+ if (watching === 0) onIdle();
290
+ }, idleGraceMs);
291
+ idleTimer.unref();
292
+ });
293
+ }
294
+
295
+ server.realRoot = realRoot;
296
+ return server;
297
+ }
298
+
299
+ /** A path for showing people: under the home directory, written with ~. */
300
+ function displayPath(abs) {
301
+ const home = require('node:os').homedir();
302
+ const rel = path.relative(home, abs);
303
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? `~${path.sep}${rel}` : abs;
304
+ }
305
+
306
+ /**
307
+ * Create the server and listen on 127.0.0.1 -- never on every interface.
308
+ * Anyone who could reach the port could read the folder.
309
+ *
310
+ * @returns {Promise<http.Server>}
311
+ */
312
+ async function startServer(options = {}) {
313
+ const { port = DEFAULT_PORT, host = DEFAULT_HOST, ...rest } = options;
314
+ const server = await createServer(rest);
315
+ await new Promise((resolve, reject) => {
316
+ server.once('error', reject);
317
+ server.listen(port, host, () => {
318
+ server.off('error', reject);
319
+ resolve();
320
+ });
321
+ });
322
+ return server;
323
+ }
324
+
325
+ module.exports = { DEFAULT_HOST, DEFAULT_PORT, createServer, displayPath, startServer, versionOf, toRel };