caracms 0.1.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 +65 -20
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +17 -3
- package/skills/caracms-adopt/look-same.mjs +24 -11
- package/skills/caracms-blocks/SKILL.md +69 -4
- package/skills/caracms-content/SKILL.md +2 -1
- package/skills/caracms-tina/SKILL.md +431 -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/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +4 -2
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +28 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -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/editor.css +2 -0
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +27 -6
- package/src/fields.ts +63 -15
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +17 -4
- package/src/index.ts +53 -7
- package/src/links.ts +35 -0
- package/src/live/README.md +5 -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 +54 -24
- package/src/live/branches.ts +19 -0
- 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 +177 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +15 -4
- 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 +40 -7
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -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/forms/rules.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface FormFieldConfig {
|
|
|
43
43
|
* Plain text, trimmed; blank is the same as leaving it out.
|
|
44
44
|
*/
|
|
45
45
|
prefix?: string;
|
|
46
|
+
/** A textarea's height in lines (`rows`), a whole number from 1 to 50. Default 6. Textarea only. */
|
|
47
|
+
rows?: number;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** One form (Contracts → The forms config). Its key is its id: stored on submissions, keys `formRecipients`; never renamed once live. */
|
|
@@ -66,6 +68,7 @@ export interface ResolvedFormField {
|
|
|
66
68
|
error?: string;
|
|
67
69
|
placeholder?: string;
|
|
68
70
|
prefix?: string;
|
|
71
|
+
rows?: number;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** A form as the package uses it: its fields as a list, in order, each with its type. Plain JSON. */
|
|
@@ -122,7 +125,7 @@ export const FILE_ACCEPT = Object.keys(FILE_TYPES)
|
|
|
122
125
|
.join(',');
|
|
123
126
|
|
|
124
127
|
/** The types that take a placeholder: the ones a visitor types into. */
|
|
125
|
-
const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
|
|
128
|
+
export const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
|
|
126
129
|
/** The types that take a prefix: a one-line box where a fixed lead-in reads naturally ("$", "+353"). */
|
|
127
130
|
const PREFIX_TYPES: readonly FormFieldType[] = ['text', 'tel'];
|
|
128
131
|
|
|
@@ -188,6 +191,10 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
|
|
|
188
191
|
if (placeholder && !PLACEHOLDER_TYPES.includes(type)) fail(`${at}: a ${type} field has no box to type in, so it can't take a placeholder (only ${PLACEHOLDER_TYPES.join(', ')} can).`);
|
|
189
192
|
const prefix = field.prefix?.trim() ?? '';
|
|
190
193
|
if (prefix && !PREFIX_TYPES.includes(type)) fail(`${at}: only a text or tel field takes a prefix (this one is ${type}).`);
|
|
194
|
+
if (field.rows !== undefined) {
|
|
195
|
+
if (type !== 'textarea') fail(`${at}: only a textarea takes rows (this one is ${type}).`);
|
|
196
|
+
if (!Number.isInteger(field.rows) || field.rows < 1 || field.rows > 50) fail(`${at}: rows must be a whole number from 1 to 50 (got ${JSON.stringify(field.rows)}).`);
|
|
197
|
+
}
|
|
191
198
|
fields.push({
|
|
192
199
|
name,
|
|
193
200
|
label: field.label!.trim(),
|
|
@@ -199,6 +206,7 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
|
|
|
199
206
|
...(field.error ? { error: field.error } : {}),
|
|
200
207
|
...(placeholder ? { placeholder } : {}),
|
|
201
208
|
...(prefix ? { prefix } : {}),
|
|
209
|
+
...(field.rows !== undefined ? { rows: field.rows } : {}),
|
|
202
210
|
});
|
|
203
211
|
}
|
|
204
212
|
const email = fields.find((f) => f.name === 'email');
|
package/src/head.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - **Title:** the page's `seo.title` as written; otherwise, for the home page, Site
|
|
6
6
|
* Settings' `seo.title`, then the brand's name; otherwise `<document title> · <brand name>`,
|
|
7
|
-
* where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2).
|
|
7
|
+
* where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2). With the
|
|
8
|
+
* config's `titleFallback: 'site'` (M9), every page falls back as the home page does.
|
|
8
9
|
* - **Description and share image** fall back from the page's `seo` to Site Settings'.
|
|
9
10
|
* - **Canonical link and `og:url`** only with the site's address (`SITE_URL`), and never
|
|
10
11
|
* in a preview.
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* - **The Google tag** is an ID, never a script: `GTM-…` loads Tag Manager, `G-…` loads
|
|
14
15
|
* gtag.js, anything else nothing. Only in a production build, never in a preview.
|
|
15
16
|
*/
|
|
16
|
-
import { DEFAULT_TITLE_SEPARATOR } from './config.ts';
|
|
17
|
+
import { DEFAULT_TITLE_SEPARATOR, type TitleFallback } from './config.ts';
|
|
17
18
|
import { parseSettingsBrand, type PageDocument } from './document.ts';
|
|
18
19
|
import { imageUrl, isResizable, type MediaConfig } from './media.ts';
|
|
19
20
|
import { googleTagKind, parseGoogleTag, parseSeo, SHARE_IMAGE, type Seo, type SeoImage } from './seo.ts';
|
|
@@ -33,6 +34,8 @@ export interface HeadInput {
|
|
|
33
34
|
listPage?: number;
|
|
34
35
|
/** The config's `titleSeparator` (ResolvedSite), spaces included; ` · ` when left out. */
|
|
35
36
|
titleSeparator?: string;
|
|
37
|
+
/** The config's `titleFallback` (ResolvedSite); `'page-and-brand'` when left out. */
|
|
38
|
+
titleFallback?: TitleFallback;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export interface Head {
|
|
@@ -73,7 +76,8 @@ export function headOf(input: HeadInput): Head {
|
|
|
73
76
|
const docTitle = document.title.trim();
|
|
74
77
|
// Decision 16: " — page N" straight after the page's own part, before the brand, so ten pages don't share one title.
|
|
75
78
|
const paged = (text: string) => (input.listPage && input.listPage > 1 && text ? `${text} — page ${input.listPage}` : text);
|
|
76
|
-
const
|
|
79
|
+
const siteWide = home || input.titleFallback === 'site';
|
|
80
|
+
const fallback = siteWide ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
|
|
77
81
|
const title = paged(own.title ?? '') || fallback;
|
|
78
82
|
const imageValue = own.image ?? settings.seo.image ?? null;
|
|
79
83
|
const image = imageValue ? shareImage(imageValue, input.media) : null;
|
|
@@ -100,12 +104,20 @@ export function googleTagSnippet(tag: NonNullable<Head['googleTag']>): string {
|
|
|
100
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>`;
|
|
101
105
|
}
|
|
102
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
|
+
|
|
103
114
|
/**
|
|
104
115
|
* PageProps' `path` (MEC trial item 2): the document's address, the same in a public render
|
|
105
116
|
* and in its preview. A preview's is `previewUrl`, its document's URL (content.ts →
|
|
106
117
|
* documentUrl; null when it has no page). A public render's is the address it's served at,
|
|
107
118
|
* decoded, with no trailing slash, and a paged listing's `/page/N` taken off (`listPage` > 1):
|
|
108
|
-
* `/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`).
|
|
109
121
|
*/
|
|
110
122
|
export function documentPath(input: { preview: boolean; previewUrl: string | null; pathname: string; listPage: number }): string | null {
|
|
111
123
|
if (input.preview) return input.previewUrl === null ? null : cleanPath(input.previewUrl) || '/';
|
|
@@ -116,6 +128,7 @@ export function documentPath(input: { preview: boolean; previewUrl: string | nul
|
|
|
116
128
|
// Not valid percent-encoding: kept as it came.
|
|
117
129
|
}
|
|
118
130
|
path = cleanPath(path) || '/';
|
|
131
|
+
path = withoutHtml(path);
|
|
119
132
|
if (input.listPage > 1) path = path.replace(new RegExp(`/page/${input.listPage}$`), '') || '/';
|
|
120
133
|
return path;
|
|
121
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/links.ts
CHANGED
|
@@ -76,6 +76,41 @@ export function normaliseAddress(url: string): string {
|
|
|
76
76
|
* `/`, `/about`, `/journal/my-first-post`), from the store's listing (Phase 2
|
|
77
77
|
* wires it). Never an error: a warning the field shows.
|
|
78
78
|
*/
|
|
79
|
+
/** M9 Decision 7: the hidden pages in a listing, as `WarningContext.hiddenPages` takes them (address → name). */
|
|
80
|
+
export function hiddenPages(entries: Iterable<{ url: string | null; label: string; hidden?: true }>): Map<string, string> {
|
|
81
|
+
const out = new Map<string, string>();
|
|
82
|
+
for (const e of entries) if (e.hidden && e.url !== null) out.set(normaliseAddress(e.url), e.label);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** M9 Decision 7: the hidden documents in a listing, as `WarningContext.hiddenDocuments` takes them (`collection/path` → name and address). */
|
|
87
|
+
export function hiddenDocuments(entries: Iterable<{ collection: string; path: string; url: string | null; label: string; hidden?: true }>): Map<string, { label: string; url: string | null }> {
|
|
88
|
+
const out = new Map<string, { label: string; url: string | null }>();
|
|
89
|
+
for (const e of entries) if (e.hidden) out.set(`${e.collection}/${e.path}`, { label: e.label, url: e.url === null ? null : normaliseAddress(e.url) });
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* M9 Decision 7's warning for a reference (a `reference()` field) to a hidden page, worded as
|
|
95
|
+
* the link one is. Never an error: unhiding the page fixes it.
|
|
96
|
+
*/
|
|
97
|
+
export function hiddenReferenceTarget(collection: string, path: string, hidden: ReadonlyMap<string, { label: string; url: string | null }>): string | undefined {
|
|
98
|
+
const doc = hidden.get(`${collection}/${path}`);
|
|
99
|
+
if (!doc) return undefined;
|
|
100
|
+
return `Points at ${doc.url ?? path}, the ${doc.label || path} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* M9 Decision 7's warning: a link to a hidden page. `hidden` maps a hidden page's address
|
|
105
|
+
* (normalised) to its name. Never an error: unhiding the page fixes it.
|
|
106
|
+
*/
|
|
107
|
+
export function hiddenLinkTarget(href: unknown, hidden: ReadonlyMap<string, string>): string | undefined {
|
|
108
|
+
const path = internalLinkPath(href);
|
|
109
|
+
if (path === null) return undefined;
|
|
110
|
+
const name = hidden.get(path);
|
|
111
|
+
return name === undefined ? undefined : `Links to ${path}, the ${name} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
79
114
|
export function missingLinkTarget(href: unknown, addresses: Iterable<string | null | undefined>): string | undefined {
|
|
80
115
|
const path = internalLinkPath(href);
|
|
81
116
|
if (path === null) return undefined;
|
package/src/live/README.md
CHANGED
|
@@ -13,12 +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` |
|
|
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 |
|
|
18
21
|
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
19
22
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
20
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 }`) |
|
|
21
|
-
| `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 |
|
|
22
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` |
|
|
23
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) |
|
|
24
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 {
|