@glw907/cairn-cms 0.11.0 → 0.17.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/auth/crypto.d.ts +8 -2
- package/dist/auth/crypto.d.ts.map +1 -1
- package/dist/auth/crypto.js +12 -2
- package/dist/auth/store.d.ts +2 -0
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +17 -5
- package/dist/components/ComponentForm.svelte +33 -10
- package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +4 -6
- package/dist/components/EditPage.svelte.d.ts +1 -1
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/IconPicker.svelte +53 -7
- package/dist/components/IconPicker.svelte.d.ts +7 -3
- package/dist/components/IconPicker.svelte.d.ts.map +1 -1
- package/dist/content/adapter.d.ts +4 -0
- package/dist/content/adapter.d.ts.map +1 -0
- package/dist/content/adapter.js +4 -0
- package/dist/content/concepts.js +2 -2
- package/dist/content/schema.d.ts +75 -0
- package/dist/content/schema.d.ts.map +1 -0
- package/dist/content/schema.js +72 -0
- package/dist/content/types.d.ts +30 -7
- package/dist/content/types.d.ts.map +1 -1
- package/dist/content/validate.d.ts +5 -3
- package/dist/content/validate.d.ts.map +1 -1
- package/dist/content/validate.js +14 -7
- package/dist/delivery/content-index.d.ts +8 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +23 -12
- package/dist/delivery/feeds.d.ts +1 -1
- package/dist/delivery/feeds.d.ts.map +1 -1
- package/dist/delivery/feeds.js +31 -16
- package/dist/delivery/index.d.ts +5 -1
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +2 -0
- package/dist/delivery/seo-fields.d.ts +22 -0
- package/dist/delivery/seo-fields.d.ts.map +1 -0
- package/dist/delivery/seo-fields.js +32 -0
- package/dist/delivery/site-index.d.ts +2 -2
- package/dist/delivery/site-index.d.ts.map +1 -1
- package/dist/delivery/site-index.js +16 -18
- package/dist/delivery/site-indexes.d.ts +26 -0
- package/dist/delivery/site-indexes.d.ts.map +1 -0
- package/dist/delivery/site-indexes.js +30 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +14 -0
- package/dist/github/signing.d.ts +12 -0
- package/dist/github/signing.d.ts.map +1 -1
- package/dist/github/signing.js +22 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/render/component-grammar.d.ts +7 -0
- package/dist/render/component-grammar.d.ts.map +1 -1
- package/dist/render/component-grammar.js +27 -8
- package/dist/render/component-validate.js +3 -3
- package/dist/render/glyph.d.ts +4 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/glyph.js +6 -2
- package/dist/render/pipeline.d.ts +10 -0
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +15 -1
- package/dist/render/registry.d.ts +23 -5
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +6 -0
- package/dist/render/rehype-dispatch.d.ts +1 -5
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +71 -19
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/remark-directives.js +37 -0
- package/dist/render/sanitize-schema.d.ts +20 -0
- package/dist/render/sanitize-schema.d.ts.map +1 -0
- package/dist/render/sanitize-schema.js +48 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -1
- package/dist/sveltekit/auth-routes.js +29 -11
- package/dist/sveltekit/content-routes.js +2 -2
- package/dist/sveltekit/guard.d.ts +1 -1
- package/dist/sveltekit/guard.d.ts.map +1 -1
- package/dist/sveltekit/guard.js +25 -10
- package/dist/sveltekit/nav-routes.js +2 -2
- package/dist/sveltekit/public-routes.d.ts +3 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +10 -3
- package/dist/sveltekit/types.d.ts +6 -0
- package/dist/sveltekit/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/lib/auth/crypto.ts +14 -2
- package/src/lib/auth/store.ts +18 -5
- package/src/lib/components/ComponentForm.svelte +33 -10
- package/src/lib/components/EditPage.svelte +4 -6
- package/src/lib/components/IconPicker.svelte +53 -7
- package/src/lib/content/adapter.ts +10 -0
- package/src/lib/content/concepts.ts +2 -2
- package/src/lib/content/schema.ts +133 -0
- package/src/lib/content/types.ts +30 -7
- package/src/lib/content/validate.ts +10 -7
- package/src/lib/delivery/content-index.ts +32 -12
- package/src/lib/delivery/feeds.ts +34 -19
- package/src/lib/delivery/index.ts +5 -1
- package/src/lib/delivery/seo-fields.ts +43 -0
- package/src/lib/delivery/site-index.ts +15 -16
- package/src/lib/delivery/site-indexes.ts +64 -0
- package/src/lib/env.ts +13 -0
- package/src/lib/github/signing.ts +32 -0
- package/src/lib/index.ts +8 -2
- package/src/lib/render/component-grammar.ts +34 -10
- package/src/lib/render/component-validate.ts +3 -3
- package/src/lib/render/glyph.ts +6 -2
- package/src/lib/render/pipeline.ts +25 -1
- package/src/lib/render/registry.ts +27 -5
- package/src/lib/render/rehype-dispatch.ts +67 -20
- package/src/lib/render/remark-directives.ts +39 -1
- package/src/lib/render/sanitize-schema.ts +57 -0
- package/src/lib/sveltekit/auth-routes.ts +30 -11
- package/src/lib/sveltekit/content-routes.ts +2 -2
- package/src/lib/sveltekit/guard.ts +25 -10
- package/src/lib/sveltekit/nav-routes.ts +2 -2
- package/src/lib/sveltekit/public-routes.ts +13 -3
- package/src/lib/sveltekit/types.ts +5 -1
- package/dist/render/sanitize.d.ts +0 -8
- package/dist/render/sanitize.d.ts.map +0 -1
- package/dist/render/sanitize.js +0 -26
- package/src/lib/render/sanitize.ts +0 -27
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { visit } from 'unist-util-visit';
|
|
2
|
+
import { dataAttrProp } from './registry.js';
|
|
3
|
+
// mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
|
|
4
|
+
function isDirectiveLabel(node) {
|
|
5
|
+
return Boolean(node.data?.directiveLabel);
|
|
6
|
+
}
|
|
7
|
+
// Stamp data-slot on a child so the rehype dispatch partitioner can route it. For a nested
|
|
8
|
+
// container directive we also set hName so it renders as a <div> wrapper rather than being
|
|
9
|
+
// dropped as an unknown directive.
|
|
10
|
+
function markSlot(node, name) {
|
|
11
|
+
const n = node;
|
|
12
|
+
const data = n.data ?? (n.data = {});
|
|
13
|
+
if (n.type === 'containerDirective')
|
|
14
|
+
data.hName = 'div';
|
|
15
|
+
data.hProperties = { ...(data.hProperties ?? {}), dataSlot: name };
|
|
16
|
+
}
|
|
2
17
|
// Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
|
|
3
18
|
// Accidental prose directives carry none, so this is almost always empty.
|
|
4
19
|
function serializeAttributes(attributes) {
|
|
@@ -42,6 +57,7 @@ export function remarkDirectiveStamp(registry) {
|
|
|
42
57
|
visit(tree, 'containerDirective', (node) => {
|
|
43
58
|
if (!known.has(node.name))
|
|
44
59
|
return;
|
|
60
|
+
const def = registry.get(node.name);
|
|
45
61
|
const attrs = node.attributes ?? {};
|
|
46
62
|
const role = attrs.role || undefined;
|
|
47
63
|
let icon = attrs.icon || undefined;
|
|
@@ -52,9 +68,30 @@ export function remarkDirectiveStamp(registry) {
|
|
|
52
68
|
properties.dataIcon = icon;
|
|
53
69
|
if (role)
|
|
54
70
|
properties.dataRole = role;
|
|
71
|
+
// Carry every declared attribute to hast so the dispatch partitioner can build the
|
|
72
|
+
// component context. data-attr-<key> survives to the element; build() consumes it and
|
|
73
|
+
// returns a fresh element, so the marker never reaches the published DOM.
|
|
74
|
+
for (const field of def?.attributes ?? []) {
|
|
75
|
+
const raw = attrs[field.key];
|
|
76
|
+
if (raw != null)
|
|
77
|
+
properties[dataAttrProp(field.key)] = raw;
|
|
78
|
+
}
|
|
55
79
|
const data = node.data ?? (node.data = {});
|
|
56
80
|
data.hName = 'div';
|
|
57
81
|
data.hProperties = properties;
|
|
82
|
+
// Mark the title label paragraph and the nested slot directives so they survive to hast
|
|
83
|
+
// and the partitioner can find them. A slot named in the component schema (other than the
|
|
84
|
+
// default body) is a nested container directive; the title is the directive [label].
|
|
85
|
+
const slotNames = new Set((def?.slots ?? []).map((s) => s.name));
|
|
86
|
+
for (const child of node.children) {
|
|
87
|
+
if (isDirectiveLabel(child) && slotNames.has('title')) {
|
|
88
|
+
markSlot(child, 'title');
|
|
89
|
+
}
|
|
90
|
+
else if (child.type === 'containerDirective' &&
|
|
91
|
+
slotNames.has(child.name)) {
|
|
92
|
+
markSlot(child, child.name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
58
95
|
});
|
|
59
96
|
visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
|
|
60
97
|
if (!parent || index == null)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Schema } from 'hast-util-sanitize';
|
|
2
|
+
import type { Root } from 'hast';
|
|
3
|
+
import { type ComponentRegistry } from './registry.js';
|
|
4
|
+
/**
|
|
5
|
+
* Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
|
|
6
|
+
* GitHub-lineage allowlist that strips scripts, inline event handlers, and javascript:/data: URLs,
|
|
7
|
+
* then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
|
|
8
|
+
* dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
|
|
9
|
+
* the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
|
|
10
|
+
* on anchors are admitted. A site extends the result through `extend`, always starting from this
|
|
11
|
+
* safe base, so it can add to the allowlist but not weaken the core strip.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildSanitizeSchema(registry: ComponentRegistry, extend?: (defaults: Schema) => Schema): Schema;
|
|
14
|
+
/**
|
|
15
|
+
* Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
|
|
16
|
+
* hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
|
|
17
|
+
* DOMPurify preview pass enforced, now on the delivered output as well.
|
|
18
|
+
*/
|
|
19
|
+
export declare function rehypeAnchorRel(): (tree: Root) => void;
|
|
20
|
+
//# sourceMappingURL=sanitize-schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize-schema.d.ts","sourceRoot":"","sources":["../../src/lib/render/sanitize-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,KAAK,EAAE,IAAI,EAAW,MAAM,MAAM,CAAC;AAE1C,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAMrE;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GACpC,MAAM,CAoBR;AAED;;;;GAIG;AACH,wBAAgB,eAAe,KACrB,MAAM,IAAI,UAOnB"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { defaultSchema } from 'hast-util-sanitize';
|
|
2
|
+
import { visit } from 'unist-util-visit';
|
|
3
|
+
import { dataAttrProp } from './registry.js';
|
|
4
|
+
// The fixed directive markers the stamp writes and the dispatch reads. They are inert data
|
|
5
|
+
// attributes, never a script vector, and must survive the floor so the dispatch still runs.
|
|
6
|
+
const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
|
|
7
|
+
/**
|
|
8
|
+
* Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
|
|
9
|
+
* GitHub-lineage allowlist that strips scripts, inline event handlers, and javascript:/data: URLs,
|
|
10
|
+
* then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
|
|
11
|
+
* dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
|
|
12
|
+
* the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
|
|
13
|
+
* on anchors are admitted. A site extends the result through `extend`, always starting from this
|
|
14
|
+
* safe base, so it can add to the allowlist but not weaken the core strip.
|
|
15
|
+
*/
|
|
16
|
+
export function buildSanitizeSchema(registry, extend) {
|
|
17
|
+
const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
|
|
18
|
+
const markers = [...FIXED_MARKERS, ...attrMarkers];
|
|
19
|
+
const attributes = defaultSchema.attributes ?? {};
|
|
20
|
+
// defaultSchema's `a` entry carries a className tuple (`['className', 'data-footnote-backref']`)
|
|
21
|
+
// that restricts a link's class to that one value. A per-tag tuple wins over a bare `*` entry, so
|
|
22
|
+
// it would strip an author's link class. Drop that tuple before admitting a free-form `className`.
|
|
23
|
+
const anchorAttrs = (attributes.a ?? []).filter((entry) => !(Array.isArray(entry) && entry[0] === 'className'));
|
|
24
|
+
const schema = {
|
|
25
|
+
...defaultSchema,
|
|
26
|
+
tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
|
|
27
|
+
attributes: {
|
|
28
|
+
...attributes,
|
|
29
|
+
'*': [...(attributes['*'] ?? []), 'className', ...markers],
|
|
30
|
+
a: [...anchorAttrs, 'className', 'target', 'rel'],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return extend ? extend(schema) : schema;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
|
|
37
|
+
* hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
|
|
38
|
+
* DOMPurify preview pass enforced, now on the delivered output as well.
|
|
39
|
+
*/
|
|
40
|
+
export function rehypeAnchorRel() {
|
|
41
|
+
return (tree) => {
|
|
42
|
+
visit(tree, 'element', (node) => {
|
|
43
|
+
if (node.tagName === 'a' && node.properties?.target === '_blank') {
|
|
44
|
+
node.properties.rel = 'noopener noreferrer';
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/auth-routes.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/auth-routes.ts"],"names":[],"mappings":"AAeA,OAAO,EAAyC,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC3G,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,IAAI,CAAC,EAAE,aAAa,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB;uBA2C7B,cAAc,KAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAnCjD,cAAc,KAAG,OAAO,CAAC;QAAE,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;yBA4CnE,cAAc,KACpB;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;2BAcxB,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;0BAyBhC,cAAc,KAAG,OAAO,CAAC,KAAK,CAAC;EAUnE"}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// against a sink. The confirm-load, confirm, and logout handlers arrive in Task 6.
|
|
4
4
|
import { redirect } from '@sveltejs/kit';
|
|
5
5
|
import { requireOrigin, requireDb } from '../env.js';
|
|
6
|
-
import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS,
|
|
7
|
-
import { findEditor, issueToken, consumeToken, createSession, deleteSession } from '../auth/store.js';
|
|
6
|
+
import { generateToken, generateSessionId, hashToken, TOKEN_TTL_MS, SESSION_TTL_MS, SEND_COOLDOWN_MS, sessionCookieName, } from '../auth/crypto.js';
|
|
7
|
+
import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
|
|
8
8
|
import { buildMagicLinkMessage, cloudflareSend } from '../email.js';
|
|
9
9
|
export function createAuthRoutes(config) {
|
|
10
10
|
const send = config.send ?? cloudflareSend;
|
|
@@ -21,11 +21,27 @@ export function createAuthRoutes(config) {
|
|
|
21
21
|
const email = String(form.get('email') ?? '').trim().toLowerCase();
|
|
22
22
|
const editor = email ? await findEditor(db, email) : null;
|
|
23
23
|
if (editor) {
|
|
24
|
-
const token = generateToken();
|
|
25
24
|
const now = Date.now();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Per-email cooldown: skip the reissue and send when a token for this email was issued within
|
|
26
|
+
// the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
|
|
27
|
+
// the non-leak property holds.
|
|
28
|
+
if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
|
|
29
|
+
const token = generateToken();
|
|
30
|
+
await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
|
|
31
|
+
const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
|
|
32
|
+
// The token row is the security-critical write the email depends on, so it is awaited. The
|
|
33
|
+
// send is a post-response side effect, handed to waitUntil so a slow email provider does not
|
|
34
|
+
// hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
|
|
35
|
+
// failure is logged so observability survives a backgrounded send.
|
|
36
|
+
const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch((err) => console.error('cairn: magic-link send failed', err));
|
|
37
|
+
// adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
|
|
38
|
+
// deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
|
|
39
|
+
const ctx = event.platform?.ctx ?? event.platform?.context;
|
|
40
|
+
if (ctx?.waitUntil)
|
|
41
|
+
ctx.waitUntil(sending);
|
|
42
|
+
else
|
|
43
|
+
await sending;
|
|
44
|
+
}
|
|
29
45
|
}
|
|
30
46
|
return { sent: true };
|
|
31
47
|
}
|
|
@@ -62,11 +78,12 @@ export function createAuthRoutes(config) {
|
|
|
62
78
|
throw redirect(303, '/admin/login?error=expired');
|
|
63
79
|
const id = generateSessionId();
|
|
64
80
|
await createSession(db, id, email, now + SESSION_TTL_MS, now);
|
|
65
|
-
event.
|
|
81
|
+
const secure = event.url.protocol === 'https:';
|
|
82
|
+
event.cookies.set(sessionCookieName(secure), id, {
|
|
66
83
|
path: '/',
|
|
67
84
|
httpOnly: true,
|
|
68
|
-
//
|
|
69
|
-
secure
|
|
85
|
+
// __Host- needs Secure unconditionally on https; local http dev drops the prefix and Secure.
|
|
86
|
+
secure,
|
|
70
87
|
sameSite: 'lax',
|
|
71
88
|
maxAge: Math.floor(SESSION_TTL_MS / 1000),
|
|
72
89
|
});
|
|
@@ -75,10 +92,11 @@ export function createAuthRoutes(config) {
|
|
|
75
92
|
/** POST /admin/auth/logout. Deletes the session row and clears the cookie. */
|
|
76
93
|
async function logoutAction(event) {
|
|
77
94
|
const db = requireDb(event.platform?.env ?? {});
|
|
78
|
-
const
|
|
95
|
+
const name = sessionCookieName(event.url.protocol === 'https:');
|
|
96
|
+
const id = event.cookies.get(name);
|
|
79
97
|
if (id)
|
|
80
98
|
await deleteSession(db, id);
|
|
81
|
-
event.cookies.delete(
|
|
99
|
+
event.cookies.delete(name, { path: '/' });
|
|
82
100
|
throw redirect(303, '/admin/login');
|
|
83
101
|
}
|
|
84
102
|
return { loginLoad, requestAction, confirmLoad, confirmAction, logoutAction };
|
|
@@ -8,7 +8,7 @@ import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown }
|
|
|
8
8
|
import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
|
|
9
9
|
import { appCredentials } from '../github/credentials.js';
|
|
10
10
|
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
11
|
-
import {
|
|
11
|
+
import { cachedInstallationToken } from '../github/signing.js';
|
|
12
12
|
import { CommitConflictError } from '../github/types.js';
|
|
13
13
|
/** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
|
|
14
14
|
function sessionOf(event) {
|
|
@@ -25,7 +25,7 @@ function conceptOf(runtime, params) {
|
|
|
25
25
|
return concept;
|
|
26
26
|
}
|
|
27
27
|
export function createContentRoutes(runtime, deps = {}) {
|
|
28
|
-
const mintToken = deps.mintToken ?? ((env) =>
|
|
28
|
+
const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
29
29
|
/** Layout load for every admin page: the nav, the user, and the active path. */
|
|
30
30
|
function layoutLoad(event) {
|
|
31
31
|
const editor = sessionOf(event);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Editor } from '../auth/types.js';
|
|
2
2
|
import type { HandleInput, RequestContext } from './types.js';
|
|
3
|
-
/** The SvelteKit `Handle` that guards `/admin
|
|
3
|
+
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
4
4
|
export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
|
|
5
5
|
/** For a protected load/action: the session the guard already resolved, or a login redirect. */
|
|
6
6
|
export declare function requireSession(event: RequestContext): Editor;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/guard.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/guard.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAwB9D,kFAAkF;AAClF,wBAAgB,eAAe,KACA,oBAAoB,WAAW,KAAG,OAAO,CAAC,QAAQ,CAAC,CAcjF;AAED,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI5D;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAI1D"}
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// stays free of a site's App.* ambient types.
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { resolveSession } from '../auth/store.js';
|
|
6
|
-
import {
|
|
6
|
+
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
7
|
/** The login page and the auth endpoints are public; everything else under /admin is gated. */
|
|
8
8
|
function isPublicAdminPath(pathname) {
|
|
9
9
|
return pathname === '/admin/login' || pathname.startsWith('/admin/auth/');
|
|
@@ -11,20 +11,35 @@ function isPublicAdminPath(pathname) {
|
|
|
11
11
|
function isAdminPath(pathname) {
|
|
12
12
|
return pathname === '/admin' || pathname.startsWith('/admin/');
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
|
|
16
|
+
* design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
|
|
17
|
+
*/
|
|
18
|
+
function applySecurityHeaders(headers) {
|
|
19
|
+
headers.set('X-Content-Type-Options', 'nosniff');
|
|
20
|
+
headers.set('X-Frame-Options', 'DENY');
|
|
21
|
+
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
22
|
+
headers.set('Referrer-Policy', 'no-referrer');
|
|
23
|
+
headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
24
|
+
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
25
|
+
}
|
|
26
|
+
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
15
27
|
export function createAuthGuard() {
|
|
16
28
|
return async function handle({ event, resolve }) {
|
|
17
29
|
const { pathname } = event.url;
|
|
18
|
-
if (!isAdminPath(pathname)
|
|
30
|
+
if (!isAdminPath(pathname))
|
|
19
31
|
return resolve(event);
|
|
32
|
+
if (!isPublicAdminPath(pathname)) {
|
|
33
|
+
const env = event.platform?.env ?? {};
|
|
34
|
+
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
35
|
+
const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
|
|
36
|
+
if (!editor)
|
|
37
|
+
throw redirect(303, '/admin/login');
|
|
38
|
+
event.locals.editor = editor;
|
|
20
39
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!editor)
|
|
25
|
-
throw redirect(303, '/admin/login');
|
|
26
|
-
event.locals.editor = editor;
|
|
27
|
-
return resolve(event);
|
|
40
|
+
const response = await resolve(event);
|
|
41
|
+
applySecurityHeaders(response.headers);
|
|
42
|
+
return response;
|
|
28
43
|
};
|
|
29
44
|
}
|
|
30
45
|
/** For a protected load/action: the session the guard already resolved, or a login redirect. */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and commit paths are unit-testable against a fetch double with an injected token.
|
|
4
4
|
import { redirect, error } from '@sveltejs/kit';
|
|
5
5
|
import { appCredentials } from '../github/credentials.js';
|
|
6
|
-
import {
|
|
6
|
+
import { cachedInstallationToken } from '../github/signing.js';
|
|
7
7
|
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
8
8
|
import { CommitConflictError } from '../github/types.js';
|
|
9
9
|
import { parseSiteConfig, extractMenu, validateNavTree, setMenu } from '../nav/site-config.js';
|
|
@@ -19,7 +19,7 @@ function isConflict(err) {
|
|
|
19
19
|
return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
|
|
20
20
|
}
|
|
21
21
|
export function createNavRoutes(runtime, deps = {}) {
|
|
22
|
-
const mintToken = deps.mintToken ?? ((env) =>
|
|
22
|
+
const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
23
23
|
/** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
|
|
24
24
|
async function pageOptions(token) {
|
|
25
25
|
const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
|
|
@@ -17,6 +17,9 @@ export interface PublicRoutesDeps {
|
|
|
17
17
|
rss?: string;
|
|
18
18
|
json?: string;
|
|
19
19
|
};
|
|
20
|
+
/** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
|
|
21
|
+
* canonical URL, so a relative path such as "/og/default.png" works. */
|
|
22
|
+
defaultImage?: string;
|
|
20
23
|
}
|
|
21
24
|
/** The archive and tag list data: summaries the template renders. */
|
|
22
25
|
export interface ListData {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// from globs, so it stays in the prerender graph and out of the runtime Worker.
|
|
6
6
|
import { error } from '@sveltejs/kit';
|
|
7
7
|
import { buildSeoMeta } from '../delivery/seo.js';
|
|
8
|
+
import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
|
|
8
9
|
/** Build the public loaders for a site's unified index. */
|
|
9
10
|
export function createPublicRoutes(deps) {
|
|
10
|
-
const { site, render, origin, siteName, description, feeds } = deps;
|
|
11
|
+
const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
|
|
11
12
|
/** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
|
|
12
13
|
function indexOf(conceptId) {
|
|
13
14
|
const index = site.concept(conceptId);
|
|
@@ -22,16 +23,22 @@ export function createPublicRoutes(deps) {
|
|
|
22
23
|
throw error(404, `Not found: ${event.url.pathname}`);
|
|
23
24
|
const { newer, older } = site.adjacent(entry);
|
|
24
25
|
const canonicalUrl = origin + entry.permalink;
|
|
26
|
+
const fields = readSeoFields(entry.frontmatter);
|
|
27
|
+
const rawImage = fields.image ?? defaultImage;
|
|
28
|
+
const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
|
|
25
29
|
// A dated entry is an article; an undated one (a page) is a website.
|
|
26
30
|
const seo = buildSeoMeta({
|
|
27
31
|
title: entry.title,
|
|
28
|
-
description:
|
|
32
|
+
description: fields.description || entry.excerpt || description,
|
|
29
33
|
canonicalUrl,
|
|
30
34
|
siteName,
|
|
31
35
|
type: entry.date ? 'article' : 'website',
|
|
32
36
|
...(entry.date ? { published: entry.date } : {}),
|
|
33
37
|
...(entry.updated ? { modified: entry.updated } : {}),
|
|
34
|
-
|
|
38
|
+
...(image ? { image } : {}),
|
|
39
|
+
...(fields.robots ? { robots: fields.robots } : {}),
|
|
40
|
+
...(fields.author ? { author: fields.author } : {}),
|
|
41
|
+
...(entry.date ? { feeds } : {}),
|
|
35
42
|
});
|
|
36
43
|
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|
|
37
44
|
}
|
|
@@ -22,6 +22,12 @@ export interface RequestContext {
|
|
|
22
22
|
};
|
|
23
23
|
platform?: {
|
|
24
24
|
env?: AuthEnv;
|
|
25
|
+
ctx?: {
|
|
26
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
27
|
+
};
|
|
28
|
+
context?: {
|
|
29
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
30
|
+
};
|
|
25
31
|
};
|
|
26
32
|
setHeaders(headers: Record<string, string>): void;
|
|
27
33
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,SAAS,CAAC;IACnB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAExD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC/D,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,SAAS,CAAC;IACnB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QACT,GAAG,CAAC,EAAE,OAAO,CAAC;QACd,GAAG,CAAC,EAAE;YAAE,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;SAAE,CAAC;QACrD,OAAO,CAAC,EAAE;YAAE,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;SAAE,CAAC;KAC1D,CAAC;IAGF,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;CACnD;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,CAAC;IACtB,OAAO,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC;CAC9D"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -73,11 +73,12 @@
|
|
|
73
73
|
"@types/hast": "^3.0.4",
|
|
74
74
|
"@types/mdast": "^4.0.4",
|
|
75
75
|
"codemirror": "^6.0.2",
|
|
76
|
-
"dompurify": "^3.4.7",
|
|
77
76
|
"gray-matter": "^4",
|
|
77
|
+
"hast-util-sanitize": "^5.0.2",
|
|
78
78
|
"hastscript": "^9.0.1",
|
|
79
79
|
"mdast-util-directive": "^3.1.0",
|
|
80
80
|
"rehype-raw": "^7.0.0",
|
|
81
|
+
"rehype-sanitize": "^6.0.0",
|
|
81
82
|
"rehype-slug": "^6.0.0",
|
|
82
83
|
"rehype-stringify": "^10.0.1",
|
|
83
84
|
"remark-directive": "^4.0.0",
|
package/src/lib/auth/crypto.ts
CHANGED
|
@@ -2,8 +2,17 @@
|
|
|
2
2
|
// code runs unchanged in workerd. The store keeps only the hash of a token, never the
|
|
3
3
|
// token itself (spec 7.1).
|
|
4
4
|
|
|
5
|
-
/** The session cookie name. */
|
|
6
|
-
|
|
5
|
+
/** The base session cookie name, prefixed with __Host- when the cookie is Secure. */
|
|
6
|
+
const COOKIE_BASE = 'cairn_session';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The session cookie name. On https the cookie is Secure and takes the __Host- prefix, which
|
|
10
|
+
* binds it to the origin (the browser enforces Secure, Path=/, and no Domain). On local http
|
|
11
|
+
* dev the prefix is dropped, since __Host- requires Secure and the dev cookie cannot set it.
|
|
12
|
+
*/
|
|
13
|
+
export function sessionCookieName(secure: boolean): string {
|
|
14
|
+
return secure ? `__Host-${COOKIE_BASE}` : COOKIE_BASE;
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
/** Magic-link tokens live 10 minutes. */
|
|
9
18
|
export const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
@@ -11,6 +20,9 @@ export const TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
|
11
20
|
/** Sessions live 30 days. */
|
|
12
21
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
13
22
|
|
|
23
|
+
/** A magic link is sent at most once per email per minute, to throttle inbox flooding. */
|
|
24
|
+
export const SEND_COOLDOWN_MS = 60 * 1000;
|
|
25
|
+
|
|
14
26
|
function randomBase64Url(byteLength = 32): string {
|
|
15
27
|
const bytes = new Uint8Array(byteLength);
|
|
16
28
|
crypto.getRandomValues(bytes);
|
package/src/lib/auth/store.ts
CHANGED
|
@@ -28,13 +28,23 @@ export async function issueToken(
|
|
|
28
28
|
now: number,
|
|
29
29
|
): Promise<void> {
|
|
30
30
|
await db.batch([
|
|
31
|
-
|
|
31
|
+
// Replace this email's prior token, and sweep any expired token while here (no cron needed).
|
|
32
|
+
db.prepare('DELETE FROM magic_token WHERE email = ? OR expires_at <= ?').bind(email, now),
|
|
32
33
|
db
|
|
33
34
|
.prepare('INSERT INTO magic_token (token_hash, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
34
35
|
.bind(tokenHash, email, expiresAt, now),
|
|
35
36
|
]);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** True when a magic-link token for this email was issued at or after `since`, for the send cooldown. */
|
|
40
|
+
export async function recentlyIssued(db: D1Database, email: string, since: number): Promise<boolean> {
|
|
41
|
+
const row = await db
|
|
42
|
+
.prepare('SELECT 1 AS one FROM magic_token WHERE email = ? AND created_at >= ? LIMIT 1')
|
|
43
|
+
.bind(email, since)
|
|
44
|
+
.first<{ one: number }>();
|
|
45
|
+
return row != null;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
/**
|
|
39
49
|
* Consume a token in one atomic statement. A returned email means the token was present and
|
|
40
50
|
* unexpired and is now gone, so the link is single-use by construction on strongly-consistent D1.
|
|
@@ -55,10 +65,13 @@ export async function createSession(
|
|
|
55
65
|
expiresAt: number,
|
|
56
66
|
now: number,
|
|
57
67
|
): Promise<void> {
|
|
58
|
-
await db
|
|
59
|
-
|
|
60
|
-
.
|
|
61
|
-
|
|
68
|
+
await db.batch([
|
|
69
|
+
// Sweep expired sessions on login, so abandoned rows do not accumulate (no cron needed).
|
|
70
|
+
db.prepare('DELETE FROM session WHERE expires_at <= ?').bind(now),
|
|
71
|
+
db
|
|
72
|
+
.prepare('INSERT INTO session (id, email, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
73
|
+
.bind(id, email, expiresAt, now),
|
|
74
|
+
]);
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
@@ -38,6 +38,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
38
38
|
return Array.isArray(v) ? v : [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
|
|
42
|
+
// identity instead of index. A mid-list removal then drops the right DOM node and the focused
|
|
43
|
+
// item follows the data. Ids come from a monotonic module-local counter, never Math.random or
|
|
44
|
+
// Date.now. The value arrays in values.slots stay the canonical string lists serializeComponent
|
|
45
|
+
// reads, so the emitted markdown is unchanged. emptyValues seeds every repeatable slot to [], so
|
|
46
|
+
// the id lists start empty and stay in lockstep with the values through addItem/removeItem.
|
|
47
|
+
let nextId = 0;
|
|
48
|
+
const itemIds = $state<Record<string, number[]>>(
|
|
49
|
+
untrack(() => Object.fromEntries((def.slots ?? []).filter((s) => s.kind === 'repeatable').map((s) => [s.name, []]))),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// emptyValues and the itemIds seed both cover every repeatable slot, so this read always hits.
|
|
53
|
+
function slotIds(name: string): number[] {
|
|
54
|
+
return itemIds[name] ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addItem(name: string): void {
|
|
58
|
+
slotItems(name).push('');
|
|
59
|
+
slotIds(name).push(nextId++);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeItem(name: string, index: number): void {
|
|
63
|
+
slotItems(name).splice(index, 1);
|
|
64
|
+
slotIds(name).splice(index, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
// Typed accessors over the unions so explicit value targets stay sound.
|
|
42
68
|
function asString(key: string): string {
|
|
43
69
|
const v = values.attributes[key];
|
|
@@ -79,7 +105,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
79
105
|
<input
|
|
80
106
|
class="checkbox checkbox-sm"
|
|
81
107
|
type="checkbox"
|
|
82
|
-
aria-label={field.label}
|
|
83
108
|
aria-invalid={Boolean(errors[field.key])}
|
|
84
109
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
85
110
|
checked={asBool(field.key)}
|
|
@@ -92,7 +117,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
92
117
|
<span class="text-sm font-medium">{field.label}</span>
|
|
93
118
|
<select
|
|
94
119
|
class="select"
|
|
95
|
-
aria-label={field.label}
|
|
96
120
|
aria-invalid={Boolean(errors[field.key])}
|
|
97
121
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
98
122
|
value={asString(field.key)}
|
|
@@ -107,6 +131,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
107
131
|
<span class="text-sm font-medium">{field.label}</span>
|
|
108
132
|
<IconPicker
|
|
109
133
|
{icons}
|
|
134
|
+
label={field.label}
|
|
110
135
|
value={asString(field.key)}
|
|
111
136
|
required={field.required ?? false}
|
|
112
137
|
onChange={(name) => (values.attributes[field.key] = name)}
|
|
@@ -117,7 +142,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
117
142
|
<span class="text-sm font-medium">{field.label}</span>
|
|
118
143
|
<input
|
|
119
144
|
class="input"
|
|
120
|
-
aria-label={field.label}
|
|
121
145
|
aria-invalid={Boolean(errors[field.key])}
|
|
122
146
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
123
147
|
value={asString(field.key)}
|
|
@@ -134,7 +158,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
134
158
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
135
159
|
<textarea
|
|
136
160
|
class="textarea"
|
|
137
|
-
aria-label={slot.label}
|
|
138
161
|
aria-invalid={Boolean(errors[slot.name])}
|
|
139
162
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
140
163
|
rows={3}
|
|
@@ -147,7 +170,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
147
170
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
148
171
|
<input
|
|
149
172
|
class="input"
|
|
150
|
-
aria-label={slot.label}
|
|
151
173
|
aria-invalid={Boolean(errors[slot.name])}
|
|
152
174
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
153
175
|
value={slotString(slot.name)}
|
|
@@ -160,16 +182,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
160
182
|
|
|
161
183
|
{#each repeatableSlots as slot (slot.name)}
|
|
162
184
|
{@const items = slotItems(slot.name)}
|
|
185
|
+
{@const ids = slotIds(slot.name)}
|
|
163
186
|
<fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
|
|
164
187
|
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
165
|
-
<!--
|
|
166
|
-
{#each
|
|
188
|
+
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
|
|
189
|
+
{#each ids as id, i (id)}
|
|
167
190
|
<div class="flex items-center gap-2">
|
|
168
|
-
<input class="input input-sm flex-1" aria-label={`${slot.label}
|
|
169
|
-
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() =>
|
|
191
|
+
<input class="input input-sm flex-1" aria-label={`${slot.label} ${i + 1}`} bind:value={items[i]} />
|
|
192
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
|
|
170
193
|
</div>
|
|
171
194
|
{/each}
|
|
172
|
-
<button type="button" class="btn btn-sm self-start" onclick={() =>
|
|
195
|
+
<button type="button" class="btn btn-sm self-start" onclick={() => addItem(slot.name)}>Add item</button>
|
|
173
196
|
{#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
|
|
174
197
|
</fieldset>
|
|
175
198
|
{/each}
|