@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,321 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import chokidar from 'chokidar';
|
|
3
|
+
import { documentEtag } from '../spec/wire.js';
|
|
4
|
+
import { isReservedNodePath } from '../paths.js';
|
|
5
|
+
import {
|
|
6
|
+
readdirIfExists, statIfExists, statInfo, readFileTextIfExists, unlinkIfExists,
|
|
7
|
+
} from '../util/fsx.js';
|
|
8
|
+
|
|
9
|
+
// The overlay reconciler: a boot walk and a watcher, both of which agree with
|
|
10
|
+
// the save lane rather than racing it.
|
|
11
|
+
//
|
|
12
|
+
// The cost model is the whole design. A row exists only for a path that has
|
|
13
|
+
// state or history, so the walk is one readdir pass plus work proportional to
|
|
14
|
+
// the number of ROWS, never to the number of files. Someone will point makerclay
|
|
15
|
+
// at their home directory in week one; 50k untouched files must cost 50k stat
|
|
16
|
+
// calls and nothing else. Digests are computed lazily, on first serve or save.
|
|
17
|
+
|
|
18
|
+
const DEBRIS = /^\..+\.(\d+)\.\d+\.tmp$/;
|
|
19
|
+
|
|
20
|
+
export function createScanner({
|
|
21
|
+
paths, nodes, locks, events, clock, versions = null, logger = console,
|
|
22
|
+
}) {
|
|
23
|
+
const missing = new Set();
|
|
24
|
+
const watchers = [];
|
|
25
|
+
const key = (owner, relPath) => `${owner}\x1f${relPath}`;
|
|
26
|
+
|
|
27
|
+
async function walk(root, prefix = '', depth = 0, out = new Map()) {
|
|
28
|
+
if (depth > paths.maxFolderDepth + 1) return out;
|
|
29
|
+
for (const entry of await readdirIfExists(root, { withFileTypes: true })) {
|
|
30
|
+
if (entry.name.startsWith('.')) continue;
|
|
31
|
+
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
32
|
+
const full = path.join(root, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
await walk(full, relPath, depth + 1, out);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Deliberate: a symlink is neither a directory nor a file here, so it is
|
|
38
|
+
// skipped rather than followed. Do not "fix" this into stat-following.
|
|
39
|
+
// The inode matcher below rebinds a row to whatever carries its inode, and
|
|
40
|
+
// a followed symlink lets anyone who can write one in this tree rebind a
|
|
41
|
+
// row's identity, history and private flag onto a target of their choosing.
|
|
42
|
+
if (!entry.isFile()) continue;
|
|
43
|
+
const info = await statInfo(full);
|
|
44
|
+
if (info) out.set(relPath, info);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A kill between the temp write and the rename leaves `.name.pid.n.tmp`
|
|
50
|
+
// behind. It is already unreachable (paths.js 404s every dotfile), so this is
|
|
51
|
+
// housekeeping, and it is only safe at boot: the single-writer lock is what
|
|
52
|
+
// guarantees no other process has one of these open.
|
|
53
|
+
async function sweepDebris(root, prefix = '', depth = 0) {
|
|
54
|
+
let swept = 0;
|
|
55
|
+
if (depth > paths.maxFolderDepth + 1) return swept;
|
|
56
|
+
for (const entry of await readdirIfExists(root, { withFileTypes: true })) {
|
|
57
|
+
const full = path.join(root, entry.name);
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
if (entry.name.startsWith('.')) continue;
|
|
60
|
+
swept += await sweepDebris(full, `${prefix}${entry.name}/`, depth + 1);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const debris = DEBRIS.exec(entry.name);
|
|
64
|
+
if (!debris) continue;
|
|
65
|
+
// Our OWN pid is a save in flight, not debris. Only HTTP requests wait on
|
|
66
|
+
// boot, so an embedder calling host.documents.replace() straight away runs
|
|
67
|
+
// alongside this sweep, and deleting its temp file fails the save.
|
|
68
|
+
if (Number(debris[1]) === process.pid) continue;
|
|
69
|
+
if (await unlinkIfExists(full)) swept += 1;
|
|
70
|
+
}
|
|
71
|
+
return swept;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function refreshRow(owner, relPath, row, stat) {
|
|
75
|
+
const full = path.join(paths.realmRoot(owner), relPath);
|
|
76
|
+
return await locks.withLock(full, async () => {
|
|
77
|
+
const bytes = await readFileTextIfExists(full);
|
|
78
|
+
if (bytes == null) return null;
|
|
79
|
+
const etag = documentEtag(bytes);
|
|
80
|
+
const signature = {
|
|
81
|
+
bytes: Buffer.byteLength(bytes, 'utf8'), mtimeMs: stat.mtimeMs,
|
|
82
|
+
};
|
|
83
|
+
// Nothing to announce, but the mtime still moved and it is the key the
|
|
84
|
+
// unchanged fast path reads. Now that the kernel stamps observedEtag this
|
|
85
|
+
// branch fires on an ordinary `touch` after a save, and a row left holding
|
|
86
|
+
// a stale mtime is one this scan and every scan after it re-reads.
|
|
87
|
+
if (etag === row.observedEtag) {
|
|
88
|
+
nodes.update(row, signature);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const updated = nodes.update(row, { observedEtag: etag, ...signature });
|
|
92
|
+
events.emit('node-changed', { node: updated, source: 'scanner' });
|
|
93
|
+
return updated;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Only ever called for rows whose file vanished or was replaced, so the cost
|
|
98
|
+
// is proportional to real moves rather than to folder size. Two signals,
|
|
99
|
+
// strongest first: an inode is exact and free, and a digest is a proof that
|
|
100
|
+
// costs a bounded read.
|
|
101
|
+
async function rebindRenames(owner, gone, seen, inodeAt) {
|
|
102
|
+
if (!gone.length) return 0;
|
|
103
|
+
const indexed = new Set(nodes.list({ owner }).map((row) => row.path));
|
|
104
|
+
const unclaimed = [...seen].filter(([relPath]) => !indexed.has(relPath));
|
|
105
|
+
let rebound = 0;
|
|
106
|
+
|
|
107
|
+
async function claim(row, relPath) {
|
|
108
|
+
const moved = await reanchor(row, relPath);
|
|
109
|
+
missing.delete(key(owner, row.path));
|
|
110
|
+
events.emit('node-renamed', { node: moved, from: row.path, source: 'scanner' });
|
|
111
|
+
indexed.add(relPath);
|
|
112
|
+
rebound += 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function byInode(row) {
|
|
116
|
+
if (!row.ino) return null;
|
|
117
|
+
const at = inodeAt.get(`${row.dev}:${row.ino}`);
|
|
118
|
+
if (!at) return null;
|
|
119
|
+
// An inode is an identity only while its file is alive. For a row whose
|
|
120
|
+
// file is gone, which is the only kind this runs for, the number alone
|
|
121
|
+
// proves nothing: ext4, XFS and btrfs hand a freed inode straight back out.
|
|
122
|
+
// Birth time is what separates a rename from a reissue, and content cannot,
|
|
123
|
+
// because a rename that also rewrites the bytes is precisely what this
|
|
124
|
+
// strategy exists to catch.
|
|
125
|
+
const info = seen.get(at);
|
|
126
|
+
if (info?.birthtimeMs && row.updatedAt && info.birthtimeMs > row.updatedAt) return null;
|
|
127
|
+
return at;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The size filter is free: `seen` already holds every stat. On a tree with
|
|
131
|
+
// nothing missing this does zero reads, and when something IS missing it
|
|
132
|
+
// reads only the byte-exact size matches.
|
|
133
|
+
async function byDigest(row) {
|
|
134
|
+
const want = row.observedEtag || row.etag;
|
|
135
|
+
if (!want || row.bytes == null) return null;
|
|
136
|
+
let hit = null;
|
|
137
|
+
for (const [relPath, info] of unclaimed) {
|
|
138
|
+
if (info.size !== row.bytes || indexed.has(relPath)) continue;
|
|
139
|
+
const bytes = await readFileTextIfExists(path.join(paths.realmRoot(owner), relPath));
|
|
140
|
+
if (bytes == null || documentEtag(bytes) !== want) continue;
|
|
141
|
+
// Two byte-identical candidates: there is no way to tell which is the
|
|
142
|
+
// one that moved, so strand the history rather than guess wrong.
|
|
143
|
+
if (hit) return null;
|
|
144
|
+
hit = relPath;
|
|
145
|
+
}
|
|
146
|
+
return hit;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let pending = gone.slice();
|
|
150
|
+
for (const strategy of [byInode, byDigest]) {
|
|
151
|
+
const next = [];
|
|
152
|
+
for (const row of pending) {
|
|
153
|
+
const found = await strategy(row);
|
|
154
|
+
if (found && !indexed.has(found)) {
|
|
155
|
+
await claim(row, found);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
next.push(row);
|
|
159
|
+
}
|
|
160
|
+
pending = next;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
gone.length = 0;
|
|
164
|
+
gone.push(...pending);
|
|
165
|
+
return rebound;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Written after the bytes have already moved, so a failure here costs a stale
|
|
169
|
+
// record and never the rebind. Same deal as ops.js's patchMetaQuietly.
|
|
170
|
+
async function patchRecordPath(node, relPath) {
|
|
171
|
+
if (!versions) return;
|
|
172
|
+
try {
|
|
173
|
+
await versions.patchMeta(node, { path: relPath });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logger.warn?.('[makerclay] could not update the durable record:', error?.message || error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The record's path is patched here for the same reason ops.move patches it:
|
|
180
|
+
// this is the lane that absorbs a rename made in Finder or a terminal, so
|
|
181
|
+
// leaving the record behind goes stale on exactly the renames this host is
|
|
182
|
+
// built for. It also fails OPEN, because a record whose path is stale rebuilds
|
|
183
|
+
// a row at a path nothing is at, while the real file serves with no row.
|
|
184
|
+
async function reanchor(row, relPath) {
|
|
185
|
+
const moved = nodes.update(row, { path: relPath, updatedAt: clock.now() });
|
|
186
|
+
await patchRecordPath(moved, relPath);
|
|
187
|
+
return moved;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function scan({ owner = '', sweep = true } = {}) {
|
|
191
|
+
const root = paths.realmRoot(owner);
|
|
192
|
+
const swept = sweep ? await sweepDebris(root) : 0;
|
|
193
|
+
const seen = await walk(root);
|
|
194
|
+
// Where two live paths share one inode (a hardlink), the entry is null:
|
|
195
|
+
// "this inode is here" is no longer a single answer, so nothing may guess.
|
|
196
|
+
const inodeAt = new Map();
|
|
197
|
+
for (const [relPath, info] of seen) {
|
|
198
|
+
if (!info.ino) continue;
|
|
199
|
+
const at = `${info.dev}:${info.ino}`;
|
|
200
|
+
inodeAt.set(at, inodeAt.has(at) ? null : relPath);
|
|
201
|
+
}
|
|
202
|
+
// Reserved rows anchor identity for things that are not files in this tree
|
|
203
|
+
// (collection records). The walk cannot see them, so without this every one
|
|
204
|
+
// of them would report as permanently missing.
|
|
205
|
+
const rows = nodes.list({ owner }).filter((row) => !isReservedNodePath(row.path));
|
|
206
|
+
const rowPaths = new Set(rows.map((row) => row.path));
|
|
207
|
+
|
|
208
|
+
const gone = [];
|
|
209
|
+
let refreshed = 0;
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
const info = seen.get(row.path);
|
|
212
|
+
if (!info) {
|
|
213
|
+
gone.push(row);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// The row's own file is alive at ANOTHER unindexed path, so this path now
|
|
217
|
+
// holds something else. That is `mv a.html archive.html; cp t.html a.html`,
|
|
218
|
+
// an ordinary publishing move, and also what a git checkout across a
|
|
219
|
+
// rename looks like. Binding to the path would hand this row's identity,
|
|
220
|
+
// history and private flag to a stranger's file.
|
|
221
|
+
const elsewhere = row.ino ? inodeAt.get(`${row.dev}:${row.ino}`) : null;
|
|
222
|
+
if (elsewhere && elsewhere !== row.path && !rowPaths.has(elsewhere)) {
|
|
223
|
+
gone.push(row);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
missing.delete(key(owner, row.path));
|
|
227
|
+
// Recorded HERE, not in refreshRow, which returns early when the bytes are
|
|
228
|
+
// unchanged. A replace-in-place with identical content (git checkout,
|
|
229
|
+
// rsync, cp over the top) changes the inode without changing a byte, and
|
|
230
|
+
// `rsync -a` preserves mtime and size too, so the unchanged fast path
|
|
231
|
+
// below would skip it entirely.
|
|
232
|
+
if (row.dev !== info.dev || row.ino !== info.ino) {
|
|
233
|
+
nodes.update(row, { dev: info.dev, ino: info.ino });
|
|
234
|
+
}
|
|
235
|
+
const unchanged = row.mtimeMs === info.mtimeMs && row.bytes === info.size;
|
|
236
|
+
if (unchanged) continue;
|
|
237
|
+
if (await refreshRow(owner, row.path, row, info)) refreshed += 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rebound = await rebindRenames(owner, gone, seen, inodeAt);
|
|
241
|
+
// A row whose file is gone is marked, never tombstoned. An unmounted volume
|
|
242
|
+
// and a deliberate delete look identical from here, and only one of them
|
|
243
|
+
// should cost someone their history; the API delete lane tombstones.
|
|
244
|
+
for (const row of gone) missing.add(key(owner, row.path));
|
|
245
|
+
|
|
246
|
+
return { owner, files: seen.size, rows: rows.length, refreshed, rebound, missing: gone.length, swept };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The boot scan is not the only rename lane. chokidar reports a rename as an
|
|
250
|
+
// unlink followed by an add, and until this existed the add was announced as a
|
|
251
|
+
// brand new file: the old row kept a path that no longer resolved, and the new
|
|
252
|
+
// path had no row at all, which means it served PUBLIC until the next restart.
|
|
253
|
+
//
|
|
254
|
+
// Ordering is not a coincidence. `awaitWriteFinish` holds the add back by at
|
|
255
|
+
// least its stability threshold while unlink fires immediately, so by the time
|
|
256
|
+
// this runs the vacated row is already marked missing.
|
|
257
|
+
async function rebindOne(owner, relPath) {
|
|
258
|
+
const gone = nodes.list({ owner }).filter((row) => missing.has(key(owner, row.path)));
|
|
259
|
+
if (!gone.length) return null;
|
|
260
|
+
const info = await statInfo(path.join(paths.realmRoot(owner), relPath));
|
|
261
|
+
if (!info?.isFile) return null;
|
|
262
|
+
const seen = new Map([[relPath, info]]);
|
|
263
|
+
const inodeAt = new Map(info.ino ? [[`${info.dev}:${info.ino}`, relPath]] : []);
|
|
264
|
+
return (await rebindRenames(owner, gone, seen, inodeAt)) ? nodes.get(owner, relPath) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function onDiskChange(owner, relPath) {
|
|
268
|
+
const row = nodes.get(owner, relPath);
|
|
269
|
+
if (!row) {
|
|
270
|
+
// rebindRenames emits node-renamed through claim, so a rebind announces
|
|
271
|
+
// itself and this lane must not announce it a second time.
|
|
272
|
+
if (await rebindOne(owner, relPath)) return;
|
|
273
|
+
events.emit('node-appeared', { owner, path: relPath, source: 'watcher' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const full = path.join(paths.realmRoot(owner), relPath);
|
|
277
|
+
const stat = await statIfExists(full);
|
|
278
|
+
if (!stat) return;
|
|
279
|
+
missing.delete(key(owner, relPath));
|
|
280
|
+
await refreshRow(owner, relPath, row, stat);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function watch({ owner = '' } = {}) {
|
|
284
|
+
const root = paths.realmRoot(owner);
|
|
285
|
+
const watcher = chokidar.watch(root, {
|
|
286
|
+
ignoreInitial: true,
|
|
287
|
+
ignored: (target) => path.basename(target).startsWith('.'),
|
|
288
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
|
|
289
|
+
depth: paths.maxFolderDepth + 1,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const relative = (full) => path.relative(root, path.resolve(full));
|
|
293
|
+
const guard = (fn) => (full) => Promise.resolve(fn(relative(full)))
|
|
294
|
+
.catch((error) => logger.error?.('[makerclay] watcher:', error?.message || error));
|
|
295
|
+
|
|
296
|
+
watcher.on('add', guard((relPath) => onDiskChange(owner, relPath)));
|
|
297
|
+
watcher.on('change', guard((relPath) => onDiskChange(owner, relPath)));
|
|
298
|
+
watcher.on('unlink', guard((relPath) => {
|
|
299
|
+
const row = nodes.get(owner, relPath);
|
|
300
|
+
if (!row) return;
|
|
301
|
+
missing.add(key(owner, relPath));
|
|
302
|
+
events.emit('node-missing', { node: row, source: 'watcher' });
|
|
303
|
+
}));
|
|
304
|
+
|
|
305
|
+
watchers.push(watcher);
|
|
306
|
+
return watcher;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
scan,
|
|
311
|
+
watch,
|
|
312
|
+
isMissing: (owner, relPath) => missing.has(key(owner, relPath)),
|
|
313
|
+
missingPaths: () => [...missing].map((entry) => {
|
|
314
|
+
const [owner, relPath] = entry.split('\x1f');
|
|
315
|
+
return { owner, path: relPath };
|
|
316
|
+
}),
|
|
317
|
+
async close() {
|
|
318
|
+
await Promise.all(watchers.splice(0).map((watcher) => watcher.close()));
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ulid } from '../util/id.js';
|
|
2
|
+
import { stableId } from './identity.js';
|
|
3
|
+
import { isDocumentPath } from '../paths.js';
|
|
4
|
+
|
|
5
|
+
// Node CRUD over the store driver, with a provisional path when there is no
|
|
6
|
+
// driver at all. Every caller in the core resolves a node through here rather
|
|
7
|
+
// than touching the driver, so `store: null` degrades in exactly one place.
|
|
8
|
+
|
|
9
|
+
export function kindFor(relPath, isDir = false) {
|
|
10
|
+
if (isDir) return 'dir';
|
|
11
|
+
return isDocumentPath(relPath) ? 'doc' : 'file';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createNodes({ store, clock, ids = { ulid } }) {
|
|
15
|
+
function provisional(owner, relPath, kind) {
|
|
16
|
+
const now = clock.now();
|
|
17
|
+
return {
|
|
18
|
+
id: stableId(owner, relPath),
|
|
19
|
+
owner: owner ?? '',
|
|
20
|
+
path: relPath,
|
|
21
|
+
kind: kind || kindFor(relPath),
|
|
22
|
+
etag: null,
|
|
23
|
+
observedEtag: null,
|
|
24
|
+
bytes: null,
|
|
25
|
+
mtimeMs: null,
|
|
26
|
+
dev: null,
|
|
27
|
+
ino: null,
|
|
28
|
+
seq: 0,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
deletedAt: null,
|
|
32
|
+
private: false,
|
|
33
|
+
isolated: null,
|
|
34
|
+
signups: false,
|
|
35
|
+
sourceId: null,
|
|
36
|
+
provisional: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function get(owner, relPath) {
|
|
41
|
+
if (!store) return null;
|
|
42
|
+
return store.getNode(owner ?? '', relPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function byId(id) {
|
|
46
|
+
if (!store) return null;
|
|
47
|
+
return store.getNodeById(id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A node object for (owner, path) whether or not a row exists. Nothing is
|
|
51
|
+
// written: rows are materialized lazily on first save or first version, so a
|
|
52
|
+
// 50k-file folder costs no rows until someone actually edits something.
|
|
53
|
+
function resolve(owner, relPath, { kind = null } = {}) {
|
|
54
|
+
return get(owner, relPath) || provisional(owner, relPath, kind);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function materialize(node, patch = {}) {
|
|
58
|
+
if (!store) return { ...node, ...patch };
|
|
59
|
+
return store.tx(() => {
|
|
60
|
+
const existing = store.getNodeById(node.id) || store.getNode(node.owner, node.path);
|
|
61
|
+
if (existing) {
|
|
62
|
+
return store.updateNode(existing.id, { updatedAt: clock.now(), seq: store.nextSeq(), ...patch });
|
|
63
|
+
}
|
|
64
|
+
const now = clock.now();
|
|
65
|
+
return store.insertNode({
|
|
66
|
+
...node,
|
|
67
|
+
id: node.provisional ? ids.ulid() : node.id,
|
|
68
|
+
seq: store.nextSeq(),
|
|
69
|
+
createdAt: now,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
...patch,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function update(node, patch) {
|
|
77
|
+
if (!store) return { ...node, ...patch };
|
|
78
|
+
const row = store.getNodeById(node.id);
|
|
79
|
+
if (!row) return materialize(node, patch);
|
|
80
|
+
return store.updateNode(row.id, { updatedAt: clock.now(), ...patch });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function list(options) {
|
|
84
|
+
if (!store) return [];
|
|
85
|
+
return store.listNodes(options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tombstone(node, at = clock.now()) {
|
|
89
|
+
if (!store) return { ...node, deletedAt: at };
|
|
90
|
+
const row = store.getNodeById(node.id) || materialize(node);
|
|
91
|
+
return store.tombstone(row.id, at);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function restore(node) {
|
|
95
|
+
if (!store) return { ...node, deletedAt: null };
|
|
96
|
+
return store.updateNode(node.id, { deletedAt: null, updatedAt: clock.now() });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
provisional,
|
|
101
|
+
get,
|
|
102
|
+
byId,
|
|
103
|
+
resolve,
|
|
104
|
+
materialize,
|
|
105
|
+
update,
|
|
106
|
+
list,
|
|
107
|
+
tombstone,
|
|
108
|
+
restore,
|
|
109
|
+
kindFor,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/pages.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { can } from './auth/can.js';
|
|
4
|
+
import { jsonError } from './json-errors.js';
|
|
5
|
+
import { statIfExists, readFileText } from './util/fsx.js';
|
|
6
|
+
import { documentEtag } from './spec/wire.js';
|
|
7
|
+
|
|
8
|
+
// Every HTML page the host serves for itself.
|
|
9
|
+
//
|
|
10
|
+
// The naming rule keeps the surface unambiguous: PAGES end in .html, APIs do
|
|
11
|
+
// not. `/_/versions` returns JSON, `/_/versions.html` is the page that renders
|
|
12
|
+
// it. The dashboard at `/_/` is the one exception, because a bare host root
|
|
13
|
+
// should open something.
|
|
14
|
+
//
|
|
15
|
+
// The editor is the first conditional-save client in the ecosystem: it sends
|
|
16
|
+
// Document-URL and If-Match, which is exactly where two-tab clobbers actually
|
|
17
|
+
// happen and the one place with no snapshot pipeline in the way.
|
|
18
|
+
|
|
19
|
+
const CM_FILES = new Set([
|
|
20
|
+
'lib/codemirror.js',
|
|
21
|
+
'lib/codemirror.css',
|
|
22
|
+
'mode/xml/xml.js',
|
|
23
|
+
'mode/javascript/javascript.js',
|
|
24
|
+
'mode/css/css.js',
|
|
25
|
+
'mode/htmlmixed/htmlmixed.js',
|
|
26
|
+
'addon/edit/closetag.js',
|
|
27
|
+
'addon/edit/matchbrackets.js',
|
|
28
|
+
'addon/selection/active-line.js',
|
|
29
|
+
'addon/search/search.js',
|
|
30
|
+
'addon/search/searchcursor.js',
|
|
31
|
+
'addon/search/jump-to-line.js',
|
|
32
|
+
'addon/dialog/dialog.js',
|
|
33
|
+
'addon/dialog/dialog.css',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function locateCodeMirror() {
|
|
37
|
+
try {
|
|
38
|
+
const require_ = createRequire(import.meta.url);
|
|
39
|
+
return path.dirname(path.dirname(require_.resolve('codemirror/lib/codemirror.js')));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mountPageRoutes(ctx) {
|
|
46
|
+
const {
|
|
47
|
+
router, config, ui,
|
|
48
|
+
gateCtx, wrap, resolveTarget, serve, saveTokens,
|
|
49
|
+
} = ctx;
|
|
50
|
+
|
|
51
|
+
const codemirror = locateCodeMirror();
|
|
52
|
+
|
|
53
|
+
function requireOwner(req, res, next) {
|
|
54
|
+
if (can(req.actor, 'admin', null, gateCtx)) return next();
|
|
55
|
+
// A person, not a script: send them to sign in rather than to a JSON blob.
|
|
56
|
+
// A tenant is already signed in, though, so sending them to the sign-in page
|
|
57
|
+
// is a redirect loop; they get their own document instead.
|
|
58
|
+
if (req.method === 'GET' && req.accepts(['html', 'json']) !== 'json') {
|
|
59
|
+
if (req.actor?.kind === 'tenant') return res.redirect(303, '/_/tenant/home');
|
|
60
|
+
return res.redirect(303, `/_/login?next=${encodeURIComponent(req.originalUrl)}`);
|
|
61
|
+
}
|
|
62
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Not owner-gated: the login and setup pages need it before anyone is signed
|
|
66
|
+
// in, and it is a stylesheet.
|
|
67
|
+
router.get('/ui.css', wrap(async (req, res) => {
|
|
68
|
+
const file = await ui.resolveFile('app.css');
|
|
69
|
+
if (!file) return jsonError(res, 'not-found', 'Not found');
|
|
70
|
+
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
71
|
+
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
72
|
+
return res.sendFile(file);
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// ---- the dashboard -----------------------------------------------------
|
|
76
|
+
|
|
77
|
+
router.get('/', requireOwner, wrap(async (req, res) => {
|
|
78
|
+
if (config.features?.dashboard === false) return jsonError(res, 'not-found', 'Not found');
|
|
79
|
+
return ui.send(res, 'dashboard', {
|
|
80
|
+
title: 'makerclay',
|
|
81
|
+
host: config.publicUrl || '',
|
|
82
|
+
tenants: config.features?.tenants ? 'true' : 'false',
|
|
83
|
+
uploads: config.features?.uploads === false ? 'false' : 'true',
|
|
84
|
+
shares: config.features?.share === false ? 'false' : 'true',
|
|
85
|
+
collections: config.features?.collections === false ? 'false' : 'true',
|
|
86
|
+
});
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
router.get('/trash.html', requireOwner, wrap(async (req, res) => {
|
|
90
|
+
return ui.send(res, 'trash', { title: 'Trash' });
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
router.get('/attic.html', requireOwner, wrap(async (req, res) => {
|
|
94
|
+
return ui.send(res, 'attic', { title: 'The attic' });
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
router.get('/records.html', requireOwner, wrap(async (req, res) => {
|
|
98
|
+
if (config.features?.collections === false) return jsonError(res, 'not-found', 'Not found');
|
|
99
|
+
const slug = String(req.query.slug || '').replace(/[^a-z0-9_]/gi, '');
|
|
100
|
+
return ui.send(res, 'records', { title: 'Submissions', slug });
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
router.get('/versions.html', requireOwner, wrap(async (req, res) => {
|
|
104
|
+
const relPath = String(req.query.path || '').replace(/^\/+/, '');
|
|
105
|
+
return ui.send(res, 'versions', {
|
|
106
|
+
title: relPath ? `History of ${path.basename(relPath)}` : 'History',
|
|
107
|
+
path: relPath,
|
|
108
|
+
name: relPath ? path.basename(relPath) : '',
|
|
109
|
+
});
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
// ---- the source editor -------------------------------------------------
|
|
113
|
+
|
|
114
|
+
router.get(/^\/edit\/(.*)$/, requireOwner, wrap(async (req, res) => {
|
|
115
|
+
if (config.features?.editor === false) return jsonError(res, 'not-found', 'Not found');
|
|
116
|
+
const relPath = decodeURIComponent(req.params[0] || '').replace(/^\/+/, '');
|
|
117
|
+
if (!relPath) return jsonError(res, 'bad-request', 'Which file?');
|
|
118
|
+
|
|
119
|
+
const found = await resolveTarget(req.actor, `/${relPath}`, 'read');
|
|
120
|
+
if (!found) return jsonError(res, 'not-found', 'Not found');
|
|
121
|
+
if (!can(req.actor, 'edit-source', found.node, gateCtx)) {
|
|
122
|
+
return jsonError(res, 'not-found', 'Not found');
|
|
123
|
+
}
|
|
124
|
+
const stat = await statIfExists(found.real);
|
|
125
|
+
if (!stat?.isFile()) return jsonError(res, 'not-found', 'Not found');
|
|
126
|
+
|
|
127
|
+
const source = await readFileText(found.real);
|
|
128
|
+
|
|
129
|
+
// A sandboxed document's save lane is token-only, and the editor is a real
|
|
130
|
+
// client of it. Without this, turning on the "run this file in a sandbox"
|
|
131
|
+
// toggle would quietly break the file's own editor.
|
|
132
|
+
let saveUrl = '/_/save';
|
|
133
|
+
if (serve?.isolationFor(found.node)) {
|
|
134
|
+
const token = await saveTokens?.mintFor({ actor: req.actor, node: found.node });
|
|
135
|
+
if (token) saveUrl = `/_/save/${token}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return ui.send(res, 'editor', {
|
|
139
|
+
title: `Edit ${path.basename(found.node.path)}`,
|
|
140
|
+
path: found.node.path,
|
|
141
|
+
name: path.basename(found.node.path),
|
|
142
|
+
saveUrl,
|
|
143
|
+
// The editor holds the etag it loaded and sends it back as If-Match, so a
|
|
144
|
+
// second tab cannot silently overwrite the first.
|
|
145
|
+
etag: documentEtag(source),
|
|
146
|
+
source,
|
|
147
|
+
mode: /\.(css)$/i.test(found.node.path) ? 'css'
|
|
148
|
+
: /\.(js|mjs)$/i.test(found.node.path) ? 'javascript'
|
|
149
|
+
: /\.(json)$/i.test(found.node.path) ? 'application/json'
|
|
150
|
+
: 'htmlmixed',
|
|
151
|
+
});
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
// The vendored editor tree, allowlisted file by file. No CDN: an offline box
|
|
155
|
+
// has to be able to edit its own source.
|
|
156
|
+
router.get(/^\/editor\/(.+)$/, (req, res) => {
|
|
157
|
+
if (!codemirror) return jsonError(res, 'not-found', 'This host has no bundled editor.');
|
|
158
|
+
const rest = req.params[0];
|
|
159
|
+
if (!CM_FILES.has(rest)) return jsonError(res, 'not-found', 'Not found');
|
|
160
|
+
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
161
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
162
|
+
return res.sendFile(path.join(codemirror, rest));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { codemirror };
|
|
166
|
+
}
|