@kernhq/module-quire 0.13.1 → 0.14.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/dist/contract/models.d.ts +185 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +133 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +14 -0
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +82 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/properties.d.ts +4 -4
- package/dist/contract/router.d.ts +535 -8
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +133 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +519 -8
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +128 -0
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/export/html.d.ts +67 -0
- package/dist/server/export/html.d.ts.map +1 -0
- package/dist/server/export/html.js +206 -0
- package/dist/server/export/html.js.map +1 -0
- package/dist/server/export/markdown.d.ts +51 -0
- package/dist/server/export/markdown.d.ts.map +1 -0
- package/dist/server/export/markdown.js +312 -0
- package/dist/server/export/markdown.js.map +1 -0
- package/dist/server/export/pdf.d.ts +20 -0
- package/dist/server/export/pdf.d.ts.map +1 -0
- package/dist/server/export/pdf.js +91 -0
- package/dist/server/export/pdf.js.map +1 -0
- package/dist/server/export/zip.d.ts +31 -0
- package/dist/server/export/zip.d.ts.map +1 -0
- package/dist/server/export/zip.js +158 -0
- package/dist/server/export/zip.js.map +1 -0
- package/dist/server/import/csv.d.ts +77 -0
- package/dist/server/import/csv.d.ts.map +1 -0
- package/dist/server/import/csv.js +263 -0
- package/dist/server/import/csv.js.map +1 -0
- package/dist/server/import/html.d.ts +52 -0
- package/dist/server/import/html.d.ts.map +1 -0
- package/dist/server/import/html.js +472 -0
- package/dist/server/import/html.js.map +1 -0
- package/dist/server/import/markdown.d.ts +63 -0
- package/dist/server/import/markdown.d.ts.map +1 -0
- package/dist/server/import/markdown.js +692 -0
- package/dist/server/import/markdown.js.map +1 -0
- package/dist/server/import/plan.d.ts +70 -0
- package/dist/server/import/plan.d.ts.map +1 -0
- package/dist/server/import/plan.js +761 -0
- package/dist/server/import/plan.js.map +1 -0
- package/dist/server/import/ydoc.d.ts +35 -0
- package/dist/server/import/ydoc.d.ts.map +1 -0
- package/dist/server/import/ydoc.js +91 -0
- package/dist/server/import/ydoc.js.map +1 -0
- package/dist/server/import/zip.d.ts +63 -0
- package/dist/server/import/zip.d.ts.map +1 -0
- package/dist/server/import/zip.js +308 -0
- package/dist/server/import/zip.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/schema.d.ts +445 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +146 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/databases.d.ts +5 -5
- package/dist/server/services/export.d.ts +176 -0
- package/dist/server/services/export.d.ts.map +1 -0
- package/dist/server/services/export.js +822 -0
- package/dist/server/services/export.js.map +1 -0
- package/dist/server/services/import.d.ts +109 -0
- package/dist/server/services/import.d.ts.map +1 -0
- package/dist/server/services/import.js +570 -0
- package/dist/server/services/import.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -1
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +60 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/versions.d.ts +1 -1
- package/migrations/0010_transfers.sql +154 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/client/components/ExportDialog.svelte +685 -0
- package/src/client/components/ImportDialog.svelte +702 -0
- package/src/client/components/ImportReport.svelte +310 -0
- package/src/client/components/SidebarSpaces.svelte +79 -0
- package/src/client/i18n.ts +614 -0
- package/src/client/index.ts +32 -0
- package/src/client/mock.ts +318 -0
- package/src/client/module.ts +36 -0
- package/src/client/pages/PageView.svelte +36 -0
- package/src/client/pages/TransfersPage.svelte +570 -0
- package/src/client/permissions.ts +11 -0
- package/src/client/query.ts +23 -0
- package/src/client/transfers.ts +142 -0
- package/src/contract/models.ts +152 -0
- package/src/contract/permissions.ts +84 -0
- package/src/contract/router.ts +147 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { KernError, uuidv7 } from '@kernhq/kernel';
|
|
2
|
+
import { and, asc, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
|
|
3
|
+
import { pageDocFromState } from '../document.js';
|
|
4
|
+
import { exportedHtmlDocument, exportLinks } from '../export/html.js';
|
|
5
|
+
import { pageDocToMarkdown } from '../export/markdown.js';
|
|
6
|
+
import { htmlToPdf } from '../export/pdf.js';
|
|
7
|
+
import { safeZipPath, writeZip } from '../export/zip.js';
|
|
8
|
+
import { referencesIn, renderPageDoc } from '../render.js';
|
|
9
|
+
import { exportJobs, pages, pageVersions, spaces } from '../schema.js';
|
|
10
|
+
import { slugifyTitle } from './publications.js';
|
|
11
|
+
/** How long an artefact is kept. Long enough to fetch twice, short enough not to be an archive. */
|
|
12
|
+
export const EXPORT_TTL_DAYS = 7;
|
|
13
|
+
/**
|
|
14
|
+
* How long a job may sit un-finished before it is given up on.
|
|
15
|
+
*
|
|
16
|
+
* **Nothing else can end a job that lost its worker.** `run` writes `failed` from its own `catch`,
|
|
17
|
+
* and the router writes it when the queue refuses the send — both need the process to still be
|
|
18
|
+
* there. A worker killed mid-export (an OOM, a deploy, `docker compose restart core-worker`) leaves
|
|
19
|
+
* the row `running`, and pg-boss's own give-up path ends at pg-boss: `kernel.jobs` registers a
|
|
20
|
+
* handler and nothing else, so there is no dead-letter callback that reaches this table. Measured
|
|
21
|
+
* with a real worker and a SIGKILL: the row was still `running`, `finished_at` null, after the
|
|
22
|
+
* pg-boss job had reached its terminal `failed` state. `exports.list` — which the transfers screen
|
|
23
|
+
* polls every two seconds — reported `running` for ever, and the dialog spun with
|
|
24
|
+
* `aria-busy="true"` for ever.
|
|
25
|
+
*
|
|
26
|
+
* The number is the job's whole budget with room to spare, so nothing legitimate is ever cut off:
|
|
27
|
+
* `quireJobs` gives `export` `expireInSeconds: 900` with `retryLimit: 2`, so three attempts and
|
|
28
|
+
* their backoff are under fifty minutes, and `import` one attempt of an hour. A job still running at
|
|
29
|
+
* two hours has already been abandoned by the queue.
|
|
30
|
+
*/
|
|
31
|
+
const ABANDONED_AFTER_MS = 2 * 60 * 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* What a job that lost its worker says.
|
|
34
|
+
*
|
|
35
|
+
* Diagnostic like every other `error` here — the state's own label is what the screen says in the
|
|
36
|
+
* reader's language — and written for the person who has to decide what to do next rather than for
|
|
37
|
+
* the one who wrote the worker.
|
|
38
|
+
*/
|
|
39
|
+
const ABANDONED = 'This export stopped before it finished and was given up on after two hours. The worker running ' +
|
|
40
|
+
'it went away — usually a restart, or a job too big for the memory it had. Nothing was written. ' +
|
|
41
|
+
'Start it again, a subtree at a time if it was a whole space.';
|
|
42
|
+
/** Neither `done` nor `failed`, and old enough that nothing is still going to happen to it. */
|
|
43
|
+
const isAbandoned = (row) => (row.state === 'queued' || row.state === 'running') &&
|
|
44
|
+
row.createdAt.getTime() < Date.now() - ABANDONED_AFTER_MS;
|
|
45
|
+
/** How long a download link lives. It is minted per request, so it never has to outlive one. */
|
|
46
|
+
const DOWNLOAD_TTL_SEC = 900;
|
|
47
|
+
/** A picture bigger than this is left out rather than allowed to define the size of the archive. */
|
|
48
|
+
const MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024;
|
|
49
|
+
/** The whole artefact, held in memory before it is written. Beyond this the job fails and says so. */
|
|
50
|
+
const MAX_ARTEFACT_BYTES = 256 * 1024 * 1024;
|
|
51
|
+
/** Enough pages that no real space is refused, few enough that one job cannot become an outage. */
|
|
52
|
+
const MAX_PAGES = 5000;
|
|
53
|
+
/** How often the row's counters are written while the job runs. A progress bar, not a write log. */
|
|
54
|
+
const PROGRESS_EVERY = 25;
|
|
55
|
+
/**
|
|
56
|
+
* What a format produces, which is deliberately not a decision the job makes at run time.
|
|
57
|
+
*
|
|
58
|
+
* Markdown and HTML are always a zip, even for a single page with no pictures. A page's attachments
|
|
59
|
+
* live beside it, so the container has to exist as soon as there is one attachment — and a rule that
|
|
60
|
+
* says "a zip, unless" produces two shapes for one request and a filename nobody can predict. The
|
|
61
|
+
* uniform shape is also what makes an export re-importable: a folder of `.md` with its media beside
|
|
62
|
+
* it is exactly the "plain folder of Markdown" the import side reads.
|
|
63
|
+
*
|
|
64
|
+
* A PDF is one document however much went into it, because that is what a PDF is for: a subtree
|
|
65
|
+
* becomes one file with a page break between pages and internal links between them.
|
|
66
|
+
*/
|
|
67
|
+
const ARTEFACT = {
|
|
68
|
+
markdown: { extension: 'zip', contentType: 'application/zip' },
|
|
69
|
+
html: { extension: 'zip', contentType: 'application/zip' },
|
|
70
|
+
pdf: { extension: 'pdf', contentType: 'application/pdf' },
|
|
71
|
+
docx: {
|
|
72
|
+
extension: 'docx',
|
|
73
|
+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Why `docx` is declared and refused.
|
|
78
|
+
*
|
|
79
|
+
* `prosemirror-docx` was the intended route and does not fit, for a structural reason rather than
|
|
80
|
+
* for want of effort: it serialises a **ProseMirror `Node`**, and building one needs the schema —
|
|
81
|
+
* which lives in `@kernhq/ui`'s `page-schema.ts`, is assembled out of a dozen Tiptap extensions, and
|
|
82
|
+
* is browser code. A server that has deliberately never loaded Tiptap (see `document.ts`, which
|
|
83
|
+
* reads a page out of Yjs *without* a schema for exactly this reason) has none to give it. Beyond
|
|
84
|
+
* that, the library ships handlers for the basic nodes only, so Quire's callouts, toggles, task
|
|
85
|
+
* lists and page mentions would each need one written by hand against an API that throws on an
|
|
86
|
+
* unknown node.
|
|
87
|
+
*
|
|
88
|
+
* The alternative — emitting OOXML directly — is a zip of XML this file could write, and that is the
|
|
89
|
+
* reason it is not written: nothing here can open the result in Word, so the only thing that could
|
|
90
|
+
* ship is a file that is *probably* valid. A refusal an operator reads is better than a document a
|
|
91
|
+
* customer cannot open, so the format refuses at `start`, immediately, rather than after a job.
|
|
92
|
+
*/
|
|
93
|
+
const DOCX_REFUSAL = 'Word export is not available yet. Export as HTML or PDF, both of which Word opens, or as ' +
|
|
94
|
+
'Markdown to move the pages somewhere else.';
|
|
95
|
+
export const exportArtefactKey = (workspaceId, fileId) => `ws/${workspaceId}/quire/exports/${fileId}`;
|
|
96
|
+
/**
|
|
97
|
+
* The `# Title` line at the top of an exported Markdown file.
|
|
98
|
+
*
|
|
99
|
+
* Exported so the round-trip test can build its fixture from the line the job actually writes rather
|
|
100
|
+
* than from a copy of it — a title is the one thing in an export that has to come back *identical*,
|
|
101
|
+
* because it is the page's name rather than part of its body.
|
|
102
|
+
*
|
|
103
|
+
* The escape set is the same one `export/markdown.ts` uses for inline text — what changes meaning
|
|
104
|
+
* anywhere — plus **one position**: a run of hashes at the end. That is the ATX *closing sequence*,
|
|
105
|
+
* so `Roadmap #` written plainly is a heading whose content is `Roadmap`, per CommonMark, and the
|
|
106
|
+
* page came back under a different name. Escaping the run is the only spelling that survives, and
|
|
107
|
+
* escaping it only there is what keeps `# Sharp C#` — where the hash is ordinary text — readable in
|
|
108
|
+
* every other editor. The reader's half of this is `RE_ATX_CLOSING` in `import/markdown.ts`.
|
|
109
|
+
*/
|
|
110
|
+
export const markdownTitleLine = (title) => `# ${title.replace(/([\\`*_[\]<>|~])/g, '\\$1').replace(/(^|[ \t])(#+)([ \t]*)$/, '$1\\$2$3')}`;
|
|
111
|
+
const countsOf = (value) => {
|
|
112
|
+
const raw = (value ?? {});
|
|
113
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.trunc(v) : 0);
|
|
114
|
+
return { total: n(raw.total), done: n(raw.done), skipped: n(raw.skipped), failed: n(raw.failed) };
|
|
115
|
+
};
|
|
116
|
+
export function toExportJob(row) {
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
workspaceId: row.workspaceId,
|
|
120
|
+
requestedBy: row.requestedBy,
|
|
121
|
+
scope: row.scope,
|
|
122
|
+
targetId: row.targetId,
|
|
123
|
+
format: row.format,
|
|
124
|
+
state: row.state,
|
|
125
|
+
fileId: row.fileId,
|
|
126
|
+
error: row.error,
|
|
127
|
+
counts: countsOf(row.counts),
|
|
128
|
+
createdAt: row.createdAt.toISOString(),
|
|
129
|
+
finishedAt: row.finishedAt?.toISOString() ?? null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** The path of one archive folder written from another, so a link between two files resolves. */
|
|
133
|
+
export function relativeFolder(from, to, file) {
|
|
134
|
+
const a = from ? from.split('/') : [];
|
|
135
|
+
const b = to ? to.split('/') : [];
|
|
136
|
+
let shared = 0;
|
|
137
|
+
while (shared < a.length && shared < b.length && a[shared] === b[shared])
|
|
138
|
+
shared++;
|
|
139
|
+
const up = Array.from({ length: a.length - shared }, () => '..');
|
|
140
|
+
const path = [...up, ...b.slice(shared), file].join('/');
|
|
141
|
+
return path.startsWith('.') ? path : `./${path}`;
|
|
142
|
+
}
|
|
143
|
+
/** A file name that is a name and not a path, and that no two pictures in one folder can share. */
|
|
144
|
+
function attachmentName(raw, taken) {
|
|
145
|
+
const cleaned = safeZipPath(raw.split('/').pop() ?? '') || 'attachment';
|
|
146
|
+
const dot = cleaned.lastIndexOf('.');
|
|
147
|
+
const stem = dot > 0 ? cleaned.slice(0, dot) : cleaned;
|
|
148
|
+
const extension = dot > 0 ? cleaned.slice(dot) : '';
|
|
149
|
+
let name = cleaned;
|
|
150
|
+
for (let n = 2; taken.has(name); n++)
|
|
151
|
+
name = `${stem}-${n}${extension}`;
|
|
152
|
+
taken.add(name);
|
|
153
|
+
return name;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Tree order, and a folder per page whose siblings cannot collide.
|
|
157
|
+
*
|
|
158
|
+
* A page whose parent is not in the export is a root of the archive, which is what puts a subtree's
|
|
159
|
+
* own root at the top level. Sibling slugs are suffixed `-2`, `-3` in tree order, the same rule
|
|
160
|
+
* `withPaths` uses for a published site — so two people exporting the same space get the same names.
|
|
161
|
+
*/
|
|
162
|
+
export function prepareFolders(selected) {
|
|
163
|
+
const byId = new Map(selected.map((p) => [p.id, p]));
|
|
164
|
+
const children = new Map();
|
|
165
|
+
for (const page of selected) {
|
|
166
|
+
const parent = page.parentId !== null && byId.has(page.parentId) ? page.parentId : null;
|
|
167
|
+
children.set(parent, [...(children.get(parent) ?? []), page]);
|
|
168
|
+
}
|
|
169
|
+
const out = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const walk = (parent, base, trail) => {
|
|
172
|
+
const taken = new Set();
|
|
173
|
+
for (const page of children.get(parent) ?? []) {
|
|
174
|
+
// `seen` is what stops a cycle — a page that became its own ancestor — from recurring for ever.
|
|
175
|
+
if (seen.has(page.id))
|
|
176
|
+
continue;
|
|
177
|
+
seen.add(page.id);
|
|
178
|
+
const stem = slugifyTitle(page.title || 'Untitled');
|
|
179
|
+
let slug = stem;
|
|
180
|
+
for (let n = 2; taken.has(slug); n++)
|
|
181
|
+
slug = `${stem}-${n}`;
|
|
182
|
+
taken.add(slug);
|
|
183
|
+
const folder = safeZipPath(base ? `${base}/${slug}` : slug) || slug;
|
|
184
|
+
out.push({ ...page, folder, trail });
|
|
185
|
+
walk(page.id, folder, [...trail, page.title || 'Untitled']);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
walk(null, '', []);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
export function quireExport(kernel, access) {
|
|
192
|
+
/** The row, or `notFound`. Fenced to the person who asked for it — see `requested_by`'s comment. */
|
|
193
|
+
async function own(tx, workspaceId, jobId, principal) {
|
|
194
|
+
const [row] = await tx
|
|
195
|
+
.select()
|
|
196
|
+
.from(exportJobs)
|
|
197
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)))
|
|
198
|
+
.limit(1);
|
|
199
|
+
if (!row)
|
|
200
|
+
throw KernError.notFound('Export');
|
|
201
|
+
/*
|
|
202
|
+
* NOT_FOUND rather than FORBIDDEN, for the usual reason: a subtree export flattens pages of
|
|
203
|
+
* different readerships into one artefact, so "that one is not yours" would confirm that
|
|
204
|
+
* somebody else exported something and when. An id another person's job owns names nothing here.
|
|
205
|
+
*/
|
|
206
|
+
const mine = principal.instanceAdmin || principal.kind === 'service' || row.requestedBy === principal.userId;
|
|
207
|
+
if (!mine)
|
|
208
|
+
throw KernError.notFound('Export');
|
|
209
|
+
return row;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Tell the one person whose job this is that it moved — and nobody else.
|
|
213
|
+
*
|
|
214
|
+
* Every other entity in this module announces with `kernel.realtime.change`, which publishes to the
|
|
215
|
+
* **workspace** channel; the gateway subscribes every socket to its workspace at `hello`, with no
|
|
216
|
+
* per-message filter, so a `change` there is read by every member. For a page or a label that is
|
|
217
|
+
* correct and is the point. For a transfer it contradicts the rest of the feature: `own()` answers
|
|
218
|
+
* NOT_FOUND rather than FORBIDDEN precisely so that an id belonging to somebody else's export
|
|
219
|
+
* confirms nothing, and `list` returns only the caller's own rows — and then the broadcast handed
|
|
220
|
+
* the whole workspace the job's id and the moment it changed. The payload carried no content, so
|
|
221
|
+
* what leaked was that a named colleague exported something and when, which is the fact the
|
|
222
|
+
* NOT_FOUND was protecting.
|
|
223
|
+
*
|
|
224
|
+
* `toUser` is delivered by the gateway straight to that user's sockets (`kern.rt.user.<id>`, which
|
|
225
|
+
* every socket is joined to at `hello`), and `@kernhq/ui`'s realtime client dispatches on `msg.t`
|
|
226
|
+
* without caring which channel carried it — so the invalidation reaches the screen exactly as
|
|
227
|
+
* before. An instance admin may *read* another person's job through `own()`; they do not get a
|
|
228
|
+
* push about it, which is the same asymmetry as the list.
|
|
229
|
+
*/
|
|
230
|
+
async function announce(workspaceId, requestedBy, jobId) {
|
|
231
|
+
await kernel.realtime
|
|
232
|
+
.toUser(requestedBy, {
|
|
233
|
+
t: 'change',
|
|
234
|
+
workspaceId: workspaceId,
|
|
235
|
+
change: { module: 'quire', entity: 'export', id: jobId, op: 'updated' },
|
|
236
|
+
})
|
|
237
|
+
.catch(() => undefined);
|
|
238
|
+
}
|
|
239
|
+
async function patch(workspaceId, requestedBy, jobId, values) {
|
|
240
|
+
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
241
|
+
await tx
|
|
242
|
+
.update(exportJobs)
|
|
243
|
+
.set(values)
|
|
244
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)));
|
|
245
|
+
});
|
|
246
|
+
await announce(workspaceId, requestedBy, jobId);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Every page in scope that this person may read, in position order, with the version to draw.
|
|
250
|
+
*
|
|
251
|
+
* One flat read of the space rather than a recursive query per page: `access.scopeOf` is a
|
|
252
|
+
* recursive query each time, and a five-hundred-page space would be five hundred of them before a
|
|
253
|
+
* byte is written. Every ancestor of a page is in the same space by construction — `pages.create`
|
|
254
|
+
* and `pages.move` both refuse a parent from another one — so the flat read holds every chain.
|
|
255
|
+
*/
|
|
256
|
+
async function select(tx, principal, workspaceId, job) {
|
|
257
|
+
let spaceId;
|
|
258
|
+
let title;
|
|
259
|
+
if (job.scope === 'space') {
|
|
260
|
+
const space = await access.spaceRow(tx, workspaceId, job.targetId);
|
|
261
|
+
spaceId = space.id;
|
|
262
|
+
title = space.name;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const page = await access.pageRow(tx, workspaceId, job.targetId);
|
|
266
|
+
spaceId = page.spaceId;
|
|
267
|
+
title = page.title;
|
|
268
|
+
}
|
|
269
|
+
const all = await tx
|
|
270
|
+
.select({
|
|
271
|
+
id: pages.id,
|
|
272
|
+
parentId: pages.parentId,
|
|
273
|
+
title: pages.title,
|
|
274
|
+
archivedAt: pages.archivedAt,
|
|
275
|
+
publishedVersionId: pages.publishedVersionId,
|
|
276
|
+
})
|
|
277
|
+
.from(pages)
|
|
278
|
+
.where(and(eq(pages.workspaceId, workspaceId), eq(pages.spaceId, spaceId), isNull(pages.deletedAt)))
|
|
279
|
+
.orderBy(asc(pages.position));
|
|
280
|
+
const parentOf = new Map(all.map((p) => [p.id, p.parentId]));
|
|
281
|
+
const ancestorsOf = (id) => {
|
|
282
|
+
const chain = [];
|
|
283
|
+
const seen = new Set([id]);
|
|
284
|
+
let at = parentOf.get(id) ?? null;
|
|
285
|
+
while (at !== null && !seen.has(at)) {
|
|
286
|
+
chain.push(at);
|
|
287
|
+
seen.add(at);
|
|
288
|
+
at = parentOf.get(at) ?? null;
|
|
289
|
+
}
|
|
290
|
+
return chain;
|
|
291
|
+
};
|
|
292
|
+
/* Trashed pages are already out of `all`; archived ones are out of the tree on purpose. */
|
|
293
|
+
const live = all.filter((p) => p.archivedAt === null);
|
|
294
|
+
let inScope = live;
|
|
295
|
+
if (job.scope !== 'space') {
|
|
296
|
+
const wanted = new Set([job.targetId]);
|
|
297
|
+
// `live` is ordered by position, which is not tree order, so this runs until it settles.
|
|
298
|
+
for (let changed = true; changed;) {
|
|
299
|
+
changed = false;
|
|
300
|
+
for (const p of live)
|
|
301
|
+
if (!wanted.has(p.id) && p.parentId !== null && wanted.has(p.parentId)) {
|
|
302
|
+
wanted.add(p.id);
|
|
303
|
+
changed = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
inScope = live.filter((p) => wanted.has(p.id));
|
|
307
|
+
// `page` takes the one page; `subtree` takes it and everything beneath it.
|
|
308
|
+
if (job.scope === 'page')
|
|
309
|
+
inScope = inScope.filter((p) => p.id === job.targetId);
|
|
310
|
+
}
|
|
311
|
+
if (inScope.length > MAX_PAGES)
|
|
312
|
+
throw KernError.badRequest(`This export covers ${inScope.length} pages, and one job may carry ${MAX_PAGES}. ` +
|
|
313
|
+
'Export a subtree at a time.');
|
|
314
|
+
const verdicts = await Promise.all(inScope.map(async (p) => ({
|
|
315
|
+
id: p.id,
|
|
316
|
+
allowed: await access.canPage(principal, 'quire.page.view', workspaceId, {
|
|
317
|
+
pageId: p.id,
|
|
318
|
+
spaceId,
|
|
319
|
+
ancestorIds: ancestorsOf(p.id),
|
|
320
|
+
}),
|
|
321
|
+
})));
|
|
322
|
+
const allowed = new Set(verdicts.filter((v) => v.allowed).map((v) => v.id));
|
|
323
|
+
/*
|
|
324
|
+
* A page whose parent was withheld goes with it, because its folder path is built from its
|
|
325
|
+
* ancestors' titles — keeping the child would print the title of the page that was withheld.
|
|
326
|
+
*
|
|
327
|
+
* The walk stops at the edge of the export rather than at the top of the tree, and that is the
|
|
328
|
+
* whole of it: an ancestor outside the scope was never checked, so it is not in `allowed`, and a
|
|
329
|
+
* walk that kept going would find it missing and drop every page. A `page`-scoped export is the
|
|
330
|
+
* case that makes this visible — its one page has a parent that is not in the export at all.
|
|
331
|
+
*/
|
|
332
|
+
const inScopeIds = new Set(inScope.map((p) => p.id));
|
|
333
|
+
const kept = inScope.filter((p) => {
|
|
334
|
+
if (!allowed.has(p.id))
|
|
335
|
+
return false;
|
|
336
|
+
let at = p.parentId;
|
|
337
|
+
const seen = new Set([p.id]);
|
|
338
|
+
while (at !== null && inScopeIds.has(at) && !seen.has(at)) {
|
|
339
|
+
if (!allowed.has(at))
|
|
340
|
+
return false;
|
|
341
|
+
seen.add(at);
|
|
342
|
+
at = parentOf.get(at) ?? null;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
});
|
|
346
|
+
const states = new Map();
|
|
347
|
+
/*
|
|
348
|
+
* The published version if there is one, the newest stored version otherwise — never the live
|
|
349
|
+
* document. `page_versions.id` is a uuidv7, so `desc(id)` is newest-first without a sort on a
|
|
350
|
+
* timestamp two versions can share.
|
|
351
|
+
*/
|
|
352
|
+
const pinned = kept.map((p) => p.publishedVersionId).filter((id) => id !== null);
|
|
353
|
+
if (pinned.length > 0) {
|
|
354
|
+
const rows = await tx
|
|
355
|
+
.select({ pageId: pageVersions.pageId, state: pageVersions.state })
|
|
356
|
+
.from(pageVersions)
|
|
357
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, pinned)));
|
|
358
|
+
for (const r of rows)
|
|
359
|
+
states.set(r.pageId, r.state);
|
|
360
|
+
}
|
|
361
|
+
for (const p of kept) {
|
|
362
|
+
if (states.has(p.id))
|
|
363
|
+
continue;
|
|
364
|
+
const [newest] = await tx
|
|
365
|
+
.select({ state: pageVersions.state })
|
|
366
|
+
.from(pageVersions)
|
|
367
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.pageId, p.id)))
|
|
368
|
+
.orderBy(desc(pageVersions.id))
|
|
369
|
+
.limit(1);
|
|
370
|
+
if (newest)
|
|
371
|
+
states.set(p.id, newest.state);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
title,
|
|
375
|
+
skipped: inScope.length - kept.length,
|
|
376
|
+
pages: kept.map((p) => ({
|
|
377
|
+
id: p.id,
|
|
378
|
+
parentId: p.parentId,
|
|
379
|
+
title: p.title,
|
|
380
|
+
state: states.get(p.id) ?? null,
|
|
381
|
+
})),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** The picture with this id, if it belongs to this workspace and is small enough to carry. */
|
|
385
|
+
async function attachment(workspaceId, fileId) {
|
|
386
|
+
try {
|
|
387
|
+
const file = await kernel.call('core.files.get', { id: fileId });
|
|
388
|
+
/*
|
|
389
|
+
* The workspace is compared here rather than trusted. `core.files.get` answers a **service**
|
|
390
|
+
* principal without a membership check — that is what makes it callable from a module at all —
|
|
391
|
+
* so this comparison is the only thing keeping an export inside its tenant. The id comes out
|
|
392
|
+
* of a page document, which is data somebody wrote.
|
|
393
|
+
*/
|
|
394
|
+
if (!file || file.workspaceId !== workspaceId || file.status !== 'ready' || !file.key)
|
|
395
|
+
return null;
|
|
396
|
+
if (file.size > MAX_ATTACHMENT_BYTES)
|
|
397
|
+
return null;
|
|
398
|
+
const object = await kernel.storage.get(file.key);
|
|
399
|
+
const chunks = [];
|
|
400
|
+
for await (const chunk of object.body)
|
|
401
|
+
chunks.push(Buffer.from(chunk));
|
|
402
|
+
return { name: file.name, mimeType: file.mimeType, bytes: Buffer.concat(chunks) };
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
// A deleted file, storage that is not configured, a picture somebody purged: the page is still
|
|
406
|
+
// worth exporting without it, and `renderPageDoc` drops an unresolvable picture by design.
|
|
407
|
+
kernel.log.warn({ err: String(err), fileId }, 'quire: an export could not read an attachment');
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* A zip of one folder per page: `index.md` or `index.html`, with `media/` beside it.
|
|
413
|
+
*
|
|
414
|
+
* Links between two pages that are both in the archive become relative paths, so the export opens
|
|
415
|
+
* and navigates on a laptop with nothing running. A mention of a page that is *not* in the
|
|
416
|
+
* archive — withheld, purged, or in another space — degrades to plain text rather than to a link
|
|
417
|
+
* that goes nowhere, the same rule `renderPageDoc` follows.
|
|
418
|
+
*/
|
|
419
|
+
async function buildZip(workspaceId, prepared, format, counts, progress) {
|
|
420
|
+
const file = format === 'markdown' ? 'index.md' : 'index.html';
|
|
421
|
+
const folderOf = new Map(prepared.map((p) => [p.id, p.folder]));
|
|
422
|
+
const entries = [];
|
|
423
|
+
let written = 0;
|
|
424
|
+
for (const page of prepared) {
|
|
425
|
+
try {
|
|
426
|
+
const doc = pageDocFromState(page.state);
|
|
427
|
+
const media = new Map();
|
|
428
|
+
const taken = new Set();
|
|
429
|
+
for (const fileId of doc ? referencesIn(doc).fileIds : []) {
|
|
430
|
+
const found = await attachment(workspaceId, fileId);
|
|
431
|
+
if (!found)
|
|
432
|
+
continue;
|
|
433
|
+
const name = attachmentName(found.name, taken);
|
|
434
|
+
entries.push({ path: `${page.folder}/media/${name}`, data: found.bytes });
|
|
435
|
+
media.set(fileId, `media/${name}`);
|
|
436
|
+
}
|
|
437
|
+
const fileSrc = (id) => media.get(id) ?? null;
|
|
438
|
+
const pageHref = (id) => {
|
|
439
|
+
const to = folderOf.get(id);
|
|
440
|
+
return to === undefined ? null : relativeFolder(page.folder, to, file);
|
|
441
|
+
};
|
|
442
|
+
const title = page.title || 'Untitled';
|
|
443
|
+
let body;
|
|
444
|
+
if (format === 'markdown') {
|
|
445
|
+
body = `${markdownTitleLine(title)}\n\n${pageDocToMarkdown(doc, { fileSrc, pageHref })}`;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
/*
|
|
449
|
+
* Both of this page's addresses are ones `safeHref` refuses — a relative picture and a
|
|
450
|
+
* relative link to another file — so they go through the renderer as tokens and come back
|
|
451
|
+
* afterwards. See `exportLinks` for why that is the right place to bend rather than
|
|
452
|
+
* `safeHref`.
|
|
453
|
+
*/
|
|
454
|
+
const links = exportLinks();
|
|
455
|
+
const html = renderPageDoc(doc, {
|
|
456
|
+
fileSrc: (id) => {
|
|
457
|
+
const path = fileSrc(id);
|
|
458
|
+
return path === null ? null : links.to(path);
|
|
459
|
+
},
|
|
460
|
+
pageHref: (id) => {
|
|
461
|
+
const path = pageHref(id);
|
|
462
|
+
return path === null ? null : links.to(path);
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
body = links.resolve(exportedHtmlDocument({
|
|
466
|
+
title,
|
|
467
|
+
pages: [{ id: page.id, title, trail: page.trail, html }],
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
entries.push({ path: `${page.folder}/${file}`, data: Buffer.from(body, 'utf8') });
|
|
471
|
+
counts.done++;
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
counts.failed++;
|
|
475
|
+
kernel.log.warn({ err: String(err), pageId: page.id }, 'quire: a page could not be exported');
|
|
476
|
+
}
|
|
477
|
+
if (++written % PROGRESS_EVERY === 0)
|
|
478
|
+
await progress();
|
|
479
|
+
}
|
|
480
|
+
return writeZip(entries);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* One PDF for the whole export.
|
|
484
|
+
*
|
|
485
|
+
* Every page becomes a section of one document with a page break between them, so a subtree is a
|
|
486
|
+
* booklet rather than a folder of files — and a mention of another exported page becomes an
|
|
487
|
+
* internal link, which is the thing a PDF can do that a folder of PDFs cannot. Pictures are
|
|
488
|
+
* `data:` URIs: Gotenberg's Chromium fetches whatever the document references from inside its own
|
|
489
|
+
* container, so anything else would be a broken picture or a storage URL handed to a third
|
|
490
|
+
* process.
|
|
491
|
+
*/
|
|
492
|
+
async function buildPdf(workspaceId, prepared, title, counts, progress) {
|
|
493
|
+
const known = new Set(prepared.map((p) => p.id));
|
|
494
|
+
// One token space for the whole document: the same picture on two pages is inlined once.
|
|
495
|
+
const links = exportLinks();
|
|
496
|
+
const sections = [];
|
|
497
|
+
let written = 0;
|
|
498
|
+
for (const page of prepared) {
|
|
499
|
+
try {
|
|
500
|
+
const doc = pageDocFromState(page.state);
|
|
501
|
+
const media = new Map();
|
|
502
|
+
for (const fileId of doc ? referencesIn(doc).fileIds : []) {
|
|
503
|
+
const found = await attachment(workspaceId, fileId);
|
|
504
|
+
if (!found)
|
|
505
|
+
continue;
|
|
506
|
+
media.set(fileId, `data:${found.mimeType};base64,${found.bytes.toString('base64')}`);
|
|
507
|
+
}
|
|
508
|
+
sections.push({
|
|
509
|
+
id: page.id,
|
|
510
|
+
title: page.title || 'Untitled',
|
|
511
|
+
trail: page.trail,
|
|
512
|
+
html: renderPageDoc(doc, {
|
|
513
|
+
// `data:` is one of the two shapes `safeHref` refuses; `#p-<id>` is a fragment, which it
|
|
514
|
+
// accepts, so only the picture needs a token.
|
|
515
|
+
fileSrc: (id) => {
|
|
516
|
+
const inlined = media.get(id);
|
|
517
|
+
return inlined === undefined ? null : links.to(inlined);
|
|
518
|
+
},
|
|
519
|
+
pageHref: (id) => (known.has(id) ? `#p-${id}` : null),
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
counts.done++;
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
counts.failed++;
|
|
526
|
+
kernel.log.warn({ err: String(err), pageId: page.id }, 'quire: a page could not be exported');
|
|
527
|
+
}
|
|
528
|
+
if (++written % PROGRESS_EVERY === 0)
|
|
529
|
+
await progress();
|
|
530
|
+
}
|
|
531
|
+
const document = links.resolve(exportedHtmlDocument({ title: title || 'Untitled', pages: sections }));
|
|
532
|
+
return htmlToPdf(document, { title });
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
toExportJob,
|
|
536
|
+
/**
|
|
537
|
+
* Record the request and hand it to a worker.
|
|
538
|
+
*
|
|
539
|
+
* Nothing is rendered here. A whole space is unbounded work and a PDF is a round trip to
|
|
540
|
+
* Chromium, so the request answers with a row somebody can watch rather than a response somebody
|
|
541
|
+
* has to keep a browser open for. The row exists before the job is sent, which is the order that
|
|
542
|
+
* matters: a job whose row is missing fails, and a row whose job never arrived stays `queued`
|
|
543
|
+
* and can be sent again.
|
|
544
|
+
*/
|
|
545
|
+
async start(tx, principal, workspaceId, input) {
|
|
546
|
+
if (input.format === 'docx')
|
|
547
|
+
throw KernError.badRequest(DOCX_REFUSAL, { format: 'docx' });
|
|
548
|
+
const userId = principal.userId;
|
|
549
|
+
if (!userId)
|
|
550
|
+
throw new KernError('FORBIDDEN', 'An export belongs to a person, and this caller is a service');
|
|
551
|
+
// Existence before anything is queued: an id that names nothing is a 404 now rather than a job
|
|
552
|
+
// that fails in a minute's time.
|
|
553
|
+
if (input.scope === 'space')
|
|
554
|
+
await access.spaceRow(tx, workspaceId, input.targetId);
|
|
555
|
+
else
|
|
556
|
+
await access.pageRow(tx, workspaceId, input.targetId);
|
|
557
|
+
const [row] = await tx
|
|
558
|
+
.insert(exportJobs)
|
|
559
|
+
.values({
|
|
560
|
+
id: uuidv7(),
|
|
561
|
+
workspaceId,
|
|
562
|
+
requestedBy: userId,
|
|
563
|
+
scope: input.scope,
|
|
564
|
+
targetId: input.targetId,
|
|
565
|
+
format: input.format,
|
|
566
|
+
state: 'queued',
|
|
567
|
+
counts: { total: 0, done: 0, skipped: 0, failed: 0 },
|
|
568
|
+
})
|
|
569
|
+
.returning();
|
|
570
|
+
if (!row)
|
|
571
|
+
throw new KernError('INTERNAL', 'The export could not be recorded');
|
|
572
|
+
return row;
|
|
573
|
+
},
|
|
574
|
+
/**
|
|
575
|
+
* One job, and the one place a *watched* job can give up on itself.
|
|
576
|
+
*
|
|
577
|
+
* The dialog polls this and never the list, so `reap` — which runs beside the list — would never
|
|
578
|
+
* reach the row somebody is actually looking at. One row rather than a workspace-wide update,
|
|
579
|
+
* because this is on a path that runs every second and a half while a job is moving: the write
|
|
580
|
+
* happens only for a row that is already stale, which is never, until it is.
|
|
581
|
+
*/
|
|
582
|
+
async get(tx, workspaceId, jobId, principal) {
|
|
583
|
+
const row = await own(tx, workspaceId, jobId, principal);
|
|
584
|
+
if (!isAbandoned(row))
|
|
585
|
+
return row;
|
|
586
|
+
const [failed] = await tx
|
|
587
|
+
.update(exportJobs)
|
|
588
|
+
.set({ state: 'failed', error: ABANDONED, finishedAt: new Date() })
|
|
589
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)))
|
|
590
|
+
.returning();
|
|
591
|
+
return failed ?? row;
|
|
592
|
+
},
|
|
593
|
+
/** This person's own exports, newest first. Somebody else's are not listed and cannot be. */
|
|
594
|
+
list(tx, workspaceId, principal, limit) {
|
|
595
|
+
const userId = principal.userId;
|
|
596
|
+
if (!userId)
|
|
597
|
+
return Promise.resolve([]);
|
|
598
|
+
return tx
|
|
599
|
+
.select()
|
|
600
|
+
.from(exportJobs)
|
|
601
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.requestedBy, userId)))
|
|
602
|
+
.orderBy(desc(exportJobs.createdAt))
|
|
603
|
+
.limit(limit);
|
|
604
|
+
},
|
|
605
|
+
/**
|
|
606
|
+
* A short-lived link to the artefact, minted per request and never stored.
|
|
607
|
+
*
|
|
608
|
+
* This is the fence. A subtree export flattens pages of different readerships into one file, so
|
|
609
|
+
* whoever can fetch it can read everything that went into it — which is right for the person the
|
|
610
|
+
* permission check was run as and for nobody else. Minting the URL here rather than writing one
|
|
611
|
+
* into the row is what puts that check at the moment of the fetch instead of an hour earlier.
|
|
612
|
+
*/
|
|
613
|
+
async downloadUrl(tx, workspaceId, row) {
|
|
614
|
+
if (row.state !== 'done' || !row.fileId)
|
|
615
|
+
return null;
|
|
616
|
+
const artefact = ARTEFACT[row.format] ?? ARTEFACT.markdown;
|
|
617
|
+
const [target] = row.scope === 'space'
|
|
618
|
+
? await tx
|
|
619
|
+
.select({ title: spaces.name })
|
|
620
|
+
.from(spaces)
|
|
621
|
+
.where(and(eq(spaces.workspaceId, workspaceId), eq(spaces.id, row.targetId)))
|
|
622
|
+
.limit(1)
|
|
623
|
+
: await tx
|
|
624
|
+
.select({ title: pages.title })
|
|
625
|
+
.from(pages)
|
|
626
|
+
.where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, row.targetId)))
|
|
627
|
+
.limit(1);
|
|
628
|
+
const name = `${slugifyTitle(target?.title || 'quire export')}.${artefact.extension}`;
|
|
629
|
+
try {
|
|
630
|
+
return await kernel.storage.presignGet(exportArtefactKey(workspaceId, row.fileId), {
|
|
631
|
+
expiresIn: DOWNLOAD_TTL_SEC,
|
|
632
|
+
filename: name,
|
|
633
|
+
disposition: 'attachment',
|
|
634
|
+
contentType: artefact.contentType,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
/*
|
|
639
|
+
* Signing is arithmetic and fails only when storage is not configured at all — an instance
|
|
640
|
+
* with no S3 credentials. A screen polling this every second must not be answered with a 500
|
|
641
|
+
* because of that: null means "no link", which is what there is.
|
|
642
|
+
*/
|
|
643
|
+
kernel.log.warn({ err: String(err), jobId: row.id }, 'quire: an export link could not be signed');
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
/**
|
|
648
|
+
* Mark a job failed without having run it.
|
|
649
|
+
*
|
|
650
|
+
* There is exactly one caller: the router, when the queue refuses the job it has just recorded.
|
|
651
|
+
* Without it that row sits `queued` for ever and reads as work in progress, which is the worst
|
|
652
|
+
* of the three states to be wrong about.
|
|
653
|
+
*/
|
|
654
|
+
async fail(tx, workspaceId, jobId, reason) {
|
|
655
|
+
await tx
|
|
656
|
+
.update(exportJobs)
|
|
657
|
+
.set({ state: 'failed', error: reason.slice(0, 2000), finishedAt: new Date() })
|
|
658
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId)));
|
|
659
|
+
},
|
|
660
|
+
/**
|
|
661
|
+
* Fail this workspace's abandoned jobs, so a dead one stops reading as work in progress.
|
|
662
|
+
*
|
|
663
|
+
* Called from the same two places as `sweep` and for the same reason — see the note there for
|
|
664
|
+
* why neither can be a cron job. It is deliberately a separate method: `sweep` deletes an
|
|
665
|
+
* artefact that has aged out of a job that *finished*, and this ends a job that never will.
|
|
666
|
+
*
|
|
667
|
+
* The screen that is being lied to is the one that fixes it: the transfers list polls
|
|
668
|
+
* `exports.list` every two seconds while anything is running, so an abandoned job is failed by
|
|
669
|
+
* the very poll that would otherwise draw its spinner for ever. `get` does the same for the one
|
|
670
|
+
* row a dialog is watching.
|
|
671
|
+
*
|
|
672
|
+
* Marked, never deleted. The row is the only record that somebody asked for this and did not get
|
|
673
|
+
* it, and it is what `sweep` will remove on its own schedule.
|
|
674
|
+
*/
|
|
675
|
+
async reap(tx, workspaceId) {
|
|
676
|
+
const abandoned = await tx
|
|
677
|
+
.update(exportJobs)
|
|
678
|
+
.set({ state: 'failed', error: ABANDONED, finishedAt: new Date() })
|
|
679
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), inArray(exportJobs.state, ['queued', 'running']), lt(exportJobs.createdAt, new Date(Date.now() - ABANDONED_AFTER_MS))))
|
|
680
|
+
.returning({ id: exportJobs.id });
|
|
681
|
+
return abandoned.length;
|
|
682
|
+
},
|
|
683
|
+
/**
|
|
684
|
+
* Throw away this workspace's expired artefacts.
|
|
685
|
+
*
|
|
686
|
+
* Called from `start` and `list` rather than from a cron, and the reason is worth writing down:
|
|
687
|
+
* a scheduled sweep has to enumerate workspaces, and every tenant table here is under FORCE
|
|
688
|
+
* row-level security keyed on `app.workspace_id`, so a cross-workspace scan has nothing to scan
|
|
689
|
+
* with. Quire keeps no `workspaces` table of its own — the tracker does, which is how its cron
|
|
690
|
+
* jobs manage — and adding one is a migration rather than a service.
|
|
691
|
+
*
|
|
692
|
+
* The limit of doing it this way is real, which is why it is stated: a workspace whose exports
|
|
693
|
+
* nobody looks at again keeps its artefacts until somebody does. They are bounded by what that
|
|
694
|
+
* workspace exported, unreachable without a row, and the row is the only thing that addresses
|
|
695
|
+
* the object — so this is stale storage, not a leak.
|
|
696
|
+
*/
|
|
697
|
+
async sweep(tx, workspaceId) {
|
|
698
|
+
const before = new Date(Date.now() - EXPORT_TTL_DAYS * 24 * 60 * 60 * 1000);
|
|
699
|
+
const stale = await tx
|
|
700
|
+
.delete(exportJobs)
|
|
701
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), lt(exportJobs.createdAt, before)))
|
|
702
|
+
.returning({ fileId: exportJobs.fileId });
|
|
703
|
+
await Promise.all(stale
|
|
704
|
+
.map((r) => r.fileId)
|
|
705
|
+
.filter((id) => id !== null)
|
|
706
|
+
.map((id) => kernel.storage.delete(exportArtefactKey(workspaceId, id)).catch(() => undefined)));
|
|
707
|
+
return stale.length;
|
|
708
|
+
},
|
|
709
|
+
/**
|
|
710
|
+
* The job.
|
|
711
|
+
*
|
|
712
|
+
* Three phases, and the split is not tidiness. The database work happens in short transactions;
|
|
713
|
+
* decoding a space's worth of Yjs, pulling its pictures out of storage and waiting on Chromium
|
|
714
|
+
* happen between them, holding nothing. The artefact is written to storage in one piece, and
|
|
715
|
+
* only then does the row point at it — so there is no moment at which `file_id` names bytes that
|
|
716
|
+
* are still arriving, and a failure anywhere leaves `file_id` null and the row `failed`.
|
|
717
|
+
*/
|
|
718
|
+
async run(workspaceId, jobId) {
|
|
719
|
+
/*
|
|
720
|
+
* The claim is **one conditional UPDATE**, and reading the row first was the bug.
|
|
721
|
+
*
|
|
722
|
+
* `select` then `update` is a lost update under READ COMMITTED: two workers both read
|
|
723
|
+
* `queued`, both write `running`, and both run the job. That is not hypothetical here — a
|
|
724
|
+
* pg-boss job that outlives `expireInSeconds` (900 for an export, which `MAX_PAGES = 5000`
|
|
725
|
+
* makes an ordinary large space) is re-dispatched while the first attempt is still going. Both
|
|
726
|
+
* attempts then built the archive and `put` it under a fresh uuid, the row named one of them,
|
|
727
|
+
* and the other became an object in storage that nothing addresses — `sweep` deletes the
|
|
728
|
+
* object named by a row's `file_id`, so it could never reach it. Measured: two concurrent
|
|
729
|
+
* `run`s wrote two objects and orphaned one.
|
|
730
|
+
*
|
|
731
|
+
* `where state = 'queued'` closes it, because Postgres re-evaluates the predicate against the
|
|
732
|
+
* committed row after waiting for the other transaction's lock: the loser matches no row and
|
|
733
|
+
* returns. What that costs is automatic recovery — a row stays `running` until `reap` gives up
|
|
734
|
+
* on it after two hours, rather than being re-run by the next retry. That is the right trade
|
|
735
|
+
* in both directions: a second export attempt leaks an artefact, and a second *import* attempt
|
|
736
|
+
* writes every page in the archive twice.
|
|
737
|
+
*/
|
|
738
|
+
const job = await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
739
|
+
const [row] = await tx
|
|
740
|
+
.update(exportJobs)
|
|
741
|
+
.set({ state: 'running', error: null })
|
|
742
|
+
.where(and(eq(exportJobs.workspaceId, workspaceId), eq(exportJobs.id, jobId), eq(exportJobs.state, 'queued')))
|
|
743
|
+
.returning();
|
|
744
|
+
return row ?? null;
|
|
745
|
+
});
|
|
746
|
+
if (!job)
|
|
747
|
+
return;
|
|
748
|
+
await announce(workspaceId, job.requestedBy, jobId);
|
|
749
|
+
const counts = { total: 0, done: 0, skipped: 0, failed: 0 };
|
|
750
|
+
const progress = () => patch(workspaceId, job.requestedBy, jobId, { counts });
|
|
751
|
+
try {
|
|
752
|
+
const format = job.format;
|
|
753
|
+
if (format === 'docx')
|
|
754
|
+
throw KernError.badRequest(DOCX_REFUSAL, { format: 'docx' });
|
|
755
|
+
/*
|
|
756
|
+
* The export runs as the person who asked for it, which is the whole permission model here.
|
|
757
|
+
* A job has no principal of its own, so it fetches theirs — and when it cannot, it fails
|
|
758
|
+
* rather than falling back to something more permissive.
|
|
759
|
+
*/
|
|
760
|
+
const principal = await kernel
|
|
761
|
+
.call('core.users.principal', { userId: job.requestedBy })
|
|
762
|
+
.catch(() => null);
|
|
763
|
+
if (!principal)
|
|
764
|
+
throw new KernError('INTERNAL', 'The person who asked for this export could not be identified');
|
|
765
|
+
/*
|
|
766
|
+
* Asked again, now, at the scope `exports.start` asked it at — because a job runs minutes
|
|
767
|
+
* after it was queued and a permission can be taken away in between.
|
|
768
|
+
*
|
|
769
|
+
* The per-page `quire.page.view` check below is not a substitute for this one, and the gap
|
|
770
|
+
* between them is exactly the shape of the hole: revoking somebody's *export* permission
|
|
771
|
+
* leaves every page they may still read, so a job queued a moment earlier finished with a
|
|
772
|
+
* complete archive of the space and `exports.get` signed a link to it. That is the one
|
|
773
|
+
* revocation the key exists for — the leaver's last afternoon — and it did not hold.
|
|
774
|
+
* `services/import.ts` re-asks for the same reason; the difference is only that an import
|
|
775
|
+
* writes, so the consequence of missing it was noticed there first.
|
|
776
|
+
*/
|
|
777
|
+
await kernel.database.withWorkspace(workspaceId, async (tx) => {
|
|
778
|
+
if (job.scope === 'space') {
|
|
779
|
+
await access.spaceRow(tx, workspaceId, job.targetId);
|
|
780
|
+
await access.requireSpace(principal, 'quire.page.export', workspaceId, job.targetId);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
await access.pageRow(tx, workspaceId, job.targetId);
|
|
784
|
+
const scope = await access.scopeOf(tx, workspaceId, job.targetId);
|
|
785
|
+
await access.requirePage(principal, 'quire.page.export', workspaceId, scope);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
const selected = await kernel.database.withWorkspace(workspaceId, (tx) => select(tx, principal, workspaceId, job));
|
|
789
|
+
counts.total = selected.pages.length + selected.skipped;
|
|
790
|
+
counts.skipped = selected.skipped;
|
|
791
|
+
await progress();
|
|
792
|
+
const prepared = prepareFolders(selected.pages);
|
|
793
|
+
const bytes = format === 'pdf'
|
|
794
|
+
? await buildPdf(workspaceId, prepared, selected.title, counts, progress)
|
|
795
|
+
: await buildZip(workspaceId, prepared, format, counts, progress);
|
|
796
|
+
if (bytes.length > MAX_ARTEFACT_BYTES)
|
|
797
|
+
throw KernError.badRequest(`This export came to ${Math.round(bytes.length / 1_048_576)} MB and the limit is ` +
|
|
798
|
+
`${MAX_ARTEFACT_BYTES / 1_048_576} MB. Export a subtree at a time.`);
|
|
799
|
+
const fileId = uuidv7();
|
|
800
|
+
await kernel.storage.put(exportArtefactKey(workspaceId, fileId), bytes, ARTEFACT[format].contentType);
|
|
801
|
+
await patch(workspaceId, job.requestedBy, jobId, {
|
|
802
|
+
state: 'done',
|
|
803
|
+
fileId,
|
|
804
|
+
counts,
|
|
805
|
+
finishedAt: new Date(),
|
|
806
|
+
});
|
|
807
|
+
kernel.log.info({ jobId, workspaceId, format, bytes: bytes.length }, 'quire: export finished');
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
811
|
+
await patch(workspaceId, job.requestedBy, jobId, {
|
|
812
|
+
state: 'failed',
|
|
813
|
+
error: message.slice(0, 2000),
|
|
814
|
+
counts,
|
|
815
|
+
finishedAt: new Date(),
|
|
816
|
+
});
|
|
817
|
+
kernel.log.warn({ err: message, jobId, workspaceId }, 'quire: export failed');
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
//# sourceMappingURL=export.js.map
|