@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
package/src/nodes/ops.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import path from 'upath';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { can } from '../auth/can.js';
|
|
4
|
+
import { HostError } from '../spec/codes.js';
|
|
5
|
+
import { isDocumentPath } from '../paths.js';
|
|
6
|
+
import { nameError, uniqueName, splitName, assetsDirFor } from './names.js';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import {
|
|
9
|
+
statIfExists, rename, mkdirp, rmrf, readdirIfExists,
|
|
10
|
+
atomicWrite, fsyncDir, copyFile, createExclusive,
|
|
11
|
+
} from '../util/fsx.js';
|
|
12
|
+
|
|
13
|
+
// Tree operations, every one of them write-ahead journaled (§9.2).
|
|
14
|
+
//
|
|
15
|
+
// The filesystem and SQLite cannot share a transaction, and a best-effort
|
|
16
|
+
// rollback list does not survive `kill -9`. So each op is three phases with a
|
|
17
|
+
// durable record between them:
|
|
18
|
+
//
|
|
19
|
+
// 1. insert an `operations` row describing the intent (phase 'planned')
|
|
20
|
+
// 2. do the FS work, which is a single atomic rename (phase 'fs-done')
|
|
21
|
+
// 3. one store transaction: rewrite rows, bump seq, DELETE the operation row
|
|
22
|
+
//
|
|
23
|
+
// Startup then reads whatever rows survived and finishes or unwinds them
|
|
24
|
+
// (recovery.js). The rename in phase 2 is the pivot: before it nothing has
|
|
25
|
+
// happened, after it the metadata is merely behind.
|
|
26
|
+
//
|
|
27
|
+
// Invariant 2 still holds here. No function in this file writes a DOCUMENT;
|
|
28
|
+
// documents move as opaque bytes (rename/copy) or go through the mutation
|
|
29
|
+
// kernel. `putFile` is the one sanctioned direct write and it refuses .html.
|
|
30
|
+
|
|
31
|
+
const TRASH_SEPARATOR = '__';
|
|
32
|
+
|
|
33
|
+
function isInside(parent, child) {
|
|
34
|
+
return child === parent || child.startsWith(`${parent}/`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createOps({
|
|
38
|
+
paths, nodes, store, versions, locks, events, clock, ids,
|
|
39
|
+
logger = console, replace = null,
|
|
40
|
+
}) {
|
|
41
|
+
function assert(condition, code, message) {
|
|
42
|
+
if (!condition) throw new HostError(code, message);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function authorize(actor, action, node, ctx) {
|
|
46
|
+
assert(can(actor, action, node, ctx), 'forbidden', 'You do not have permission to do that.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function checkName(name) {
|
|
50
|
+
const problem = nameError(name);
|
|
51
|
+
assert(!problem, 'bad-request', problem || '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The op wrapper. `fsWork` must be idempotent-or-atomic; `commit` runs inside
|
|
55
|
+
// the store transaction and therefore must be synchronous.
|
|
56
|
+
async function runOperation({ kind, nodeId = null, payload }, fsWork, commit) {
|
|
57
|
+
const opId = ids.ulid();
|
|
58
|
+
let landed = false;
|
|
59
|
+
if (store) {
|
|
60
|
+
store.insertOperation({ id: opId, kind, nodeId, phase: 'planned', payload, startedAt: clock.now() });
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const fsResult = await fsWork();
|
|
64
|
+
landed = true;
|
|
65
|
+
if (store) store.setOperationPhase(opId, 'fs-done');
|
|
66
|
+
if (!store) return commit(fsResult);
|
|
67
|
+
return store.tx(() => {
|
|
68
|
+
const value = commit(fsResult);
|
|
69
|
+
store.deleteOperation(opId);
|
|
70
|
+
return value;
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// The FS half is a single rename, so a throw before it landed means
|
|
74
|
+
// nothing happened and the plan is only noise. A throw after it landed
|
|
75
|
+
// leaves the row behind on purpose, for startup recovery to finish.
|
|
76
|
+
if (!landed && store) store.deleteOperation(opId);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listDescendants(owner, prefix) {
|
|
82
|
+
if (!store) return [];
|
|
83
|
+
return store.listNodes({ owner, prefix: `${prefix}/`, includeDeleted: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rewriteDescendants(owner, fromPath, toPath) {
|
|
87
|
+
return listDescendants(owner, fromPath).map((row) => store.updateNode(row.id, {
|
|
88
|
+
path: `${toPath}${row.path.slice(fromPath.length)}`,
|
|
89
|
+
updatedAt: clock.now(),
|
|
90
|
+
seq: store.nextSeq(),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Display-only bookkeeping: the versions directory is found by the identity
|
|
95
|
+
// half of its name, so a failure here costs a nice folder name and nothing
|
|
96
|
+
// else. It must never fail a move that has already landed on disk.
|
|
97
|
+
async function moveVersionSlug(node, toPath) {
|
|
98
|
+
if (node.kind !== 'doc') return;
|
|
99
|
+
try {
|
|
100
|
+
await versions.renameSlug(node, toPath);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger.warn?.('[makerclay] could not rename versions folder (cosmetic):', error?.message || error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Written after the bytes have already moved, so a failure here costs a stale
|
|
107
|
+
// record and never the operation. The boot reconcile and the next publish both
|
|
108
|
+
// repair it.
|
|
109
|
+
async function patchMetaQuietly(node, patch) {
|
|
110
|
+
try {
|
|
111
|
+
await versions.patchMeta(node, patch);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.warn?.('[makerclay] could not update the durable record:', error?.message || error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A document's uploads live in `assets-<stem>/` beside it, and the URLs the
|
|
118
|
+
// upload lane hands back are relative to the document, so the pair has to
|
|
119
|
+
// travel together or every image 404s at the new path. Done after the
|
|
120
|
+
// document's own operation rather than inside it: the journal reads which
|
|
121
|
+
// side of ONE rename a crash landed on, and a second rename inside the same
|
|
122
|
+
// step would make that unreadable. A crash between the two leaves the folder
|
|
123
|
+
// behind, which is exactly what happened every time before this existed.
|
|
124
|
+
async function carryAssets(actor, owner, from, to, ctx, run) {
|
|
125
|
+
if (!isDocumentPath(from)) return;
|
|
126
|
+
const fromAssets = assetsDirFor(from);
|
|
127
|
+
const toAssets = assetsDirFor(to);
|
|
128
|
+
if (fromAssets === toAssets) return;
|
|
129
|
+
if (!(await statIfExists(await paths.resolveWrite(owner, fromAssets)))) return;
|
|
130
|
+
// Occupied means the destination document already has assets of its own.
|
|
131
|
+
// Merging two asset folders is a guess about which file wins, so the folder
|
|
132
|
+
// stays where it is and the operation still succeeds.
|
|
133
|
+
if (await statIfExists(await paths.resolveWrite(owner, toAssets))) {
|
|
134
|
+
logger.warn?.(
|
|
135
|
+
`[makerclay] ${fromAssets} stayed put: ${toAssets} already exists.`,
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await run({ actor, owner, from: fromAssets, to: toAssets, ctx });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveExisting(owner, relPath) {
|
|
143
|
+
const real = await paths.resolveWrite(owner, relPath);
|
|
144
|
+
const stat = await statIfExists(real);
|
|
145
|
+
assert(stat, 'not-found', 'Not found');
|
|
146
|
+
return { real, stat };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rowFor(owner, relPath, kind) {
|
|
150
|
+
return nodes.materialize({ ...nodes.resolve(owner, relPath, { kind }), kind });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- create ------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async function mkdir({ actor, owner = '', dir = '', name, ctx = {} }) {
|
|
156
|
+
checkName(name);
|
|
157
|
+
const relPath = dir ? `${dir}/${name}` : name;
|
|
158
|
+
const node = nodes.resolve(owner, relPath, { kind: 'dir' });
|
|
159
|
+
authorize(actor, 'write', node, ctx);
|
|
160
|
+
|
|
161
|
+
const real = await paths.resolveWrite(owner, relPath);
|
|
162
|
+
assert(!(await statIfExists(real)), 'conflict', 'Something with that name is already there.');
|
|
163
|
+
|
|
164
|
+
const created = await runOperation(
|
|
165
|
+
{ kind: 'mkdir', payload: { owner, path: relPath } },
|
|
166
|
+
async () => { await mkdirp(real); await fsyncDir(path.dirname(real)); },
|
|
167
|
+
() => rowFor(owner, relPath, 'dir'),
|
|
168
|
+
);
|
|
169
|
+
events.emit('node-created', { node: created });
|
|
170
|
+
return created;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A new document is a mutation of a document, so it goes through the kernel
|
|
174
|
+
// (invariant 2) rather than getting its own write path here. What ops adds is
|
|
175
|
+
// the parent directory and a free name.
|
|
176
|
+
async function createDocument({ actor, owner = '', dir = '', name, html, ctx = {} }) {
|
|
177
|
+
assert(replace, 'server-error', 'This host has no mutation kernel.');
|
|
178
|
+
checkName(name);
|
|
179
|
+
assert(isDocumentPath(name), 'bad-request', 'A document name must end in .html.');
|
|
180
|
+
|
|
181
|
+
const parentReal = dir ? await paths.resolveWrite(owner, dir) : paths.realmRoot(owner);
|
|
182
|
+
await mkdirp(parentReal);
|
|
183
|
+
const free = await uniqueName(parentReal, name);
|
|
184
|
+
const relPath = dir ? `${dir}/${free}` : free;
|
|
185
|
+
|
|
186
|
+
const result = await replace({
|
|
187
|
+
actor, owner, relPath, bytes: html, trigger: 'user', source: 'create', ctx,
|
|
188
|
+
});
|
|
189
|
+
events.emit('node-created', { node: result.node });
|
|
190
|
+
return { node: result.node, path: relPath, etag: result.etag, derived: result.derived };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The one sanctioned write that does not go through the mutation kernel, and
|
|
194
|
+
// it refuses documents so that stays true. Uploads are files: same tree, same
|
|
195
|
+
// rename, same delete, same trash as everything else.
|
|
196
|
+
async function putFile({ actor, owner = '', dir = '', name, content, encoding = null, ctx = {} }) {
|
|
197
|
+
checkName(name);
|
|
198
|
+
assert(!isDocumentPath(name), 'bad-request', 'HTML files are documents: save them through the save lane.');
|
|
199
|
+
|
|
200
|
+
const parentReal = dir ? await paths.resolveWrite(owner, dir) : paths.realmRoot(owner);
|
|
201
|
+
await mkdirp(parentReal);
|
|
202
|
+
const free = await uniqueName(parentReal, name);
|
|
203
|
+
const relPath = dir ? `${dir}/${free}` : free;
|
|
204
|
+
|
|
205
|
+
const node = nodes.resolve(owner, relPath, { kind: 'file' });
|
|
206
|
+
authorize(actor, 'write', node, ctx);
|
|
207
|
+
const real = await paths.resolveWrite(owner, relPath);
|
|
208
|
+
|
|
209
|
+
const created = await runOperation(
|
|
210
|
+
{ kind: 'create', payload: { owner, path: relPath } },
|
|
211
|
+
async () => {
|
|
212
|
+
await atomicWrite(real, content, encoding);
|
|
213
|
+
return await statIfExists(real);
|
|
214
|
+
},
|
|
215
|
+
(stat) => {
|
|
216
|
+
const row = rowFor(owner, relPath, 'file');
|
|
217
|
+
if (!store) return row;
|
|
218
|
+
return store.updateNode(row.id, {
|
|
219
|
+
bytes: stat ? stat.size : null,
|
|
220
|
+
mtimeMs: stat ? stat.mtimeMs : null,
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
events.emit('node-created', { node: created });
|
|
225
|
+
return { node: created, path: relPath };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The upload lane's write, and the only place the HOST names the file rather
|
|
229
|
+
// than the person. Two things follow from that, and both are the point.
|
|
230
|
+
//
|
|
231
|
+
// The tail is the content hash, so uploading the same bytes twice converges on
|
|
232
|
+
// the file already stored instead of leaving a second copy of it. And the name
|
|
233
|
+
// is claimed with an exclusive create, never by looking for a free one first:
|
|
234
|
+
// check-then-write loses the race between two uploads of one filename, and the
|
|
235
|
+
// loser's bytes land on top of the winner's with both callers told they
|
|
236
|
+
// succeeded. `uniqueName` above has exactly that shape, which is why this does
|
|
237
|
+
// not use it.
|
|
238
|
+
//
|
|
239
|
+
// Authorization is the CALLER's job here, and it is deliberately not repeated.
|
|
240
|
+
// The permission being exercised is "may this actor write to this document",
|
|
241
|
+
// held against the document, and a save token grants precisely that and nothing
|
|
242
|
+
// else. Re-checking against the stored file's own node would fail for every
|
|
243
|
+
// token-bearing document, which is most of them.
|
|
244
|
+
async function putUpload({ owner = '', dir, name, content }) {
|
|
245
|
+
const base = path.basename(String(name || ''));
|
|
246
|
+
const { stem, ext } = splitName(base);
|
|
247
|
+
const cleanExt = nameError(`x${ext}`) ? '' : ext;
|
|
248
|
+
const cleanStem = nameError(`${stem}${cleanExt}`) || !stem ? 'file' : stem;
|
|
249
|
+
|
|
250
|
+
const parentReal = await paths.resolveWrite(owner, dir);
|
|
251
|
+
await mkdirp(parentReal);
|
|
252
|
+
const digest = crypto.createHash('sha256').update(content).digest('hex');
|
|
253
|
+
|
|
254
|
+
let chosen = null;
|
|
255
|
+
let wrote = false;
|
|
256
|
+
for (let length = 6; length <= 32 && !chosen; length += 2) {
|
|
257
|
+
const candidate = `${cleanStem}-${digest.slice(0, length)}${cleanExt}`;
|
|
258
|
+
const real = path.join(parentReal, candidate);
|
|
259
|
+
try {
|
|
260
|
+
await createExclusive(real, content);
|
|
261
|
+
chosen = candidate;
|
|
262
|
+
wrote = true;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error.code !== 'EEXIST') throw error;
|
|
265
|
+
// Same name, so same content hash prefix: either the identical file (the
|
|
266
|
+
// common case, and the answer is the file already there) or a prefix
|
|
267
|
+
// collision between different bytes, which lengthens the tail.
|
|
268
|
+
const existing = await fs.readFile(real).catch(() => null);
|
|
269
|
+
if (existing && existing.equals(content)) chosen = candidate;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
assert(chosen, 'server-error', 'Could not find a free name for that file.');
|
|
273
|
+
|
|
274
|
+
const relPath = `${dir}/${chosen}`;
|
|
275
|
+
const real = path.join(parentReal, chosen);
|
|
276
|
+
|
|
277
|
+
// The claim above is already atomic and self-healing, so it needs no journal
|
|
278
|
+
// to unwind: a crash between it and the row below leaves a real file that the
|
|
279
|
+
// scanner registers on the next walk, never a half-written one.
|
|
280
|
+
const node = nodes.resolve(owner, relPath, { kind: 'file' });
|
|
281
|
+
const stat = await statIfExists(real);
|
|
282
|
+
const row = store
|
|
283
|
+
? store.tx(() => store.updateNode(nodes.materialize(node).id, {
|
|
284
|
+
bytes: stat ? stat.size : null,
|
|
285
|
+
mtimeMs: stat ? stat.mtimeMs : null,
|
|
286
|
+
}))
|
|
287
|
+
: node;
|
|
288
|
+
if (wrote) events.emit('node-created', { node: row });
|
|
289
|
+
return { node: row, path: relPath, name: chosen, bytes: content.length, created: wrote };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---- move --------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
async function move({ actor, owner = '', from, to, ctx = {} }) {
|
|
295
|
+
checkName(path.basename(to));
|
|
296
|
+
assert(!isInside(from, to), 'bad-request', 'A folder cannot be moved inside itself.');
|
|
297
|
+
|
|
298
|
+
const source = await resolveExisting(owner, from);
|
|
299
|
+
const node = nodes.materialize(nodes.resolve(owner, from, {
|
|
300
|
+
kind: source.stat.isDirectory() ? 'dir' : undefined,
|
|
301
|
+
}));
|
|
302
|
+
authorize(actor, 'write', node, ctx);
|
|
303
|
+
|
|
304
|
+
const targetReal = await paths.resolveWrite(owner, to);
|
|
305
|
+
assert(!(await statIfExists(targetReal)), 'conflict', 'Something with that name is already there.');
|
|
306
|
+
|
|
307
|
+
// A rename can change what the file IS. page.txt -> page.html must stop
|
|
308
|
+
// being served raw, and kind is what decides both the serve lane and the
|
|
309
|
+
// sandbox, so it is recomputed rather than carried over.
|
|
310
|
+
//
|
|
311
|
+
// Hoisted out of the commit callback because the durable record needs the
|
|
312
|
+
// same value and is written FIRST, in the FS half. Two copies of this
|
|
313
|
+
// expression drift, and the copy that drifts is the record, which is what a
|
|
314
|
+
// rebuild trusts. A directory keeps its kind whatever it is renamed to: a
|
|
315
|
+
// folder called `x.html` is still a folder.
|
|
316
|
+
const kind = nodes.kindFor(to, source.stat.isDirectory());
|
|
317
|
+
|
|
318
|
+
// Both ends take the lock, in a fixed order, so a save racing a rename
|
|
319
|
+
// cannot land bytes at a path this op is about to empty.
|
|
320
|
+
const [first, second] = [source.real, targetReal].sort();
|
|
321
|
+
let movedChildren = [];
|
|
322
|
+
const moved = await locks.withLock(first, () => locks.withLock(second, () => runOperation(
|
|
323
|
+
{ kind: 'move', nodeId: node.id, payload: { owner, from, to, fromReal: source.real, toReal: targetReal } },
|
|
324
|
+
async () => {
|
|
325
|
+
await mkdirp(path.dirname(targetReal));
|
|
326
|
+
await rename(source.real, targetReal);
|
|
327
|
+
await fsyncDir(path.dirname(targetReal));
|
|
328
|
+
await moveVersionSlug(node, to);
|
|
329
|
+
await patchMetaQuietly(node, { path: to, kind });
|
|
330
|
+
},
|
|
331
|
+
() => {
|
|
332
|
+
const row = store
|
|
333
|
+
? store.updateNode(node.id, { path: to, kind, updatedAt: clock.now(), seq: store.nextSeq() })
|
|
334
|
+
: { ...node, path: to, kind };
|
|
335
|
+
if (source.stat.isDirectory()) movedChildren = rewriteDescendants(owner, from, to);
|
|
336
|
+
return row;
|
|
337
|
+
},
|
|
338
|
+
)));
|
|
339
|
+
|
|
340
|
+
for (const child of movedChildren) await patchMetaQuietly(child, { path: child.path });
|
|
341
|
+
|
|
342
|
+
await carryAssets(actor, owner, from, to, ctx, move);
|
|
343
|
+
|
|
344
|
+
events.emit('node-moved', { node: moved, from });
|
|
345
|
+
return moved;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- copy --------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
async function copyTree(fromReal, toReal) {
|
|
351
|
+
const stat = await statIfExists(fromReal);
|
|
352
|
+
if (!stat) return;
|
|
353
|
+
if (!stat.isDirectory()) {
|
|
354
|
+
await copyFile(fromReal, toReal);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
await mkdirp(toReal);
|
|
358
|
+
for (const entry of await readdirIfExists(fromReal, { withFileTypes: true })) {
|
|
359
|
+
if (entry.name.startsWith('.')) continue;
|
|
360
|
+
await copyTree(path.join(fromReal, entry.name), path.join(toReal, entry.name));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function copy({ actor, owner = '', from, to = null, ctx = {} }) {
|
|
365
|
+
const source = await resolveExisting(owner, from);
|
|
366
|
+
const node = nodes.resolve(owner, from);
|
|
367
|
+
authorize(actor, 'read', node, ctx);
|
|
368
|
+
|
|
369
|
+
const dir = path.dirname(to || from);
|
|
370
|
+
const parentReal = dir === '.' ? paths.realmRoot(owner) : await paths.resolveWrite(owner, dir);
|
|
371
|
+
await mkdirp(parentReal);
|
|
372
|
+
const free = await uniqueName(parentReal, path.basename(to || from));
|
|
373
|
+
const relPath = dir === '.' ? free : `${dir}/${free}`;
|
|
374
|
+
|
|
375
|
+
authorize(actor, 'write', nodes.resolve(owner, relPath), ctx);
|
|
376
|
+
const targetReal = await paths.resolveWrite(owner, relPath);
|
|
377
|
+
|
|
378
|
+
// Built under a hidden sibling name and renamed into place, so a crash
|
|
379
|
+
// leaves either nothing or the whole tree, never a partial one that
|
|
380
|
+
// `settle()` would read as finished because all it can see is that the
|
|
381
|
+
// destination exists. Same shape as publishInto's temp-then-link.
|
|
382
|
+
const staging = path.join(parentReal, `.makerclay-copy-${crypto.randomBytes(8).toString('hex')}`);
|
|
383
|
+
|
|
384
|
+
const created = await runOperation(
|
|
385
|
+
{ kind: 'copy', nodeId: node.id, payload: { owner, from, to: relPath, staging } },
|
|
386
|
+
async () => {
|
|
387
|
+
try {
|
|
388
|
+
await copyTree(source.real, staging);
|
|
389
|
+
await rename(staging, targetReal);
|
|
390
|
+
await fsyncDir(path.dirname(targetReal));
|
|
391
|
+
} catch (error) {
|
|
392
|
+
await rmrf(staging);
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
return await statIfExists(targetReal);
|
|
396
|
+
},
|
|
397
|
+
(stat) => {
|
|
398
|
+
const kind = stat?.isDirectory() ? 'dir' : nodes.kindFor(relPath);
|
|
399
|
+
return rowFor(owner, relPath, kind);
|
|
400
|
+
},
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
await carryAssets(actor, owner, from, relPath, ctx, copy);
|
|
404
|
+
|
|
405
|
+
events.emit('node-created', { node: created, copiedFrom: from });
|
|
406
|
+
return created;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---- trash / restore / purge -------------------------------------------
|
|
410
|
+
|
|
411
|
+
async function trash({ actor, owner = '', relPath, ctx = {} }) {
|
|
412
|
+
const source = await resolveExisting(owner, relPath);
|
|
413
|
+
const node = nodes.materialize(nodes.resolve(owner, relPath, {
|
|
414
|
+
kind: source.stat.isDirectory() ? 'dir' : undefined,
|
|
415
|
+
}));
|
|
416
|
+
authorize(actor, 'write', node, ctx);
|
|
417
|
+
|
|
418
|
+
const target = paths.trashPath(node.id, path.basename(relPath));
|
|
419
|
+
const at = clock.now();
|
|
420
|
+
|
|
421
|
+
let trashedChildren = [];
|
|
422
|
+
const gone = await locks.withLock(source.real, () => runOperation(
|
|
423
|
+
{ kind: 'trash', nodeId: node.id, payload: { owner, from: relPath, to: target, fromReal: source.real } },
|
|
424
|
+
async () => {
|
|
425
|
+
await mkdirp(paths.dirs.trash);
|
|
426
|
+
await rename(source.real, target);
|
|
427
|
+
await fsyncDir(paths.dirs.trash);
|
|
428
|
+
await patchMetaQuietly(node, { trashed: true });
|
|
429
|
+
},
|
|
430
|
+
() => {
|
|
431
|
+
const row = nodes.tombstone(node, at);
|
|
432
|
+
if (store) {
|
|
433
|
+
trashedChildren = listDescendants(owner, relPath);
|
|
434
|
+
store.deleteSaveTokensForNode(node.id);
|
|
435
|
+
// The platform hard-deletes the record's auth row here, so a restored
|
|
436
|
+
// keyed record comes back public with no code. Soft-delete it WITH the
|
|
437
|
+
// node and restore it WITH the node (§15).
|
|
438
|
+
if (store.getRecordAuth(node.id)) store.updateRecordAuth(node.id, { deletedAt: at });
|
|
439
|
+
for (const child of trashedChildren) {
|
|
440
|
+
store.tombstone(child.id, at);
|
|
441
|
+
store.deleteSaveTokensForNode(child.id);
|
|
442
|
+
if (store.getRecordAuth(child.id)) store.updateRecordAuth(child.id, { deletedAt: at });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return row;
|
|
446
|
+
},
|
|
447
|
+
));
|
|
448
|
+
|
|
449
|
+
// Every descendant's record, not just the operation's own node. The rows are
|
|
450
|
+
// tombstoned inside the commit above, but a rebuild trusts the RECORD, so a
|
|
451
|
+
// child whose record still says `trashed: false` comes back as a live row
|
|
452
|
+
// pointing at bytes that are in trash/. Same shape as move's descendant
|
|
453
|
+
// patch, and for the same reason.
|
|
454
|
+
for (const child of trashedChildren) await patchMetaQuietly(child, { trashed: true });
|
|
455
|
+
|
|
456
|
+
events.emit('node-trashed', { node: gone });
|
|
457
|
+
return gone;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function restoreFromTrash({ actor, nodeId, ctx = {} }) {
|
|
461
|
+
assert(store, 'not-found', 'This host keeps no trash.');
|
|
462
|
+
const node = store.getNodeById(nodeId);
|
|
463
|
+
assert(node && node.deletedAt, 'not-found', 'Not in the trash.');
|
|
464
|
+
authorize(actor, 'write', { ...node, deletedAt: null }, ctx);
|
|
465
|
+
|
|
466
|
+
const source = paths.trashPath(node.id, path.basename(node.path));
|
|
467
|
+
assert(await statIfExists(source), 'not-found', 'The trashed bytes are gone.');
|
|
468
|
+
|
|
469
|
+
const dir = path.dirname(node.path);
|
|
470
|
+
const parentReal = dir === '.' ? paths.realmRoot(node.owner) : await paths.resolveWrite(node.owner, dir);
|
|
471
|
+
await mkdirp(parentReal);
|
|
472
|
+
const free = await uniqueName(parentReal, path.basename(node.path));
|
|
473
|
+
const relPath = dir === '.' ? free : `${dir}/${free}`;
|
|
474
|
+
const targetReal = await paths.resolveWrite(node.owner, relPath);
|
|
475
|
+
|
|
476
|
+
let restoredChildren = [];
|
|
477
|
+
const back = await runOperation(
|
|
478
|
+
{ kind: 'restore', nodeId: node.id, payload: { owner: node.owner, from: source, to: relPath, toReal: targetReal } },
|
|
479
|
+
async () => {
|
|
480
|
+
await rename(source, targetReal);
|
|
481
|
+
await fsyncDir(path.dirname(targetReal));
|
|
482
|
+
await moveVersionSlug(node, relPath);
|
|
483
|
+
await patchMetaQuietly(node, { trashed: false, path: relPath });
|
|
484
|
+
},
|
|
485
|
+
() => {
|
|
486
|
+
const row = store.updateNode(node.id, {
|
|
487
|
+
path: relPath, deletedAt: null, updatedAt: clock.now(), seq: store.nextSeq(),
|
|
488
|
+
});
|
|
489
|
+
const auth = store.getRecordAuth(node.id);
|
|
490
|
+
if (auth?.deletedAt) store.updateRecordAuth(node.id, { deletedAt: null });
|
|
491
|
+
restoredChildren = listDescendants(node.owner, node.path).map((child) => {
|
|
492
|
+
const childAuth = store.getRecordAuth(child.id);
|
|
493
|
+
if (childAuth?.deletedAt) store.updateRecordAuth(child.id, { deletedAt: null });
|
|
494
|
+
return store.updateNode(child.id, {
|
|
495
|
+
path: `${relPath}${child.path.slice(node.path.length)}`,
|
|
496
|
+
deletedAt: null,
|
|
497
|
+
updatedAt: clock.now(),
|
|
498
|
+
seq: store.nextSeq(),
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
return row;
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// The mirror of trash's cascade: the row half already rewrote every
|
|
506
|
+
// descendant's path, and the record has to learn both facts or the next
|
|
507
|
+
// rebuild puts them back in the trash they just came out of.
|
|
508
|
+
for (const child of restoredChildren) {
|
|
509
|
+
await patchMetaQuietly(child, { trashed: false, path: child.path });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
events.emit('node-restored', { node: back });
|
|
513
|
+
return back;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function purge({ actor, nodeId, ctx = {} }) {
|
|
517
|
+
assert(store, 'not-found', 'This host keeps no trash.');
|
|
518
|
+
const node = store.getNodeById(nodeId);
|
|
519
|
+
assert(node && node.deletedAt, 'not-found', 'Not in the trash.');
|
|
520
|
+
// The trash is the box owner's floor. `can` refuses the owner a WRITE on a
|
|
521
|
+
// tenant's document on purpose (someone else's instance is their document),
|
|
522
|
+
// but the bytes in here are no longer anyone's instance: they are storage
|
|
523
|
+
// the owner is being asked to keep. Without the admin arm a departed
|
|
524
|
+
// tenant's documents are unpurgeable forever, because the gate answers for
|
|
525
|
+
// an owner who no longer exists.
|
|
526
|
+
assert(
|
|
527
|
+
can(actor, 'admin', node, ctx) || can(actor, 'write', { ...node, deletedAt: null }, ctx),
|
|
528
|
+
'forbidden', 'You do not have permission to do that.',
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const target = paths.trashPath(node.id, path.basename(node.path));
|
|
532
|
+
const children = listDescendants(node.owner, node.path);
|
|
533
|
+
|
|
534
|
+
await runOperation(
|
|
535
|
+
{ kind: 'purge', nodeId: node.id, payload: { owner: node.owner, path: node.path, trash: target } },
|
|
536
|
+
async () => {
|
|
537
|
+
await rmrf(target);
|
|
538
|
+
for (const child of children) await versions.purge(child);
|
|
539
|
+
await versions.purge(node);
|
|
540
|
+
},
|
|
541
|
+
() => {
|
|
542
|
+
// The schema cascades shares, save tokens, collections, record_auth and
|
|
543
|
+
// the data-loss baseline off this one delete.
|
|
544
|
+
for (const child of children) store.deleteNode(child.id);
|
|
545
|
+
return store.deleteNode(node.id);
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
events.emit('node-purged', { node });
|
|
550
|
+
return { id: node.id, path: node.path };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Reports what it actually removed. A swallowed refusal here told the owner
|
|
554
|
+
// their trash was empty while the bytes were still on disk, which is the one
|
|
555
|
+
// answer this lane must never give.
|
|
556
|
+
async function emptyTrash({ actor, ctx = {} }) {
|
|
557
|
+
if (!store) return { purged: 0, failed: [] };
|
|
558
|
+
const rows = store.listTrash().filter((row) => !row.path.includes('/'));
|
|
559
|
+
const failed = [];
|
|
560
|
+
let purged = 0;
|
|
561
|
+
for (const row of rows) {
|
|
562
|
+
try {
|
|
563
|
+
await purge({ actor, nodeId: row.id, ctx });
|
|
564
|
+
purged += 1;
|
|
565
|
+
} catch (error) {
|
|
566
|
+
logger.warn?.('[makerclay] could not purge', row.path, error?.message || error);
|
|
567
|
+
failed.push({ id: row.id, path: row.path, reason: error?.message || String(error) });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { purged, failed };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---- flags -------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
const FLAG_OPS = { private: 'private', signups: 'signups', isolated: 'isolate' };
|
|
576
|
+
|
|
577
|
+
async function setFlag({ actor, owner = '', relPath, flag, value, ctx = {} }) {
|
|
578
|
+
assert(FLAG_OPS[flag], 'bad-request', `Unknown flag: ${flag}`);
|
|
579
|
+
assert(store, 'not-found', 'This host keeps no metadata.');
|
|
580
|
+
const node = nodes.materialize(nodes.resolve(owner, relPath));
|
|
581
|
+
authorize(actor, 'admin', node, ctx);
|
|
582
|
+
|
|
583
|
+
// Durable BEFORE the row, and allowed to throw. This is the direction that
|
|
584
|
+
// must never be lost: a flag that says private in the database and nothing
|
|
585
|
+
// on disk is a flag that a database loss turns back into public.
|
|
586
|
+
const flagValue = flag === 'isolated' && value === null ? null : !!value;
|
|
587
|
+
await versions.patchMeta(node, { [flag]: flagValue }, { create: true });
|
|
588
|
+
|
|
589
|
+
const updated = store.updateNode(node.id, {
|
|
590
|
+
[flag]: flagValue,
|
|
591
|
+
updatedAt: clock.now(),
|
|
592
|
+
seq: store.nextSeq(),
|
|
593
|
+
});
|
|
594
|
+
events.emit('node-updated', { node: updated, flag });
|
|
595
|
+
return updated;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
mkdir,
|
|
600
|
+
createDocument,
|
|
601
|
+
putFile,
|
|
602
|
+
putUpload,
|
|
603
|
+
move,
|
|
604
|
+
copy,
|
|
605
|
+
trash,
|
|
606
|
+
restoreFromTrash,
|
|
607
|
+
purge,
|
|
608
|
+
emptyTrash,
|
|
609
|
+
setFlag,
|
|
610
|
+
uniqueName,
|
|
611
|
+
TRASH_SEPARATOR,
|
|
612
|
+
};
|
|
613
|
+
}
|