caracms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What sign-in keeps in Redis: one-time link tokens and the rate-limit counters (M4
|
|
3
|
+
* Decision 14; the template's `server/login-store.mjs`, over our RedisClient).
|
|
4
|
+
*
|
|
5
|
+
* Every key starts with `caracms:auth:` and has an expiry, so nothing here outlives
|
|
6
|
+
* 15 minutes.
|
|
7
|
+
*
|
|
8
|
+
* **Link tokens:** 32 random bytes, base64url (43 characters) in the link. **Only the
|
|
9
|
+
* token's SHA-256 is stored**, `caracms:auth:link:<hash>` → `{ email, next }`, for 15
|
|
10
|
+
* minutes, so a leaked Redis yields no usable link. `peek` is a plain GET: loading the
|
|
11
|
+
* confirm page never spends a token (mail scanners load every link). **`spend` is
|
|
12
|
+
* GETDEL**: atomic, so two presses can't both sign in.
|
|
13
|
+
*
|
|
14
|
+
* **Rate limits:** a fixed 15-minute window per key, started by the first hit and never
|
|
15
|
+
* extended. Link requests: **3 per address and 10 per IP**. Backup sign-in: 5 attempts
|
|
16
|
+
* per IP, counting every attempt (the template's `BACKUP_LIMIT`). The address and the IP
|
|
17
|
+
* are SHA-256'd in the key names, so no address sits in Redis in the clear and a header
|
|
18
|
+
* can't put arbitrary text into a key.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
21
|
+
import type { RedisClient } from '../redis.ts';
|
|
22
|
+
|
|
23
|
+
export const LINK_TTL_MS = 15 * 60 * 1000;
|
|
24
|
+
export const RATE_WINDOW_MS = 15 * 60 * 1000;
|
|
25
|
+
export const LINK_LIMITS = { perAddress: 3, perIp: 10 } as const;
|
|
26
|
+
export const BACKUP_LIMIT = 5;
|
|
27
|
+
const PREFIX = 'caracms:auth:';
|
|
28
|
+
|
|
29
|
+
export const sha256Hex = (value: string) => createHash('sha256').update(value, 'utf8').digest('hex');
|
|
30
|
+
export const newLinkToken = () => randomBytes(32).toString('base64url');
|
|
31
|
+
/** Only tokens this module could have minted reach Redis. */
|
|
32
|
+
export const isTokenShape = (token: unknown): token is string => typeof token === 'string' && /^[A-Za-z0-9_-]{43}$/.test(token);
|
|
33
|
+
export const linkKey = (token: string) => `${PREFIX}link:${sha256Hex(token)}`;
|
|
34
|
+
|
|
35
|
+
export interface LinkRecord {
|
|
36
|
+
email: string;
|
|
37
|
+
next: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parse(raw: string | null): LinkRecord | null {
|
|
41
|
+
if (raw === null) return null;
|
|
42
|
+
try {
|
|
43
|
+
const record = JSON.parse(raw) as Partial<LinkRecord>;
|
|
44
|
+
return typeof record?.email === 'string' ? { email: record.email, next: typeof record.next === 'string' ? record.next : '' } : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LinkStore {
|
|
51
|
+
/** Store a new token for this (normalised) address; returns the token for the link. */
|
|
52
|
+
issue(email: string, next: string): Promise<string>;
|
|
53
|
+
/** Read without spending (the confirm page). Null for missing, expired, used or malformed. */
|
|
54
|
+
peek(token: unknown): Promise<LinkRecord | null>;
|
|
55
|
+
/** Read and delete in one step (the press). Null if it was already gone. */
|
|
56
|
+
spend(token: unknown): Promise<LinkRecord | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createLinkStore(redis: RedisClient): LinkStore {
|
|
60
|
+
return {
|
|
61
|
+
async issue(email, next) {
|
|
62
|
+
const token = newLinkToken();
|
|
63
|
+
await redis.set(linkKey(token), JSON.stringify({ email, next }), { px: LINK_TTL_MS });
|
|
64
|
+
return token;
|
|
65
|
+
},
|
|
66
|
+
async peek(token) {
|
|
67
|
+
return isTokenShape(token) ? parse(await redis.get(linkKey(token))) : null;
|
|
68
|
+
},
|
|
69
|
+
async spend(token) {
|
|
70
|
+
return isTokenShape(token) ? parse(await redis.getdel(linkKey(token))) : null;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RateLimits {
|
|
76
|
+
/** One link request: counts against both the address and the IP; whether either is now over. */
|
|
77
|
+
linkRequestOver(email: string, ip: string): Promise<boolean>;
|
|
78
|
+
/** One backup sign-in attempt from this IP: whether it's over. */
|
|
79
|
+
backupAttemptOver(ip: string): Promise<boolean>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createRateLimits(redis: RedisClient): RateLimits {
|
|
83
|
+
/** Count one hit; the window starts with the first and isn't extended by later ones. */
|
|
84
|
+
const hit = async (name: string) => {
|
|
85
|
+
const key = `${PREFIX}rl:${name}`;
|
|
86
|
+
const count = await redis.incr(key);
|
|
87
|
+
// The first hit starts the window. A key left with no expiry (a crash between the
|
|
88
|
+
// two calls) is given one on its next hit, so a counter can never stick.
|
|
89
|
+
if (count === 1 || (await redis.pttl(key)) === -1) await redis.pexpire(key, RATE_WINDOW_MS);
|
|
90
|
+
return count;
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
async linkRequestOver(email, ip) {
|
|
94
|
+
const [byAddress, byIp] = await Promise.all([hit(`link-address:${sha256Hex(email)}`), hit(`link-ip:${sha256Hex(ip)}`)]);
|
|
95
|
+
return byAddress > LINK_LIMITS.perAddress || byIp > LINK_LIMITS.perIp;
|
|
96
|
+
},
|
|
97
|
+
async backupAttemptOver(ip) {
|
|
98
|
+
return (await hit(`backup-ip:${sha256Hex(ip)}`)) > BACKUP_LIMIT;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in pages under `/admin/login` (M4 Decision 14; the template's
|
|
3
|
+
* `server/admin-login.mjs`, without Auth.js). A Request in, a Response out.
|
|
4
|
+
*
|
|
5
|
+
* GET /admin/login[?next=][&backup=1][&signedout=1]
|
|
6
|
+
* The email form. `?backup=1` swaps in the backup form (only while the backup
|
|
7
|
+
* account is on); `?signedout=1` adds "You're signed out".
|
|
8
|
+
* POST /admin/login (form: email, next[, again])
|
|
9
|
+
* **Request a link: a plain form POST.** Rate limits (3 per address, 10 per IP,
|
|
10
|
+
* per 15 minutes), then the whitelist; the link is issued and emailed (or
|
|
11
|
+
* logged) AFTER the response, so **every path answers the same "Check your
|
|
12
|
+
* email" page** in about the same time. A malformed address is the one other
|
|
13
|
+
* answer (400, the form's own error), which says nothing about the whitelist.
|
|
14
|
+
* POST /admin/login?backup=1 Script off: shows the backup form again. Reads nothing.
|
|
15
|
+
* GET /admin/login/confirm?token=…
|
|
16
|
+
* Where the emailed link lands. **Read-only** (GET, never GETDEL: mail scanners
|
|
17
|
+
* open links): "Sign in as …"; "Switch to …?" when this browser is signed in as
|
|
18
|
+
* someone else; straight to `next` with the token left unspent when it's
|
|
19
|
+
* already signed in as that address; or the one dead-link page (410).
|
|
20
|
+
* POST /admin/login/confirm Script off: 303 back to the GET. Spends nothing.
|
|
21
|
+
* POST /admin/login/signin (JSON: `{ kind: 'link', token }` or
|
|
22
|
+
* `{ kind: 'backup', username, password, next }`) The press. **The token is
|
|
23
|
+
* spent here only**, with GETDEL, and the address checked against the whitelist
|
|
24
|
+
* again. Answers `{ location }` with the session cookie, or `{ error, reason }`
|
|
25
|
+
* (`dead-link` 410, `wrong` 401, `limited` 429, `failed`).
|
|
26
|
+
* POST /admin/login/signout Clears the cookie; 303 to `?signedout=1`, or
|
|
27
|
+
* `{ ok, location }` to a JSON request.
|
|
28
|
+
* GET /admin/login/signed-in?next=
|
|
29
|
+
* After signing in on a narrow window (M2 Decision 8): signed in, open the
|
|
30
|
+
* editor on a larger screen, with Sign out.
|
|
31
|
+
*
|
|
32
|
+
* Every POST requires the same origin (403 otherwise): the guard lets these paths
|
|
33
|
+
* through, so they check it themselves. Responses are `Cache-Control: private,
|
|
34
|
+
* no-store`, `X-Robots-Tag: noindex, nofollow`, `nosniff`, `Referrer-Policy:
|
|
35
|
+
* same-origin` (not `no-referrer`, under which browsers send `Origin: null` on the
|
|
36
|
+
* form POSTs) and a CSP with only the page's own style and script, by hash.
|
|
37
|
+
*/
|
|
38
|
+
import type { Auth } from './auth.ts';
|
|
39
|
+
import { normaliseEmail } from './allow.ts';
|
|
40
|
+
import { clientIp, isRead, isSameOrigin } from './request.ts';
|
|
41
|
+
import { backupPerson, cleanNext, DEFAULT_NEXT, LOGIN_PATH, loginHref, safeEqual } from './session.ts';
|
|
42
|
+
import {
|
|
43
|
+
backupView,
|
|
44
|
+
confirmView,
|
|
45
|
+
deadLinkView,
|
|
46
|
+
emailView,
|
|
47
|
+
layout,
|
|
48
|
+
messageView,
|
|
49
|
+
sentView,
|
|
50
|
+
signedInNarrowView,
|
|
51
|
+
SIGNED_IN_PATH,
|
|
52
|
+
SIGNIN_PATH,
|
|
53
|
+
type Brand,
|
|
54
|
+
type PageAssets,
|
|
55
|
+
type SafeHtml,
|
|
56
|
+
} from './views.ts';
|
|
57
|
+
|
|
58
|
+
export const CONFIRM_PATH = `${LOGIN_PATH}/confirm`;
|
|
59
|
+
export const SIGNOUT_PATH = `${LOGIN_PATH}/signout`;
|
|
60
|
+
export { SIGNIN_PATH, SIGNED_IN_PATH };
|
|
61
|
+
|
|
62
|
+
/** Largest form or JSON body read. An address and a password are tiny. */
|
|
63
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
64
|
+
|
|
65
|
+
export interface LoginContext {
|
|
66
|
+
auth: Auth;
|
|
67
|
+
brand: Brand;
|
|
68
|
+
assets: PageAssets;
|
|
69
|
+
/** The adapter's socket address (Astro's `clientAddress`), read lazily: it can throw. */
|
|
70
|
+
clientAddress: () => string | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const BASE_HEADERS = {
|
|
74
|
+
'Cache-Control': 'private, no-store',
|
|
75
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
76
|
+
'X-Content-Type-Options': 'nosniff',
|
|
77
|
+
'Referrer-Policy': 'same-origin',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
class BodyTooLarge extends Error {}
|
|
81
|
+
|
|
82
|
+
async function readBody(request: Request): Promise<string> {
|
|
83
|
+
if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) throw new BodyTooLarge();
|
|
84
|
+
const text = await request.text();
|
|
85
|
+
if (Buffer.byteLength(text) > MAX_BODY_BYTES) throw new BodyTooLarge();
|
|
86
|
+
return text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function handleLogin(request: Request, ctx: LoginContext): Promise<Response> {
|
|
90
|
+
const { auth, brand, assets } = ctx;
|
|
91
|
+
const url = new URL(request.url);
|
|
92
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
93
|
+
const method = request.method;
|
|
94
|
+
const query = url.searchParams;
|
|
95
|
+
const extra: Record<string, string> = {};
|
|
96
|
+
|
|
97
|
+
const page = (status: number, title: string, body: SafeHtml, opts: { script?: boolean; quiet?: boolean; bare?: boolean } = {}) => {
|
|
98
|
+
const { html, csp } = layout({ title, brand, assets, body, home: url.host, ...opts });
|
|
99
|
+
return new Response(method === 'HEAD' ? null : html, {
|
|
100
|
+
status,
|
|
101
|
+
headers: { ...BASE_HEADERS, ...extra, 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': csp },
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const redirect = (location: string, cookie?: string) => {
|
|
105
|
+
const headers = new Headers({ ...BASE_HEADERS, Location: location });
|
|
106
|
+
if (cookie) headers.append('Set-Cookie', cookie);
|
|
107
|
+
return new Response(null, { status: 303, headers });
|
|
108
|
+
};
|
|
109
|
+
const json = (status: number, body: unknown, cookie?: string) => {
|
|
110
|
+
const headers = new Headers({ ...BASE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' });
|
|
111
|
+
if (cookie) headers.append('Set-Cookie', cookie);
|
|
112
|
+
return new Response(JSON.stringify(body), { status, headers });
|
|
113
|
+
};
|
|
114
|
+
const notAllowed = (allow: string) => {
|
|
115
|
+
extra.Allow = allow;
|
|
116
|
+
return page(405, 'Not allowed', messageView('Not allowed', 'Use the form on the sign-in page.'));
|
|
117
|
+
};
|
|
118
|
+
const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', 'Send the form from the sign-in page.'));
|
|
119
|
+
const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// ────────────────────────────────────────────────────────────── confirm
|
|
123
|
+
if (pathname === CONFIRM_PATH) {
|
|
124
|
+
if (method === 'POST') return redirect(`${CONFIRM_PATH}${url.search}`);
|
|
125
|
+
if (!isRead(method)) return notAllowed('GET, HEAD, POST');
|
|
126
|
+
const record = await auth.links.peek(query.get('token'));
|
|
127
|
+
if (!record) return page(410, 'Link expired', deadLinkView());
|
|
128
|
+
const next = cleanNext(record.next);
|
|
129
|
+
const who = auth.identify(request.headers.get('cookie'));
|
|
130
|
+
if (who.state === 'in' && who.payload.kind === 'link' && who.person.email === record.email) {
|
|
131
|
+
// Already in as this address: go on, and leave the token unspent.
|
|
132
|
+
return redirect(next);
|
|
133
|
+
}
|
|
134
|
+
const current = who.state !== 'in' ? null : who.payload.kind === 'backup' ? 'the backup account' : who.person.email;
|
|
135
|
+
return page(200, 'Sign in', confirmView({ email: record.email, next, siteName: auth.siteName, current }), { script: true, quiet: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─────────────────────────────────────────────────────────────── signin
|
|
139
|
+
if (pathname === SIGNIN_PATH) {
|
|
140
|
+
if (method !== 'POST') return notAllowed('POST');
|
|
141
|
+
if (!isSameOrigin(request)) return json(403, { error: 'Sign in from the sign-in page.', reason: 'failed' });
|
|
142
|
+
let body: Record<string, unknown>;
|
|
143
|
+
try {
|
|
144
|
+
const parsed: unknown = JSON.parse(await readBody(request));
|
|
145
|
+
if (!parsed || typeof parsed !== 'object') throw new Error('not an object');
|
|
146
|
+
body = parsed as Record<string, unknown>;
|
|
147
|
+
} catch {
|
|
148
|
+
return json(400, { error: 'Send { kind, … } as JSON.', reason: 'failed' });
|
|
149
|
+
}
|
|
150
|
+
if (body.kind === 'link') {
|
|
151
|
+
const record = await auth.links.spend(body.token);
|
|
152
|
+
const email = normaliseEmail(record?.email);
|
|
153
|
+
// Spent, and the address still on the whitelist (it may have been removed since).
|
|
154
|
+
if (!record || !email || !auth.whitelist.allows(email)) return json(410, { error: 'This link has expired or was already used.', reason: 'dead-link' });
|
|
155
|
+
auth.log(`signed in: ${email}`);
|
|
156
|
+
return json(200, { location: cleanNext(record.next) }, auth.startSession({ kind: 'link', email }));
|
|
157
|
+
}
|
|
158
|
+
if (body.kind === 'backup') {
|
|
159
|
+
const { backup } = auth;
|
|
160
|
+
if (!backup.enabled) return json(404, { error: 'There is no backup account on this site.', reason: 'failed' });
|
|
161
|
+
// Every attempt counts, the right password included, so the limit can't confirm a guess.
|
|
162
|
+
let over = true;
|
|
163
|
+
try {
|
|
164
|
+
over = await auth.limits.backupAttemptOver(clientIp(request, ctx.clientAddress));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
auth.log(`could not count a backup sign-in attempt, so it was refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
|
+
}
|
|
168
|
+
if (over) return json(429, { error: 'Too many attempts. Try again in 15 minutes.', reason: 'limited' });
|
|
169
|
+
// Both compared every time: no early exit on a wrong username.
|
|
170
|
+
const userOk = safeEqual(body.username, backup.user);
|
|
171
|
+
const passwordOk = safeEqual(body.password, backup.password);
|
|
172
|
+
if (!(userOk && passwordOk)) return json(401, { error: "That username and password didn't work.", reason: 'wrong' });
|
|
173
|
+
auth.log(`signed in: the backup account (${backupPerson(backup.user).name})`);
|
|
174
|
+
return json(200, { location: cleanNext(body.next) }, auth.startSession({ kind: 'backup' }));
|
|
175
|
+
}
|
|
176
|
+
return json(400, { error: 'Unknown kind of sign-in.', reason: 'failed' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ────────────────────────────────────────────────────────────── signout
|
|
180
|
+
if (pathname === SIGNOUT_PATH) {
|
|
181
|
+
if (method !== 'POST') return notAllowed('POST');
|
|
182
|
+
if (!isSameOrigin(request)) return wantsJson ? json(403, { error: 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
|
|
183
|
+
const location = loginHref({ signedout: '1' });
|
|
184
|
+
return wantsJson ? json(200, { ok: true, location }, auth.clearCookie()) : redirect(location, auth.clearCookie());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ──────────────────────────────────────────────────────────── signed-in
|
|
188
|
+
if (pathname === SIGNED_IN_PATH) {
|
|
189
|
+
if (!isRead(method)) return notAllowed('GET, HEAD');
|
|
190
|
+
const who = auth.identify(request.headers.get('cookie'));
|
|
191
|
+
if (who.state !== 'in') return redirect(LOGIN_PATH);
|
|
192
|
+
const shown = who.payload.kind === 'backup' ? 'the backup account' : who.person.email;
|
|
193
|
+
return page(200, 'Signed in', signedInNarrowView({ brand, who: shown, host: url.host }), { script: true, bare: true });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (pathname !== LOGIN_PATH) return page(404, 'Not found', messageView('Not found', 'There is no page at this address.'));
|
|
197
|
+
|
|
198
|
+
// ──────────────────────────────────────────────────────────────── login
|
|
199
|
+
const showBackup = query.get('backup') === '1' && auth.backup.enabled;
|
|
200
|
+
if (isRead(method)) {
|
|
201
|
+
const next = cleanNext(query.get('next'));
|
|
202
|
+
if (showBackup) return page(200, 'Backup account', backupView({ next }), { script: true });
|
|
203
|
+
return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled }));
|
|
204
|
+
}
|
|
205
|
+
if (method !== 'POST') return notAllowed('GET, HEAD, POST');
|
|
206
|
+
if (!isSameOrigin(request)) return crossOrigin();
|
|
207
|
+
if (showBackup) return page(200, 'Backup account', backupView({ next: cleanNext(query.get('next')) }), { script: true });
|
|
208
|
+
|
|
209
|
+
let form: URLSearchParams;
|
|
210
|
+
try {
|
|
211
|
+
form = new URLSearchParams(await readBody(request));
|
|
212
|
+
} catch {
|
|
213
|
+
return page(400, 'Sign in', emailView({ next: DEFAULT_NEXT, invalid: 'Enter your email address.', backupEnabled: auth.backup.enabled }));
|
|
214
|
+
}
|
|
215
|
+
const next = cleanNext(form.get('next'));
|
|
216
|
+
const typed = (form.get('email') ?? '').trim().slice(0, 320);
|
|
217
|
+
const email = normaliseEmail(typed);
|
|
218
|
+
if (!email) {
|
|
219
|
+
const invalid = typed ? 'Enter a full email address.' : 'Enter your email address.';
|
|
220
|
+
return page(400, 'Sign in', emailView({ next, value: typed, invalid, backupEnabled: auth.backup.enabled }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Every address counts against the limits, allowed or not, so the time taken is the same.
|
|
224
|
+
const ip = clientIp(request, ctx.clientAddress);
|
|
225
|
+
let over = true;
|
|
226
|
+
try {
|
|
227
|
+
over = await auth.limits.linkRequestOver(email, ip);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// No counters, no link: fail closed.
|
|
230
|
+
auth.log(`could not check the rate limits, so no link was sent: ${error instanceof Error ? error.message : String(error)}`);
|
|
231
|
+
}
|
|
232
|
+
if (over) auth.log(`link request refused by the rate limit (ip ${ip})`);
|
|
233
|
+
else if (auth.whitelist.allows(email)) void auth.sendLink(email, next, url.origin);
|
|
234
|
+
else auth.log(`no link sent: ${email} isn't on the whitelist`);
|
|
235
|
+
return page(200, 'Check your email', sentView({ email, next, again: form.get('again') === '1', backupEnabled: auth.backup.enabled }));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
auth.log(`sign-in page failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
238
|
+
return page(500, 'Something went wrong', messageView('Something went wrong', "Couldn't finish that. Try again in a moment."));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Local mode has no sign-in (Decision 1): every sign-in page goes to the editor. */
|
|
243
|
+
export function localLoginRedirect(): Response {
|
|
244
|
+
return new Response(null, { status: 303, headers: { Location: DEFAULT_NEXT, 'Cache-Control': 'private, no-store' } });
|
|
245
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard as Astro middleware (M4 Decision 14), registered by the integration with
|
|
3
|
+
* `addMiddleware({ order: 'pre' })` after Phase 2's 503 middleware for an
|
|
4
|
+
* unconfigured server. The check itself is guard.ts.
|
|
5
|
+
*
|
|
6
|
+
* - **Local mode: it does nothing** (M3's behaviour, no sign-in; Decision 1).
|
|
7
|
+
* - **Live mode**: a path outside `/admin` and `/_caracms/` goes straight on, without
|
|
8
|
+
* even building the runtime, so the public site never waits on GitHub or Redis.
|
|
9
|
+
* A guarded path that passes records the person (`setPerson`) for the route.
|
|
10
|
+
* - **Unconfigured** never reaches here in a correct setup (Phase 2's middleware
|
|
11
|
+
* answers first); if it does, a guarded path gets a 503 rather than through.
|
|
12
|
+
*
|
|
13
|
+
* The mode is Phase 2's `editorMode()`, so the two agree on live and local.
|
|
14
|
+
*/
|
|
15
|
+
import type { MiddlewareHandler } from 'astro';
|
|
16
|
+
import { site } from 'virtual:caracms/config';
|
|
17
|
+
import { editorMode } from '../../editor/server/mode.ts';
|
|
18
|
+
import { setPerson } from '../locals.ts';
|
|
19
|
+
import { guard, isGuardedPath } from './guard.ts';
|
|
20
|
+
import { authFor } from './index.ts';
|
|
21
|
+
|
|
22
|
+
const unavailable = (text: string) => new Response(text, { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'private, no-store' } });
|
|
23
|
+
|
|
24
|
+
export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
25
|
+
if (!isGuardedPath(context.url.pathname)) return next();
|
|
26
|
+
const mode = editorMode();
|
|
27
|
+
if (mode.kind === 'local') return next();
|
|
28
|
+
if (mode.kind !== 'live') return unavailable("The editor isn't set up on this server yet.");
|
|
29
|
+
|
|
30
|
+
let auth;
|
|
31
|
+
try {
|
|
32
|
+
auth = await authFor(site, mode.settings, context.site);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('[caracms] sign-in: the live runtime could not start:', error);
|
|
35
|
+
return unavailable("The editor couldn't start. Try again in a moment.");
|
|
36
|
+
}
|
|
37
|
+
const result = guard(context.request, auth);
|
|
38
|
+
if (result.kind === 'respond') return result.response;
|
|
39
|
+
if (result.kind === 'skip') return next();
|
|
40
|
+
|
|
41
|
+
setPerson(context.locals, result.person);
|
|
42
|
+
const response = await next();
|
|
43
|
+
if (!result.setCookie) return response;
|
|
44
|
+
try {
|
|
45
|
+
response.headers.append('Set-Cookie', result.setCookie);
|
|
46
|
+
return response;
|
|
47
|
+
} catch {
|
|
48
|
+
// Immutable headers (a Response.redirect): copy it.
|
|
49
|
+
const copy = new Response(response.body, response);
|
|
50
|
+
copy.headers.append('Set-Cookie', result.setCookie);
|
|
51
|
+
return copy;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two things read off a request: the client's IP (for rate limits) and whether a write
|
|
3
|
+
* came from this site.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const first = (value: string | null) => (value ?? '').split(',')[0]!.trim();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The client's IP, which keys the per-IP rate limits (the template's `clientIp()`,
|
|
10
|
+
* `docs/admin-login.md` → Rate limits and the client IP). In order:
|
|
11
|
+
*
|
|
12
|
+
* 1. **`X-Real-IP`.** Railway's edge overwrites it with the address that connected, so
|
|
13
|
+
* behind Railway it's the one value the client can't choose.
|
|
14
|
+
* 2. The socket's address as the adapter gives it (`clientAddress`).
|
|
15
|
+
* 3. The first `X-Forwarded-For` hop, only when there's nothing else. Never ahead of
|
|
16
|
+
* the socket: the client writes the leftmost hop, so keyed on it an attacker could
|
|
17
|
+
* rotate past every per-IP limit.
|
|
18
|
+
*
|
|
19
|
+
* Off Railway (dev, tests) nothing proxies, so a spoofed `X-Real-IP` only rate-limits
|
|
20
|
+
* the spoofer's own requests: accepted, as in the template.
|
|
21
|
+
*/
|
|
22
|
+
export function clientIp(request: Request, clientAddress: () => string | undefined): string {
|
|
23
|
+
const real = first(request.headers.get('x-real-ip'));
|
|
24
|
+
if (real) return real.slice(0, 64);
|
|
25
|
+
let socket: string | undefined;
|
|
26
|
+
try {
|
|
27
|
+
socket = clientAddress();
|
|
28
|
+
} catch {
|
|
29
|
+
/* not available here */
|
|
30
|
+
}
|
|
31
|
+
if (socket) return socket.slice(0, 64);
|
|
32
|
+
return first(request.headers.get('x-forwarded-for')).slice(0, 64) || 'unknown';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a write (any non-GET/HEAD) came from this site: **an `Origin` whose host is
|
|
37
|
+
* the request's own host**, and no `Sec-Fetch-Site` saying otherwise. A missing
|
|
38
|
+
* `Origin`, or the opaque `null` (a sandboxed frame, `Referrer-Policy: no-referrer`
|
|
39
|
+
* form posts), is refused: every current browser sends `Origin` on a POST.
|
|
40
|
+
*
|
|
41
|
+
* Host only, not the scheme, as M3's write route does: behind Railway's edge the
|
|
42
|
+
* server itself sees plain http while the page is https.
|
|
43
|
+
*/
|
|
44
|
+
export function isSameOrigin(request: Request): boolean {
|
|
45
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
46
|
+
if (fetchSite && fetchSite !== 'same-origin') return false;
|
|
47
|
+
const origin = request.headers.get('origin');
|
|
48
|
+
if (!origin || origin === 'null') return false;
|
|
49
|
+
try {
|
|
50
|
+
return new URL(origin).host === new URL(request.url).host;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const isRead = (method: string) => method === 'GET' || method === 'HEAD';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in's routes and middleware, for the integration (src/index.ts), which injects
|
|
3
|
+
* them beside the editor's. One route serves every `/admin/login…` page (login.ts);
|
|
4
|
+
* Astro ranks it above `/admin/[...path]`.
|
|
5
|
+
*/
|
|
6
|
+
export const LOGIN_ROUTES = [{ pattern: '/admin/login/[...path]', entrypoint: 'editor/routes/login/login.ts' }] as const;
|
|
7
|
+
|
|
8
|
+
/** The guard, relative to src/ (index.ts resolves it against its own URL). */
|
|
9
|
+
export const GUARD_MIDDLEWARE = 'live/auth/middleware.ts';
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session, the backup account and `next` (M4 Decision 14; the template's
|
|
3
|
+
* `server/auth-options.mjs`, without Auth.js).
|
|
4
|
+
*
|
|
5
|
+
* **The session is a cookie the site signs itself**: `<payload>.<mac>`, the payload
|
|
6
|
+
* base64url JSON, the mac HMAC-SHA256 over it under a key derived from
|
|
7
|
+
* `CARACMS_SECRET`, compared in constant time. `HttpOnly`, `Secure` in production,
|
|
8
|
+
* `SameSite=Lax`, `Path=/`, no `Domain`. It lasts **30 days from last use**: the guard
|
|
9
|
+
* renews it (a new `renewed` and `expires`) when it's more than an hour old, so a
|
|
10
|
+
* poll every 5 s doesn't set a cookie every 5 s. In production the name has the
|
|
11
|
+
* `__Host-` prefix, so no other host can plant one. Changing `CARACMS_SECRET` signs
|
|
12
|
+
* everyone out.
|
|
13
|
+
*
|
|
14
|
+
* Two kinds:
|
|
15
|
+
* - `link`: an address that spent a link. **The whitelist is checked again on every
|
|
16
|
+
* request** (guard.ts), so removing an address signs that person out on their next one.
|
|
17
|
+
* - `backup`: the backup account. It carries a fingerprint of the password it signed
|
|
18
|
+
* in with (an HMAC under the secret, never the password), so **changing the password
|
|
19
|
+
* signs backup sessions out**.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
22
|
+
import type { Person } from '../messages.ts';
|
|
23
|
+
|
|
24
|
+
export const LOGIN_PATH = '/admin/login';
|
|
25
|
+
export const DEFAULT_NEXT = '/admin';
|
|
26
|
+
export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60;
|
|
27
|
+
/** Renew the cookie once it's this old, so it stays 30 days from last use. */
|
|
28
|
+
export const SESSION_RENEW_MS = 60 * 60 * 1000;
|
|
29
|
+
/** Shorter means no backup sign-in, and no link to it. */
|
|
30
|
+
export const BACKUP_MIN_PASSWORD = 24;
|
|
31
|
+
|
|
32
|
+
/** The cookie's name: `__Host-` prefixed when it's `Secure` (production). */
|
|
33
|
+
export const sessionCookieName = (secure: boolean) => (secure ? '__Host-caracms_session' : 'caracms_session');
|
|
34
|
+
|
|
35
|
+
export type SessionPayload =
|
|
36
|
+
| { v: 1; kind: 'link'; email: string; renewed: number; expires: number }
|
|
37
|
+
| { v: 1; kind: 'backup'; user: string; fingerprint: string; renewed: number; expires: number };
|
|
38
|
+
|
|
39
|
+
const macKey = (secret: string) => createHmac('sha256', secret).update('caracms session v1').digest();
|
|
40
|
+
|
|
41
|
+
/** Constant-time string comparison: both sides hashed first, so lengths never leak. */
|
|
42
|
+
export function safeEqual(a: unknown, b: string): boolean {
|
|
43
|
+
const digest = (s: string) => createHash('sha256').update(s, 'utf8').digest();
|
|
44
|
+
return timingSafeEqual(digest(typeof a === 'string' ? a : ''), digest(b));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function signSession(payload: SessionPayload, secret: string): string {
|
|
48
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
49
|
+
const mac = createHmac('sha256', macKey(secret)).update(body).digest('base64url');
|
|
50
|
+
return `${body}.${mac}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The payload, if the cookie is ours, intact and not expired; else null. */
|
|
54
|
+
export function verifySession(value: string | undefined | null, secret: string, now: number): SessionPayload | null {
|
|
55
|
+
if (!value || value.length > 4096) return null;
|
|
56
|
+
const dot = value.indexOf('.');
|
|
57
|
+
if (dot <= 0 || dot !== value.lastIndexOf('.')) return null;
|
|
58
|
+
const body = value.slice(0, dot);
|
|
59
|
+
const given = Buffer.from(value.slice(dot + 1), 'base64url');
|
|
60
|
+
const expected = createHmac('sha256', macKey(secret)).update(body).digest();
|
|
61
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
|
|
62
|
+
let payload: unknown;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const p = payload as Partial<SessionPayload> & Record<string, unknown>;
|
|
69
|
+
if (!p || p.v !== 1 || typeof p.expires !== 'number' || typeof p.renewed !== 'number' || !(p.expires > now)) return null;
|
|
70
|
+
if (p.kind === 'link' && typeof p.email === 'string') return p as SessionPayload;
|
|
71
|
+
if (p.kind === 'backup' && typeof p.user === 'string' && typeof p.fingerprint === 'string') return p as SessionPayload;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A fresh payload for a sign-in, or a renewal of an existing one. */
|
|
76
|
+
export function freshPayload(
|
|
77
|
+
identity: { kind: 'link'; email: string } | { kind: 'backup'; user: string; fingerprint: string },
|
|
78
|
+
now: number,
|
|
79
|
+
): SessionPayload {
|
|
80
|
+
return { v: 1, ...identity, renewed: now, expires: now + SESSION_MAX_AGE_S * 1000 } as SessionPayload;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CookieOptions {
|
|
84
|
+
secure: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The Set-Cookie value for a session. */
|
|
88
|
+
export function sessionCookie(value: string, { secure }: CookieOptions): string {
|
|
89
|
+
return `${sessionCookieName(secure)}=${value}; Path=/; Max-Age=${SESSION_MAX_AGE_S}; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The Set-Cookie value that clears it (sign out). */
|
|
93
|
+
export function clearedSessionCookie({ secure }: CookieOptions): string {
|
|
94
|
+
return `${sessionCookieName(secure)}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One cookie's value out of a Cookie header. */
|
|
98
|
+
export function readCookie(header: string | null, name: string): string | null {
|
|
99
|
+
if (!header) return null;
|
|
100
|
+
for (const part of header.split(';')) {
|
|
101
|
+
const eq = part.indexOf('=');
|
|
102
|
+
if (eq < 0) continue;
|
|
103
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── The backup account ────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export type BackupConfig = { enabled: true; user: string; password: string; fingerprint: string } | { enabled: false };
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The backup account as configured: both variables, and a password of at least 24
|
|
114
|
+
* characters. **Under 24 characters means no backup sign-in and no link to it**, with
|
|
115
|
+
* one line in the log. Neither set: off, silently.
|
|
116
|
+
*/
|
|
117
|
+
export function backupConfig(backup: { user: string; password: string } | null, secret: string, log: (line: string) => void): BackupConfig {
|
|
118
|
+
if (!backup) return { enabled: false };
|
|
119
|
+
if (backup.password.length < BACKUP_MIN_PASSWORD) {
|
|
120
|
+
log(`CARACMS_BACKUP_PASSWORD is shorter than ${BACKUP_MIN_PASSWORD} characters: the backup account is off.`);
|
|
121
|
+
return { enabled: false };
|
|
122
|
+
}
|
|
123
|
+
return { enabled: true, user: backup.user, password: backup.password, fingerprint: passwordFingerprint(backup.user, backup.password, secret) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The backup password's fingerprint for the session: an HMAC under the secret, never the password. */
|
|
127
|
+
export function passwordFingerprint(user: string, password: string, secret: string): string {
|
|
128
|
+
return createHmac('sha256', secret).update(`caracms backup\u0000${user}\u0000${password}`, 'utf8').digest('base64url').slice(0, 22);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The backup account as a person: an address that can't be anyone's (`.invalid`), named by its username. */
|
|
132
|
+
export function backupPerson(user: string): Person {
|
|
133
|
+
return { email: `${user.toLowerCase().replace(/[^a-z0-9._-]/g, '-')}@backup.invalid`, name: user };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── `next` ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* **`next` must be a same-origin path under `/admin`**, else `/admin`. Refused:
|
|
140
|
+
* protocol-relative (`//evil.com`, `/\evil.com`), absolute URLs (`https://evil.com/admin`),
|
|
141
|
+
* paths outside `/admin` (`/x`), backslashes and control characters anywhere,
|
|
142
|
+
* percent-encoded slashes or backslashes (`%2f`, `%5c`), dot segments that leave
|
|
143
|
+
* `/admin` once resolved, and the sign-in pages themselves.
|
|
144
|
+
*/
|
|
145
|
+
export function cleanNext(value: unknown): string {
|
|
146
|
+
if (typeof value !== 'string') return DEFAULT_NEXT;
|
|
147
|
+
const raw = value.trim();
|
|
148
|
+
if (!raw || raw.length > 2048) return DEFAULT_NEXT;
|
|
149
|
+
if (/[\\\u0000-\u001f\u007f]/.test(raw)) return DEFAULT_NEXT;
|
|
150
|
+
if (/%(?:2f|5c|00)/i.test(raw)) return DEFAULT_NEXT;
|
|
151
|
+
if (!raw.startsWith('/') || raw.startsWith('//')) return DEFAULT_NEXT;
|
|
152
|
+
const base = 'http://next.invalid';
|
|
153
|
+
let url: URL;
|
|
154
|
+
try {
|
|
155
|
+
url = new URL(raw, base);
|
|
156
|
+
} catch {
|
|
157
|
+
return DEFAULT_NEXT;
|
|
158
|
+
}
|
|
159
|
+
if (url.origin !== base) return DEFAULT_NEXT;
|
|
160
|
+
const { pathname } = url;
|
|
161
|
+
if (pathname !== '/admin' && !pathname.startsWith('/admin/')) return DEFAULT_NEXT;
|
|
162
|
+
if (pathname === LOGIN_PATH || pathname.startsWith(`${LOGIN_PATH}/`)) return DEFAULT_NEXT;
|
|
163
|
+
return `${pathname}${url.search}${url.hash}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `/admin/login?next=…`, with `next` left off when it's the default. */
|
|
167
|
+
export function loginHref(params: Record<string, string> = {}, next = DEFAULT_NEXT): string {
|
|
168
|
+
const search = new URLSearchParams(params);
|
|
169
|
+
if (next && next !== DEFAULT_NEXT) search.set('next', next);
|
|
170
|
+
const query = search.toString();
|
|
171
|
+
return query ? `${LOGIN_PATH}?${query}` : LOGIN_PATH;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A `Cookie` header value for a link session as this person (tests, and Phase 2's
|
|
176
|
+
* route tests over HTTP). The address must also be on the whitelist to pass the guard.
|
|
177
|
+
*/
|
|
178
|
+
export function sessionCookieFor(person: Pick<Person, 'email'>, secret: string, options: { secure?: boolean; now?: number } = {}): string {
|
|
179
|
+
const value = signSession(freshPayload({ kind: 'link', email: person.email.trim().toLowerCase() }, options.now ?? Date.now()), secret);
|
|
180
|
+
return `${sessionCookieName(options.secure ?? false)}=${value}`;
|
|
181
|
+
}
|