@makerclay/core 1.0.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/LICENSE +221 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/admin/listing.js +149 -0
- package/src/admin/routes.js +362 -0
- package/src/attic.js +168 -0
- package/src/auth/can.js +79 -0
- package/src/auth/csrf.js +74 -0
- package/src/auth/none.js +24 -0
- package/src/auth/password.js +281 -0
- package/src/auth/passwords.js +79 -0
- package/src/auth/rate-limit.js +66 -0
- package/src/auth/sessions.js +86 -0
- package/src/auth/token-lanes.js +72 -0
- package/src/boot.js +120 -0
- package/src/client.js +99 -0
- package/src/collections/index.js +397 -0
- package/src/collections/routes.js +166 -0
- package/src/create-host.js +351 -0
- package/src/derived/data-extractor.js +22 -0
- package/src/derived/index.js +121 -0
- package/src/documents/format-html.js +313 -0
- package/src/documents/replace.js +257 -0
- package/src/documents/root-attrs.js +171 -0
- package/src/documents/serve.js +137 -0
- package/src/documents/stale.js +20 -0
- package/src/index.js +12 -0
- package/src/inspect.js +136 -0
- package/src/json-errors.js +59 -0
- package/src/livesync.js +75 -0
- package/src/nodes/identity.js +14 -0
- package/src/nodes/names.js +57 -0
- package/src/nodes/ops.js +613 -0
- package/src/nodes/scanner.js +321 -0
- package/src/nodes/store.js +111 -0
- package/src/pages.js +166 -0
- package/src/paths.js +302 -0
- package/src/recovery/overlay.js +327 -0
- package/src/recovery/replay.js +236 -0
- package/src/recovery-ui.js +185 -0
- package/src/recovery.js +30 -0
- package/src/requests.js +73 -0
- package/src/routes/meta.js +62 -0
- package/src/routes/read.js +105 -0
- package/src/routes/save.js +102 -0
- package/src/routes/sync.js +118 -0
- package/src/routes/upload.js +128 -0
- package/src/share/index.js +207 -0
- package/src/share/save-tokens.js +65 -0
- package/src/spec/codes.js +42 -0
- package/src/spec/meta.js +52 -0
- package/src/spec/wire.js +115 -0
- package/src/store/index.js +29 -0
- package/src/store/migrations/001-init.sql +114 -0
- package/src/store/sqlite.js +540 -0
- package/src/templates.js +50 -0
- package/src/tenants/index.js +355 -0
- package/src/tenants/isolation.js +91 -0
- package/src/tenants/routes.js +131 -0
- package/src/ui.js +95 -0
- package/src/util/cookies.js +26 -0
- package/src/util/express.js +8 -0
- package/src/util/fsx.js +205 -0
- package/src/util/id.js +37 -0
- package/src/util/lockfile.js +52 -0
- package/src/util/locks.js +35 -0
- package/src/util/multipart.js +33 -0
- package/src/versions/files.js +307 -0
- package/src/versions/index.js +16 -0
- package/src/versions/naming.js +172 -0
- package/src/versions/routes.js +91 -0
- package/src/wire-compat.js +61 -0
- package/ui/app.css +164 -0
- package/ui/attic.html +198 -0
- package/ui/dashboard.html +456 -0
- package/ui/editor.html +156 -0
- package/ui/error.html +18 -0
- package/ui/login.html +58 -0
- package/ui/records.html +173 -0
- package/ui/recovery.html +152 -0
- package/ui/setup.html +61 -0
- package/ui/share-qr.html +44 -0
- package/ui/templates/blank.html +16 -0
- package/ui/templates/devlog.html +71 -0
- package/ui/templates/hackable-dashboard.html +145 -0
- package/ui/templates/kanban.html +85 -0
- package/ui/templates/landing.html +108 -0
- package/ui/templates/writer.html +50 -0
- package/ui/tenants.html +172 -0
- package/ui/trash.html +134 -0
- package/ui/versions.html +114 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { jsonError } from '../json-errors.js';
|
|
3
|
+
import { readFileText } from '../util/fsx.js';
|
|
4
|
+
import { extractData, parseExtractionRules } from '../derived/data-extractor.js';
|
|
5
|
+
import { applySecurityHeaders } from '../tenants/isolation.js';
|
|
6
|
+
import { wrap } from '../util/express.js';
|
|
7
|
+
|
|
8
|
+
// Every lane that hands a node's bytes to a caller: `/_/download`, `/_/api/*`,
|
|
9
|
+
// and the document lane itself, whose `?data=` half is the second address of the
|
|
10
|
+
// same extraction feature `/_/api` serves.
|
|
11
|
+
//
|
|
12
|
+
// Together in one file because the rules they share are the ones that go wrong
|
|
13
|
+
// when they drift. `features.dataApi` gates both extraction halves; every lane
|
|
14
|
+
// that carries node bytes says the same thing about the node with
|
|
15
|
+
// `applySecurityHeaders`. Splitting them across files is how one half ends up
|
|
16
|
+
// answering after the flag was turned off.
|
|
17
|
+
//
|
|
18
|
+
// Two mount points, because one group lives under the host's `/_` prefix and the
|
|
19
|
+
// document lane is the fall-through that has to be registered after it.
|
|
20
|
+
|
|
21
|
+
// An SVG is a document that browsers happen to draw. Navigate to one directly
|
|
22
|
+
// and its script runs on this origin; pull the same file through an `img` tag,
|
|
23
|
+
// a CSS background, or any other image load and it cannot. The attachment
|
|
24
|
+
// disposition keeps exactly that split: every way a page embeds an SVG still
|
|
25
|
+
// works, and the one way it could execute becomes a download instead.
|
|
26
|
+
//
|
|
27
|
+
// Not left to the sandbox header, which would also stop it: that header is
|
|
28
|
+
// absent under `isolation: 'off'`, a configuration this host supports, and
|
|
29
|
+
// uploads must not stop being inert because someone changed an unrelated
|
|
30
|
+
// setting. This one is unconditional.
|
|
31
|
+
function applyInertImageHeaders(res, relPath) {
|
|
32
|
+
if (/\.svgz?$/i.test(relPath)) res.setHeader('Content-Disposition', 'attachment');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mountReadRoutes({ system, config, serve, derived, resolveTarget }) {
|
|
36
|
+
system.get('/download', wrap(async (req, res) => {
|
|
37
|
+
const raw = req.query.path;
|
|
38
|
+
if (typeof raw !== 'string' || !raw) return jsonError(res, 'bad-request', 'path required.');
|
|
39
|
+
const found = await resolveTarget(req.actor, raw, 'read');
|
|
40
|
+
if (!found) return jsonError(res, 'not-found', 'Not found');
|
|
41
|
+
// The attachment disposition is what stops these bytes rendering, but this
|
|
42
|
+
// lane carries node bytes like every other, so it says the same thing about
|
|
43
|
+
// the node as every other lane does.
|
|
44
|
+
applySecurityHeaders(res, serve.isolationFor(found.node));
|
|
45
|
+
res.setHeader('Content-Disposition',
|
|
46
|
+
`attachment; filename="${path.basename(found.node.path).replace(/[^\w.-]+/g, '_')}"`);
|
|
47
|
+
res.sendFile(found.real);
|
|
48
|
+
return undefined;
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
system.get(/^\/api\/(.+)$/, wrap(async (req, res) => {
|
|
52
|
+
if (config.features.dataApi === false) return jsonError(res, 'not-found', 'Not found');
|
|
53
|
+
const found = await resolveTarget(req.actor, `/${req.params[0]}`, 'read');
|
|
54
|
+
if (!found) return jsonError(res, 'not-found', 'Not found');
|
|
55
|
+
const cached = await derived.readSidecar(found.node, found.real);
|
|
56
|
+
if (cached !== null) return res.type('application/json').send(cached);
|
|
57
|
+
const html = await readFileText(found.real);
|
|
58
|
+
await derived.writeSidecar(found.node, html, found.real);
|
|
59
|
+
const fresh = await derived.readSidecar(found.node, found.real);
|
|
60
|
+
if (fresh === null) {
|
|
61
|
+
return jsonError(res, 'not-found', 'This page has no rules tag with data-rules-name~="api".');
|
|
62
|
+
}
|
|
63
|
+
return res.type('application/json').send(fresh);
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mountDocumentLane({ app, config, serve, events, resolveTarget, notFoundDocument }) {
|
|
68
|
+
app.get(/.*/, wrap(async (req, res, next) => {
|
|
69
|
+
if (req.method !== 'GET') return next();
|
|
70
|
+
const found = await resolveTarget(req.actor, req.path, 'read');
|
|
71
|
+
if (!found) return notFoundDocument(req, res);
|
|
72
|
+
|
|
73
|
+
if (req.query.data !== undefined) {
|
|
74
|
+
// The same switch as `/_/api`. Extraction is one feature with two
|
|
75
|
+
// addresses, and gating only the dedicated one meant turning the data API
|
|
76
|
+
// off left this half answering, which is not what anyone reading the flag
|
|
77
|
+
// expects.
|
|
78
|
+
if (config.features.dataApi === false) return notFoundDocument(req, res);
|
|
79
|
+
const html = await readFileText(found.real);
|
|
80
|
+
try {
|
|
81
|
+
const rules = parseExtractionRules(String(req.query.data));
|
|
82
|
+
return res.json(extractData(html, rules));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return jsonError(res, 'bad-request', `Invalid extraction rules: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (found.node.kind !== 'doc') {
|
|
89
|
+
applySecurityHeaders(res, serve.isolationFor(found.node));
|
|
90
|
+
applyInertImageHeaders(res, found.node.path);
|
|
91
|
+
return res.sendFile(found.real);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rendered = await serve.renderDocument({
|
|
95
|
+
actor: req.actor, owner: found.owner, node: found.node, real: found.real,
|
|
96
|
+
});
|
|
97
|
+
applySecurityHeaders(res, rendered.isolated);
|
|
98
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
99
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
100
|
+
events.emit('node-served', { node: rendered.node, actor: req.actor });
|
|
101
|
+
return res.send(rendered.body);
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
app.use((req, res) => notFoundDocument(req, res));
|
|
105
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { can } from '../auth/can.js';
|
|
3
|
+
import { jsonError, sendJson } from '../json-errors.js';
|
|
4
|
+
import { documentUrlHeader, ifMatchHeader, saveTrigger, readSaveBody } from '../spec/wire.js';
|
|
5
|
+
import { wrap } from '../util/express.js';
|
|
6
|
+
|
|
7
|
+
// The save lane.
|
|
8
|
+
//
|
|
9
|
+
// Two addresses into one `handleSave`, and the difference between them is only
|
|
10
|
+
// how the caller proves it may write: a cookie on `/save`, a save token on
|
|
11
|
+
// `/save/:token`. Everything after that point is the same code, which is what
|
|
12
|
+
// keeps the quota, the sandbox rule and the version record from drifting apart
|
|
13
|
+
// between the two.
|
|
14
|
+
|
|
15
|
+
export function mountSaveRoutes({
|
|
16
|
+
system, config, nodes, serve, tenants, replace, gateCtx,
|
|
17
|
+
resolveHref, resolveSaveToken, track,
|
|
18
|
+
}) {
|
|
19
|
+
const saveJson = express.json({ limit: config.limits.saveBytes, type: ['application/json', '+json'] });
|
|
20
|
+
const saveText = express.text({ type: () => true, limit: config.limits.saveBytes });
|
|
21
|
+
|
|
22
|
+
async function handleSave(req, res, grant = null) {
|
|
23
|
+
const parsed = readSaveBody(req);
|
|
24
|
+
if (!parsed.ok) return jsonError(res, parsed.code, parsed.msg);
|
|
25
|
+
if (typeof parsed.content !== 'string') {
|
|
26
|
+
return jsonError(res, 'invalid-document', 'Expected the document as text.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let owner = '';
|
|
30
|
+
let relPath = null;
|
|
31
|
+
let actor = req.actor;
|
|
32
|
+
|
|
33
|
+
if (grant) {
|
|
34
|
+
owner = grant.node.owner;
|
|
35
|
+
relPath = grant.node.path;
|
|
36
|
+
actor = { ...req.actor, grants: { ...req.actor.grants, saveToken: grant } };
|
|
37
|
+
} else {
|
|
38
|
+
const href = documentUrlHeader(req);
|
|
39
|
+
if (!href) {
|
|
40
|
+
return jsonError(res, 'bad-request', 'Document-URL header required.');
|
|
41
|
+
}
|
|
42
|
+
const target = resolveHref(href);
|
|
43
|
+
owner = target.owner;
|
|
44
|
+
relPath = target.relPath;
|
|
45
|
+
|
|
46
|
+
// §16.6. The cookie lane is closed to a sandboxed TENANT document. It runs
|
|
47
|
+
// under an opaque origin and cannot send a cookie at all, so a
|
|
48
|
+
// cookie-authenticated save of one came from something else on this
|
|
49
|
+
// origin; requiring the token keeps that true even in a browser that
|
|
50
|
+
// ignores the CSP.
|
|
51
|
+
//
|
|
52
|
+
// Scoped to tenant realms on purpose. Under the default `untrusted` mode
|
|
53
|
+
// every file the owner drops into their own folder is sandboxed too, and
|
|
54
|
+
// closing the cookie lane there would mean rsyncing a file in silently
|
|
55
|
+
// costs you the ability to edit it in your own browser. The threat §16.1
|
|
56
|
+
// states is the tenant, and this is where it lives.
|
|
57
|
+
// Checked only for someone who could otherwise write it. Refusing before
|
|
58
|
+
// the gate would answer 403-with-a-reason to a tenant probing someone
|
|
59
|
+
// else's realm, and that distinguishes "exists but sandboxed" from
|
|
60
|
+
// "nothing here", which is the leak read refusals are 404 to avoid.
|
|
61
|
+
const node = nodes.resolve(owner, relPath);
|
|
62
|
+
if (owner && can(req.actor, 'write', node, gateCtx) && serve.isolationFor(node)) {
|
|
63
|
+
return jsonError(
|
|
64
|
+
res, 'read-only',
|
|
65
|
+
'This document runs in a sandbox, so it saves with its token rather than with a cookie.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Before the kernel, not after: a quota that only notices once the bytes are
|
|
71
|
+
// on disk is an audit log, not a limit.
|
|
72
|
+
if (owner && tenants.enabled) {
|
|
73
|
+
tenants.checkQuota(owner, { relPath, bytes: Buffer.byteLength(parsed.content, 'utf8') });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await replace({
|
|
77
|
+
actor,
|
|
78
|
+
owner,
|
|
79
|
+
relPath,
|
|
80
|
+
bytes: parsed.content,
|
|
81
|
+
ifMatch: ifMatchHeader(req),
|
|
82
|
+
trigger: saveTrigger(req),
|
|
83
|
+
senderId: req.headers['x-sender-id'] || null,
|
|
84
|
+
ctx: gateCtx,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Three keys the spec names plus msgType, so clayjs (msg/msgType) and a
|
|
88
|
+
// spec client (code/etag) both read a complete answer. [wire-compat]
|
|
89
|
+
sendJson(res, 200, { msg: result.msg, msgType: result.msgType, etag: result.etag });
|
|
90
|
+
if (owner && tenants.enabled) tenants.recordUsage(owner);
|
|
91
|
+
track(result.derived());
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
system.post('/save', saveJson, saveText, wrap((req, res) => handleSave(req, res)));
|
|
96
|
+
|
|
97
|
+
system.post('/save/:token', saveJson, saveText, wrap(async (req, res) => {
|
|
98
|
+
const grant = await resolveSaveToken(req.params.token);
|
|
99
|
+
if (!grant) return jsonError(res, 'unauthorized', 'This save token is not valid.');
|
|
100
|
+
return handleSave(req, res, grant);
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { can } from '../auth/can.js';
|
|
3
|
+
import { jsonError } from '../json-errors.js';
|
|
4
|
+
import { documentUrlHeader } from '../spec/wire.js';
|
|
5
|
+
import { hasHtmlRoot } from '../documents/format-html.js';
|
|
6
|
+
import { stripRootAttrs } from '../documents/root-attrs.js';
|
|
7
|
+
import { EPHEMERAL_ATTRS } from '../documents/replace.js';
|
|
8
|
+
import { wrap } from '../util/express.js';
|
|
9
|
+
|
|
10
|
+
// spec §10 relay.
|
|
11
|
+
//
|
|
12
|
+
// NEVER writes disk. A `snapshot` fans out to the other editors on the live
|
|
13
|
+
// lane; a `document` fans out to viewers on the saved lane.
|
|
14
|
+
|
|
15
|
+
// The v2 device-sync lane. Reserved NOW so a v1 host never accidentally serves
|
|
16
|
+
// something else from these paths and a v2 client can tell "not implemented yet"
|
|
17
|
+
// from "wrong kind of server".
|
|
18
|
+
const RESERVED_SYNC_PATHS = ['nodes', 'status', 'stream', 'control'];
|
|
19
|
+
|
|
20
|
+
export function mountSyncRoutes({
|
|
21
|
+
system, config, liveSync, nodes, gateCtx, resolveHref, resolveSaveToken,
|
|
22
|
+
}) {
|
|
23
|
+
const syncJson = express.json({ limit: config.limits.saveBytes });
|
|
24
|
+
|
|
25
|
+
async function handleRelay(req, res) {
|
|
26
|
+
if (!liveSync) return jsonError(res, 'not-found', 'This host does not advertise sync.');
|
|
27
|
+
const href = documentUrlHeader(req);
|
|
28
|
+
if (!href) return jsonError(res, 'bad-request', 'Document-URL header required.');
|
|
29
|
+
const { owner, relPath } = resolveHref(href);
|
|
30
|
+
|
|
31
|
+
const body = req.body || {};
|
|
32
|
+
const snapshot = typeof body.snapshot === 'string' ? body.snapshot
|
|
33
|
+
: typeof body.html === 'string' ? body.html : null;
|
|
34
|
+
const document = typeof body.document === 'string' ? body.document : null;
|
|
35
|
+
if (snapshot == null && document == null) {
|
|
36
|
+
return jsonError(res, 'bad-request', 'Expected {snapshot} or {document}.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The same grant the GET half takes. Both halves of one §10 address have to
|
|
40
|
+
// agree about what authenticates a caller: while only the receiver read it,
|
|
41
|
+
// a sandboxed document could subscribe to updates and never send one.
|
|
42
|
+
let actor = req.actor;
|
|
43
|
+
const token = req.query.token || req.body?.token;
|
|
44
|
+
if (token) {
|
|
45
|
+
const grant = await resolveSaveToken(String(token));
|
|
46
|
+
if (grant) actor = { ...actor, grants: { ...actor.grants, saveToken: grant } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const node = nodes.resolve(owner, relPath);
|
|
50
|
+
const lane = document != null ? 'saved' : 'live';
|
|
51
|
+
const action = lane === 'saved' ? 'read' : 'write';
|
|
52
|
+
if (!can(actor, action, node, gateCtx)) {
|
|
53
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
54
|
+
}
|
|
55
|
+
if (document != null && !hasHtmlRoot(document)) {
|
|
56
|
+
return jsonError(res, 'invalid-document', 'Not a complete HTML document.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Serve-time machinery, never a document's own bytes — the same rule
|
|
60
|
+
// replace.js applies on save. A relay frame goes to another person's browser,
|
|
61
|
+
// so a credential riding along would be minted for one principal and land on
|
|
62
|
+
// another's root.
|
|
63
|
+
const relayed = stripRootAttrs(document ?? snapshot, EPHEMERAL_ATTRS);
|
|
64
|
+
|
|
65
|
+
const delivered = liveSync.broadcast(
|
|
66
|
+
owner, relPath,
|
|
67
|
+
{ html: relayed, sender: body.sender || req.headers['x-sender-id'] || null, identityMap: body.identityMap },
|
|
68
|
+
{ lane },
|
|
69
|
+
);
|
|
70
|
+
return res.json({ ok: true, lane, delivered });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Spec §10: one address, POST to send and GET to receive. The /_/live-sync/*
|
|
74
|
+
// aliases are kept permanently alongside it. They were removed on 2026-08-21 on the
|
|
75
|
+
// reading that nothing outside our own clients speaks them; that turned out to be
|
|
76
|
+
// false. A saved document is a frozen client: the Collection dashboard opens
|
|
77
|
+
// /_/live-sync/stream from inline page script, so every dashboard ever minted names
|
|
78
|
+
// that path and no library update can reach it, and hyperclayjs hardcodes both.
|
|
79
|
+
// The exact routes only; the reserved device-lane subpaths below are unaffected,
|
|
80
|
+
// because express distinguishes a route from its subpaths.
|
|
81
|
+
system.post(['/sync', '/live-sync/save'], syncJson, wrap(handleRelay));
|
|
82
|
+
|
|
83
|
+
system.get(['/sync', '/live-sync/stream'], wrap(async (req, res) => {
|
|
84
|
+
if (!liveSync) return jsonError(res, 'not-found', 'This host does not advertise sync.');
|
|
85
|
+
// document-url is the spec spelling and wins; page-url is the pre-spec one,
|
|
86
|
+
// still read because a host being lenient about how it is addressed costs
|
|
87
|
+
// nothing and a stream that silently 404s is hard to diagnose.
|
|
88
|
+
const href = req.query['document-url'] || req.query['page-url'] || documentUrlHeader(req);
|
|
89
|
+
if (!href) return jsonError(res, 'bad-request', 'document-url parameter required.');
|
|
90
|
+
const { owner, relPath } = resolveHref(String(href));
|
|
91
|
+
const lane = req.query.lane === 'saved' ? 'saved' : 'live';
|
|
92
|
+
|
|
93
|
+
let actor = req.actor;
|
|
94
|
+
if (req.query.token) {
|
|
95
|
+
const grant = await resolveSaveToken(String(req.query.token));
|
|
96
|
+
if (grant) actor = { ...actor, grants: { ...actor.grants, saveToken: grant } };
|
|
97
|
+
}
|
|
98
|
+
const node = nodes.resolve(owner, relPath);
|
|
99
|
+
// The saved lane carries whole documents, so it is gated like a read; the
|
|
100
|
+
// live lane carries pre-strip peer snapshots, so it needs write.
|
|
101
|
+
const action = lane === 'saved' ? 'read' : 'write';
|
|
102
|
+
if (!can(actor, action, node, gateCtx)) return jsonError(res, 'not-found', 'Not found');
|
|
103
|
+
|
|
104
|
+
liveSync.subscribe(owner, relPath, res, { lane });
|
|
105
|
+
return undefined;
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// ---- the reserved v2 device lane ---------------------------------------
|
|
109
|
+
for (const name of RESERVED_SYNC_PATHS) {
|
|
110
|
+
system.all(`/sync/${name}`, (req, res) => {
|
|
111
|
+
res.status(410).json({
|
|
112
|
+
msg: 'The device sync lane is reserved for a future version.',
|
|
113
|
+
msgType: 'error',
|
|
114
|
+
code: 'reserved',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { can } from '../auth/can.js';
|
|
3
|
+
import { jsonError } from '../json-errors.js';
|
|
4
|
+
import { HostError } from '../spec/codes.js';
|
|
5
|
+
import { documentUrlHeader } from '../spec/wire.js';
|
|
6
|
+
import { readMultipart } from '../util/multipart.js';
|
|
7
|
+
import { wrap } from '../util/express.js';
|
|
8
|
+
import { assetsDirFor } from '../nodes/names.js';
|
|
9
|
+
|
|
10
|
+
// spec §9 uploads.
|
|
11
|
+
//
|
|
12
|
+
// The document's lane, and it is not the dashboard's. The dashboard puts a file
|
|
13
|
+
// wherever a person is looking, keeps the name they chose, and takes the scripts
|
|
14
|
+
// and stylesheets a site loads. A document uploading is a different act with
|
|
15
|
+
// different rules: the host picks the name so nothing is ever replaced, the file
|
|
16
|
+
// lands in one predictable place beside the document, and anything that would
|
|
17
|
+
// RUN on this origin is refused, because a document must not be able to install
|
|
18
|
+
// code on its host by calling it an upload.
|
|
19
|
+
//
|
|
20
|
+
// Keeping those as two addresses rather than one address with two modes is
|
|
21
|
+
// deliberate. Fold them together and the strict rules become a thing the caller
|
|
22
|
+
// opts into by shaping the request, when they should be a property of the door.
|
|
23
|
+
const UPLOAD_REFUSED = /\.(html?|xhtml|htmlclay|js|mjs|cjs|xml|xht|xsl|xslt)$/i;
|
|
24
|
+
|
|
25
|
+
export function mountUploadRoutes({
|
|
26
|
+
system, config, ops, tenants, gateCtx, resolveTarget, resolveSaveToken,
|
|
27
|
+
}) {
|
|
28
|
+
async function handleUpload(req, res, grant = null) {
|
|
29
|
+
if (config.capabilities.upload === false || config.features?.uploads === false) {
|
|
30
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let node = null;
|
|
34
|
+
let actor = req.actor;
|
|
35
|
+
if (grant) {
|
|
36
|
+
node = grant.node;
|
|
37
|
+
actor = { ...req.actor, grants: { ...req.actor.grants, saveToken: grant } };
|
|
38
|
+
} else {
|
|
39
|
+
const href = documentUrlHeader(req);
|
|
40
|
+
if (!href) return jsonError(res, 'bad-request', 'Document-URL header required.');
|
|
41
|
+
const found = await resolveTarget(req.actor, href, 'read');
|
|
42
|
+
if (!found) return jsonError(res, 'not-found', 'Not found');
|
|
43
|
+
node = found.node;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Uploading is writing to the document by another route, so the document's
|
|
47
|
+
// own write permission is what decides it. A save token grants exactly that,
|
|
48
|
+
// which is what makes a sandboxed document able to upload at all.
|
|
49
|
+
if (!can(actor, 'write', node, gateCtx)) {
|
|
50
|
+
return jsonError(res, 'forbidden', 'You cannot upload to this document.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const limit = config.limits.uploadBytes;
|
|
54
|
+
const contentType = String(req.headers['content-type'] || '');
|
|
55
|
+
if (!contentType.startsWith('multipart/form-data')) {
|
|
56
|
+
return jsonError(res, 'bad-request', 'Send the file as multipart/form-data.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let files;
|
|
60
|
+
try {
|
|
61
|
+
({ files } = await readMultipart(req, { limit, files: 20 }));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof HostError) return jsonError(res, error.code, error.message);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (!files.length) return jsonError(res, 'bad-request', 'No files in this upload.');
|
|
67
|
+
|
|
68
|
+
const dir = assetsDirFor(node.path);
|
|
69
|
+
|
|
70
|
+
// The whole batch is judged before any of it is stored. Refusing inside the
|
|
71
|
+
// write loop answers "that file cannot be uploaded" with the files before it
|
|
72
|
+
// already on disk, so the caller is told the upload failed and is given no
|
|
73
|
+
// way to learn what landed.
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const name = path.basename(String(file.filename || ''));
|
|
76
|
+
if (UPLOAD_REFUSED.test(name)) {
|
|
77
|
+
return jsonError(
|
|
78
|
+
res, 'unsupported-type',
|
|
79
|
+
'That kind of file runs when it is opened, so it cannot be uploaded. A document goes through the save lane.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (file.content.length > limit) {
|
|
83
|
+
return jsonError(res, 'too-large', `Files are limited to ${limit} bytes.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Uploading is writing to the document by another route, and the quota is
|
|
88
|
+
// part of writing. Charged before the bytes land, for the reason the save
|
|
89
|
+
// lane states: a quota that only notices afterwards is an audit log, not a
|
|
90
|
+
// limit. Without this a tenant's document holds an upload token and can
|
|
91
|
+
// spend the owner's disk without limit.
|
|
92
|
+
if (node.owner && tenants.enabled) {
|
|
93
|
+
const bytes = files.reduce((total, file) => total + file.content.length, 0);
|
|
94
|
+
tenants.checkQuota(node.owner, { relPath: dir, bytes });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const uploads = [];
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const name = path.basename(String(file.filename || ''));
|
|
100
|
+
const stored = await ops.putUpload({ owner: node.owner, dir, name, content: file.content });
|
|
101
|
+
// Relative to the document's own URL, which §9 says is what the client
|
|
102
|
+
// resolves against. That is the one form correct in every realm, at any
|
|
103
|
+
// folder depth, and behind a proxy that mounts the host under a subpath;
|
|
104
|
+
// an absolute path would have to rebuild the realm prefix and would be
|
|
105
|
+
// wrong in all three cases.
|
|
106
|
+
// Percent-encoded, because what goes back is a URL and the stored name is a
|
|
107
|
+
// filename: this host deliberately keeps spaces in names, and a raw space
|
|
108
|
+
// makes the reference invalid. It survives in an `img src` because the
|
|
109
|
+
// browser repairs it, and breaks outright in `srcset`, where a space is the
|
|
110
|
+
// separator, and in an unquoted CSS `url()`. `name` stays the real filename
|
|
111
|
+
// so a person can find it in the file manager.
|
|
112
|
+
uploads.push({
|
|
113
|
+
name: stored.name,
|
|
114
|
+
url: `${encodeURIComponent(path.basename(dir))}/${encodeURIComponent(stored.name)}`,
|
|
115
|
+
bytes: stored.bytes,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return res.json({ msg: 'Uploaded', uploads });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
system.post('/upload', wrap((req, res) => handleUpload(req, res)));
|
|
122
|
+
|
|
123
|
+
system.post('/upload/:token', wrap(async (req, res) => {
|
|
124
|
+
const grant = await resolveSaveToken(req.params.token);
|
|
125
|
+
if (!grant) return jsonError(res, 'unauthorized', 'This save token is not valid.');
|
|
126
|
+
return handleUpload(req, res, grant);
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import path from 'upath';
|
|
4
|
+
import qrcode from 'qrcode-generator';
|
|
5
|
+
import { can } from '../auth/can.js';
|
|
6
|
+
import { jsonError } from '../json-errors.js';
|
|
7
|
+
import { HostError } from '../spec/codes.js';
|
|
8
|
+
import { token as mintToken } from '../util/id.js';
|
|
9
|
+
|
|
10
|
+
// Share links (§13).
|
|
11
|
+
//
|
|
12
|
+
// Many links per node, each labelled ("for Ben"), each view or edit, each
|
|
13
|
+
// separately revocable, each optionally expiring. Tokens are hashed at rest and
|
|
14
|
+
// the raw value is shown exactly once.
|
|
15
|
+
//
|
|
16
|
+
// A share grants a CAPABILITY SET for one node. It never promotes an identity.
|
|
17
|
+
// That is the whole difference from the platform's no_auth -> app_user
|
|
18
|
+
// elevation, which set isOwner unconditionally and turned a link to one page
|
|
19
|
+
// into an account on the box.
|
|
20
|
+
//
|
|
21
|
+
// The cookie is keyed by NODE ID rather than by name, so renaming a shared
|
|
22
|
+
// document does not break a link someone is already holding.
|
|
23
|
+
|
|
24
|
+
const COOKIE_PREFIX = 'mk_share_';
|
|
25
|
+
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
|
|
26
|
+
|
|
27
|
+
function hashShareToken(raw) {
|
|
28
|
+
return crypto.createHash('sha256').update(String(raw)).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shareCookieName(nodeId) {
|
|
32
|
+
return `${COOKIE_PREFIX}${nodeId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createShares({ store, nodes, clock, ids = { token: mintToken }, events}) {
|
|
36
|
+
function live(share) {
|
|
37
|
+
if (!share) return null;
|
|
38
|
+
if (share.revokedAt) return null;
|
|
39
|
+
if (share.expiresAt && share.expiresAt <= clock.now()) return null;
|
|
40
|
+
return share;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveToken(raw) {
|
|
44
|
+
if (!store || !raw) return null;
|
|
45
|
+
return live(store.getShare(hashShareToken(raw)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function mint({ actor, owner = '', relPath, grantKind = 'view', label = null, expiresInDays = null, ctx = {} }) {
|
|
49
|
+
if (!store) throw new HostError('not-found', 'This host keeps no share links.');
|
|
50
|
+
if (grantKind !== 'view' && grantKind !== 'edit') {
|
|
51
|
+
throw new HostError('bad-request', 'A share is either view or edit.');
|
|
52
|
+
}
|
|
53
|
+
const node = nodes.materialize(nodes.resolve(owner, relPath));
|
|
54
|
+
if (!can(actor, 'admin', node, ctx)) throw new HostError('forbidden', 'Only the owner can share.');
|
|
55
|
+
|
|
56
|
+
const raw = ids.token(24);
|
|
57
|
+
const tokenHash = hashShareToken(raw);
|
|
58
|
+
const expiresAt = expiresInDays ? clock.now() + Number(expiresInDays) * 86_400_000 : null;
|
|
59
|
+
store.insertShare({
|
|
60
|
+
tokenHash, nodeId: node.id, grantKind, label: label ? String(label).slice(0, 120) : null,
|
|
61
|
+
createdAt: clock.now(), expiresAt,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
events.emit('node-updated', { node, flag: 'share' });
|
|
65
|
+
// The raw token is returned here and never again: the table has only its hash.
|
|
66
|
+
return { token: raw, tokenHash, nodeId: node.id, grantKind, label, expiresAt };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function revoke({ actor, tokenHash, ctx = {} }) {
|
|
70
|
+
if (!store) throw new HostError('not-found', 'This host keeps no share links.');
|
|
71
|
+
const share = store.getShare(tokenHash);
|
|
72
|
+
if (!share) throw new HostError('not-found', 'No such share link.');
|
|
73
|
+
const node = store.getNodeById(share.nodeId);
|
|
74
|
+
if (!can(actor, 'admin', node, ctx)) throw new HostError('forbidden', 'Only the owner can revoke.');
|
|
75
|
+
|
|
76
|
+
store.revokeShare(tokenHash, clock.now());
|
|
77
|
+
// Kill the save tokens minted through this link too, or the holder keeps
|
|
78
|
+
// writing for up to twelve hours after being cut off.
|
|
79
|
+
store.deleteSaveTokensForNode(share.nodeId);
|
|
80
|
+
events.emit('share-revoked', { nodeId: share.nodeId, tokenHash });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function list(nodeId) {
|
|
85
|
+
if (!store) return [];
|
|
86
|
+
return store.listShares(nodeId).map((share) => ({
|
|
87
|
+
tokenHash: share.tokenHash,
|
|
88
|
+
grantKind: share.grantKind,
|
|
89
|
+
label: share.label,
|
|
90
|
+
createdAt: share.createdAt,
|
|
91
|
+
expiresAt: share.expiresAt,
|
|
92
|
+
revokedAt: share.revokedAt,
|
|
93
|
+
lastUsedAt: share.lastUsedAt,
|
|
94
|
+
active: !!live(share),
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Every share the caller can prove they hold, whether it applies to this
|
|
99
|
+
// request or not; can() picks the one that matches the node.
|
|
100
|
+
function grantsFor(req, res) {
|
|
101
|
+
if (!store) return [];
|
|
102
|
+
const found = [];
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
|
|
105
|
+
const add = (share, raw) => {
|
|
106
|
+
if (!share || seen.has(share.tokenHash)) return;
|
|
107
|
+
seen.add(share.tokenHash);
|
|
108
|
+
found.push(share);
|
|
109
|
+
store.touchShare(share.tokenHash, clock.now());
|
|
110
|
+
if (raw && res && !req.cookies?.[shareCookieName(share.nodeId)]) {
|
|
111
|
+
res.cookie(shareCookieName(share.nodeId), raw, {
|
|
112
|
+
httpOnly: true, sameSite: 'lax', path: '/', maxAge: COOKIE_MAX_AGE,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const fromQuery = typeof req.query?.token === 'string' ? req.query.token : null;
|
|
118
|
+
if (fromQuery) add(resolveToken(fromQuery), fromQuery);
|
|
119
|
+
|
|
120
|
+
for (const [name, value] of Object.entries(req.cookies || {})) {
|
|
121
|
+
if (!name.startsWith(COOKIE_PREFIX)) continue;
|
|
122
|
+
const share = resolveToken(value);
|
|
123
|
+
if (share) add(share, null);
|
|
124
|
+
// A revoked or expired cookie is cleared so a stale one does not sit in
|
|
125
|
+
// the browser forever quietly failing.
|
|
126
|
+
else if (res) res.clearCookie(name, { path: '/' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function qrSvg(url) {
|
|
133
|
+
const qr = qrcode(0, 'M');
|
|
134
|
+
qr.addData(url);
|
|
135
|
+
qr.make();
|
|
136
|
+
return qr.createSvgTag({ cellSize: 6, margin: 2, scalable: true });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { mint, revoke, list, resolveToken, grantsFor, qrSvg, hashShareToken, shareCookieName };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function mountShareRoutes(ctx) {
|
|
143
|
+
const { router, shares, nodes, store, gateCtx, wrap, publicUrl } = ctx;
|
|
144
|
+
const json = express.json({ limit: '64kb' });
|
|
145
|
+
|
|
146
|
+
function requireOwner(req, res, next) {
|
|
147
|
+
if (!can(req.actor, 'admin', null, gateCtx)) return jsonError(res, 'not-found', 'Not found');
|
|
148
|
+
return next();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function absolute(req, relPath, token) {
|
|
152
|
+
const base = publicUrl || `${req.protocol}://${req.get('host')}`;
|
|
153
|
+
const encoded = String(relPath).split('/').map(encodeURIComponent).join('/');
|
|
154
|
+
return `${base.replace(/\/$/, '')}/${encoded}?token=${encodeURIComponent(token)}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
router.get('/share', requireOwner, wrap(async (req, res) => {
|
|
158
|
+
const relPath = String(req.query.path || '').replace(/^\/+/, '');
|
|
159
|
+
if (!relPath) return jsonError(res, 'bad-request', 'path is required.');
|
|
160
|
+
const node = nodes.get('', relPath);
|
|
161
|
+
return res.json({ ok: true, path: relPath, shares: node ? shares.list(node.id) : [] });
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
router.post('/share', requireOwner, json, wrap(async (req, res) => {
|
|
165
|
+
const relPath = String(req.body?.path || '').replace(/^\/+/, '');
|
|
166
|
+
if (!relPath) return jsonError(res, 'bad-request', 'path is required.');
|
|
167
|
+
const minted = await shares.mint({
|
|
168
|
+
actor: req.actor,
|
|
169
|
+
relPath,
|
|
170
|
+
grantKind: req.body?.grant === 'edit' ? 'edit' : 'view',
|
|
171
|
+
label: req.body?.label ?? null,
|
|
172
|
+
expiresInDays: req.body?.expiresInDays ?? null,
|
|
173
|
+
ctx: gateCtx,
|
|
174
|
+
});
|
|
175
|
+
return res.json({
|
|
176
|
+
ok: true,
|
|
177
|
+
...minted,
|
|
178
|
+
url: absolute(req, relPath, minted.token),
|
|
179
|
+
qrUrl: `/_/share/${encodeURIComponent(minted.token)}/qr`,
|
|
180
|
+
});
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
router.post('/share/revoke', requireOwner, json, wrap(async (req, res) => {
|
|
184
|
+
const tokenHash = req.body?.tokenHash
|
|
185
|
+
|| (req.body?.token ? shares.hashShareToken(req.body.token) : null);
|
|
186
|
+
if (!tokenHash) return jsonError(res, 'bad-request', 'tokenHash is required.');
|
|
187
|
+
await shares.revoke({ actor: req.actor, tokenHash, ctx: gateCtx });
|
|
188
|
+
return res.json({ ok: true });
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
// The QR page is for handing a link to someone standing next to you, so it is
|
|
192
|
+
// owner-only: it prints the raw token, which is the credential itself.
|
|
193
|
+
router.get('/share/:token/qr', requireOwner, wrap(async (req, res) => {
|
|
194
|
+
const share = shares.resolveToken(req.params.token);
|
|
195
|
+
if (!share) return jsonError(res, 'not-found', 'No such share link.');
|
|
196
|
+
const node = store?.getNodeById(share.nodeId);
|
|
197
|
+
if (!node) return jsonError(res, 'not-found', 'No such share link.');
|
|
198
|
+
const url = absolute(req, node.path, req.params.token);
|
|
199
|
+
return ctx.ui.send(res, 'share-qr', {
|
|
200
|
+
title: `Share ${path.basename(node.path)}`,
|
|
201
|
+
name: path.basename(node.path),
|
|
202
|
+
grant: share.grantKind,
|
|
203
|
+
url,
|
|
204
|
+
qr: shares.qrSvg(url),
|
|
205
|
+
});
|
|
206
|
+
}));
|
|
207
|
+
}
|