@kernhq/module-quire 0.11.1 → 0.13.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 +46 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +73 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/permissions.d.ts +17 -1
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +35 -0
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +756 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +346 -2
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +826 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +336 -2
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/schema.d.ts +318 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +86 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/access.d.ts +1 -0
- package/dist/server/services/access.d.ts.map +1 -1
- package/dist/server/services/index.d.ts +3 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +5 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +6 -0
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/publications.d.ts +191 -0
- package/dist/server/services/publications.d.ts.map +1 -0
- package/dist/server/services/publications.js +772 -0
- package/dist/server/services/publications.js.map +1 -0
- package/dist/server/services/versions.d.ts +26 -1
- package/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +44 -15
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0008_publications.sql +140 -0
- package/migrations/0009_public_asset_references.sql +35 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/src/client/components/PublishDialog.svelte +920 -0
- package/src/client/components/SidebarRecents.svelte +17 -1
- package/src/client/i18n.ts +448 -0
- package/src/client/index.ts +21 -0
- package/src/client/mock.ts +436 -2
- package/src/client/pages/PageView.svelte +139 -0
- package/src/client/public-url.ts +76 -0
- package/src/client/query.ts +16 -0
- package/src/contract/models.ts +77 -0
- package/src/contract/permissions.ts +54 -1
- package/src/contract/router.ts +380 -1
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing a page, and everything under it, to a URL a signed-out stranger can open.
|
|
3
|
+
*
|
|
4
|
+
* This file holds both halves: the authenticated CRUD an author uses, and the read path an
|
|
5
|
+
* anonymous request takes. They are together because the second one is only safe if it agrees with
|
|
6
|
+
* the first about what "public" means, and the definition lives here once:
|
|
7
|
+
*
|
|
8
|
+
* **A page is public iff it is the publication's root or a descendant of it reached without
|
|
9
|
+
* passing through a page that is opted out, archived, trashed, unpublished or not a `page`; and
|
|
10
|
+
* it has a published version that has been rendered to HTML.**
|
|
11
|
+
*
|
|
12
|
+
* Note that the walk *prunes* rather than filters. A page whose parent is private is private, even
|
|
13
|
+
* though it is inside the subtree — otherwise excluding a section would leave every page under it
|
|
14
|
+
* reachable by anyone who guessed its address, and the nav would simply not mention them. The cost
|
|
15
|
+
* is that forgetting to publish a middle page hides its children, which is the safe direction to be
|
|
16
|
+
* wrong in and is worth saying in the interface.
|
|
17
|
+
*
|
|
18
|
+
* Three things about the anonymous path that the type system cannot hold:
|
|
19
|
+
*
|
|
20
|
+
* - it runs in a **read-only transaction** (`read`, below). Once the workspace is set, row-level
|
|
21
|
+
* security has stopped being a fence around an anonymous request — the whole workspace is inside
|
|
22
|
+
* it, which is right for reading a publication and wrong for everything else. Postgres refuses
|
|
23
|
+
* the write with 25006 instead of a code review catching it;
|
|
24
|
+
* - **every query carries the publication in its own `WHERE`**. Workspace scope is not publication
|
|
25
|
+
* scope, and RLS will happily return a page in another space that nobody published;
|
|
26
|
+
* - **nothing it returns carries an id.** Pages are addressed by path, the cache validator is a
|
|
27
|
+
* hash rather than the version id, and the rendered HTML is scrubbed of `id` and `data-id`
|
|
28
|
+
* before it leaves. An id in a public response is a string somebody can try somewhere else.
|
|
29
|
+
*/
|
|
30
|
+
import { createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
import { KernError } from '@kernhq/kernel';
|
|
33
|
+
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
34
|
+
import { PUBLIC_ASSET_SEGMENT } from '../../contract/index.js';
|
|
35
|
+
import { escapeHtml } from '../render.js';
|
|
36
|
+
import { pages, pageVersions, publications } from '../schema.js';
|
|
37
|
+
import { ASSET_REFERENCE_PREFIX } from './versions.js';
|
|
38
|
+
const scrypt = promisify(scryptCb);
|
|
39
|
+
/** How deep a published site may nest before the walk stops descending. */
|
|
40
|
+
const MAX_DEPTH = 32;
|
|
41
|
+
/** How many pages one `create`/`update` will render HTML for before giving up and logging. */
|
|
42
|
+
const MAX_BACKFILL = 200;
|
|
43
|
+
/** How long an unlock token is good for. Short, because there is nothing to revoke it with. */
|
|
44
|
+
const TOKEN_TTL_MS = 12 * 60 * 60_000;
|
|
45
|
+
/** How much of a page's prose a search hit shows around the match. */
|
|
46
|
+
const SNIPPET = 180;
|
|
47
|
+
/**
|
|
48
|
+
* The largest picture this surface will hand out, and how long a reader may keep one.
|
|
49
|
+
*
|
|
50
|
+
* The bytes come back through an anonymous procedure, so an object with no ceiling on it is a way
|
|
51
|
+
* to spend the server's memory from outside. Over the cap is the same 404 as a picture that is not
|
|
52
|
+
* there — an oversized image on a published page is a defect in the page rather than something to
|
|
53
|
+
* fail a request over. A version is immutable and its reference is sealed to one file, so the cache
|
|
54
|
+
* lifetime is as long as anything in Kern gets.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
57
|
+
const ASSET_MAX_AGE = 31_536_000;
|
|
58
|
+
/**
|
|
59
|
+
* What a picture is allowed to be, because the route layer serves these from the app's own origin.
|
|
60
|
+
*
|
|
61
|
+
* A stored content type is whatever an uploader declared, and a reference resolves on the same
|
|
62
|
+
* origin as the signed-in application — so anything the browser would treat as a document rather
|
|
63
|
+
* than as an image is a way to run script beside somebody's session. The node these references come
|
|
64
|
+
* from is an image node, so the list is the image types and nothing else, and an object claiming to
|
|
65
|
+
* be anything else is the same 404 as one that is not there.
|
|
66
|
+
*
|
|
67
|
+
* `image/svg+xml` is on the list and is the one that needs saying: an SVG *is* a document, and it
|
|
68
|
+
* can carry script. It is here because a diagram in a handbook is very often one, and it is safe
|
|
69
|
+
* only because the route layer is required to serve every one of these with `nosniff`, an inline
|
|
70
|
+
* disposition and a `default-src 'none'` policy — see the note on `PublicAsset` in the contract.
|
|
71
|
+
*/
|
|
72
|
+
const ASSET_TYPES = new Set([
|
|
73
|
+
'image/png',
|
|
74
|
+
'image/jpeg',
|
|
75
|
+
'image/gif',
|
|
76
|
+
'image/webp',
|
|
77
|
+
'image/avif',
|
|
78
|
+
'image/svg+xml',
|
|
79
|
+
]);
|
|
80
|
+
/**
|
|
81
|
+
* How many passwords one publication will weigh in a minute, and why the number is here.
|
|
82
|
+
*
|
|
83
|
+
* `public.unlock` is reachable by anyone on the internet with no account, and every attempt costs
|
|
84
|
+
* the server an scrypt at N=16384 — 600 of them measured at roughly a minute of single-thread work,
|
|
85
|
+
* bought with one unauthenticated burst. The platform's own limiter is a *shared* budget across the
|
|
86
|
+
* whole API rather than a password fence, so a publication needs one of its own.
|
|
87
|
+
*
|
|
88
|
+
* It is per process and therefore multiplied by however many copies of the host service are
|
|
89
|
+
* running, which is honest rather than ideal: a counter that has to be right across a cluster
|
|
90
|
+
* belongs in the platform limiter, and until it is there this is the difference between 864,000
|
|
91
|
+
* guesses a day from one address and 14,400.
|
|
92
|
+
*/
|
|
93
|
+
const UNLOCK_ATTEMPTS = 10;
|
|
94
|
+
const UNLOCK_WINDOW_MS = 60_000;
|
|
95
|
+
/** Above this many publications tracked at once the window is dropped wholesale rather than grown. */
|
|
96
|
+
const UNLOCK_TRACKED_MAX = 5000;
|
|
97
|
+
/**
|
|
98
|
+
* A publication as a client sees it — with `hasPassword` in place of the hash.
|
|
99
|
+
*
|
|
100
|
+
* The hash never leaves this file. It is a hash, a salt and a cost, so shipping it to a browser
|
|
101
|
+
* turns an online guess, which the server can rate-limit, into an offline one, which it cannot.
|
|
102
|
+
* Row-level security is row-level: the column is inside every row the workspace can read, including
|
|
103
|
+
* on the public path once the handler has set the workspace, so "do not select it" is not a
|
|
104
|
+
* protection — mapping through this function is.
|
|
105
|
+
*/
|
|
106
|
+
export function toPublication(row) {
|
|
107
|
+
return {
|
|
108
|
+
id: row.id,
|
|
109
|
+
workspaceId: row.workspaceId,
|
|
110
|
+
rootPageId: row.rootPageId,
|
|
111
|
+
includeDescendants: row.includeDescendants,
|
|
112
|
+
slug: row.slug,
|
|
113
|
+
hasPassword: row.passwordHash !== null,
|
|
114
|
+
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
115
|
+
seoTitle: row.seoTitle,
|
|
116
|
+
seoDescription: row.seoDescription,
|
|
117
|
+
ogImageUrl: row.ogImageUrl,
|
|
118
|
+
indexable: row.indexable,
|
|
119
|
+
theme: row.theme,
|
|
120
|
+
createdBy: row.createdBy,
|
|
121
|
+
createdAt: row.createdAt.toISOString(),
|
|
122
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A title reduced to a URL segment, keeping Unicode letters and digits.
|
|
127
|
+
*
|
|
128
|
+
* `[^\p{L}\p{N}]` rather than `[^a-z0-9]`: a Persian or Arabic title would otherwise slugify to
|
|
129
|
+
* nothing at all, and a handbook in Persian would be a tree of `untitled`, `untitled-2`,
|
|
130
|
+
* `untitled-3`. Percent-encoding makes the result URL-safe; readability in the address bar is the
|
|
131
|
+
* browser's job, and every browser shows it decoded.
|
|
132
|
+
*/
|
|
133
|
+
export function slugifyTitle(title) {
|
|
134
|
+
const cleaned = title
|
|
135
|
+
.normalize('NFC')
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
138
|
+
.replace(/^-+|-+$/g, '')
|
|
139
|
+
.slice(0, 60)
|
|
140
|
+
.replace(/-+$/g, '');
|
|
141
|
+
return cleaned || 'untitled';
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Give every page in the walk its path, and nothing else its path.
|
|
145
|
+
*
|
|
146
|
+
* Siblings that slugify the same get `-2`, `-3` in `position` order, which is deterministic from the
|
|
147
|
+
* tree: two people looking at the same site get the same URLs, and nothing has to be stored. The
|
|
148
|
+
* rows arrive ordered by depth and then position, so a parent's path is always known before its
|
|
149
|
+
* children are reached.
|
|
150
|
+
*/
|
|
151
|
+
export function withPaths(rows) {
|
|
152
|
+
const pathOf = new Map();
|
|
153
|
+
const takenUnder = new Map();
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
const parentPath = row.parent_id === null ? null : (pathOf.get(row.parent_id) ?? null);
|
|
157
|
+
// A child whose parent did not survive the walk cannot be addressed, so it is not public
|
|
158
|
+
// either. Unreachable while the walk prunes, and it stays correct if it ever stops.
|
|
159
|
+
if (row.parent_id !== null && parentPath === null)
|
|
160
|
+
continue;
|
|
161
|
+
if (row.depth === 0) {
|
|
162
|
+
pathOf.set(row.id, '');
|
|
163
|
+
out.push({ ...row, path: '', parentPath: null });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const taken = takenUnder.get(row.parent_id) ?? new Set();
|
|
167
|
+
takenUnder.set(row.parent_id, taken);
|
|
168
|
+
const base = slugifyTitle(row.title);
|
|
169
|
+
let slug = base;
|
|
170
|
+
for (let n = 2; taken.has(slug); n++)
|
|
171
|
+
slug = `${base}-${n}`;
|
|
172
|
+
taken.add(slug);
|
|
173
|
+
const path = parentPath === '' ? slug : `${parentPath}/${slug}`;
|
|
174
|
+
pathOf.set(row.id, path);
|
|
175
|
+
out.push({ ...row, path, parentPath });
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
/** Compare two public paths the way a URL bar does: trimmed of slashes, case-folded, NFC. */
|
|
180
|
+
const normalisePath = (path) => path
|
|
181
|
+
.normalize('NFC')
|
|
182
|
+
.replace(/^\/+|\/+$/g, '')
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
/**
|
|
185
|
+
* The public rendering of a stored version.
|
|
186
|
+
*
|
|
187
|
+
* Two passes, in this order because the first needs what the second removes:
|
|
188
|
+
*
|
|
189
|
+
* 1. **Re-point page mentions.** `versions.html` writes `/quire/<space-key>/<page-id>`, which is
|
|
190
|
+
* an address inside the application: a stranger following it gets a sign-in screen, and the id
|
|
191
|
+
* in it belongs to a page that may not be public at all. A mention of a page in this
|
|
192
|
+
* publication becomes a link to its public path; anything else loses its `href` and stays as
|
|
193
|
+
* readable text.
|
|
194
|
+
* 2. **Strip every identifier.** `id` is the block anchor the editor wrote and `data-id` is the
|
|
195
|
+
* mentioned page's or person's id — a user id on a public page is a person's identifier handed
|
|
196
|
+
* to the internet for no reader's benefit.
|
|
197
|
+
*
|
|
198
|
+
* A regular expression over HTML is usually a mistake and is safe here for one specific reason: this
|
|
199
|
+
* HTML was produced by `render.ts`, which escapes every attribute value, so a `"` never appears
|
|
200
|
+
* inside one and `[^"]*` cannot run past the attribute it is in. Prose that happens to contain the
|
|
201
|
+
* text `id="x"` arrives as `id="x"` and is left alone. `publications.int.test.ts` holds
|
|
202
|
+
* that with a code block written to look like markup.
|
|
203
|
+
*/
|
|
204
|
+
const APP_LINK = /href="\/quire\/[^"]*"/g;
|
|
205
|
+
const APP_PAGE_LINK = /^href="\/quire\/[^"/]*\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:[/?#][^"]*)?"$/;
|
|
206
|
+
/**
|
|
207
|
+
* Pictures, held to the same rule as links: a `src` this module cannot account for does not go out.
|
|
208
|
+
*
|
|
209
|
+
* `render.ts` escapes every attribute value, so `>` never appears inside one and `[^>]*` cannot run
|
|
210
|
+
* past the tag it is in — the same property that makes the link pass above safe.
|
|
211
|
+
*
|
|
212
|
+
* Three shapes reach here and only one of them is servable. A **reference** is what the public
|
|
213
|
+
* render writes and it resolves to an address on this site. An **absolute off-site URL** is an
|
|
214
|
+
* author's own picture hosted somewhere else, and it is left alone. Everything else is dropped
|
|
215
|
+
* together with its `<img>`, and that is deliberately wide: a root-relative `src` is a link into
|
|
216
|
+
* the private application, and a *signed storage URL* — which is what versions published by 0.12.0
|
|
217
|
+
* have stored in them — is the tenant's workspace uuid and a file uuid written into a page on the
|
|
218
|
+
* public internet, with an hour before it stops working. `0009_public_asset_references.sql` rewrites
|
|
219
|
+
* the ones already in the database; this is what covers a row the migration did not reach.
|
|
220
|
+
*/
|
|
221
|
+
const IMG_TAG = /<img\b[^>]*>/g;
|
|
222
|
+
const ASSET_SRC = new RegExp(` src="${ASSET_REFERENCE_PREFIX}([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})"`);
|
|
223
|
+
const ANY_SRC = / src="([^"]*)"/;
|
|
224
|
+
const SIGNED_URL = /[?&](?:amp;)?X-Amz-(?:Signature|Credential)=/i;
|
|
225
|
+
export function publicHtml(html, basePath, resolve) {
|
|
226
|
+
/*
|
|
227
|
+
* Every link into the application, in one pass, and the page id is not anchored on the closing
|
|
228
|
+
* quote.
|
|
229
|
+
*
|
|
230
|
+
* A page mention is not the only thing that writes one of these. `safeHref` passes any
|
|
231
|
+
* root-relative path through — it has to, that is what an internal link is — so an author who
|
|
232
|
+
* pastes a page's address out of their own address bar gets `/quire/<space>/<id>#block-7` or
|
|
233
|
+
* `…?comment=1` or `…/history`, and a pattern that required `"` straight after the id matched
|
|
234
|
+
* none of them and left the whole href alone. That put a live deep link into the private
|
|
235
|
+
* application, carrying the uuid of a page this same API answers 404 for, on the public internet.
|
|
236
|
+
*
|
|
237
|
+
* So: match the whole href, then ask whether it names a page — and if the answer is no for any
|
|
238
|
+
* reason at all (no id in it, an id that is not public, an id with something glued to it), drop
|
|
239
|
+
* the href rather than keep it. One pass rather than two, because `basePath` may legally be
|
|
240
|
+
* `/quire/` and a second sweep would eat the links the first one had just written.
|
|
241
|
+
*/
|
|
242
|
+
const linked = html.replace(APP_LINK, (match) => {
|
|
243
|
+
const pageId = APP_PAGE_LINK.exec(match)?.[1] ?? null;
|
|
244
|
+
const path = pageId === null ? null : resolve.pagePath(pageId);
|
|
245
|
+
return path === null ? '' : `href="${escapeHtml(basePath + encodeURI(path))}"`;
|
|
246
|
+
});
|
|
247
|
+
const pictured = linked.replace(IMG_TAG, (tag) => {
|
|
248
|
+
const fileId = ASSET_SRC.exec(tag)?.[1] ?? null;
|
|
249
|
+
if (fileId === null) {
|
|
250
|
+
const src = ANY_SRC.exec(tag)?.[1] ?? '';
|
|
251
|
+
return /^https?:\/\//i.test(src) && !SIGNED_URL.test(src) ? tag : '';
|
|
252
|
+
}
|
|
253
|
+
const href = resolve.assetHref(fileId);
|
|
254
|
+
// A function replacement, because `$&` and friends mean something in a replacement string and
|
|
255
|
+
// a base64url token is not somewhere to find that out.
|
|
256
|
+
return href === null ? '' : tag.replace(ASSET_SRC, () => ` src="${escapeHtml(href)}"`);
|
|
257
|
+
});
|
|
258
|
+
return pictured.replace(/ (?:data-)?id="[^"]*"/g, '');
|
|
259
|
+
}
|
|
260
|
+
/** A plain-text window around the first match, for a search result. */
|
|
261
|
+
export function snippetAround(text, query) {
|
|
262
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
263
|
+
const at = flat.toLowerCase().indexOf(query.toLowerCase());
|
|
264
|
+
if (at < 0)
|
|
265
|
+
return flat.slice(0, SNIPPET);
|
|
266
|
+
const from = Math.max(0, at - Math.floor(SNIPPET / 3));
|
|
267
|
+
const to = Math.min(flat.length, from + SNIPPET);
|
|
268
|
+
return `${from > 0 ? '…' : ''}${flat.slice(from, to)}${to < flat.length ? '…' : ''}`;
|
|
269
|
+
}
|
|
270
|
+
/** `%`, `_` and `\` mean something to `ILIKE`, so a reader searching for `50%` gets `50%`. */
|
|
271
|
+
const escapeLike = (value) => value.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
272
|
+
export function quirePublications(kernel, access, versions) {
|
|
273
|
+
/**
|
|
274
|
+
* The associated data an unlock token is sealed with.
|
|
275
|
+
*
|
|
276
|
+
* AES-GCM authenticates it, so a token minted for one publication cannot be presented to another
|
|
277
|
+
* — and a token from another instance cannot be presented at all, because the key is derived from
|
|
278
|
+
* that instance's own secret. This is what makes the token a capability rather than a session:
|
|
279
|
+
* there is nothing on the server to look up, nothing to expire on a schedule, and nothing that
|
|
280
|
+
* says who is holding it.
|
|
281
|
+
*/
|
|
282
|
+
const aad = (workspaceId, publicationId) => `quire.publication.unlock:${workspaceId}:${publicationId}`;
|
|
283
|
+
/**
|
|
284
|
+
* The same mechanism, one scope wider, for a picture reference.
|
|
285
|
+
*
|
|
286
|
+
* Sealed to the **workspace** rather than to one publication, because a version is shared: the
|
|
287
|
+
* same page can be the root of two publications and its stored HTML is rendered once. Widening it
|
|
288
|
+
* costs nothing that matters — a reference only ever exists inside a page somebody has already
|
|
289
|
+
* been served, and resolving one still requires the file to be in the tree of the publication it
|
|
290
|
+
* is presented against.
|
|
291
|
+
*/
|
|
292
|
+
const assetAad = (workspaceId) => `quire.publication.asset:${workspaceId.toLowerCase()}`;
|
|
293
|
+
/** Recent password attempts per publication, for the fence described at `UNLOCK_ATTEMPTS`. */
|
|
294
|
+
const unlockAttempts = new Map();
|
|
295
|
+
async function hashPassword(plain) {
|
|
296
|
+
const salt = randomBytes(16);
|
|
297
|
+
const key = await scrypt(plain.normalize('NFC'), salt, 32, { N: 16384, r: 8, p: 1 });
|
|
298
|
+
return `$scrypt$N=16384,r=8,p=1$${salt.toString('base64url')}$${key.toString('base64url')}`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Constant-time in the comparison, and deliberately not in the parse: a malformed stored hash is
|
|
302
|
+
* a bug in this file rather than something an attacker can produce, and answering `false` for one
|
|
303
|
+
* is the safe direction.
|
|
304
|
+
*/
|
|
305
|
+
async function verifyPassword(plain, phc) {
|
|
306
|
+
const parts = phc.split('$');
|
|
307
|
+
if (parts.length !== 5 || parts[1] !== 'scrypt')
|
|
308
|
+
return false;
|
|
309
|
+
const params = Object.fromEntries((parts[2] ?? '').split(',').map((pair) => {
|
|
310
|
+
const [k, v] = pair.split('=');
|
|
311
|
+
return [k ?? '', Number(v)];
|
|
312
|
+
}));
|
|
313
|
+
const salt = Buffer.from(parts[3] ?? '', 'base64url');
|
|
314
|
+
const expected = Buffer.from(parts[4] ?? '', 'base64url');
|
|
315
|
+
if (!Number.isInteger(params.N) || !Number.isInteger(params.r) || !Number.isInteger(params.p))
|
|
316
|
+
return false;
|
|
317
|
+
if (salt.length === 0 || expected.length === 0)
|
|
318
|
+
return false;
|
|
319
|
+
const actual = await scrypt(plain.normalize('NFC'), salt, expected.length, {
|
|
320
|
+
N: params.N,
|
|
321
|
+
r: params.r,
|
|
322
|
+
p: params.p,
|
|
323
|
+
});
|
|
324
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The walk, as one recursive query.
|
|
328
|
+
*
|
|
329
|
+
* `cycle` is not paranoia: a page that had somehow become its own ancestor would otherwise hang
|
|
330
|
+
* the connection rather than fail, and this is the one endpoint where a hung connection is
|
|
331
|
+
* something a stranger can ask for. `depth < MAX_DEPTH` bounds the other direction.
|
|
332
|
+
*/
|
|
333
|
+
async function walk(tx, workspaceId, pub, opts) {
|
|
334
|
+
const rendered = opts.requireHtml;
|
|
335
|
+
const res = await tx.execute(sql `
|
|
336
|
+
with recursive tree as (
|
|
337
|
+
select p.id, p.parent_id, p.title, p.icon, p.cover_url, p.position,
|
|
338
|
+
v.id as version_id, v.created_at as published_at,
|
|
339
|
+
(v.html is not null) as has_html, 0 as depth
|
|
340
|
+
from mod_quire.pages p
|
|
341
|
+
join mod_quire.page_versions v
|
|
342
|
+
on v.workspace_id = p.workspace_id and v.id = p.published_version_id
|
|
343
|
+
where p.workspace_id = ${workspaceId}::uuid
|
|
344
|
+
and p.id = ${pub.rootPageId}::uuid
|
|
345
|
+
and p.kind = 'page'
|
|
346
|
+
and p.database_id is null
|
|
347
|
+
and p.deleted_at is null
|
|
348
|
+
and p.archived_at is null
|
|
349
|
+
and p.excluded_from_public = false
|
|
350
|
+
and (${rendered}::boolean = false or v.html is not null)
|
|
351
|
+
union all
|
|
352
|
+
select c.id, c.parent_id, c.title, c.icon, c.cover_url, c.position,
|
|
353
|
+
cv.id, cv.created_at, (cv.html is not null), tree.depth + 1
|
|
354
|
+
from mod_quire.pages c
|
|
355
|
+
join tree on c.parent_id = tree.id
|
|
356
|
+
join mod_quire.page_versions cv
|
|
357
|
+
on cv.workspace_id = ${workspaceId}::uuid and cv.id = c.published_version_id
|
|
358
|
+
where c.workspace_id = ${workspaceId}::uuid
|
|
359
|
+
and ${pub.includeDescendants}::boolean
|
|
360
|
+
and c.kind = 'page'
|
|
361
|
+
and c.database_id is null
|
|
362
|
+
and c.deleted_at is null
|
|
363
|
+
and c.archived_at is null
|
|
364
|
+
and c.excluded_from_public = false
|
|
365
|
+
and tree.depth < ${MAX_DEPTH}
|
|
366
|
+
and (${rendered}::boolean = false or cv.html is not null)
|
|
367
|
+
) cycle id set looped using cyclepath
|
|
368
|
+
select id, parent_id, title, icon, cover_url, position,
|
|
369
|
+
version_id, published_at, has_html, depth
|
|
370
|
+
from tree
|
|
371
|
+
order by depth asc, position asc
|
|
372
|
+
`);
|
|
373
|
+
return res.rows.map((row) => ({
|
|
374
|
+
id: row.id,
|
|
375
|
+
parent_id: row.parent_id,
|
|
376
|
+
title: row.title,
|
|
377
|
+
icon: row.icon,
|
|
378
|
+
cover_url: row.cover_url,
|
|
379
|
+
position: row.position,
|
|
380
|
+
version_id: row.version_id,
|
|
381
|
+
published_at: row.published_at instanceof Date ? row.published_at : new Date(row.published_at),
|
|
382
|
+
has_html: row.has_html === true,
|
|
383
|
+
depth: Number(row.depth),
|
|
384
|
+
}));
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
/**
|
|
388
|
+
* The anonymous path's transaction: the workspace set, and nothing writable.
|
|
389
|
+
*
|
|
390
|
+
* `set transaction read only` is issued after `withWorkspace`'s own `set_config`, which
|
|
391
|
+
* Postgres allows — the access mode is fixed by the first *write*, not by the first statement —
|
|
392
|
+
* and covers `pages` and `page_versions` as well as `publications`. It is the second fence, not
|
|
393
|
+
* the first: the first is that every query below carries the publication.
|
|
394
|
+
*/
|
|
395
|
+
read(workspaceId, fn) {
|
|
396
|
+
/*
|
|
397
|
+
* Lower-cased before it is set, and that is not tidiness.
|
|
398
|
+
*
|
|
399
|
+
* `withWorkspace` writes the caller's string into `app.workspace_id` verbatim, and every RLS
|
|
400
|
+
* policy compares it as **text** against `workspace_id::text`, which Postgres renders in
|
|
401
|
+
* lower case. So an upper-case uuid in a public URL sets a GUC no policy can ever match: the
|
|
402
|
+
* whole surface returns nothing on a correctly locked-down instance and serves the site
|
|
403
|
+
* normally in development and CI, where the role is a superuser and bypasses the policies
|
|
404
|
+
* altogether. That is the direction that hides a bug rather than the one that shows it, and
|
|
405
|
+
* this is the only anonymous entry point, so it is normalised here.
|
|
406
|
+
*/
|
|
407
|
+
return kernel.database.withWorkspace(workspaceId.toLowerCase(), async (tx) => {
|
|
408
|
+
await tx.execute(sql `set transaction read only`);
|
|
409
|
+
return fn(tx);
|
|
410
|
+
}, { userId: null });
|
|
411
|
+
},
|
|
412
|
+
/**
|
|
413
|
+
* The publication behind a slug, or `notFound`.
|
|
414
|
+
*
|
|
415
|
+
* Expiry is checked here rather than by a sweep, so the URL stops working at the moment its
|
|
416
|
+
* author said it would even if nothing has run since. Everything that cannot be served answers
|
|
417
|
+
* the same 404: a slug nobody has taken, one that has expired, and one whose root page has
|
|
418
|
+
* since been trashed are indistinguishable from outside, which is the point.
|
|
419
|
+
*/
|
|
420
|
+
async bySlug(tx, workspaceId, slug) {
|
|
421
|
+
const [row] = await tx
|
|
422
|
+
.select()
|
|
423
|
+
.from(publications)
|
|
424
|
+
.where(and(eq(publications.workspaceId, workspaceId), eq(publications.slug, slug)))
|
|
425
|
+
.limit(1);
|
|
426
|
+
if (!row)
|
|
427
|
+
throw new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
428
|
+
if (row.expiresAt && row.expiresAt.getTime() <= Date.now())
|
|
429
|
+
throw new KernError('NOT_FOUND', 'There is no published site at this address');
|
|
430
|
+
return row;
|
|
431
|
+
},
|
|
432
|
+
/** Whether this request may see anything but the door. */
|
|
433
|
+
async unlocked(pub, token) {
|
|
434
|
+
if (!pub.passwordHash)
|
|
435
|
+
return true;
|
|
436
|
+
if (!token)
|
|
437
|
+
return false;
|
|
438
|
+
try {
|
|
439
|
+
const claims = JSON.parse(kernel.secrets.decrypt(token, aad(pub.workspaceId, pub.id)));
|
|
440
|
+
return typeof claims.exp === 'number' && claims.exp > Date.now();
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// A forged, truncated, re-used-from-another-publication or simply stale token is not an
|
|
444
|
+
// error worth reporting to whoever sent it — it is a locked door.
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
async mintToken(pub) {
|
|
449
|
+
const exp = Date.now() + TOKEN_TTL_MS;
|
|
450
|
+
return {
|
|
451
|
+
token: kernel.secrets.encrypt(JSON.stringify({ exp }), aad(pub.workspaceId, pub.id)),
|
|
452
|
+
expiresAt: new Date(exp).toISOString(),
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
/**
|
|
456
|
+
* Weigh one password, and refuse to weigh too many.
|
|
457
|
+
*
|
|
458
|
+
* The counter is taken *before* the scrypt rather than after the answer, so a burst is stopped
|
|
459
|
+
* at the cost of a map lookup instead of buying the sender a key derivation each time. Only a
|
|
460
|
+
* publication that has a door is ever counted — a slug nobody has taken never reaches here, so
|
|
461
|
+
* the fence cannot be turned into a way of finding out which slugs exist.
|
|
462
|
+
*/
|
|
463
|
+
async checkPassword(pub, password) {
|
|
464
|
+
if (!pub.passwordHash)
|
|
465
|
+
return false;
|
|
466
|
+
const key = `${pub.workspaceId}:${pub.id}`;
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (unlockAttempts.size > UNLOCK_TRACKED_MAX)
|
|
469
|
+
unlockAttempts.clear();
|
|
470
|
+
const recent = (unlockAttempts.get(key) ?? []).filter((at) => at > now - UNLOCK_WINDOW_MS);
|
|
471
|
+
if (recent.length >= UNLOCK_ATTEMPTS) {
|
|
472
|
+
unlockAttempts.set(key, recent);
|
|
473
|
+
throw new KernError('RATE_LIMITED', 'Too many attempts. Wait a minute and try again');
|
|
474
|
+
}
|
|
475
|
+
recent.push(now);
|
|
476
|
+
unlockAttempts.set(key, recent);
|
|
477
|
+
return verifyPassword(password, pub.passwordHash);
|
|
478
|
+
},
|
|
479
|
+
/** The reference a published page carries in place of a picture's address. */
|
|
480
|
+
assetReferenceFor(workspaceId, fileId) {
|
|
481
|
+
return kernel.secrets.encrypt(fileId, assetAad(workspaceId));
|
|
482
|
+
},
|
|
483
|
+
/**
|
|
484
|
+
* The bytes of one referenced picture, or `notFound` for every way of not having them.
|
|
485
|
+
*
|
|
486
|
+
* Two questions, and the second is the one that matters. The reference decrypts to a file id —
|
|
487
|
+
* authenticated, so it cannot be forged or moved between instances or workspaces — and then the
|
|
488
|
+
* file has to be *used by a page that is public in this publication right now*. Without that
|
|
489
|
+
* second half a reference lifted from one site would resolve against another in the same
|
|
490
|
+
* workspace, and a page opted out of publishing would keep serving its illustrations after its
|
|
491
|
+
* prose had gone.
|
|
492
|
+
*
|
|
493
|
+
* The containment question is asked of the stored HTML rather than of the document, because the
|
|
494
|
+
* stored HTML is what a reader is actually served: if the reference is not in it, no published
|
|
495
|
+
* page ever asked for this file.
|
|
496
|
+
*/
|
|
497
|
+
async asset(tx, workspaceId, nodes, reference) {
|
|
498
|
+
const gone = () => new KernError('NOT_FOUND', 'There is no such picture on this site');
|
|
499
|
+
const versionIds = nodes.map((n) => n.version_id);
|
|
500
|
+
if (versionIds.length === 0)
|
|
501
|
+
throw gone();
|
|
502
|
+
let fileId;
|
|
503
|
+
try {
|
|
504
|
+
fileId = kernel.secrets.decrypt(reference, assetAad(workspaceId));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
throw gone();
|
|
508
|
+
}
|
|
509
|
+
if (!/^[0-9a-fA-F-]{36}$/.test(fileId))
|
|
510
|
+
throw gone();
|
|
511
|
+
const [used] = await tx
|
|
512
|
+
.select({ id: pageVersions.id })
|
|
513
|
+
.from(pageVersions)
|
|
514
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, versionIds), sql `${pageVersions.html} like ${`%${ASSET_REFERENCE_PREFIX}${fileId}%`}`))
|
|
515
|
+
.limit(1);
|
|
516
|
+
if (!used)
|
|
517
|
+
throw gone();
|
|
518
|
+
const file = await kernel
|
|
519
|
+
.call('core.files.get', { id: fileId })
|
|
520
|
+
.catch(() => null);
|
|
521
|
+
if (!file?.key)
|
|
522
|
+
throw gone();
|
|
523
|
+
const contentType = (file.mimeType || '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
524
|
+
if (!ASSET_TYPES.has(contentType))
|
|
525
|
+
throw gone();
|
|
526
|
+
const head = await kernel.storage.head(file.key).catch(() => null);
|
|
527
|
+
if ((head?.contentLength ?? 0) > MAX_ASSET_BYTES) {
|
|
528
|
+
kernel.log.warn({ fileId, bytes: head?.contentLength, cap: MAX_ASSET_BYTES }, 'a published picture is over the public cap and was not served');
|
|
529
|
+
throw gone();
|
|
530
|
+
}
|
|
531
|
+
const object = await kernel.storage.get(file.key).catch(() => null);
|
|
532
|
+
if (!object)
|
|
533
|
+
throw gone();
|
|
534
|
+
const chunks = [];
|
|
535
|
+
let size = 0;
|
|
536
|
+
for await (const chunk of object.body) {
|
|
537
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
538
|
+
size += buf.length;
|
|
539
|
+
// `head` is the fast refusal; this is the one that holds when the store did not answer it.
|
|
540
|
+
if (size > MAX_ASSET_BYTES) {
|
|
541
|
+
object.body.destroy();
|
|
542
|
+
throw gone();
|
|
543
|
+
}
|
|
544
|
+
chunks.push(buf);
|
|
545
|
+
}
|
|
546
|
+
return { contentType, bytes: Buffer.concat(chunks).toString('base64'), maxAge: ASSET_MAX_AGE };
|
|
547
|
+
},
|
|
548
|
+
/** Every publicly reachable page of this publication, addressed, root first. */
|
|
549
|
+
async tree(tx, workspaceId, pub) {
|
|
550
|
+
return withPaths(await walk(tx, workspaceId, pub, { requireHtml: true }));
|
|
551
|
+
},
|
|
552
|
+
/** The node at a public path, or `notFound`. Never an id — `path` names a place or nothing. */
|
|
553
|
+
find(nodes, path) {
|
|
554
|
+
const wanted = normalisePath(path);
|
|
555
|
+
const node = nodes.find((n) => normalisePath(n.path) === wanted);
|
|
556
|
+
if (!node)
|
|
557
|
+
throw new KernError('NOT_FOUND', 'There is no published page at this address');
|
|
558
|
+
return node;
|
|
559
|
+
},
|
|
560
|
+
/** The pinned version's stored HTML, scrubbed for the internet. */
|
|
561
|
+
async html(tx, workspaceId, node, nodes, basePath) {
|
|
562
|
+
const [row] = await tx
|
|
563
|
+
.select({ html: pageVersions.html })
|
|
564
|
+
.from(pageVersions)
|
|
565
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, node.version_id)))
|
|
566
|
+
.limit(1);
|
|
567
|
+
/*
|
|
568
|
+
* `=== null`, not falsy. `''` and NULL mean different things in this column and the
|
|
569
|
+
* difference is the whole reason it is nullable: NULL is "nobody has drawn this version",
|
|
570
|
+
* which is not servable, and `''` is "this version draws to nothing", which is a page
|
|
571
|
+
* somebody published while it was empty and is perfectly servable. Treating them the same
|
|
572
|
+
* way 404s a real page — it did, on every page in the fixture whose prose the renderer could
|
|
573
|
+
* not decode.
|
|
574
|
+
*/
|
|
575
|
+
if (!row || row.html === null)
|
|
576
|
+
throw new KernError('NOT_FOUND', 'There is no published page at this address');
|
|
577
|
+
const pathById = new Map(nodes.map((n) => [n.id, n.path]));
|
|
578
|
+
return publicHtml(row.html, basePath, {
|
|
579
|
+
pagePath: (id) => pathById.get(id) ?? null,
|
|
580
|
+
// Minted per read rather than stored, so the envelope can be re-keyed and so nothing
|
|
581
|
+
// durable in the database is a capability. `basePath` already ends in a slash.
|
|
582
|
+
assetHref: (fileId) => `${basePath}${PUBLIC_ASSET_SEGMENT}/${encodeURIComponent(this.assetReferenceFor(workspaceId, fileId))}`,
|
|
583
|
+
});
|
|
584
|
+
},
|
|
585
|
+
/**
|
|
586
|
+
* Search the published text of this publication's pages.
|
|
587
|
+
*
|
|
588
|
+
* The ids come from the walk, so the search cannot reach outside the publication however the
|
|
589
|
+
* query is written — and it reads `page_versions.text`, which is the published copy, rather than
|
|
590
|
+
* `pages.text`, which mirrors the live document.
|
|
591
|
+
*/
|
|
592
|
+
async search(tx, workspaceId, nodes, query, limit) {
|
|
593
|
+
const versionIds = nodes.map((n) => n.version_id);
|
|
594
|
+
if (versionIds.length === 0)
|
|
595
|
+
return [];
|
|
596
|
+
const pattern = `%${escapeLike(query.trim())}%`;
|
|
597
|
+
const rows = await tx
|
|
598
|
+
.select({ id: pageVersions.id, text: pageVersions.text })
|
|
599
|
+
.from(pageVersions)
|
|
600
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), inArray(pageVersions.id, versionIds), sql `${pageVersions.text} ilike ${pattern}`))
|
|
601
|
+
.limit(limit);
|
|
602
|
+
const byVersion = new Map(nodes.map((n) => [n.version_id, n]));
|
|
603
|
+
const hits = [];
|
|
604
|
+
for (const row of rows) {
|
|
605
|
+
const node = byVersion.get(row.id);
|
|
606
|
+
if (node)
|
|
607
|
+
hits.push({ node, snippet: snippetAround(row.text, query) });
|
|
608
|
+
}
|
|
609
|
+
// Titles are worth matching too, and they are already in hand rather than in the database.
|
|
610
|
+
for (const node of nodes) {
|
|
611
|
+
if (hits.length >= limit)
|
|
612
|
+
break;
|
|
613
|
+
if (hits.some((h) => h.node.id === node.id))
|
|
614
|
+
continue;
|
|
615
|
+
if (node.title.toLowerCase().includes(query.trim().toLowerCase()))
|
|
616
|
+
hits.push({ node, snippet: '' });
|
|
617
|
+
}
|
|
618
|
+
return hits.slice(0, limit);
|
|
619
|
+
},
|
|
620
|
+
/**
|
|
621
|
+
* A cache validator for a pinned version that is not the version's id.
|
|
622
|
+
*
|
|
623
|
+
* The id addresses `versions.get`, which asks a permission; a hash of it changes exactly when
|
|
624
|
+
* the pinned version does and can be tried nowhere.
|
|
625
|
+
*/
|
|
626
|
+
etagFor(versionId) {
|
|
627
|
+
return createHash('sha256').update(`quire.public.v1:${versionId}`).digest('base64url').slice(0, 32);
|
|
628
|
+
},
|
|
629
|
+
// ---------------------------------------------------------------- authenticated side
|
|
630
|
+
/** Every publication rooted at a page of this space, newest first. */
|
|
631
|
+
async list(tx, workspaceId, spaceId) {
|
|
632
|
+
return tx
|
|
633
|
+
.select({ pub: publications })
|
|
634
|
+
.from(publications)
|
|
635
|
+
.innerJoin(pages, and(eq(pages.workspaceId, publications.workspaceId), eq(pages.id, publications.rootPageId)))
|
|
636
|
+
.where(and(eq(publications.workspaceId, workspaceId), eq(pages.spaceId, spaceId)))
|
|
637
|
+
.orderBy(desc(publications.createdAt))
|
|
638
|
+
.then((rows) => rows.map((r) => r.pub));
|
|
639
|
+
},
|
|
640
|
+
async row(tx, workspaceId, publicationId) {
|
|
641
|
+
const [row] = await tx
|
|
642
|
+
.select()
|
|
643
|
+
.from(publications)
|
|
644
|
+
.where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)))
|
|
645
|
+
.limit(1);
|
|
646
|
+
if (!row)
|
|
647
|
+
throw KernError.notFound('Publication');
|
|
648
|
+
return row;
|
|
649
|
+
},
|
|
650
|
+
async create(tx, principal, workspaceId, input) {
|
|
651
|
+
const root = await access.pageRow(tx, workspaceId, input.rootPageId);
|
|
652
|
+
if (root.kind !== 'page')
|
|
653
|
+
throw KernError.badRequest('Only a page can be published; a live doc and a database cannot');
|
|
654
|
+
const [row] = await tx
|
|
655
|
+
.insert(publications)
|
|
656
|
+
.values({
|
|
657
|
+
workspaceId,
|
|
658
|
+
rootPageId: input.rootPageId,
|
|
659
|
+
includeDescendants: input.includeDescendants,
|
|
660
|
+
slug: input.slug,
|
|
661
|
+
passwordHash: input.password ? await hashPassword(input.password) : null,
|
|
662
|
+
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
|
663
|
+
seoTitle: input.seoTitle,
|
|
664
|
+
seoDescription: input.seoDescription,
|
|
665
|
+
ogImageUrl: input.ogImageUrl,
|
|
666
|
+
indexable: input.indexable,
|
|
667
|
+
theme: input.theme,
|
|
668
|
+
createdBy: principal.userId,
|
|
669
|
+
})
|
|
670
|
+
.returning()
|
|
671
|
+
.catch((err) => {
|
|
672
|
+
if (err.code === '23505')
|
|
673
|
+
throw KernError.conflict('That address is already taken in this workspace', 'quire.slug.taken');
|
|
674
|
+
throw err;
|
|
675
|
+
});
|
|
676
|
+
return row;
|
|
677
|
+
},
|
|
678
|
+
async update(tx, workspaceId, publicationId, patch) {
|
|
679
|
+
await this.row(tx, workspaceId, publicationId);
|
|
680
|
+
const values = { updatedAt: new Date() };
|
|
681
|
+
if (patch.slug !== undefined)
|
|
682
|
+
values.slug = patch.slug;
|
|
683
|
+
if (patch.includeDescendants !== undefined)
|
|
684
|
+
values.includeDescendants = patch.includeDescendants;
|
|
685
|
+
// Three-valued: a string sets, `null` removes, absent leaves alone. See the contract.
|
|
686
|
+
if (patch.password !== undefined)
|
|
687
|
+
values.passwordHash = patch.password === null ? null : await hashPassword(patch.password);
|
|
688
|
+
if (patch.expiresAt !== undefined)
|
|
689
|
+
values.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt);
|
|
690
|
+
if (patch.seoTitle !== undefined)
|
|
691
|
+
values.seoTitle = patch.seoTitle;
|
|
692
|
+
if (patch.seoDescription !== undefined)
|
|
693
|
+
values.seoDescription = patch.seoDescription;
|
|
694
|
+
if (patch.ogImageUrl !== undefined)
|
|
695
|
+
values.ogImageUrl = patch.ogImageUrl;
|
|
696
|
+
if (patch.indexable !== undefined)
|
|
697
|
+
values.indexable = patch.indexable;
|
|
698
|
+
if (patch.theme !== undefined)
|
|
699
|
+
values.theme = patch.theme;
|
|
700
|
+
const [row] = await tx
|
|
701
|
+
.update(publications)
|
|
702
|
+
.set(values)
|
|
703
|
+
.where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)))
|
|
704
|
+
.returning()
|
|
705
|
+
.catch((err) => {
|
|
706
|
+
if (err.code === '23505')
|
|
707
|
+
throw KernError.conflict('That address is already taken in this workspace', 'quire.slug.taken');
|
|
708
|
+
throw err;
|
|
709
|
+
});
|
|
710
|
+
return row;
|
|
711
|
+
},
|
|
712
|
+
async remove(tx, workspaceId, publicationId) {
|
|
713
|
+
await this.row(tx, workspaceId, publicationId);
|
|
714
|
+
await tx
|
|
715
|
+
.delete(publications)
|
|
716
|
+
.where(and(eq(publications.workspaceId, workspaceId), eq(publications.id, publicationId)));
|
|
717
|
+
},
|
|
718
|
+
/** "Never public", on the page rather than on any one publication. */
|
|
719
|
+
async setExcluded(tx, workspaceId, pageId, excluded) {
|
|
720
|
+
await access.pageRow(tx, workspaceId, pageId);
|
|
721
|
+
const [row] = await tx
|
|
722
|
+
.update(pages)
|
|
723
|
+
.set({ excludedFromPublic: excluded, updatedAt: new Date() })
|
|
724
|
+
.where(and(eq(pages.workspaceId, workspaceId), eq(pages.id, pageId)))
|
|
725
|
+
.returning({ excluded: pages.excludedFromPublic });
|
|
726
|
+
return row?.excluded ?? excluded;
|
|
727
|
+
},
|
|
728
|
+
/**
|
|
729
|
+
* Draw one version and keep the drawing.
|
|
730
|
+
*
|
|
731
|
+
* Called when a page is published, so the work happens once per publish rather than once per
|
|
732
|
+
* anonymous read — the version is immutable, so every render of it would be identical. Already
|
|
733
|
+
* rendered is a no-op, which is what makes it safe to call from both `publish` and the backfill.
|
|
734
|
+
*/
|
|
735
|
+
async renderVersion(tx, workspaceId, versionId) {
|
|
736
|
+
const [row] = await tx
|
|
737
|
+
.select({ id: pageVersions.id, state: pageVersions.state, html: pageVersions.html })
|
|
738
|
+
.from(pageVersions)
|
|
739
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, versionId)))
|
|
740
|
+
.limit(1);
|
|
741
|
+
if (!row || row.html !== null)
|
|
742
|
+
return;
|
|
743
|
+
// `referenced`, never `signed`: this drawing is *stored*, and a signed URL is the object's
|
|
744
|
+
// key with an hour on it. See the note on `html` in `versions.ts`.
|
|
745
|
+
const html = await versions.html(tx, workspaceId, row.state, { pictures: 'referenced' });
|
|
746
|
+
await tx
|
|
747
|
+
.update(pageVersions)
|
|
748
|
+
.set({ html })
|
|
749
|
+
.where(and(eq(pageVersions.workspaceId, workspaceId), eq(pageVersions.id, versionId)));
|
|
750
|
+
},
|
|
751
|
+
/**
|
|
752
|
+
* Render whatever in this publication's subtree has never been rendered.
|
|
753
|
+
*
|
|
754
|
+
* Only ever needed for a page published before this feature existed — a publish since then
|
|
755
|
+
* writes the HTML as it goes. It runs on `create` and `update` because that is the moment an
|
|
756
|
+
* author is asking for the site to work, and it is bounded: past `MAX_BACKFILL` the rest are
|
|
757
|
+
* left to be rendered when they are next published, and the gap is a warning rather than a
|
|
758
|
+
* failed request. A page whose HTML is missing is simply not part of the site yet, which is the
|
|
759
|
+
* same rule as a page with no published version.
|
|
760
|
+
*/
|
|
761
|
+
async backfill(tx, workspaceId, pub) {
|
|
762
|
+
const all = await walk(tx, workspaceId, pub, { requireHtml: false });
|
|
763
|
+
const missing = all.filter((r) => !r.has_html);
|
|
764
|
+
if (missing.length > MAX_BACKFILL)
|
|
765
|
+
kernel.log.warn({ publicationId: pub.id, pending: missing.length, cap: MAX_BACKFILL }, 'too many unrendered versions to publish in one request');
|
|
766
|
+
for (const row of missing.slice(0, MAX_BACKFILL))
|
|
767
|
+
await this.renderVersion(tx, workspaceId, row.version_id);
|
|
768
|
+
return Math.min(missing.length, MAX_BACKFILL);
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
//# sourceMappingURL=publications.js.map
|