@minamocms/framework 0.0.0-stage → 0.10.2
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 +240 -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 +94 -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,123 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { getMenusMtimeMs, getPageMtimeMs, PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
3
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
+
import { resolveUrl } from "../services/resolve-url.js";
|
|
5
|
+
import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
6
|
+
import { resolveSiteSettings } from "../services/site-settings.js";
|
|
7
|
+
// resolveUrl's relativePath is relative to pagesRoot (e.g. "about.json"),
|
|
8
|
+
// but renderPage's relativePath is relative to contentRoot/draftsRoot
|
|
9
|
+
// directly (e.g. "pages/about.json") - two already-correct modules with
|
|
10
|
+
// two different conventions for the same string. This is the seam
|
|
11
|
+
// between them, not an arbitrary literal.
|
|
12
|
+
function toRenderPath(pagesRelativePath) {
|
|
13
|
+
return join('pages', pagesRelativePath);
|
|
14
|
+
}
|
|
15
|
+
// A themed 404: content/pages/404.json, if it exists and is published,
|
|
16
|
+
// is rendered through the ordinary public renderPage pipeline (layout +
|
|
17
|
+
// sections + blocks, same as any other page) - no special-casing in
|
|
18
|
+
// render-page.ts itself, no separate "is this the 404 page" concept.
|
|
19
|
+
// The HTTP status is always 404 regardless, even though the body is a
|
|
20
|
+
// full HTML page. Any failure at all while attempting this - no
|
|
21
|
+
// 404.json, it's unpublished, or its own theme is broken - falls back
|
|
22
|
+
// to today's plain JSON error rather than ever turning a 404 into a
|
|
23
|
+
// 500 for a visitor who was already looking at a missing page.
|
|
24
|
+
async function sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas) {
|
|
25
|
+
try {
|
|
26
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath('404.json'), 'public', resolveSiteSettings(config, themeSchemas));
|
|
27
|
+
reply.code(404).type('text/html; charset=utf-8').send(html);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine, renderCache,
|
|
34
|
+
// Which theme rendered a cached page: one pushed since makes it stale.
|
|
35
|
+
themeGeneration, themeSchemas) {
|
|
36
|
+
// The public catch-all is registered without a /v1 prefix alongside
|
|
37
|
+
// v1Routes (which has its own exact/prefixed routes). Fastify's
|
|
38
|
+
// router already prefers exact matches over this wildcard regardless
|
|
39
|
+
// of registration order, but an *unmatched* /v1/* path would
|
|
40
|
+
// otherwise fall through to here and get treated as a page lookup
|
|
41
|
+
// instead of Fastify's real 404 - this guard is what actually keeps
|
|
42
|
+
// A4 true for any future typo'd or removed /v1/* route, not
|
|
43
|
+
// registration order.
|
|
44
|
+
if (request.url === '/v1' || request.url.startsWith('/v1/')) {
|
|
45
|
+
return reply.callNotFound();
|
|
46
|
+
}
|
|
47
|
+
// theme/root/ mirrors arbitrary bare paths (robots.txt, .well-known/*)
|
|
48
|
+
// at the site root, checked before page resolution - unlike /assets/
|
|
49
|
+
// or /media/, these aren't distinguishable by a fixed prefix, so
|
|
50
|
+
// Fastify's own "specific route beats /*" behaviour can't do this for
|
|
51
|
+
// us automatically. A miss (including the bare "/" itself - a
|
|
52
|
+
// directory, never a file) falls straight through to the existing
|
|
53
|
+
// page-lookup logic below, unchanged.
|
|
54
|
+
const rootMirrorMatch = findStaticFile(config.rootMirrorRoot, request.params['*']);
|
|
55
|
+
if (rootMirrorMatch) {
|
|
56
|
+
sendStaticFile(reply, rootMirrorMatch);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const url = `/${request.params['*']}`;
|
|
60
|
+
try {
|
|
61
|
+
const resolved = resolveUrl(config, url);
|
|
62
|
+
if (resolved.kind === 'not-found') {
|
|
63
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (resolved.kind === 'redirect') {
|
|
67
|
+
reply.code(301).header('location', resolved.to).send();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const renderPath = toRenderPath(resolved.relativePath);
|
|
71
|
+
// Validated against real filesystem state, not invalidated by
|
|
72
|
+
// hooking every write path (publish/unpublish/delete/move/batch) -
|
|
73
|
+
// see render-cache.ts's own comment for why. pageMtimeMs is null
|
|
74
|
+
// only if the file vanished between resolveUrl confirming it
|
|
75
|
+
// exists and this check (vanishingly unlikely) - falls through to
|
|
76
|
+
// an ordinary uncached render rather than treating that as
|
|
77
|
+
// fatal.
|
|
78
|
+
const pageMtimeMs = getPageMtimeMs(config, renderPath);
|
|
79
|
+
if (pageMtimeMs !== null) {
|
|
80
|
+
const menusMtimeMs = getMenusMtimeMs(config);
|
|
81
|
+
const cached = renderCache.get(renderPath);
|
|
82
|
+
if (cached &&
|
|
83
|
+
cached.pageMtimeMs === pageMtimeMs &&
|
|
84
|
+
cached.menusMtimeMs === menusMtimeMs &&
|
|
85
|
+
cached.themeGeneration === themeGeneration) {
|
|
86
|
+
reply.type('text/html; charset=utf-8').send(cached.html);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public', resolveSiteSettings(config, themeSchemas));
|
|
90
|
+
renderCache.set(renderPath, { html, pageMtimeMs, menusMtimeMs, themeGeneration });
|
|
91
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public', resolveSiteSettings(config, themeSchemas));
|
|
95
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// A traversal attempt and an ordinary miss get the identical 404 -
|
|
99
|
+
// deliberately not 400, so a public, unauthenticated route never
|
|
100
|
+
// gives an attacker a signal that distinguishes the two.
|
|
101
|
+
if (error instanceof PathSafetyError) {
|
|
102
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (error instanceof PageRenderError && error.reason === 'page-not-found') {
|
|
106
|
+
await sendNotFound(reply, config, themeTemplates, layouts, engine, url, themeSchemas);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Any other PageRenderError reason (missing-section-type,
|
|
110
|
+
// missing-block-type, template-error) is a broken theme/content
|
|
111
|
+
// problem, not something to explain to a public visitor - rethrow
|
|
112
|
+
// and let the global error handler sanitise it as a 500 (A5).
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export const publicRoutes = async (fastify, opts) => {
|
|
117
|
+
fastify.get('/*', async (request, reply) => {
|
|
118
|
+
// Read once per request: a theme pushed mid-request applies to the
|
|
119
|
+
// next one, never half of this one.
|
|
120
|
+
const theme = opts.theme.current;
|
|
121
|
+
return handlePublicRequest(request, reply, opts.config, theme.themeTemplates, theme.layouts, theme.engine, opts.renderCache, theme.generation, theme.themeSchemas);
|
|
122
|
+
});
|
|
123
|
+
};
|
|
@@ -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 PublishRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const publishRoutes: FastifyPluginAsync<PublishRouteOptions>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
3
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
4
|
+
import { PublishError, publishDrafts, publishPage, unpublishPage } from "../services/publish.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
function isNonEmptyString(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0;
|
|
8
|
+
}
|
|
9
|
+
function parsePublishBody(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const { paths, message, author } = body;
|
|
14
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return { paths, message, author };
|
|
21
|
+
}
|
|
22
|
+
function parsePublishedFlagBody(body) {
|
|
23
|
+
if (typeof body !== 'object' || body === null) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const { message, author } = body;
|
|
27
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { message, author };
|
|
31
|
+
}
|
|
32
|
+
// Shared by both handlers below: each only ever sees the subset of
|
|
33
|
+
// reasons its own service function can actually throw.
|
|
34
|
+
function replyForPublishError(reply, error) {
|
|
35
|
+
if (error.reason === 'validation-failed' || error.reason === 'duplicate-path') {
|
|
36
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (error.reason === 'draft-not-found' || error.reason === 'page-not-found') {
|
|
40
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
export const publishRoutes = async (fastify, opts) => {
|
|
46
|
+
fastify.post('/publish', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
47
|
+
const parsed = parsePublishBody(request.body);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
reply.code(400).send({
|
|
50
|
+
statusCode: 400,
|
|
51
|
+
error: 'Bad Request',
|
|
52
|
+
message: 'Expected { paths: string[], message: string, author: { name, email } }',
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await publishDrafts(opts.config, opts.theme.current.themeSchemas, parsed.paths, parsed.message, parsed.author);
|
|
58
|
+
reply.send({ ok: true });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
62
|
+
// through to the global handler's sanitised 500, the same gap
|
|
63
|
+
// every other route touching a :path already guards against.
|
|
64
|
+
if (error instanceof PathSafetyError) {
|
|
65
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (error instanceof PublishError) {
|
|
69
|
+
replyForPublishError(reply, error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
76
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
77
|
+
if (!parsed) {
|
|
78
|
+
reply.code(400).send({
|
|
79
|
+
statusCode: 400,
|
|
80
|
+
error: 'Bad Request',
|
|
81
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const relativePath = request.params['*'];
|
|
86
|
+
try {
|
|
87
|
+
await unpublishPage(opts.config, relativePath, parsed.message, parsed.author);
|
|
88
|
+
reply.send({ ok: true });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
92
|
+
// through to the global handler's sanitised 500, the same gap
|
|
93
|
+
// every other route touching a :path already guards against.
|
|
94
|
+
if (error instanceof PathSafetyError) {
|
|
95
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (error instanceof PublishError) {
|
|
99
|
+
replyForPublishError(reply, error);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
// The twin of /unpublish/* above: sets published:true on a live page
|
|
106
|
+
// in place and commits. Deliberately separate from /publish, which
|
|
107
|
+
// promotes drafts - a page that is live but unpublished has no draft
|
|
108
|
+
// to promote, so /publish cannot reach it at all (draft-not-found),
|
|
109
|
+
// and promoting a draft would publish every pending edit along with
|
|
110
|
+
// the flag. This only ever changes the one boolean.
|
|
111
|
+
fastify.post('/publish-page/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
112
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
reply.code(400).send({
|
|
115
|
+
statusCode: 400,
|
|
116
|
+
error: 'Bad Request',
|
|
117
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const relativePath = request.params['*'];
|
|
122
|
+
try {
|
|
123
|
+
await publishPage(opts.config, relativePath, parsed.message, parsed.author);
|
|
124
|
+
reply.send({ ok: true });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Same PathSafetyError guard as every other :path route here.
|
|
128
|
+
if (error instanceof PathSafetyError) {
|
|
129
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof PublishError) {
|
|
133
|
+
replyForPublishError(reply, error);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
};
|
|
@@ -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 RedirectsRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const redirectsRoutes: FastifyPluginAsync<RedirectsRouteOptions>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { ManageRedirectError, createRedirect, deleteRedirect, updateRedirect } from "../services/manage-redirects.js";
|
|
3
|
+
import { loadRedirects } from "../services/redirects.js";
|
|
4
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
function isNonEmptyString(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0;
|
|
8
|
+
}
|
|
9
|
+
function parseUpsertRedirectBody(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const { from, to, note, message, author } = body;
|
|
14
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (note !== undefined && typeof note !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { from, to, note, message, author };
|
|
24
|
+
}
|
|
25
|
+
function parseDeleteRedirectBody(body) {
|
|
26
|
+
if (typeof body !== 'object' || body === null) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const { from, message, author } = body;
|
|
30
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return { from, message, author };
|
|
34
|
+
}
|
|
35
|
+
function sendManageRedirectError(reply, error) {
|
|
36
|
+
if (error.reason === 'invalid-from' || error.reason === 'invalid-target' || error.reason === 'redirect-cycle') {
|
|
37
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (error.reason === 'not-found') {
|
|
41
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (error.reason === 'already-exists' || error.reason === 'live-content-exists') {
|
|
45
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
async function handleListRedirects(_request, reply, config) {
|
|
51
|
+
reply.send(loadRedirects(config));
|
|
52
|
+
}
|
|
53
|
+
async function handleCreateRedirect(request, reply, config) {
|
|
54
|
+
const parsed = parseUpsertRedirectBody(request.body);
|
|
55
|
+
if (!parsed) {
|
|
56
|
+
reply.code(400).send({
|
|
57
|
+
statusCode: 400,
|
|
58
|
+
error: 'Bad Request',
|
|
59
|
+
message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const result = await createRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
|
|
65
|
+
reply.send(result);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof ManageRedirectError) {
|
|
69
|
+
sendManageRedirectError(reply, error);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function handleUpdateRedirect(request, reply, config) {
|
|
76
|
+
const parsed = parseUpsertRedirectBody(request.body);
|
|
77
|
+
if (!parsed) {
|
|
78
|
+
reply.code(400).send({
|
|
79
|
+
statusCode: 400,
|
|
80
|
+
error: 'Bad Request',
|
|
81
|
+
message: 'Expected { from: string, to: string, note?: string, message: string, author: { name, email } }',
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await updateRedirect(config, parsed.from, parsed.to, parsed.note, parsed.message, parsed.author);
|
|
87
|
+
reply.send(result);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof ManageRedirectError) {
|
|
91
|
+
sendManageRedirectError(reply, error);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function handleDeleteRedirect(request, reply, config) {
|
|
98
|
+
const parsed = parseDeleteRedirectBody(request.body);
|
|
99
|
+
if (!parsed) {
|
|
100
|
+
reply.code(400).send({
|
|
101
|
+
statusCode: 400,
|
|
102
|
+
error: 'Bad Request',
|
|
103
|
+
message: 'Expected { from: string, message: string, author: { name, email } }',
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await deleteRedirect(config, parsed.from, parsed.message, parsed.author);
|
|
109
|
+
reply.code(204).send();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof ManageRedirectError) {
|
|
113
|
+
sendManageRedirectError(reply, error);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// from/to are always taken from the JSON body, never a URL path
|
|
120
|
+
// segment: they are arbitrary public URLs that may contain slashes,
|
|
121
|
+
// and critically none of these operations address a filesystem path
|
|
122
|
+
// at all (this mutates one entry inside redirects.json, not a file at
|
|
123
|
+
// a client-supplied path) - sanitisePath/path-traversal simply doesn't
|
|
124
|
+
// apply here, matching POST /content/move's existing body-carries-the-
|
|
125
|
+
// identity precedent rather than forcing an arbitrary URL into a route
|
|
126
|
+
// segment.
|
|
127
|
+
export const redirectsRoutes = async (fastify, opts) => {
|
|
128
|
+
fastify.get('/redirects', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListRedirects(request, reply, opts.config));
|
|
129
|
+
fastify.post('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleCreateRedirect(request, reply, opts.config));
|
|
130
|
+
fastify.put('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUpdateRedirect(request, reply, opts.config));
|
|
131
|
+
fastify.delete('/redirects', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteRedirect(request, reply, opts.config));
|
|
132
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { queryContent } from "../search/query-content.js";
|
|
2
|
+
import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
|
+
const FIELD_OPS = ['eq', 'gt', 'gte', 'lt', 'lte'];
|
|
4
|
+
const DEFAULT_LIMIT = 20;
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
function badRequest(reply, message) {
|
|
7
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message });
|
|
8
|
+
}
|
|
9
|
+
// field:value (op implied "eq") or field:op:value. Split on the FIRST
|
|
10
|
+
// colon, then check whether the next segment up to a second colon is
|
|
11
|
+
// one of the known op words - a value that itself contains a colon
|
|
12
|
+
// (unlikely for the fields this targets, but not impossible) still
|
|
13
|
+
// parses correctly either way, since only a genuine, recognised op
|
|
14
|
+
// token is ever treated as one.
|
|
15
|
+
function parseFilter(raw) {
|
|
16
|
+
const firstColon = raw.indexOf(':');
|
|
17
|
+
if (firstColon <= 0) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const field = raw.slice(0, firstColon);
|
|
21
|
+
const rest = raw.slice(firstColon + 1);
|
|
22
|
+
const secondColon = rest.indexOf(':');
|
|
23
|
+
if (secondColon !== -1) {
|
|
24
|
+
const maybeOp = rest.slice(0, secondColon);
|
|
25
|
+
if (FIELD_OPS.includes(maybeOp)) {
|
|
26
|
+
const value = rest.slice(secondColon + 1);
|
|
27
|
+
return value === '' ? undefined : { field, op: maybeOp, value };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return rest === '' ? undefined : { field, op: 'eq', value: rest };
|
|
31
|
+
}
|
|
32
|
+
function parseSort(raw) {
|
|
33
|
+
return raw.startsWith('-') ? { field: raw.slice(1), direction: 'desc' } : { field: raw, direction: 'asc' };
|
|
34
|
+
}
|
|
35
|
+
function parseLimit(raw) {
|
|
36
|
+
if (raw === undefined) {
|
|
37
|
+
return DEFAULT_LIMIT;
|
|
38
|
+
}
|
|
39
|
+
const parsed = Number.parseInt(raw, 10);
|
|
40
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
41
|
+
return DEFAULT_LIMIT;
|
|
42
|
+
}
|
|
43
|
+
return Math.min(parsed, MAX_LIMIT);
|
|
44
|
+
}
|
|
45
|
+
function parseOffset(raw) {
|
|
46
|
+
if (raw === undefined) {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number.parseInt(raw, 10);
|
|
50
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
51
|
+
}
|
|
52
|
+
async function handleSearch(request, reply, config) {
|
|
53
|
+
const { q, pageType, sort } = request.query;
|
|
54
|
+
const rawFilters = request.query.filter;
|
|
55
|
+
const filterStrings = rawFilters === undefined ? [] : Array.isArray(rawFilters) ? rawFilters : [rawFilters];
|
|
56
|
+
const filters = [];
|
|
57
|
+
for (const raw of filterStrings) {
|
|
58
|
+
const parsed = parseFilter(raw);
|
|
59
|
+
if (!parsed) {
|
|
60
|
+
badRequest(reply, `invalid filter "${raw}" - expected field:value or field:op:value`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (parsed.op !== 'eq' && !Number.isFinite(Number(parsed.value))) {
|
|
64
|
+
badRequest(reply, `value must be numeric for op "${parsed.op}" (filter "${raw}")`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
filters.push(parsed);
|
|
68
|
+
}
|
|
69
|
+
const response = queryContent(config.searchIndexPath, {
|
|
70
|
+
q,
|
|
71
|
+
pageType,
|
|
72
|
+
filters,
|
|
73
|
+
sort: sort ? parseSort(sort) : undefined,
|
|
74
|
+
limit: parseLimit(request.query.limit),
|
|
75
|
+
offset: parseOffset(request.query.offset),
|
|
76
|
+
});
|
|
77
|
+
reply.send(response);
|
|
78
|
+
}
|
|
79
|
+
// The one public query surface - full-text (q), structured filters,
|
|
80
|
+
// sort, and pagination all in one endpoint (query-content.ts's own
|
|
81
|
+
// queryContent), rather than three narrow ones. Deliberately no
|
|
82
|
+
// requireScope, unlike every other route in this codebase: it's
|
|
83
|
+
// read-only and can only ever surface already-published data
|
|
84
|
+
// (rebuild-index.ts never indexes drafts or unpublished content), so
|
|
85
|
+
// there's nothing here a site visitor couldn't already see by
|
|
86
|
+
// browsing the live site directly - the whole point of this route is
|
|
87
|
+
// that a theme's own front-end JS can call it directly, which a
|
|
88
|
+
// token requirement would rule out entirely (a bearer token embedded
|
|
89
|
+
// in public client-side JS is not a secret - anyone's dev tools can
|
|
90
|
+
// read it straight back out, and this agent's tokens all carry real
|
|
91
|
+
// write scopes, not just search). NO_AUTH_ROUTE_RATE_LIMIT, the same
|
|
92
|
+
// defense-in-depth GET /v1/capabilities already has.
|
|
93
|
+
//
|
|
94
|
+
// GET /search.json, not GET /v1/search: this is a stable, public,
|
|
95
|
+
// front-end-facing contract, not part of the versioned admin/
|
|
96
|
+
// integration surface under /v1 - registered without a prefix in
|
|
97
|
+
// server.ts, alongside mediaPublicRoutes/assetsRoutes/sitemapRoutes.
|
|
98
|
+
// A single reserved path, not a whole prefix (unlike /media/* or
|
|
99
|
+
// /assets/*): a site's own content page can still live at the bare
|
|
100
|
+
// /search URL (see granite-starter/theme/sections/search-demo.liquid,
|
|
101
|
+
// which does exactly that) - only this one exact path is claimed.
|
|
102
|
+
export const searchPublicRoutes = async (fastify, opts) => {
|
|
103
|
+
fastify.get('/search.json', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => handleSearch(request, reply, opts.config));
|
|
104
|
+
};
|
|
@@ -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 SearchRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const searchRoutes: FastifyPluginAsync<SearchRouteOptions>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { rebuildIndex } from "../search/rebuild-index.js";
|
|
2
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
|
+
import { requireScope } from "../services/token-auth.js";
|
|
4
|
+
// The read side (GET /search.json) lives in routes/search-public.ts,
|
|
5
|
+
// registered separately, unprefixed, in server.ts - this file only
|
|
6
|
+
// ever holds the authenticated write side, matching media.ts (write,
|
|
7
|
+
// under /v1) vs media-public.ts (public read, unprefixed).
|
|
8
|
+
export const searchRoutes = async (fastify, opts) => {
|
|
9
|
+
fastify.post('/search/rebuild', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (_request, reply) => {
|
|
10
|
+
// rebuildIndex is already self-enqueue()d (search/rebuild-index.ts) -
|
|
11
|
+
// never wrap it in a second enqueue() here, nested calls deadlock.
|
|
12
|
+
await rebuildIndex(opts.config);
|
|
13
|
+
reply.send({ ok: true });
|
|
14
|
+
});
|
|
15
|
+
};
|
|
@@ -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 SettingsRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const settingsRoutes: FastifyPluginAsync<SettingsRouteOptions>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
|
+
import { readSiteSettings, resolveSiteSettings, SaveSiteSettingsError, saveSiteSettings } from "../services/site-settings.js";
|
|
4
|
+
import { requireScope } from "../services/token-auth.js";
|
|
5
|
+
function parseSaveSettingsBody(body) {
|
|
6
|
+
if (typeof body !== 'object' || body === null) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const { settings, message, author } = body;
|
|
10
|
+
if (settings === undefined || typeof message !== 'string' || message === '' || !isValidCommitAuthor(author)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return { settings, message, author };
|
|
14
|
+
}
|
|
15
|
+
// GET: everything the admin needs to build the Site Settings form - the
|
|
16
|
+
// theme's schema (null when it defines no settings), the values saved so
|
|
17
|
+
// far, what templates actually see (saved values, defaults filling the
|
|
18
|
+
// rest), and the ETag a save must present.
|
|
19
|
+
async function handleGetSettings(reply, opts) {
|
|
20
|
+
const { themeSchemas } = opts.theme.current;
|
|
21
|
+
const stored = readSiteSettings(opts.config);
|
|
22
|
+
if (stored.etag !== null) {
|
|
23
|
+
reply.header('etag', stored.etag);
|
|
24
|
+
}
|
|
25
|
+
reply.send({
|
|
26
|
+
schema: themeSchemas.settings ?? null,
|
|
27
|
+
settings: stored.settings,
|
|
28
|
+
resolved: resolveSiteSettings(opts.config, themeSchemas),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// PUT: saves and commits at once, like a menu - no draft state. If-Match
|
|
32
|
+
// is required; "*" (or anything) is accepted while no settings file
|
|
33
|
+
// exists yet.
|
|
34
|
+
async function handleSaveSettings(request, reply, opts) {
|
|
35
|
+
const ifMatch = request.headers['if-match'];
|
|
36
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
37
|
+
reply.code(428).send({ statusCode: 428, error: 'Precondition Required', message: 'An If-Match header is required to save site settings' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const parsed = parseSaveSettingsBody(request.body);
|
|
41
|
+
if (!parsed) {
|
|
42
|
+
reply.code(400).send({
|
|
43
|
+
statusCode: 400,
|
|
44
|
+
error: 'Bad Request',
|
|
45
|
+
message: 'Expected { settings: object, message: string, author: { name, email } }',
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const etag = await saveSiteSettings(opts.config, opts.theme.current.themeSchemas, parsed.settings, ifMatch, parsed.message, parsed.author);
|
|
51
|
+
reply.header('etag', etag).send({ ok: true });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof SaveSiteSettingsError && error.reason === 'conflict') {
|
|
55
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof SaveSiteSettingsError && error.reason === 'validation-failed') {
|
|
59
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message, errors: error.errors });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export const settingsRoutes = async (fastify, opts) => {
|
|
66
|
+
fastify.get('/settings', { preHandler: requireScope(opts.tokens, 'content') }, async (_request, reply) => handleGetSettings(reply, opts));
|
|
67
|
+
fastify.put('/settings', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveSettings(request, reply, opts));
|
|
68
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
export interface SitemapRouteOptions {
|
|
4
|
+
config: SiteConfig;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildSitemapUrls(config: SiteConfig): string[];
|
|
7
|
+
export declare const sitemapRoutes: FastifyPluginAsync<SitemapRouteOptions>;
|