caracms 0.2.0 → 0.3.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/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/src/files-route.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `CARACMS_PRELAUNCH` in this same process), so a shared cache never keeps it.
|
|
30
30
|
*/
|
|
31
31
|
import type { APIRoute } from 'astro';
|
|
32
|
-
import { clientIp } from './live/auth/request.ts';
|
|
32
|
+
import { clientIp, NO_GATE_MESSAGE } from './live/auth/request.ts';
|
|
33
33
|
import { fileAddress, fileKeyOf, fileTypeOf } from './files.ts';
|
|
34
34
|
import { s3Bucket, type Bucket } from './editor/server/media/bucket.ts';
|
|
35
35
|
import { readImagesConfig } from './editor/server/media/config.ts';
|
|
@@ -67,7 +67,7 @@ export interface FileRouteContext {
|
|
|
67
67
|
/** Default `process.env`, read on each request. */
|
|
68
68
|
env?: Env;
|
|
69
69
|
production?: boolean;
|
|
70
|
-
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
|
|
70
|
+
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
|
|
71
71
|
clientAddress?: () => string | undefined;
|
|
72
72
|
/** Default the process's own (`filesLimiter`). */
|
|
73
73
|
limiter?: RateLimiter;
|
|
@@ -147,10 +147,15 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Runs-anywhere Decision 8: a production server not started by `caracms start` has no gate, so no visitor's IP for the limit.
|
|
151
|
+
const production = ctx.production ?? isProductionBuild();
|
|
152
|
+
const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), production);
|
|
153
|
+
if (ip === null) return text(503, NO_GATE_MESSAGE, {}, head);
|
|
154
|
+
|
|
150
155
|
// 4. The media variables.
|
|
151
156
|
const env = ctx.env ?? process.env;
|
|
152
157
|
const log = ctx.log ?? ((line: string) => console.error(line));
|
|
153
|
-
const config = readImagesConfig(env,
|
|
158
|
+
const config = readImagesConfig(env, production);
|
|
154
159
|
if (config.kind === 'unset') {
|
|
155
160
|
logOnce(log, `[caracms] files: /files/ answers 503: ${config.message}${config.missing.length ? ` (missing: ${config.missing.join(', ')})` : ''}`);
|
|
156
161
|
return text(503, UNAVAILABLE_TEXT, {}, head);
|
|
@@ -158,7 +163,7 @@ export async function handleFileRequest(request: Request, ctx: FileRouteContext
|
|
|
158
163
|
|
|
159
164
|
// 5. The visitor's allowance.
|
|
160
165
|
const limiter = ctx.limiter ?? filesLimiter(env);
|
|
161
|
-
if (await limiter.hit(
|
|
166
|
+
if (await limiter.hit(ip)) {
|
|
162
167
|
return text(429, RATE_LIMITED_TEXT, { 'Retry-After': String(Math.ceil(FILES_LIMIT.windowMs / 1000)) }, head);
|
|
163
168
|
}
|
|
164
169
|
|
package/src/forms/endpoint.ts
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import type { ResolvedSite } from '../config.ts';
|
|
39
39
|
import { publicOrigin } from '../live/auth/index.ts';
|
|
40
|
-
import { clientIp } from '../live/auth/request.ts';
|
|
40
|
+
import { clientIp, NO_GATE_MESSAGE } from '../live/auth/request.ts';
|
|
41
41
|
import { readCapped } from '../editor/server/media/upload.ts';
|
|
42
|
+
import { isProductionBuild } from '../media.ts';
|
|
42
43
|
import { readFormSettings, sendNotification, type FormSettings } from './email.ts';
|
|
43
44
|
import { formLimiter, type FormRateLimiter } from './rate-limit.ts';
|
|
44
45
|
import { cleanReturn, FILE_KINDS, FILE_TYPES, formsAcceptFiles, HONEYPOT, looksLikeEmail, MAX_FILE_BYTES, MAX_FILES, type ResolvedForm } from './rules.ts';
|
|
@@ -65,24 +66,14 @@ export const BAD_EMAIL_MESSAGE = "That email address doesn't look right.";
|
|
|
65
66
|
export const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
66
67
|
const TURNSTILE_TIMEOUT_MS = 5_000;
|
|
67
68
|
|
|
68
|
-
/** `SITE_URL` as an address (an http(s) origin), or undefined when it's unset or isn't one. */
|
|
69
|
-
function siteUrlFromEnv(env: Env): URL | undefined {
|
|
70
|
-
const raw = env['SITE_URL']?.trim();
|
|
71
|
-
if (!raw) return undefined;
|
|
72
|
-
try {
|
|
73
|
-
const url = new URL(raw);
|
|
74
|
-
return url.protocol === 'https:' || url.protocol === 'http:' ? url : undefined;
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
69
|
export { cleanReturn };
|
|
81
70
|
|
|
82
71
|
export interface FormContext {
|
|
83
72
|
site: Pick<ResolvedSite, 'root' | 'forms' | 'timeZone'>;
|
|
84
|
-
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP`. */
|
|
73
|
+
/** The socket's address (Astro's `clientAddress`), read after `X-Real-IP` under `astro dev`. */
|
|
85
74
|
clientAddress?: () => string | undefined;
|
|
75
|
+
/** A production server: the IP comes from `caracms start`'s gate only (runs-anywhere Decision 8). Default: the build's define. */
|
|
76
|
+
production?: boolean;
|
|
86
77
|
env?: Env;
|
|
87
78
|
/** Astro's `site` (the route passes `context.site`): the public origin for the email's editor link. */
|
|
88
79
|
astroSite?: URL;
|
|
@@ -244,7 +235,11 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
|
|
|
244
235
|
return reply(405, { error: 'This address takes a form post.' }, { Allow: 'POST' });
|
|
245
236
|
}
|
|
246
237
|
|
|
247
|
-
const ip = clientIp(request, ctx.clientAddress ?? (() => undefined));
|
|
238
|
+
const ip = clientIp(request, ctx.clientAddress ?? (() => undefined), ctx.production ?? isProductionBuild());
|
|
239
|
+
if (ip === null) {
|
|
240
|
+
await request.body?.cancel().catch(() => {});
|
|
241
|
+
return reply(503, { error: NO_GATE_MESSAGE });
|
|
242
|
+
}
|
|
248
243
|
const takesFiles = formsAcceptFiles(ctx.site.forms);
|
|
249
244
|
const declared = Number(request.headers.get('content-length') ?? NaN);
|
|
250
245
|
const multipart = (request.headers.get('content-type') ?? '').toLowerCase().includes('multipart/form-data');
|
|
@@ -332,7 +327,7 @@ export async function handleForm(request: Request, ctx: FormContext): Promise<Re
|
|
|
332
327
|
let email;
|
|
333
328
|
try {
|
|
334
329
|
const settings = await (ctx.readSettings ?? (() => readFormSettings(ctx.site.root)))();
|
|
335
|
-
const siteUrl = publicOrigin(ctx.astroSite
|
|
330
|
+
const siteUrl = publicOrigin(ctx.astroSite, env);
|
|
336
331
|
email = await sendNotification({ submission, form, settings, siteUrl, timeZone: ctx.site.timeZone, env, fetch: ctx.fetch, now, log });
|
|
337
332
|
} catch (error) {
|
|
338
333
|
email = { status: 'failed' as const, at: now().toISOString(), to: [], error: `Could not read Site Settings: ${error instanceof Error ? error.message : String(error)}` };
|
package/src/head.ts
CHANGED
|
@@ -104,12 +104,20 @@ export function googleTagSnippet(tag: NonNullable<Head['googleTag']>): string {
|
|
|
104
104
|
return `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(tag.id)}"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${id});</script>`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* An address served from a `build.format: 'file'` prerender (`/about.html`, Home at
|
|
109
|
+
* `/index.html`) as the page's own address (`/about`, `/`): a document's address never ends in
|
|
110
|
+
* `.html` (Urban Return port). Others are kept as they are.
|
|
111
|
+
*/
|
|
112
|
+
export const withoutHtml = (path: string): string => path.replace(/(?:\/index)?\.html$/, '') || '/';
|
|
113
|
+
|
|
107
114
|
/**
|
|
108
115
|
* PageProps' `path` (MEC trial item 2): the document's address, the same in a public render
|
|
109
116
|
* and in its preview. A preview's is `previewUrl`, its document's URL (content.ts →
|
|
110
117
|
* documentUrl; null when it has no page). A public render's is the address it's served at,
|
|
111
118
|
* decoded, with no trailing slash, and a paged listing's `/page/N` taken off (`listPage` > 1):
|
|
112
|
-
* `/journal/page/2` is `/journal`'s page 2.
|
|
119
|
+
* `/journal/page/2` is `/journal`'s page 2. A `build.format: 'file'` prerender's `.html` comes
|
|
120
|
+
* off too (`withoutHtml`).
|
|
113
121
|
*/
|
|
114
122
|
export function documentPath(input: { preview: boolean; previewUrl: string | null; pathname: string; listPage: number }): string | null {
|
|
115
123
|
if (input.preview) return input.previewUrl === null ? null : cleanPath(input.previewUrl) || '/';
|
|
@@ -120,6 +128,7 @@ export function documentPath(input: { preview: boolean; previewUrl: string | nul
|
|
|
120
128
|
// Not valid percent-encoding: kept as it came.
|
|
121
129
|
}
|
|
122
130
|
path = cleanPath(path) || '/';
|
|
131
|
+
path = withoutHtml(path);
|
|
123
132
|
if (input.listPage > 1) path = path.replace(new RegExp(`/page/${input.listPage}$`), '') || '/';
|
|
124
133
|
return path;
|
|
125
134
|
}
|
package/src/index.ts
CHANGED
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
*/
|
|
49
49
|
import type { AstroIntegration } from 'astro';
|
|
50
50
|
import { fontProviders } from 'astro/config';
|
|
51
|
-
import {
|
|
51
|
+
import { execFileSync } from 'node:child_process';
|
|
52
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
52
53
|
import { createRequire } from 'node:module';
|
|
53
54
|
import { isAbsolute, join, sep } from 'node:path';
|
|
54
55
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -59,6 +60,7 @@ import { writeSchemaFiles } from './cli/schemas.ts';
|
|
|
59
60
|
import { configFile } from './cli/site.ts';
|
|
60
61
|
import { readCodeRoutes } from './code-routes-disk.ts';
|
|
61
62
|
import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
|
|
63
|
+
import { HOST_COMMIT_VARIABLES } from './live/mode.ts';
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
export type { SiteConfig, ResolvedSite } from './config.ts';
|
|
@@ -169,6 +171,25 @@ export function siteAddress(astroSite: string | undefined, env: Record<string, s
|
|
|
169
171
|
return { url: `${url.origin}${url.pathname}`.replace(/\/+$/, ''), problem: null };
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Runs-anywhere Decision 9: the commit a build is made from, baked into the server.
|
|
176
|
+
* CARACMS_RUNNING_COMMIT or a host's commit variable at build, else `git rev-parse HEAD`
|
|
177
|
+
* when the build has a `.git` folder (Railway's and most Docker builds don't); null
|
|
178
|
+
* when neither. A value at run time wins over it (live/mode.ts → `runningCommitFrom`).
|
|
179
|
+
*/
|
|
180
|
+
export function buildCommit(root: string, env: Record<string, string | undefined> = process.env): string | null {
|
|
181
|
+
for (const name of ['CARACMS_RUNNING_COMMIT', ...HOST_COMMIT_VARIABLES]) {
|
|
182
|
+
const v = env[name]?.trim();
|
|
183
|
+
if (v) return v;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim();
|
|
187
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
172
193
|
/** Pre-M7 item 8: Site Settings' redirects, served as 301s on the public site. */
|
|
173
194
|
export const REDIRECT_MIDDLEWARE = 'redirect-middleware.ts';
|
|
174
195
|
|
|
@@ -198,10 +219,11 @@ export function contentWatchIgnores(site: Pick<ResolvedSite, 'collections' | 'ro
|
|
|
198
219
|
/**
|
|
199
220
|
* The hosts Astro may take a request's address from (`security.allowedDomains`).
|
|
200
221
|
* Without them, Astro ignores the Host header and every request reads as
|
|
201
|
-
* `localhost:<port>`. Behind
|
|
222
|
+
* `localhost:<port>`. Behind a host's proxy that breaks the same-origin checks on the
|
|
202
223
|
* sign-in form and the editor's writes, and the addresses the pages show. Taken from
|
|
203
|
-
* Astro's `site
|
|
204
|
-
* (live/
|
|
224
|
+
* Astro's `site` (SITE_URL when the config sets none: `siteAddress`), then Railway's
|
|
225
|
+
* public domain: the same chain as emailed links (live/mode.ts → `addressFrom`;
|
|
226
|
+
* runs-anywhere Decision 7). A site's own `allowedDomains` wins.
|
|
205
227
|
* Read at build time: Astro puts the list in the server's manifest.
|
|
206
228
|
*/
|
|
207
229
|
export function trustedDomains(
|
|
@@ -301,8 +323,23 @@ async function loadSiteEnv(mode: string, root: string): Promise<Record<string, s
|
|
|
301
323
|
return loadEnv(mode, root, '');
|
|
302
324
|
}
|
|
303
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Runs-anywhere Decisions 7 and 9: what a build baked in, also written beside the server's
|
|
328
|
+
* entry (`dist/server/caracms-build.json`) for `caracms start`'s gate, which can't import
|
|
329
|
+
* the site: `{ "site": "https://fernhill.ie" | null, "commit": "<sha>" | null }`. `site` is
|
|
330
|
+
* Astro's `site` as built (the config's, else SITE_URL's: `siteAddress`); `commit` is
|
|
331
|
+
* `buildCommit()`'s.
|
|
332
|
+
*/
|
|
333
|
+
export const BUILD_INFO_FILE = 'caracms-build.json';
|
|
334
|
+
export interface BuildInfo {
|
|
335
|
+
site: string | null;
|
|
336
|
+
commit: string | null;
|
|
337
|
+
}
|
|
338
|
+
|
|
304
339
|
export default function caracms(config: SiteConfig): AstroIntegration {
|
|
305
340
|
let site: ResolvedSite;
|
|
341
|
+
let buildInfo: BuildInfo | null = null;
|
|
342
|
+
let serverDir: URL | null = null;
|
|
306
343
|
return {
|
|
307
344
|
name: 'caracms',
|
|
308
345
|
hooks: {
|
|
@@ -369,8 +406,10 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
369
406
|
const domains = trustedDomains(astroConfig.site ?? address.url ?? undefined, astroConfig.security?.allowedDomains);
|
|
370
407
|
if (command === 'build' && !astroConfig.security?.allowedDomains?.length) {
|
|
371
408
|
if (domains.length) logger.info(`trusting the Host header for ${domains.map((d) => d.hostname).join(', ')}`);
|
|
372
|
-
else logger.warn("the site's public address isn't known (set
|
|
409
|
+
else logger.warn("the site's public address isn't known (set SITE_URL, at build and at run time): behind a proxy, sign-in and the editor's saves will be refused as cross-origin.");
|
|
373
410
|
}
|
|
411
|
+
const commit = dev ? null : buildCommit(site.root);
|
|
412
|
+
if (command === 'build') buildInfo = { site: address.url, commit };
|
|
374
413
|
|
|
375
414
|
updateConfig({
|
|
376
415
|
...(!astroConfig.site && address.url ? { site: address.url } : {}),
|
|
@@ -382,6 +421,9 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
382
421
|
define: {
|
|
383
422
|
__CARACMS_HARFBUZZ__: JSON.stringify(harfbuzz),
|
|
384
423
|
__CARACMS_PRODUCTION__: JSON.stringify(!dev),
|
|
424
|
+
// Runs-anywhere Decisions 7 and 9: the address and the commit the build was made with (editor/server/mode.ts → bakedAtBuild).
|
|
425
|
+
__CARACMS_SITE__: JSON.stringify(astroConfig.site ?? address.url ?? null),
|
|
426
|
+
__CARACMS_BUILD_COMMIT__: JSON.stringify(commit),
|
|
385
427
|
__CARACMS_TOKENS_CSS__: JSON.stringify(readFileSync(new URL('./editor/styles/tokens.css', import.meta.url), 'utf8')),
|
|
386
428
|
},
|
|
387
429
|
// Installed from npm, the package ships TypeScript and .astro source.
|
|
@@ -425,8 +467,12 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
425
467
|
},
|
|
426
468
|
// Posts and tags, Decision 15: a production build reads each document once (read.ts). Never in dev.
|
|
427
469
|
'astro:build:start': () => shareBuildReads(true),
|
|
428
|
-
'astro:build:done': () =>
|
|
429
|
-
|
|
470
|
+
'astro:build:done': () => {
|
|
471
|
+
shareBuildReads(false);
|
|
472
|
+
if (buildInfo && serverDir && existsSync(serverDir)) writeFileSync(new URL(BUILD_INFO_FILE, serverDir), `${JSON.stringify(buildInfo)}\n`);
|
|
473
|
+
},
|
|
474
|
+
'astro:config:done': ({ injectTypes, config: done }) => {
|
|
475
|
+
serverDir = done.build.server;
|
|
430
476
|
injectTypes({ filename: 'caracms.d.ts', content: '/// <reference types="caracms/virtual" />\n' });
|
|
431
477
|
},
|
|
432
478
|
},
|
package/src/live/README.md
CHANGED
|
@@ -13,13 +13,15 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
13
13
|
| `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
|
|
14
14
|
| `messages.ts` | The commit-message contract (`draftMessage`, `mergeMessage`, `publishMessage`, `parseMessage`), `Person`, `displayName` |
|
|
15
15
|
| `status.ts` | Pure: `waitingFromCommits()`, `publishState()`, `PublishRecord` |
|
|
16
|
-
| `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`) |
|
|
16
|
+
| `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`). Redis diet: `batch()`, a pipeline sent again whole only when every command in it is safe to send twice |
|
|
17
|
+
| `status-cache.ts` | Redis diet. `StatusCache` (one per process, `git.statusCache`): what the status poll reads, by Redis key, re-read once 30 s old, refreshes single-flight; the modules that write Redis tell it what landed (`string`, `hash`, `add`, `drop`, `landed`) |
|
|
18
|
+
| `notices.ts` | Redis diet. `Notices`: "published as it stood" notices and dismissed "draft kept" ones, written through the status cache |
|
|
17
19
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
18
20
|
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
19
21
|
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
20
22
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
21
23
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
22
|
-
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll, lapse only once committed, releasing, Edit here; presence |
|
|
24
|
+
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
|
|
23
25
|
| `sweep.ts` | Phase 2. `Sweep`: one commit per tick for everything ready; ticks only while something is pending (no Redis call otherwise); `commitAll` fills `git.commitPending` |
|
|
24
26
|
| `drafts-store.ts` | Phase 2. The live `DraftsStore`: the copy with autosaves on top (a gone one leaves the file out); `write` takes the lock and stores the autosave. M5a Phase 3: `create` (the lock, then the new document as an autosave) and `delete` (the lock, then a gone autosave, the lock released to drop once committed), with `editor/server/create.ts`'s rules. M5a: any kind of document; Site Settings' autosave is marked `sweepAtOnce` (the sweep commits it on its next tick, no idle wait); `source()` is the copy with every autosave on top (a preview's reads) |
|
|
25
27
|
| `editor.ts` | Phase 2. `createLiveEditor(runtime)` / `liveEditor(site, settings)`: the store, sweep and locks, and the handlers for write, status, lock, publish, history, try-again, notice |
|
package/src/live/auth/auth.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface AuthOptions {
|
|
|
65
65
|
|
|
66
66
|
export interface Auth {
|
|
67
67
|
readonly secure: boolean;
|
|
68
|
+
/** The site's public origin (`AuthOptions.siteOrigin`): the mismatch sentences name it (runs-anywhere Decision 7). */
|
|
69
|
+
readonly siteOrigin: string | null;
|
|
68
70
|
readonly cookieName: string;
|
|
69
71
|
readonly whitelist: Whitelist;
|
|
70
72
|
readonly links: LinkStore;
|
|
@@ -97,6 +99,7 @@ export function createAuth(options: AuthOptions): Auth {
|
|
|
97
99
|
|
|
98
100
|
return {
|
|
99
101
|
secure: options.secure,
|
|
102
|
+
siteOrigin: options.siteOrigin,
|
|
100
103
|
cookieName,
|
|
101
104
|
whitelist,
|
|
102
105
|
links,
|
|
@@ -136,7 +139,7 @@ export function createAuth(options: AuthOptions): Auth {
|
|
|
136
139
|
const emailing = options.env['NODE_ENV'] === 'production';
|
|
137
140
|
const origin = options.siteOrigin ?? (emailing ? null : requestOrigin);
|
|
138
141
|
if (!origin) {
|
|
139
|
-
log("no sign-in link sent: the site's public address isn't known (set
|
|
142
|
+
log("no sign-in link sent: the site's public address isn't known (set SITE_URL, when the site is built and when it runs).");
|
|
140
143
|
return 'not-sent';
|
|
141
144
|
}
|
|
142
145
|
const token = await links.issue(email, next);
|
package/src/live/auth/guard.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { Person } from '../messages.ts';
|
|
24
24
|
import type { Auth, SignedOutReason } from './auth.ts';
|
|
25
|
-
import { isRead, isSameOrigin } from './request.ts';
|
|
25
|
+
import { addressMismatch, isRead, isSameOrigin } from './request.ts';
|
|
26
26
|
import { cleanNext, LOGIN_PATH, loginHref } from './session.ts';
|
|
27
27
|
|
|
28
28
|
/** Whether the guard looks at this path at all. */
|
|
@@ -93,7 +93,8 @@ export function guard(request: Request, auth: Auth): GuardResult {
|
|
|
93
93
|
const api = isApi(raw);
|
|
94
94
|
|
|
95
95
|
if (!isRead(request.method) && !isSameOrigin(request)) {
|
|
96
|
-
|
|
96
|
+
// Runs-anywhere Decision 7: the editor's own write, refused because the site's address isn't the one in use.
|
|
97
|
+
const error = addressMismatch(request, auth.siteOrigin) ?? 'This only takes requests from the editor on this site.';
|
|
97
98
|
if (api) return { kind: 'respond', response: json(403, { error, code: 'cross-origin' } satisfies ForbiddenBody) };
|
|
98
99
|
return { kind: 'respond', response: new Response(error, { status: 403, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...NO_STORE } }) };
|
|
99
100
|
}
|
package/src/live/auth/index.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
|
|
21
21
|
import { brandFrom } from '../../editor/brand.ts';
|
|
22
|
-
import type
|
|
22
|
+
import { addressFrom, type LiveSettings } from '../mode.ts';
|
|
23
23
|
import { liveRuntime } from '../runtime.ts';
|
|
24
24
|
import { createAuth, type Auth } from './auth.ts';
|
|
25
25
|
|
|
@@ -27,13 +27,12 @@ const SHARED = Symbol.for('caracms.auth');
|
|
|
27
27
|
type Shared = { [SHARED]?: Promise<Auth> };
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* The site's public origin for emailed links: Astro's `site`,
|
|
31
|
-
* domain
|
|
30
|
+
* The site's public origin for emailed links: Astro's `site`, then SITE_URL, then
|
|
31
|
+
* Railway's public domain (runs-anywhere Decision 7: the chain is mode.ts → `addressFrom`).
|
|
32
|
+
* Never a request's Host header (auth.ts → `siteOrigin`).
|
|
32
33
|
*/
|
|
33
34
|
export function publicOrigin(astroSite: URL | undefined, env: Record<string, string | undefined> = process.env): string | null {
|
|
34
|
-
|
|
35
|
-
const domain = env['RAILWAY_PUBLIC_DOMAIN']?.trim();
|
|
36
|
-
return domain ? `https://${domain}` : null;
|
|
35
|
+
return addressFrom(env, astroSite?.href);
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/** The process's Auth, built on first use over the shared live runtime. */
|
|
@@ -50,7 +49,7 @@ export function authFor(site: ResolvedSite, settings: LiveSettings, astroSite?:
|
|
|
50
49
|
readSettings: () => rt.git.read(SITE_SETTINGS_FILE),
|
|
51
50
|
// M5a Decision 10: Site Settings' brand name as committed in `drafts`.
|
|
52
51
|
siteName: () => brandFrom(rt.git.read(SITE_SETTINGS_FILE), site).name,
|
|
53
|
-
siteOrigin: publicOrigin(astroSite),
|
|
52
|
+
siteOrigin: settings.address ?? publicOrigin(astroSite),
|
|
54
53
|
env: process.env,
|
|
55
54
|
now: rt.now,
|
|
56
55
|
}),
|
package/src/live/auth/login.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
*/
|
|
38
38
|
import type { Auth } from './auth.ts';
|
|
39
39
|
import { normaliseEmail } from './allow.ts';
|
|
40
|
-
import { clientIp, isRead, isSameOrigin } from './request.ts';
|
|
40
|
+
import { addressMismatch, addressNotice, clientIp, isRead, isSameOrigin, NO_GATE_MESSAGE } from './request.ts';
|
|
41
41
|
import { backupPerson, cleanNext, DEFAULT_NEXT, LOGIN_PATH, loginHref, safeEqual } from './session.ts';
|
|
42
42
|
import {
|
|
43
43
|
backupView,
|
|
@@ -115,8 +115,17 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
115
115
|
extra.Allow = allow;
|
|
116
116
|
return page(405, 'Not allowed', messageView('Not allowed', 'Use the form on the sign-in page.'));
|
|
117
117
|
};
|
|
118
|
-
|
|
118
|
+
// Runs-anywhere Decision 7: refused because the site's address isn't the one in use, the sentence says so, naming SITE_URL.
|
|
119
|
+
const mismatch = () => addressMismatch(request, auth.siteOrigin);
|
|
120
|
+
const crossOrigin = () => page(403, 'Not allowed', messageView('Not allowed', mismatch() ?? 'Send the form from the sign-in page.'));
|
|
119
121
|
const wantsJson = (request.headers.get('accept') ?? '').includes('application/json');
|
|
122
|
+
// Runs-anywhere Decision 8: on a production server (`auth.secure` is production), the IP
|
|
123
|
+
// comes from `caracms start`'s gate alone; without it, no sign-in at all (acceptance check 17).
|
|
124
|
+
const ip = clientIp(request, ctx.clientAddress, auth.secure);
|
|
125
|
+
if (ip === null) {
|
|
126
|
+
if (pathname === SIGNIN_PATH || wantsJson) return json(503, { error: NO_GATE_MESSAGE, reason: 'failed' });
|
|
127
|
+
return page(503, 'Sign-in is off', messageView('Sign-in is off', NO_GATE_MESSAGE));
|
|
128
|
+
}
|
|
120
129
|
|
|
121
130
|
try {
|
|
122
131
|
// ────────────────────────────────────────────────────────────── confirm
|
|
@@ -138,7 +147,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
138
147
|
// ─────────────────────────────────────────────────────────────── signin
|
|
139
148
|
if (pathname === SIGNIN_PATH) {
|
|
140
149
|
if (method !== 'POST') return notAllowed('POST');
|
|
141
|
-
if (!isSameOrigin(request)) return json(403, { error: 'Sign in from the sign-in page.', reason: 'failed' });
|
|
150
|
+
if (!isSameOrigin(request)) return json(403, { error: mismatch() ?? 'Sign in from the sign-in page.', reason: 'failed' });
|
|
142
151
|
let body: Record<string, unknown>;
|
|
143
152
|
try {
|
|
144
153
|
const parsed: unknown = JSON.parse(await readBody(request));
|
|
@@ -161,7 +170,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
161
170
|
// Every attempt counts, the right password included, so the limit can't confirm a guess.
|
|
162
171
|
let over = true;
|
|
163
172
|
try {
|
|
164
|
-
over = await auth.limits.backupAttemptOver(
|
|
173
|
+
over = await auth.limits.backupAttemptOver(ip);
|
|
165
174
|
} catch (error) {
|
|
166
175
|
auth.log(`could not count a backup sign-in attempt, so it was refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
167
176
|
}
|
|
@@ -179,7 +188,7 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
179
188
|
// ────────────────────────────────────────────────────────────── signout
|
|
180
189
|
if (pathname === SIGNOUT_PATH) {
|
|
181
190
|
if (method !== 'POST') return notAllowed('POST');
|
|
182
|
-
if (!isSameOrigin(request)) return wantsJson ? json(403, { error: 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
|
|
191
|
+
if (!isSameOrigin(request)) return wantsJson ? json(403, { error: mismatch() ?? 'Sign out from the editor.', reason: 'cross-origin' }) : crossOrigin();
|
|
183
192
|
const location = loginHref({ signedout: '1' });
|
|
184
193
|
return wantsJson ? json(200, { ok: true, location }, auth.clearCookie()) : redirect(location, auth.clearCookie());
|
|
185
194
|
}
|
|
@@ -199,8 +208,9 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
199
208
|
const showBackup = query.get('backup') === '1' && auth.backup.enabled;
|
|
200
209
|
if (isRead(method)) {
|
|
201
210
|
const next = cleanNext(query.get('next'));
|
|
202
|
-
|
|
203
|
-
return page(200, '
|
|
211
|
+
const notice = addressNotice(request, auth.siteOrigin);
|
|
212
|
+
if (showBackup) return page(200, 'Backup account', backupView({ next, notice }), { script: true });
|
|
213
|
+
return page(200, 'Sign in', emailView({ next, signedOut: query.get('signedout') === '1', backupEnabled: auth.backup.enabled, notice }));
|
|
204
214
|
}
|
|
205
215
|
if (method !== 'POST') return notAllowed('GET, HEAD, POST');
|
|
206
216
|
if (!isSameOrigin(request)) return crossOrigin();
|
|
@@ -221,7 +231,6 @@ export async function handleLogin(request: Request, ctx: LoginContext): Promise<
|
|
|
221
231
|
}
|
|
222
232
|
|
|
223
233
|
// Every address counts against the limits, allowed or not, so the time taken is the same.
|
|
224
|
-
const ip = clientIp(request, ctx.clientAddress);
|
|
225
234
|
let over = true;
|
|
226
235
|
try {
|
|
227
236
|
over = await auth.limits.linkRequestOver(email, ip);
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* even building the runtime, so the public site never waits on GitHub or Redis.
|
|
9
9
|
* A guarded path that passes records the person (`setPerson`) for the route.
|
|
10
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.
|
|
11
|
+
* answers first); if it does, a guarded path gets a 503 rather than through. The
|
|
12
|
+
* public form endpoint's POST is the exception: Phase 2 lets it through, and so does this, since
|
|
13
|
+
* a site's forms work without the editor (M6 Decision 5; the Urban Return port found it
|
|
14
|
+
* answering 503 here until the GitHub App's variables were set). A GET to it is as
|
|
15
|
+
* unavailable as any other guarded path, as live mode's let-through is POST-only too.
|
|
12
16
|
*
|
|
13
17
|
* The mode is Phase 2's `editorMode()`, so the two agree on live and local.
|
|
14
18
|
*/
|
|
@@ -16,7 +20,7 @@ import type { MiddlewareHandler } from 'astro';
|
|
|
16
20
|
import { site } from 'virtual:caracms/config';
|
|
17
21
|
import { editorMode } from '../../editor/server/mode.ts';
|
|
18
22
|
import { setPerson } from '../locals.ts';
|
|
19
|
-
import { guard, isGuardedPath } from './guard.ts';
|
|
23
|
+
import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
|
|
20
24
|
import { authFor } from './index.ts';
|
|
21
25
|
|
|
22
26
|
const unavailable = (text: string) => new Response(text, { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'private, no-store' } });
|
|
@@ -25,7 +29,7 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
|
25
29
|
if (!isGuardedPath(context.url.pathname)) return next();
|
|
26
30
|
const mode = editorMode();
|
|
27
31
|
if (mode.kind === 'local') return next();
|
|
28
|
-
if (mode.kind !== 'live') return unavailable("The editor isn't set up on this server yet.");
|
|
32
|
+
if (mode.kind !== 'live') return isFormEndpoint(context.url.pathname) && context.request.method === 'POST' ? next() : unavailable("The editor isn't set up on this server yet.");
|
|
29
33
|
|
|
30
34
|
let auth;
|
|
31
35
|
try {
|
package/src/live/auth/request.ts
CHANGED
|
@@ -6,20 +6,53 @@
|
|
|
6
6
|
const first = (value: string | null) => (value ?? '').split(',')[0]!.trim();
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
9
|
+
* The header `caracms start`'s gate sets on every request, replacing any copy the request
|
|
10
|
+
* brought (runs-anywhere Decision 8, the spec's Contracts): the visitor's IP, decided from
|
|
11
|
+
* the one source `CARACMS_CLIENT_IP_HEADER` names.
|
|
12
|
+
*/
|
|
13
|
+
export const CLIENT_IP_HEADER = 'x-caracms-client-ip';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The mark `caracms start` puts on its own process before importing the built site, which
|
|
17
|
+
* runs in that same process (`bin/gate.mjs` → `GATED_MARK`, `markProcessGated()`):
|
|
18
|
+
* `globalThis[Symbol.for('caracms.gated')] === true`. It's what makes the header above
|
|
19
|
+
* trustworthy: with the mark there, every request reached the site through the gate, which
|
|
20
|
+
* replaced any copy the visitor sent. Without it (`node dist/server/entry.mjs`), nothing
|
|
21
|
+
* replaced it, so the header is the visitor's to choose and is never read.
|
|
22
|
+
*/
|
|
23
|
+
export const GATED_MARK = Symbol.for('caracms.gated');
|
|
24
|
+
|
|
25
|
+
/** Whether this process was started by `caracms start` (the mark above). */
|
|
26
|
+
export const isGatedProcess = (): boolean => (globalThis as Record<symbol, unknown>)[GATED_MARK] === true;
|
|
27
|
+
|
|
28
|
+
/** The 503 on sign-in, forms and downloads of a production server with no gate (Decision 8; acceptance check 17). */
|
|
29
|
+
export const NO_GATE_MESSAGE =
|
|
30
|
+
"This site wasn't started with `caracms start`, so it can't tell its visitors apart for the limits on signing in, forms and downloads. A developer can start it with `caracms start` (the site's start script).";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The client's IP, which keys the per-IP rate limits (sign-in, forms, downloads).
|
|
11
34
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
35
|
+
* **In production, only `x-caracms-client-ip`**, which `caracms start`'s gate sets on
|
|
36
|
+
* every request from the one trusted source, and only in a process the gate marked
|
|
37
|
+
* (`GATED_MARK`). Null when the mark is missing, whatever the request carries: the server
|
|
38
|
+
* wasn't started by `caracms start`, so a header there is one the visitor sent. Null too
|
|
39
|
+
* when the header is absent. Either way the caller answers 503 with `NO_GATE_MESSAGE`.
|
|
40
|
+
* Nothing else on the request is trusted there: Astro's Node adapter reports the first
|
|
41
|
+
* `X-Forwarded-For` entry as `clientAddress` whenever the Host is trusted, which caracms
|
|
42
|
+
* always arranges for its own domain, so the adapter's address is the visitor's to choose
|
|
43
|
+
* (`node_modules/astro/dist/core/app/node.js`), and `X-Real-IP` is trustworthy only behind
|
|
44
|
+
* a proxy that overwrites it.
|
|
18
45
|
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
46
|
+
* **Under `astro dev`** (no gate, and nothing to protect), as before: `X-Real-IP`, then
|
|
47
|
+
* the socket's address as the adapter gives it, then the first `X-Forwarded-For` hop.
|
|
48
|
+
* A spoofed one only rate-limits the spoofer's own requests.
|
|
21
49
|
*/
|
|
22
|
-
export function clientIp(request: Request, clientAddress: () => string | undefined): string {
|
|
50
|
+
export function clientIp(request: Request, clientAddress: () => string | undefined, production = false): string | null {
|
|
51
|
+
if (production) {
|
|
52
|
+
if (!isGatedProcess()) return null;
|
|
53
|
+
const gated = request.headers.get(CLIENT_IP_HEADER)?.trim();
|
|
54
|
+
return gated ? gated.slice(0, 64) : null;
|
|
55
|
+
}
|
|
23
56
|
const real = first(request.headers.get('x-real-ip'));
|
|
24
57
|
if (real) return real.slice(0, 64);
|
|
25
58
|
let socket: string | undefined;
|
|
@@ -53,4 +86,52 @@ export function isSameOrigin(request: Request): boolean {
|
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Runs-anywhere Decision 7: the sentence for a site reached at an address that isn't its
|
|
91
|
+
* own, naming SITE_URL. The likeliest setup mistake: SITE_URL at www while people use the
|
|
92
|
+
* apex (or the other way round), or set differently at build and at run time.
|
|
93
|
+
*/
|
|
94
|
+
export function addressMismatchMessage(reached: string, address: string | null): string {
|
|
95
|
+
return address
|
|
96
|
+
? `This site's address is set to ${address} (SITE_URL), but it was opened at ${reached}, so signing in and saving are refused here. Open it at ${address}, or set SITE_URL to ${reached} where the site is built and where it runs.`
|
|
97
|
+
: `This site's address isn't set, so signing in and saving are refused. Set SITE_URL to ${reached} where the site is built and where it runs.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The address the visitor typed (the proxy's forwarded host, else Host), when it isn't the one Astro read the request as. */
|
|
101
|
+
export function reachedElsewhere(request: Request): string | null {
|
|
102
|
+
const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
|
|
103
|
+
if (!host) return null;
|
|
104
|
+
const url = new URL(request.url);
|
|
105
|
+
if (host === url.host) return null;
|
|
106
|
+
const proto = first(request.headers.get('x-forwarded-proto')) || url.protocol.replace(':', '');
|
|
107
|
+
return `${proto}://${host}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* For a write refused by `isSameOrigin`: the mismatch sentence when the browser was on
|
|
112
|
+
* this site all along (it says `Sec-Fetch-Site: same-origin`, or its `Origin` is the
|
|
113
|
+
* request's own Host) but Astro, trusting only the site's address, read the request as
|
|
114
|
+
* another host. Null for a real cross-site request.
|
|
115
|
+
*/
|
|
116
|
+
export function addressMismatch(request: Request, address: string | null): string | null {
|
|
117
|
+
let origin: URL;
|
|
118
|
+
try {
|
|
119
|
+
origin = new URL(request.headers.get('origin') ?? '');
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (origin.host === new URL(request.url).host) return null;
|
|
124
|
+
const fetchSite = request.headers.get('sec-fetch-site');
|
|
125
|
+
const host = first(request.headers.get('x-forwarded-host')) || first(request.headers.get('host'));
|
|
126
|
+
if (fetchSite !== 'same-origin' && !(!fetchSite && origin.host === host)) return null;
|
|
127
|
+
return addressMismatchMessage(origin.origin, address);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** On a page with no `Origin` (the sign-in form's GET): the sentence when the site is opened at an address that isn't its own. */
|
|
131
|
+
export function addressNotice(request: Request, address: string | null): string | null {
|
|
132
|
+
const reached = reachedElsewhere(request);
|
|
133
|
+
if (!reached || (address && new URL(address).host === new URL(reached).host)) return null;
|
|
134
|
+
return addressMismatchMessage(reached, address);
|
|
135
|
+
}
|
|
136
|
+
|
|
56
137
|
export const isRead = (method: string) => method === 'GET' || method === 'HEAD';
|
package/src/live/auth/views.ts
CHANGED
|
@@ -301,9 +301,9 @@ const FINE = html`<p class="fine">Not you? Close this tab.</p>`;
|
|
|
301
301
|
const backupLink = (next: string) => html`<p class="si-alt"><a class="linkbtn" href="${loginHref({ backup: '1' }, next)}">Use the backup account</a></p>`;
|
|
302
302
|
|
|
303
303
|
/** The email form: plain, signed out, or with the malformed-address error. */
|
|
304
|
-
export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean }): SafeHtml {
|
|
304
|
+
export function emailView(input: { next: string; value?: string; invalid?: string; signedOut?: boolean; backupEnabled: boolean; notice?: string | null }): SafeHtml {
|
|
305
305
|
const bad = !!input.invalid;
|
|
306
|
-
return html`<section class="si-card">${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
|
|
306
|
+
return html`<section class="si-card">${addressNoticeHtml(input.notice)}${input.signedOut ? html`<p class="notice info" role="status">${ic('check', 'i-sm')}You're signed out.</p>` : ''}<h1>Sign in</h1><p class="sub">We'll email you a sign-in link.</p><form method="post" action="${LOGIN_PATH}" novalidate><input type="hidden" name="next" value="${input.next}"><div class="field"><label class="field-label" for="si-email">Email address</label><input class="input" id="si-email" name="email" type="email" autocomplete="email" inputmode="email" spellcheck="false" autofocus value="${input.value ?? ''}"${new SafeHtml(bad ? ' aria-invalid="true" aria-describedby="si-err"' : '')}>${
|
|
307
307
|
bad ? html`<p class="field-err" id="si-err">${ic('warn', 'i-sm')}<span>${input.invalid}</span></p>` : ''
|
|
308
308
|
}</div><button class="btn accent block" type="submit">Send sign-in link</button></form></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
|
|
309
309
|
}
|
|
@@ -317,9 +317,14 @@ export function sentView(input: { email: string; next: string; again: boolean; b
|
|
|
317
317
|
return html`<section class="si-card">${input.again ? html`<p class="notice ok" role="status">${ic('check', 'i-sm')}Sent again.</p>` : ''}<h1>Check your email</h1><p>If <strong>${input.email}</strong> can sign in, a link is on its way. It expires in 15 minutes.</p><p class="fine">Not there? Check spam, or send again.</p><div class="row-acts"><form method="post" action="${LOGIN_PATH}"><input type="hidden" name="email" value="${input.email}"><input type="hidden" name="next" value="${input.next}"><input type="hidden" name="again" value="1"><button class="btn" type="submit">Send again</button></form><a class="linkbtn" href="${loginHref({}, input.next)}">Use a different address</a></div></section>${input.backupEnabled ? backupLink(input.next) : ''}`;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/** Runs-anywhere Decision 7: the site was opened at an address that isn't its own (request.ts → `addressNotice`). */
|
|
321
|
+
function addressNoticeHtml(notice: string | null | undefined): SafeHtml | string {
|
|
322
|
+
return notice ? html`<p class="notice fail" role="alert" data-notice="address">${ic('warn', 'i-sm')}${notice}</p>` : '';
|
|
323
|
+
}
|
|
324
|
+
|
|
320
325
|
/** The backup form. Posting it without script only shows it again. */
|
|
321
|
-
export function backupView(input: { next: string }): SafeHtml {
|
|
322
|
-
return html`<section class="si-card"
|
|
326
|
+
export function backupView(input: { next: string; notice?: string | null }): SafeHtml {
|
|
327
|
+
return html`<section class="si-card">${addressNoticeHtml(input.notice)}<p class="notice fail" id="signin-error" role="alert" hidden></p><h1>Backup account</h1><p class="sub">Details are in the shared password manager.</p>${NOSCRIPT}<form method="post" action="${loginHref({ backup: '1' }, input.next)}" data-signin="backup" data-next="${input.next}"><div class="field"><label class="field-label" for="si-user">Username</label><input class="input" id="si-user" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus required></div><div class="field"><label class="field-label" for="si-pass">Password</label><input class="input" id="si-pass" name="password" type="password" autocomplete="current-password" required></div><button class="btn accent block" type="submit">Sign in</button></form></section><p class="si-alt"><a class="linkbtn" href="${loginHref({}, input.next)}">Use email sign-in instead</a></p>`;
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
/**
|