Repository navigation
Expand file tree
/
Copy pathinstanceLayout.ts
More file actions
369 lines (330 loc) · 14.6 KB
/
Copy pathinstanceLayout.ts
File metadata and controls
369 lines (330 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
import type { Deck } from "./deck";
import { documentUrlOf } from "./subjectUrl";
/**
* Where an instance keeps its documents (see docs/data-model.md): the
* meta, preferences, palettes and catalog documents at fixed names in
* the container, and one cards and one reviews document per deck, named
* in the deck's catalog entry.
*/
export function ensureTrailingSlash(url: string): string {
return url.endsWith("/") ? url : `${url}/`;
}
export function metaUrlOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}meta.ttl`;
}
export function preferencesUrlOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}preferences.ttl`;
}
/** The palettes the user designed (domain/customPalette.ts), one subject each, with their color sets. */
export function palettesUrlOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}palettes.ttl`;
}
/** A palette the user designed, `#palette-<id>` in the instance's palettes document. */
export function paletteUrlOf(instanceUrl: string, id: string): string {
return `${palettesUrlOf(instanceUrl)}#palette-${id}`;
}
/**
* Whether `iri` is a subject of the instance's palettes document, as a
* request would send both (isSameUrl): the only place a palette the
* preferences name is read from. Any other IRI is a palette this version
* does not know, and is never fetched.
*/
export function isPaletteOf(instanceUrl: string, iri: string): boolean {
const hash = iri.indexOf("#");
return (
hash > 0 && hash < iri.length - 1 && isSameUrl(iri.slice(0, hash), palettesUrlOf(instanceUrl))
);
}
export function catalogUrlOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}catalog.ttl`;
}
/** The instance a deck is in: the container of its catalog entry's document. */
export function instanceUrlOfDeck(deckUrl: string): string {
const catalog = deckUrl.split("#")[0];
return catalog.slice(0, catalog.lastIndexOf("/") + 1);
}
/**
* Whether `url` is written as a request sends it: an absolute URL that
* parsing leaves as it is, with no dot segment (`..`, `%2e%2e`), no
* default port, no upper case in its scheme or host, and nothing else a
* request would change.
*/
export function isCanonicalUrl(url: string): boolean {
return canonicalUrlOf(url) === url;
}
/**
* An absolute URL as a request would send it (`new URL(text).href`):
* dot segments resolved, its host in lower case and punycode, a
* character outside ASCII percent-encoded; null for text that is none.
* What a pod states may be spelled any way, so an address is compared
* with another in this form, which is the one fetched.
*/
export function canonicalUrlOf(text: string): string | null {
try {
return new URL(text).href;
} catch {
return null;
}
}
/**
* The address of an instance's folder as the user types or a link
* names it, as a request sends it (canonicalUrlOf) and ending in a
* slash; text that is no URL, ending in a slash, for the caller to
* refuse.
*/
export function instanceAddressOf(text: string): string {
return ensureTrailingSlash(canonicalUrlOf(text) ?? text);
}
/** Whether a request would send `text` to an http(s) address (canonicalUrlOf) with no query or fragment. */
export function isPlainHttpUrl(text: string): boolean {
const url = canonicalUrlOf(text);
return url !== null && /^https?:\/\/[^?#]*$/.test(url);
}
/**
* Whether the path of `text` has a dot segment (`.`, `..`, `%2e%2e` in
* any case, with tabs and line breaks a request drops), which a request
* resolves away: the text then seems to name another folder than the one
* it leads to.
*/
export function hasDotSegment(text: string): boolean {
return /[/\\](?:\.|%2e){1,2}(?:[/\\?#]|$)/i.test(text.replace(/[\t\n\r]/g, ""));
}
/**
* Whether a registration's address may be taken as an instance's as it
* is written: an http(s) address with no query or fragment
* (isPlainHttpUrl) and no dot segment (hasDotSegment). Another spelling
* of the same folder (its host's case, a character outside ASCII) is
* that folder, as every address made from it is compared as a request
* sends it.
*/
export function isInstanceAddress(text: string): boolean {
return isPlainHttpUrl(text) && !hasDotSegment(text);
}
/**
* What no document Solid Memo keeps in a folder has after the folder's
* address: a query or fragment, a `;` (a path parameter to some servers),
* an encoded slash, backslash or percent sign, a NUL, an overlong UTF-8
* byte (`%c0`, `%c1`), an IIS `%u` escape, a segment that begins with
* a dot (`.`, `%2e`), or an empty one (`//`, which a client may merge
* into one slash before it sends the request). A request leaves each as
* it is, and a server or client may still read one as a step out of the
* folder or to another document; every document the app names there
* (`decks/<id>.ttl`, `drafts/<name>/v<N>/…`) has none.
*/
const OUT_OF_PLACE = /[?#;]|%(?:2f|5c|25|00|c0|c1|u)|(?:^|\/)(?:\.|%2e|\/)/i;
/**
* Whether `url` is the address of a document in the folder `container`,
* or in one of its folders, as a request would resolve both
* (canonicalUrlOf): below the folder, not a folder itself, and with
* nothing after the folder a server might read as a step out of it
* (OUT_OF_PLACE). A catalog, a type index or a link is data anyone may
* have written (docs/data-model.md, "Where an instance's documents
* are"), so an address it names is held to this before Solid Memo reads,
* writes or deletes there.
*/
export function isDocumentIn(url: string, container: string): boolean {
const resolved = canonicalUrlOf(url);
const root = canonicalUrlOf(ensureTrailingSlash(container));
return (
resolved !== null &&
root !== null &&
resolved.startsWith(root) &&
!resolved.endsWith("/") &&
!OUT_OF_PLACE.test(resolved.slice(root.length))
);
}
/** Whether two addresses are one as a request would send them (canonicalUrlOf). */
export function isSameUrl(a: string, b: string): boolean {
const resolved = canonicalUrlOf(a);
return resolved !== null && resolved === canonicalUrlOf(b);
}
/**
* What no deck's document has in its path, as a request sends it: a
* `;` (a path parameter to some servers), an encoded slash, backslash or
* percent sign, a NUL, an overlong UTF-8 byte, an IIS `%u` escape, a
* segment that begins with a dot, or an empty one (`//`); see
* OUT_OF_PLACE.
*/
const UNSAFE_PATH = /;|%(?:2f|5c|25|00|c0|c1|u)|\/(?:\.|%2e|\/)/i;
/** A name a server keeps for another resource's access control or metadata. */
const AUXILIARY = /\.(?:acl|acr|meta)$/i;
/**
* Whether a deck's document may be read at `url`, wherever it is
* (docs/data-model.md, "Where an instance's documents are"): an http(s)
* address with no query or fragment (isPlainHttpUrl), no dot segment
* (hasDotSegment), and, as a request sends it, nothing in its path a
* server might read as a step elsewhere (UNSAFE_PATH), not a folder,
* and not another resource's access control or metadata (`.acl`,
* `.acr`, `.meta`). Its folder and name are free: a pod may be laid out
* any way, flat at its root too.
*/
export function isSafeDocumentUrl(url: string): boolean {
if (!isPlainHttpUrl(url) || hasDotSegment(url)) return false;
const path = new URL(canonicalUrlOf(url)!).pathname;
return !path.endsWith("/") && !UNSAFE_PATH.test(path) && !AUXILIARY.test(path);
}
/**
* A percent-encoded character that needs no encoding (a letter, a digit,
* `-`, `.`, `_`, `~`), which a request leaves as it is and a server may
* decode: `x.ac%6C` is another spelling of `x.acl`.
*/
const ENCODED_UNRESERVED = /%(?:3[0-9]|4[1-9a-f]|5[0-9a]|6[1-9a-f]|7[0-9a]|2d|2e|5f|7e)/i;
/**
* Whether the pod fence lets a request that writes (any method but GET,
* HEAD and OPTIONS) go to `url` (docs/boundaries.md, "The pod fence"):
* an http(s) address with no query or fragment, written as a request
* sends it (isCanonicalUrl), with no dot segment (hasDotSegment),
* nothing in its path a server might read as a step elsewhere
* (UNSAFE_PATH), no character encoded that needs no encoding, and not
* another resource's access control or metadata (`.acl`, `.acr`,
* `.meta`, in any case). A folder may be written; which documents may
* be is the adapters' to say.
*/
export function isSafeWriteUrl(url: string): boolean {
if (!isCanonicalUrl(url) || !isPlainHttpUrl(url) || hasDotSegment(url)) return false;
const path = new URL(url).pathname;
return !UNSAFE_PATH.test(path) && !ENCODED_UNRESERVED.test(path) && !AUXILIARY.test(path);
}
/**
* Whether `url` is one of the instance's own documents, which no deck's
* may be: its meta, preferences, palettes, catalog and digest documents,
* and any in its `history/`, `drafts/` or `releases/`.
*/
function isInstanceOwnDocument(instanceUrl: string, url: string): boolean {
const base = ensureTrailingSlash(instanceUrl);
return (
[
metaUrlOf(base),
preferencesUrlOf(base),
palettesUrlOf(base),
catalogUrlOf(base),
digestUrlOf(base),
].some((own) => isSameUrl(url, own)) ||
["history/", "drafts/", "releases/"].some((folder) => isDocumentIn(url, `${base}${folder}`))
);
}
/**
* Whether `url` may be a deck's document of the instance at
* `instanceUrl`: one a deck's may be read at (isSafeDocumentUrl), and
* not one of the instance's own documents.
*/
export function isDeckDocumentAddress(instanceUrl: string, url: string): boolean {
return isSafeDocumentUrl(url) && !isInstanceOwnDocument(instanceUrl, url);
}
/**
* Whether a deck can be used: its entry a subject of an instance's
* catalog (`<instance>catalog.ttl#…`), and its cards and reviews
* documents where a deck's document may be read (isSafeDocumentUrl), none
* of them one of the instance's own documents. Where they are is free;
* whether one may be written to is what it says (ownsDocument). A deck
* the catalog names otherwise is not read, written or deleted through
* (docs/data-model.md, "Where an instance's documents are").
*/
export function isDeckInPlace(
deck: Pick<Deck, "url" | "cardsDocumentUrl" | "reviewsDocumentUrl">,
): boolean {
const catalog = canonicalUrlOf(documentUrlOf(deck.url));
if (catalog === null) return false;
const instance = instanceUrlOfDeck(catalog);
return (
catalog === catalogUrlOf(instance) &&
[deck.cardsDocumentUrl, deck.reviewsDocumentUrl].every((url) =>
isDeckDocumentAddress(instance, url),
)
);
}
/**
* Whether a deck's document says it is the deck's: the document itself
* (`<>`) is dcterms:isPartOf the deck's catalog entry (`partOf`, the
* values it states), compared as a request sends the address. Only
* someone who may write the document can state it, so it is what lets
* Solid Memo write to the document or delete it (docs/data-model.md,
* "Where an instance's documents are"), wherever it is.
*/
export function ownsDocument(partOf: readonly string[], deckUrl: string): boolean {
return partOf.some((url) => isSameUrl(url, deckUrl));
}
/** Every document the decks use: their cards and reviews documents. */
export function documentsInUse(decks: readonly Deck[]): Set<string> {
return new Set(decks.flatMap((deck) => [deck.cardsDocumentUrl, deck.reviewsDocumentUrl]));
}
/**
* The instance's digest document: derived data (domain/studyDigest.ts),
* not one of the documents an instance is checked by.
*/
export function digestUrlOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}digest.ttl`;
}
/** The container where a new deck's cards document is made, `decks/`; an existing deck's may be anywhere its entry names. */
export function cardsContainerOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}decks/`;
}
/** The container where a new deck's reviews document is made, `reviews/`; an existing deck's may be anywhere its entry names. */
export function reviewsContainerOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}reviews/`;
}
/**
* The instance's answer log (domain/answer.ts): one document per study
* month in this container, `history/<YYYY-MM>.ttl`. Source data the
* statistics are computed from, checked in the full check only.
*/
export function historyContainerOf(instanceUrl: string): string {
return `${ensureTrailingSlash(instanceUrl)}history/`;
}
/**
* The answer log's document for a study month, "YYYY-MM". Any other
* text throws: a study day may come from a pod, and the document must
* be one in `history/`.
*/
export function historyUrlOf(instanceUrl: string, month: string): string {
if (!/^\d{4}-\d{2}$/.test(month)) throw new Error(`Not a study month: ${JSON.stringify(month)}`);
return `${historyContainerOf(instanceUrl)}${month}.ttl`;
}
/** The study month a document of the answer log is for; null for any other resource. */
export function monthOfHistoryUrl(instanceUrl: string, url: string): string | null {
const container = historyContainerOf(instanceUrl);
if (!url.startsWith(container)) return null;
return /^(\d{4}-\d{2})\.ttl$/.exec(url.slice(container.length))?.[1] ?? null;
}
/**
* The digest subject about a document, a deck or a month of the answer
* log (by its document), `#<kind>-<where>`: where
* it is under the instance, else its whole URL, with every character
* but letters, digits, ".", "_" and "-" made a "-".
*/
export function digestSubjectOf(
instanceUrl: string,
kind: "receipt" | "schedule" | "month",
target: string,
): string {
const container = ensureTrailingSlash(instanceUrl);
const where = target.startsWith(container) ? target.slice(container.length) : target;
return `${digestUrlOf(instanceUrl)}#${kind}-${where.replace(/[^A-Za-z0-9._-]/g, "-")}`;
}
/** The instance's catalog: the dcat:Catalog subject of its catalog document. */
export function catalogNodeUrlOf(instanceUrl: string): string {
return `${catalogUrlOf(instanceUrl)}#catalog`;
}
/**
* A deck group of the instance (domain/deckTree.ts): a subject of its
* catalog document beside the decks, `#group-<id>`.
*/
export function deckGroupUrlOf(instanceUrl: string, id: string): string {
return `${catalogUrlOf(instanceUrl)}#group-${id}`;
}
/** Every document an instance may hold, given its decks: fixed ones first, then the decks' documents, by their own address, those a deck's may be (isSafeDocumentUrl). */
export function instanceDocumentUrls(instanceUrl: string, decks: readonly Deck[]): string[] {
return [
metaUrlOf(instanceUrl),
preferencesUrlOf(instanceUrl),
palettesUrlOf(instanceUrl),
catalogUrlOf(instanceUrl),
...decks
.flatMap((deck) => [deck.cardsDocumentUrl, deck.reviewsDocumentUrl])
.map(documentUrlOf)
.filter(isSafeDocumentUrl),
];
}