caracms 0.3.3 → 0.3.4
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/bin/caracms.mjs +30 -6
- package/bin/gate.mjs +157 -6
- package/package.json +1 -1
- package/src/redirect-middleware.ts +3 -0
package/bin/caracms.mjs
CHANGED
|
@@ -12,20 +12,22 @@
|
|
|
12
12
|
// The Node adapter's standalone server sends prerendered pages and static files
|
|
13
13
|
// before Astro's middleware runs, so a password or a `noindex` header can't be added
|
|
14
14
|
// there. This serves the adapter's own handler (static files, then Astro) behind the
|
|
15
|
-
// gate (gate.mjs): robots.txt, the pre-launch password,
|
|
15
|
+
// gate (gate.mjs): robots.txt, the pre-launch password, `X-Robots-Tag`, a page beside a
|
|
16
|
+
// folder of the same name, then Site Settings' redirects.
|
|
16
17
|
//
|
|
17
18
|
// It runs from the site's root (the directory with `dist/` and `content/`) and reads,
|
|
18
19
|
// once at start:
|
|
19
20
|
// PORT (default 8080), HOST (default 0.0.0.0), the site's address (Astro's `site` from
|
|
20
21
|
// dist/server/caracms-build.json, else SITE_URL, else RAILWAY_PUBLIC_DOMAIN),
|
|
21
22
|
// CARACMS_PRELAUNCH (`user:password`), CARACMS_CLIENT_IP_HEADER (else the detected
|
|
22
|
-
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
23
|
+
// host's default; with neither, it exits with the fix), and content/site-settings.json →
|
|
24
|
+
// brand.name and redirects.
|
|
23
25
|
|
|
24
26
|
import { existsSync, readFileSync } from 'node:fs';
|
|
25
27
|
import { createServer } from 'node:http';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { pathToFileURL } from 'node:url';
|
|
28
|
-
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource } from './gate.mjs';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
30
|
+
import { createGate, describeClientIpSource, markProcessGated, parsePrelaunch, parseSiteUrl, publicAddress, readBuildInfo, readClientIpSource, readRedirects } from './gate.mjs';
|
|
29
31
|
|
|
30
32
|
const USAGE = `Usage: caracms <command>, from the site's root
|
|
31
33
|
|
|
@@ -56,6 +58,20 @@ function readBrandName(root) {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The adapter's static files folder, as it finds it (@astrojs/node → resolveClientDir):
|
|
63
|
+
* the built server-to-client path from its options, applied to `dist/server` here.
|
|
64
|
+
* `dist/client` when the options don't say.
|
|
65
|
+
*/
|
|
66
|
+
function clientDirOf(options, root) {
|
|
67
|
+
const serverDir = join(root, 'dist/server');
|
|
68
|
+
try {
|
|
69
|
+
return resolve(serverDir, relative(fileURLToPath(new URL(options.server)), fileURLToPath(new URL(options.client))));
|
|
70
|
+
} catch {
|
|
71
|
+
return join(root, 'dist/client');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
async function start() {
|
|
60
76
|
const root = process.cwd();
|
|
61
77
|
const entry = join(root, 'dist/server/entry.mjs');
|
|
@@ -87,7 +103,15 @@ async function start() {
|
|
|
87
103
|
const publicSite = publicAddress(process.env, readBuildInfo(root));
|
|
88
104
|
const siteUrl = publicSite?.value;
|
|
89
105
|
const prelaunch = process.env.CARACMS_PRELAUNCH;
|
|
90
|
-
const gate = createGate({
|
|
106
|
+
const gate = createGate({
|
|
107
|
+
handler: built.handler,
|
|
108
|
+
clientIpSource: ipSource.source,
|
|
109
|
+
siteUrl,
|
|
110
|
+
prelaunch,
|
|
111
|
+
brandName: readBrandName(root),
|
|
112
|
+
clientDir: clientDirOf(built.options, root),
|
|
113
|
+
redirects: readRedirects(root),
|
|
114
|
+
});
|
|
91
115
|
|
|
92
116
|
const server = createServer(gate);
|
|
93
117
|
server.on('error', (err) => fatal(err.message));
|
package/bin/gate.mjs
CHANGED
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
// 2. The pre-launch password (`CARACMS_PRELAUNCH`), except for the editor and its
|
|
13
13
|
// API, `/_astro/…` and `/health`.
|
|
14
14
|
// 3. `X-Robots-Tag` on the way out, kept whatever the adapter writes.
|
|
15
|
-
// 4.
|
|
15
|
+
// 4. A page beside a folder of the same name (`services/advisory.html` and
|
|
16
|
+
// `services/advisory/`) is asked for by its file: the adapter would look in the
|
|
17
|
+
// folder for an index.html and answer 404.
|
|
18
|
+
// 5. Site Settings' redirects, on what would be a 404: a prerendered 404 page never
|
|
19
|
+
// reaches Astro's middleware (redirect-middleware.ts, which serves them in dev).
|
|
20
|
+
// 6. The adapter's own handler (static files first, then Astro).
|
|
16
21
|
|
|
17
22
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
19
24
|
import { isIP } from 'node:net';
|
|
20
|
-
import { join } from 'node:path';
|
|
25
|
+
import { join, resolve, sep } from 'node:path';
|
|
21
26
|
import { CLOUDFLARE_IPV4, CLOUDFLARE_IPV6 } from './cloudflare-ips.mjs';
|
|
22
27
|
|
|
23
28
|
/** The header the gate owns. */
|
|
@@ -199,6 +204,88 @@ export function parseSiteUrl(value) {
|
|
|
199
204
|
return { host: url.host.toLowerCase(), origin: url.origin, indexable };
|
|
200
205
|
}
|
|
201
206
|
|
|
207
|
+
// ── Pages beside a folder, and redirects ────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
const isFile = (path) => {
|
|
210
|
+
try {
|
|
211
|
+
return statSync(path).isFile();
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const isDirectory = (path) => {
|
|
217
|
+
try {
|
|
218
|
+
return statSync(path).isDirectory();
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The file to serve for a page whose address is also a folder: with `build.format:
|
|
226
|
+
* 'file'`, `/services/advisory` is `services/advisory.html`, beside a `services/advisory/`
|
|
227
|
+
* folder of the pages under it. The adapter sees the folder, looks for its index.html
|
|
228
|
+
* and answers 404, so the gate asks for the `.html` itself. Null for anything else: a
|
|
229
|
+
* path the two views don't agree on, `/`, a trailing slash, a file extension, a folder
|
|
230
|
+
* with its own index.html, or no such file under `clientDir`.
|
|
231
|
+
*/
|
|
232
|
+
export function pageBesideFolder(clientDir, views) {
|
|
233
|
+
const path = views.full;
|
|
234
|
+
if (!clientDir || path === null || views.app !== path || path === '/' || path.endsWith('/')) return null;
|
|
235
|
+
if (/\.[^/]*$/.test(path.slice(path.lastIndexOf('/')))) return null;
|
|
236
|
+
const root = resolve(clientDir);
|
|
237
|
+
const folder = resolve(root, '.' + path);
|
|
238
|
+
if (!folder.startsWith(root + sep)) return null;
|
|
239
|
+
if (!isDirectory(folder) || isFile(join(folder, 'index.html')) || !isFile(folder + '.html')) return null;
|
|
240
|
+
return path + '.html';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Site Settings' redirects, as the package reads them (src/redirects.ts; kept in step by
|
|
245
|
+
* gate.test.ts): `/path` addresses without a trailing slash; an entry that isn't two of
|
|
246
|
+
* them, goes nowhere or repeats a `from` is dropped.
|
|
247
|
+
*/
|
|
248
|
+
export function redirectAddress(value) {
|
|
249
|
+
if (typeof value !== 'string') return null;
|
|
250
|
+
const v = value.trim();
|
|
251
|
+
if (!/^\/(?!\/)[^\s?#]*$/.test(v)) return null;
|
|
252
|
+
const trimmed = v.replace(/\/+$/, '');
|
|
253
|
+
return trimmed === '' ? '/' : trimmed;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function parseRedirects(raw) {
|
|
257
|
+
if (!Array.isArray(raw)) return [];
|
|
258
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const entry of raw) {
|
|
261
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
262
|
+
const from = redirectAddress(entry.from);
|
|
263
|
+
const to = redirectAddress(entry.to);
|
|
264
|
+
if (!from || !to || same(from, to) || out.some((r) => same(r.from, from))) continue;
|
|
265
|
+
out.push({ from, to });
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Where a request for `pathname` (as sent) goes, or null: matched decoded, without a trailing slash; letter case counts. */
|
|
271
|
+
export function redirectFor(list, pathname) {
|
|
272
|
+
let path = pathname;
|
|
273
|
+
try {
|
|
274
|
+
path = decodeURI(pathname);
|
|
275
|
+
} catch {}
|
|
276
|
+
path = path.replace(/\/+$/, '') || '/';
|
|
277
|
+
return list.find((r) => r.from === path)?.to ?? null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `content/site-settings.json` → `redirects` under `root`, read once at start (the file is the build's). Empty when it's missing or unreadable. */
|
|
281
|
+
export function readRedirects(root) {
|
|
282
|
+
try {
|
|
283
|
+
return parseRedirects(JSON.parse(readFileSync(join(root, 'content/site-settings.json'), 'utf8'))?.redirects);
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
// ── The visitor's IP (Decision 8) ───────────────────────────────────────────────
|
|
203
290
|
|
|
204
291
|
/** The header the gate sets on every request; `clientIp()` (src/live/auth/request.ts) reads only this in production. */
|
|
@@ -406,9 +493,9 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest();
|
|
|
406
493
|
* The gate, as `(req, res) => void` around the adapter's `handler`.
|
|
407
494
|
* @param {{ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => unknown,
|
|
408
495
|
* clientIpSource: string, siteUrl?: string, prelaunch?: string, brandName?: string,
|
|
409
|
-
* log?: (line: string) => void }} options
|
|
496
|
+
* clientDir?: string, redirects?: { from: string, to: string }[], log?: (line: string) => void }} options
|
|
410
497
|
*/
|
|
411
|
-
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, log = (line) => console.error(line) }) {
|
|
498
|
+
export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandName, clientDir, redirects = [], log = (line) => console.error(line) }) {
|
|
412
499
|
if (!CLIENT_IP_SOURCES.includes(clientIpSource)) throw new Error(`createGate: clientIpSource must be one of ${CLIENT_IP_SOURCES.join(', ')}.`);
|
|
413
500
|
let warnedMissing = false;
|
|
414
501
|
const site = parseSiteUrl(siteUrl);
|
|
@@ -481,7 +568,24 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
481
568
|
const robots = guarded ? NOINDEX_NOFOLLOW : indexable ? null : NOINDEX;
|
|
482
569
|
if (robots) holdHeader(res, ROBOTS_HEADER, robots);
|
|
483
570
|
|
|
484
|
-
|
|
571
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
572
|
+
// 4. A page beside a folder of the same name, by its file.
|
|
573
|
+
// (As sent, plus `.html`: the views are decoded, the request target mustn't be.)
|
|
574
|
+
const file = guarded ? null : pageBesideFolder(clientDir, views);
|
|
575
|
+
const raw = rawPathOf(req.url);
|
|
576
|
+
if (file && raw !== null) {
|
|
577
|
+
req.url = raw + '.html' + req.url.slice(raw.length);
|
|
578
|
+
} else if (!guarded && redirects.length) {
|
|
579
|
+
// 5. A redirect, only where the site answers 404: a real page always wins.
|
|
580
|
+
const to = raw === null ? null : redirectFor(redirects, raw);
|
|
581
|
+
if (to) {
|
|
582
|
+
const query = req.url.indexOf('?');
|
|
583
|
+
redirectOn404(res, to + (query === -1 ? '' : req.url.slice(query)));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 6. The adapter.
|
|
485
589
|
try {
|
|
486
590
|
const result = handler(req, res);
|
|
487
591
|
if (result && typeof result.catch === 'function') result.catch((err) => fail(res, err));
|
|
@@ -492,6 +596,53 @@ export function createGate({ handler, clientIpSource, siteUrl, prelaunch, brandN
|
|
|
492
596
|
return gate;
|
|
493
597
|
}
|
|
494
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Turns the response into a 301 to `location` if the handler answers 404, however it
|
|
601
|
+
* writes it (`writeHead`, or a status and an implicit head on the first write or end).
|
|
602
|
+
* The 404's own headers and body are dropped; the held robots header stays.
|
|
603
|
+
*/
|
|
604
|
+
function redirectOn404(res, location) {
|
|
605
|
+
const writeHead = res.writeHead.bind(res);
|
|
606
|
+
const write = res.write.bind(res);
|
|
607
|
+
const end = res.end.bind(res);
|
|
608
|
+
let redirected = false;
|
|
609
|
+
const redirect = () => {
|
|
610
|
+
redirected = true;
|
|
611
|
+
for (const name of res.getHeaderNames()) res.removeHeader(name);
|
|
612
|
+
writeHead(301, { Location: location, 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
|
613
|
+
end();
|
|
614
|
+
};
|
|
615
|
+
const callback = (args) => {
|
|
616
|
+
const cb = args.find((a) => typeof a === 'function');
|
|
617
|
+
if (cb) queueMicrotask(cb);
|
|
618
|
+
};
|
|
619
|
+
const is404 = () => !res.headersSent && res.statusCode === 404;
|
|
620
|
+
res.writeHead = (status, ...rest) => {
|
|
621
|
+
if (redirected) return res;
|
|
622
|
+
if (status === 404 && !res.headersSent) {
|
|
623
|
+
redirect();
|
|
624
|
+
return res;
|
|
625
|
+
}
|
|
626
|
+
return writeHead(status, ...rest);
|
|
627
|
+
};
|
|
628
|
+
res.write = (...args) => {
|
|
629
|
+
if (!redirected && is404()) redirect();
|
|
630
|
+
if (redirected) {
|
|
631
|
+
callback(args);
|
|
632
|
+
return true;
|
|
633
|
+
}
|
|
634
|
+
return write(...args);
|
|
635
|
+
};
|
|
636
|
+
res.end = (...args) => {
|
|
637
|
+
if (!redirected && is404()) redirect();
|
|
638
|
+
if (redirected) {
|
|
639
|
+
callback(args);
|
|
640
|
+
return res;
|
|
641
|
+
}
|
|
642
|
+
return end(...args);
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
495
646
|
function fail(res, err) {
|
|
496
647
|
console.error('caracms start: the request failed:', err);
|
|
497
648
|
if (!res.headersSent) {
|
package/package.json
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* goes out with the Publish that moves the page. Re-read when the file changes
|
|
11
11
|
* (its modification time), so local edits apply at once.
|
|
12
12
|
* - The editor's own paths (`/admin`, `/_caracms/`) are never redirected.
|
|
13
|
+
*
|
|
14
|
+
* This serves them in dev. A built site's 404 page is prerendered, sent without this
|
|
15
|
+
* middleware running, so `caracms start`'s gate (bin/gate.mjs) serves them there.
|
|
13
16
|
*/
|
|
14
17
|
import { readFile, stat } from 'node:fs/promises';
|
|
15
18
|
import { join } from 'node:path';
|