@minamocms/framework 0.0.0-stage → 0.10.1
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/CHANGELOG.md +234 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { openLocalFsMediaDriver } from "../media/drivers/local-fs-driver.js";
|
|
2
|
+
import { mimeTypeFor } from "../services/mime-types.js";
|
|
3
|
+
export function resolveRange(header, size) {
|
|
4
|
+
if (header === undefined) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
8
|
+
if (!match) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const [, rawStart, rawEnd] = match;
|
|
12
|
+
if (rawStart === '' && rawEnd === '') {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
// "bytes=-500" means the LAST 500 bytes, not "from 0 to 500" - a
|
|
16
|
+
// classic misreading, and one that silently serves the wrong part of
|
|
17
|
+
// a file rather than failing.
|
|
18
|
+
if (rawStart === '') {
|
|
19
|
+
const suffixLength = Number(rawEnd);
|
|
20
|
+
if (suffixLength === 0) {
|
|
21
|
+
return 'unsatisfiable';
|
|
22
|
+
}
|
|
23
|
+
return { start: Math.max(0, size - suffixLength), end: size - 1 };
|
|
24
|
+
}
|
|
25
|
+
const start = Number(rawStart);
|
|
26
|
+
if (start >= size) {
|
|
27
|
+
return 'unsatisfiable';
|
|
28
|
+
}
|
|
29
|
+
// An end past the last byte is clamped, not rejected: browsers
|
|
30
|
+
// routinely ask for more than exists (e.g. bytes=0-999999 on a small
|
|
31
|
+
// file) and expect the server to simply return what it has.
|
|
32
|
+
const end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
33
|
+
if (end < start) {
|
|
34
|
+
return 'unsatisfiable';
|
|
35
|
+
}
|
|
36
|
+
return { start, end };
|
|
37
|
+
}
|
|
38
|
+
async function handleMediaRequest(request, reply, config) {
|
|
39
|
+
const relativePath = request.params['*'];
|
|
40
|
+
// Never read: Fastify doesn't require query params to be declared or
|
|
41
|
+
// consumed, so a Shopify-style ?width=1500 suffix
|
|
42
|
+
// (docs/cms-build-plan.md's Phase 4) is automatically inert with no
|
|
43
|
+
// code needed to "ignore" it - this route is the MVP passthrough
|
|
44
|
+
// driver, real resizing is separately-scoped future work.
|
|
45
|
+
const bytes = await openLocalFsMediaDriver(config.mediaRoot).get(relativePath);
|
|
46
|
+
if (bytes === null) {
|
|
47
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${relativePath}"` });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// nosniff: mimeTypeFor() falls back to application/octet-stream for
|
|
51
|
+
// anything outside its small known-extension table (uploads aren't
|
|
52
|
+
// restricted to that table, only to the images-only allowlist in
|
|
53
|
+
// media.ts), and without this header some browsers will still
|
|
54
|
+
// content-sniff an octet-stream response - the same stored-content
|
|
55
|
+
// risk class SVG rejection already exists to avoid for that specific
|
|
56
|
+
// case.
|
|
57
|
+
// Access-Control-Allow-Origin: * - same reasoning as assets.ts:
|
|
58
|
+
// fonts specifically enforce CORS unconditionally, and the admin's
|
|
59
|
+
// preview route proxies a site's HTML into its own origin (with a
|
|
60
|
+
// <base href> fix), making genuinely cross-origin browser requests
|
|
61
|
+
// for what looks like a same-origin path. Consistent with this
|
|
62
|
+
// route already being deliberately unauthenticated and public.
|
|
63
|
+
//
|
|
64
|
+
// Cache-Control: immutable, a full year - safe unconditionally
|
|
65
|
+
// because filenames are content-addressed (a hash of the file's own
|
|
66
|
+
// bytes, see media/filename.ts): a given URL's content can never
|
|
67
|
+
// change, so there is no invalidation case to ever design for here.
|
|
68
|
+
// Accept-Ranges on every media response, not just ranged ones - it is
|
|
69
|
+
// how a client learns ranges are available at all before asking.
|
|
70
|
+
//
|
|
71
|
+
// This matters far beyond scrubbing: Safari and iOS open a <video>
|
|
72
|
+
// with "Range: bytes=0-1" and will refuse to play at all if answered
|
|
73
|
+
// with a plain 200, so range support is what makes video work in
|
|
74
|
+
// those browsers rather than an optimisation. It also lets
|
|
75
|
+
// preload="metadata" fetch just the header instead of pulling the
|
|
76
|
+
// whole file down to read a duration.
|
|
77
|
+
//
|
|
78
|
+
// Sliced from the already-buffered read rather than streamed: the
|
|
79
|
+
// driver's own get() contract returns a whole Buffer (drivers/
|
|
80
|
+
// driver.ts) and assets.ts documents reply.send(stream) returning an
|
|
81
|
+
// empty body in this Fastify version, verified there with a minimal
|
|
82
|
+
// repro. With uploads capped (10MB by default) the memory held per
|
|
83
|
+
// request is bounded and no worse than this route's existing
|
|
84
|
+
// behaviour, so slicing buys correct semantics without taking on
|
|
85
|
+
// that risk or widening the driver interface a future object-storage
|
|
86
|
+
// driver would have to satisfy.
|
|
87
|
+
reply
|
|
88
|
+
.header('X-Content-Type-Options', 'nosniff')
|
|
89
|
+
.header('Access-Control-Allow-Origin', '*')
|
|
90
|
+
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
|
91
|
+
.header('Accept-Ranges', 'bytes')
|
|
92
|
+
.type(mimeTypeFor(relativePath));
|
|
93
|
+
const range = resolveRange(request.headers.range, bytes.length);
|
|
94
|
+
if (range === 'unsatisfiable') {
|
|
95
|
+
// 416 carries its own required Content-Range naming the real size,
|
|
96
|
+
// which is how a client corrects itself rather than retrying blind.
|
|
97
|
+
reply.code(416).header('Content-Range', `bytes */${bytes.length}`).send();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (range !== null) {
|
|
101
|
+
const slice = bytes.subarray(range.start, range.end + 1);
|
|
102
|
+
reply
|
|
103
|
+
.code(206)
|
|
104
|
+
.header('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`)
|
|
105
|
+
.header('Content-Length', slice.length)
|
|
106
|
+
.send(slice);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
reply.send(bytes);
|
|
110
|
+
}
|
|
111
|
+
// Deliberately and permanently unauthenticated, same reasoning as
|
|
112
|
+
// assets.ts: uploaded media must be fetchable by any visitor's browser
|
|
113
|
+
// without a token, same as the public website itself. Registered
|
|
114
|
+
// without a /v1 prefix, directly on app in server.ts, alongside
|
|
115
|
+
// assetsRoutes/publicRoutes.
|
|
116
|
+
//
|
|
117
|
+
// Reads go through MediaStorageDriver.get() rather than raw fs calls
|
|
118
|
+
// (unlike assets.ts, which touches fs directly since theme assets are
|
|
119
|
+
// always local) - the one deliberate deviation from assets.ts's own
|
|
120
|
+
// shape, since a future object-storage driver needs this route to
|
|
121
|
+
// stay storage-agnostic. sanitisePath is called inside the driver
|
|
122
|
+
// itself (local-fs-driver.ts), not here - see that file's own comment.
|
|
123
|
+
export const mediaPublicRoutes = async (fastify, opts) => {
|
|
124
|
+
fastify.get('/media/*', async (request, reply) => handleMediaRequest(request, reply, opts.config));
|
|
125
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
4
|
+
export interface MediaRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
maxUploadBytes: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const mediaRoutes: FastifyPluginAsync<MediaRouteOptions>;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import multipart from '@fastify/multipart';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS } from "../media/filename.js";
|
|
4
|
+
import { ManageMediaError, deleteMedia, listMedia, putMedia } from "../media/manage-media.js";
|
|
5
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
6
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
7
|
+
import { requireScope } from "../services/token-auth.js";
|
|
8
|
+
function sendManageMediaError(reply, error) {
|
|
9
|
+
if (error.reason === 'not-found') {
|
|
10
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
async function handleListMedia(_request, reply, config) {
|
|
16
|
+
reply.send(await listMedia(config));
|
|
17
|
+
}
|
|
18
|
+
async function handleUploadMedia(request, reply, config) {
|
|
19
|
+
const data = await request.file();
|
|
20
|
+
if (!data) {
|
|
21
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected a multipart file upload' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Checked against the *original* uploaded filename, not the
|
|
25
|
+
// client-supplied mimetype header (trivially spoofable) and not the
|
|
26
|
+
// stored content-addressed filename (built only after this check
|
|
27
|
+
// passes, from the same already-validated extension).
|
|
28
|
+
const extension = extname(data.filename).toLowerCase();
|
|
29
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
30
|
+
reply.code(415).send({
|
|
31
|
+
statusCode: 415,
|
|
32
|
+
error: 'Unsupported Media Type',
|
|
33
|
+
message: `"${extension}" is not an accepted media type`,
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// No try/catch around toBuffer(): @fastify/multipart's own
|
|
38
|
+
// RequestFileTooLargeError already carries .statusCode = 413, so it
|
|
39
|
+
// passes straight through server.ts's existing global error handler
|
|
40
|
+
// unchanged - the same "just throw and let the existing handler map
|
|
41
|
+
// it" pattern already used for AuthError elsewhere. busboy enforces
|
|
42
|
+
// limits.fileSize *during* the stream (rejecting once the cap is
|
|
43
|
+
// hit, never silently truncating), not after buffering an unbounded
|
|
44
|
+
// body into memory first - this is what "capped at the stream
|
|
45
|
+
// level" actually means in practice.
|
|
46
|
+
const bytes = await data.toBuffer();
|
|
47
|
+
const result = await putMedia(config, data.filename, bytes);
|
|
48
|
+
reply.code(201).send(result);
|
|
49
|
+
}
|
|
50
|
+
async function handleDeleteMedia(request, reply, config) {
|
|
51
|
+
const filename = request.params['*'];
|
|
52
|
+
try {
|
|
53
|
+
await deleteMedia(config, filename);
|
|
54
|
+
reply.code(204).send();
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof PathSafetyError) {
|
|
58
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${filename}"` });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof ManageMediaError) {
|
|
62
|
+
sendManageMediaError(reply, error);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export const mediaRoutes = async (fastify, opts) => {
|
|
69
|
+
// Registered inside this plugin's own encapsulation scope (media.ts
|
|
70
|
+
// is registered plainly, never wrapped in fastify-plugin's fp() -
|
|
71
|
+
// matching this codebase's house rule so Group B's auth preHandler
|
|
72
|
+
// hooks keep working), not globally in server.ts - confines the
|
|
73
|
+
// multipart content-type parser and .file()/.files() decorations to
|
|
74
|
+
// media routes only, leaving every other route file's default JSON
|
|
75
|
+
// body parsing completely untouched.
|
|
76
|
+
await fastify.register(multipart, { limits: { fileSize: opts.maxUploadBytes } });
|
|
77
|
+
fastify.get('/media', { preHandler: requireScope(opts.tokens, 'media') }, async (request, reply) => handleListMedia(request, reply, opts.config));
|
|
78
|
+
fastify.post('/media', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUploadMedia(request, reply, opts.config));
|
|
79
|
+
fastify.delete('/media/*', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteMedia(request, reply, opts.config));
|
|
80
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
4
|
+
export interface MenusRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const menusRoutes: FastifyPluginAsync<MenusRouteOptions>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { ManageMenuError, renameMenu, saveMenu } from "../services/manage-menus.js";
|
|
3
|
+
import { findMenuReferences, isValidMenuHandle } from "../services/menu-references.js";
|
|
4
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
5
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
6
|
+
import { requireScope } from "../services/token-auth.js";
|
|
7
|
+
function isNonEmptyString(value) {
|
|
8
|
+
return typeof value === 'string' && value.length > 0;
|
|
9
|
+
}
|
|
10
|
+
// The body is wrapped ({ content, message, author }), not raw menu
|
|
11
|
+
// content the way PUT /v1/drafts/:path is: unlike a draft save, this
|
|
12
|
+
// commits immediately, so it needs the same message/author metadata
|
|
13
|
+
// every other commit-creating write in this API takes in its body
|
|
14
|
+
// (redirects, publish, delete, move) - a header-based convention would
|
|
15
|
+
// be a new, inconsistent pattern for no reason.
|
|
16
|
+
function parseSaveMenuBody(body) {
|
|
17
|
+
if (typeof body !== 'object' || body === null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const { content, message, author } = body;
|
|
21
|
+
if (content === undefined || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return { content, message, author };
|
|
25
|
+
}
|
|
26
|
+
async function handleSaveMenu(request, reply, config) {
|
|
27
|
+
const relativePath = request.params['*'];
|
|
28
|
+
// A static per-request property (is the header present at all on
|
|
29
|
+
// *this* request), not shared mutable state - no TOCTOU risk in
|
|
30
|
+
// checking it here, before the queue, matching drafts.ts's own
|
|
31
|
+
// precedent exactly (the ETag comparison itself must happen inside
|
|
32
|
+
// the queued job, this presence check does not).
|
|
33
|
+
const ifMatch = request.headers['if-match'];
|
|
34
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
35
|
+
reply.code(428).send({
|
|
36
|
+
statusCode: 428,
|
|
37
|
+
error: 'Precondition Required',
|
|
38
|
+
message: 'An If-Match header is required to save a menu',
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const parsed = parseSaveMenuBody(request.body);
|
|
43
|
+
if (!parsed) {
|
|
44
|
+
reply.code(400).send({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
error: 'Bad Request',
|
|
47
|
+
message: 'Expected { content: object, message: string, author: { name, email } }',
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const newEtag = await saveMenu(config, relativePath, parsed.content, ifMatch, parsed.message, parsed.author);
|
|
53
|
+
reply.header('etag', newEtag).send({ ok: true });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof PathSafetyError) {
|
|
57
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No menu at "${relativePath}"` });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof ManageMenuError && error.reason === 'conflict') {
|
|
61
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
|
|
65
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function parseRenameMenuBody(body) {
|
|
72
|
+
if (typeof body !== 'object' || body === null) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const { from, to, message, author } = body;
|
|
76
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { from, to, message, author };
|
|
80
|
+
}
|
|
81
|
+
// Takes handles (what a layout writes: menus.<handle>.items), not file
|
|
82
|
+
// paths - a rename can only ever move a menu within content/menus/.
|
|
83
|
+
// If-Match is the menu's current etag, as for a save.
|
|
84
|
+
async function handleRenameMenu(request, reply, config) {
|
|
85
|
+
const ifMatch = request.headers['if-match'];
|
|
86
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
87
|
+
reply.code(428).send({
|
|
88
|
+
statusCode: 428,
|
|
89
|
+
error: 'Precondition Required',
|
|
90
|
+
message: 'An If-Match header is required to rename a menu',
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const parsed = parseRenameMenuBody(request.body);
|
|
95
|
+
if (!parsed) {
|
|
96
|
+
reply.code(400).send({
|
|
97
|
+
statusCode: 400,
|
|
98
|
+
error: 'Bad Request',
|
|
99
|
+
message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const result = await renameMenu(config, parsed.from, parsed.to, ifMatch, parsed.message, parsed.author);
|
|
105
|
+
reply.header('etag', result.etag).send({ ok: true, staleThemeReferences: result.staleThemeReferences });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof PathSafetyError) {
|
|
109
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Not a valid menu handle' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof ManageMenuError && error.reason === 'not-found') {
|
|
113
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (error instanceof ManageMenuError && error.reason === 'conflict') {
|
|
117
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
|
|
121
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Read-only and advisory: which theme files mention a handle, so the
|
|
128
|
+
// admin can warn before a rename or delete empties a nav.
|
|
129
|
+
function handleMenuReferences(request, reply, config) {
|
|
130
|
+
const handle = request.query.handle;
|
|
131
|
+
if (typeof handle !== 'string' || !isValidMenuHandle(handle)) {
|
|
132
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected ?handle=<menu handle>' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
reply.send({ handle, themeFiles: findMenuReferences(config, handle) });
|
|
136
|
+
}
|
|
137
|
+
// GET/DELETE/move for menus/ paths deliberately stay on the existing
|
|
138
|
+
// generic /v1/content routes (Group N) - only this write path is new.
|
|
139
|
+
// Its own namespace (/v1/menus, not wedged into /v1/content), matching
|
|
140
|
+
// how redirects got /v1/redirects rather than overloading /v1/content
|
|
141
|
+
// with type-conditional write behaviour.
|
|
142
|
+
export const menusRoutes = async (fastify, opts) => {
|
|
143
|
+
fastify.put('/menus/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveMenu(request, reply, opts.config));
|
|
144
|
+
// Static paths, distinct from PUT /menus/* by method (POST/GET), the
|
|
145
|
+
// same way POST /content/move sits beside DELETE /content/*.
|
|
146
|
+
fastify.post('/menus/rename', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleRenameMenu(request, reply, opts.config));
|
|
147
|
+
fastify.get('/menus/references', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleMenuReferences(request, reply, opts.config));
|
|
148
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface PreviewRevisionRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const previewRevisionRoutes: FastifyPluginAsync<PreviewRevisionRouteOptions>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { PageRenderError, parsePageContent, renderLoadedPage } from "../renderer/render-page.js";
|
|
3
|
+
import { GitShowError, readFileAtRevision } from "../services/git-history.js";
|
|
4
|
+
import { isValidGitRef } from "../services/git.js";
|
|
5
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
6
|
+
import { requireScope } from "../services/token-auth.js";
|
|
7
|
+
import { urlToPagePath } from "../services/urls.js";
|
|
8
|
+
import { resolveSiteSettings } from "../services/site-settings.js";
|
|
9
|
+
// A dedicated top-level route rather than nesting under /preview/*
|
|
10
|
+
// (e.g. /preview/:ref/*): page URLs can be arbitrarily nested
|
|
11
|
+
// (E1 - content/pages/about/team.json resolves at /about/team), so a
|
|
12
|
+
// parametric+wildcard sibling registered under the same prefix as the
|
|
13
|
+
// existing bare /preview/* wildcard would make any multi-segment
|
|
14
|
+
// preview URL genuinely ambiguous between "page team under about" and
|
|
15
|
+
// "ref=about, page /team". Keeping this on its own /preview-revision
|
|
16
|
+
// prefix, a sibling of /preview rather than nested inside it, avoids
|
|
17
|
+
// that collision entirely - the same shape /git/show/:ref/* already
|
|
18
|
+
// uses alongside /preview/*.
|
|
19
|
+
//
|
|
20
|
+
// readFileAtRevision resolves paths relative to config.siteRoot, not
|
|
21
|
+
// config.contentRoot (confirmed by git-history.ts/git.test.ts's own
|
|
22
|
+
// "content/pages/about.json" usage) - so unlike preview.ts's
|
|
23
|
+
// toRenderPath (contentRoot-relative), the path built here is prefixed
|
|
24
|
+
// with "content/".
|
|
25
|
+
function toRevisionPath(pagesRelativePath) {
|
|
26
|
+
return join('content', 'pages', pagesRelativePath);
|
|
27
|
+
}
|
|
28
|
+
async function handlePreviewRevisionRequest(request, reply, config, themeTemplates, layouts, engine, themeSchemas) {
|
|
29
|
+
const { ref } = request.params;
|
|
30
|
+
const url = `/${request.params['*']}`;
|
|
31
|
+
if (!isValidGitRef(ref)) {
|
|
32
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: `Invalid ref: "${ref}"` });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const repoRelativePath = toRevisionPath(urlToPagePath(url));
|
|
37
|
+
const raw = readFileAtRevision(config, ref, repoRelativePath);
|
|
38
|
+
const page = parsePageContent(raw.toString('utf-8'));
|
|
39
|
+
// Today's site settings, as with menus: only the page itself is
|
|
40
|
+
// shown as it was at that revision.
|
|
41
|
+
const html = await renderLoadedPage(page, config, themeTemplates, layouts, engine, resolveSiteSettings(config, themeSchemas));
|
|
42
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof PathSafetyError) {
|
|
46
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (error instanceof GitShowError) {
|
|
50
|
+
const code = error.reason === 'invalid-ref' ? 400 : 404;
|
|
51
|
+
reply.code(code).send({
|
|
52
|
+
statusCode: code,
|
|
53
|
+
error: code === 400 ? 'Bad Request' : 'Not Found',
|
|
54
|
+
message: error.message,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof SyntaxError) {
|
|
59
|
+
reply.code(404).send({
|
|
60
|
+
statusCode: 404,
|
|
61
|
+
error: 'Not Found',
|
|
62
|
+
message: `No renderable page at "${url}" at revision "${ref}"`,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (error instanceof PageRenderError) {
|
|
67
|
+
if (error.reason === 'page-not-found') {
|
|
68
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// missing-section-type/missing-block-type/missing-layout/
|
|
72
|
+
// template-error: a deliberate divergence from preview.ts and
|
|
73
|
+
// public.ts, which let these reasons bubble to an uncaught 500.
|
|
74
|
+
// For those routes, a broken theme/content combination reachable
|
|
75
|
+
// today is genuinely an operational bug worth alerting on. Here,
|
|
76
|
+
// "today's theme doesn't have a section type this old revision
|
|
77
|
+
// used" is an expected, user-facing outcome (themes/layouts/menus
|
|
78
|
+
// are never revision-pinned, only the page body is - see
|
|
79
|
+
// render-page.ts's renderLoadedPage comment), not a bug - the
|
|
80
|
+
// admin needs a stable 4xx it can catch and turn into "this
|
|
81
|
+
// revision can't be previewed with the current theme" copy,
|
|
82
|
+
// not a raw 500.
|
|
83
|
+
reply.code(422).send({
|
|
84
|
+
statusCode: 422,
|
|
85
|
+
error: 'Unprocessable Entity',
|
|
86
|
+
message: error.message,
|
|
87
|
+
reason: error.reason,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export const previewRevisionRoutes = async (fastify, opts) => {
|
|
95
|
+
fastify.get('/preview-revision/:ref/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRevisionRequest(request, reply, opts.config, opts.theme.current.themeTemplates, opts.theme.current.layouts, opts.theme.current.engine, opts.theme.current.themeSchemas));
|
|
96
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface PreviewRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const previewRoutes: FastifyPluginAsync<PreviewRouteOptions>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
3
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
+
import { requireScope } from "../services/token-auth.js";
|
|
5
|
+
import { urlToPagePath } from "../services/urls.js";
|
|
6
|
+
import { resolveSiteSettings, resolveSiteSettingsFrom } from "../services/site-settings.js";
|
|
7
|
+
import { validateSiteSettings } from "../services/validation.js";
|
|
8
|
+
// Same pagesRoot/contentRoot seam as public.ts - see that file's
|
|
9
|
+
// toRenderPath comment. Preview never consults redirects.json:
|
|
10
|
+
// redirects are a public-URL concept, and an editor previewing a
|
|
11
|
+
// specific page path isn't redirected.
|
|
12
|
+
function toRenderPath(pagesRelativePath) {
|
|
13
|
+
return join('pages', pagesRelativePath);
|
|
14
|
+
}
|
|
15
|
+
// ?settings=<JSON>: site settings an editor has changed in the admin but
|
|
16
|
+
// not saved, so the preview shows them before they go live. Never
|
|
17
|
+
// written anywhere. Values the theme's schema rejects (or that aren't
|
|
18
|
+
// JSON) are ignored and the saved settings used, the same as no query:
|
|
19
|
+
// a half-typed value must never break the preview.
|
|
20
|
+
function previewSiteSettings(query, config, themeSchemas) {
|
|
21
|
+
const raw = query?.settings;
|
|
22
|
+
if (typeof raw === 'string') {
|
|
23
|
+
try {
|
|
24
|
+
const given = JSON.parse(raw);
|
|
25
|
+
if (validateSiteSettings(given, themeSchemas).valid) {
|
|
26
|
+
return resolveSiteSettingsFrom(given, themeSchemas);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Not JSON: fall through to the saved settings.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return resolveSiteSettings(config, themeSchemas);
|
|
34
|
+
}
|
|
35
|
+
async function handlePreviewRequest(request, reply, config, themeTemplates, layouts, engine, themeSchemas) {
|
|
36
|
+
const url = `/${request.params['*']}`;
|
|
37
|
+
try {
|
|
38
|
+
const relativePath = urlToPagePath(url);
|
|
39
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(relativePath), 'preview', previewSiteSettings(request.query, config, themeSchemas));
|
|
40
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error instanceof PathSafetyError) {
|
|
44
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof PageRenderError && error.reason === 'page-not-found') {
|
|
48
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const previewRoutes = async (fastify, opts) => {
|
|
55
|
+
fastify.get('/preview/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRequest(request, reply, opts.config, opts.theme.current.themeTemplates, opts.theme.current.layouts, opts.theme.current.engine, opts.theme.current.themeSchemas));
|
|
56
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { RenderCache } from '../renderer/render-cache.ts';
|
|
4
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
5
|
+
export interface PublicRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
renderCache: RenderCache;
|
|
9
|
+
}
|
|
10
|
+
export declare const publicRoutes: FastifyPluginAsync<PublicRouteOptions>;
|