@o-a/cms-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -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 +9 -0
- package/dist/create-site/generate-site.js +109 -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/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 +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -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 +1153 -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/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -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 +36 -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/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -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 +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -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 +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -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 +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -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 +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -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 +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -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 +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -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/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -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 +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -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/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 +5 -0
- package/dist/services/theme-component-file.js +32 -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 +63 -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 +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/package.json +53 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { Liquid } from 'liquidjs';
|
|
3
|
+
import type { SiteConfig } from '../config.ts';
|
|
4
|
+
import type { ThemeTemplates } from '../renderer/theme-templates.ts';
|
|
5
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
6
|
+
import type { ThemeSchemas } from '../services/validation.ts';
|
|
7
|
+
import type { PageTemplate } from '../services/theme-page-templates.ts';
|
|
8
|
+
export interface V1RouteOptions {
|
|
9
|
+
config: SiteConfig;
|
|
10
|
+
themeSchemas: ThemeSchemas;
|
|
11
|
+
pageTemplates: PageTemplate[];
|
|
12
|
+
themeTemplates: ThemeTemplates;
|
|
13
|
+
layouts: Record<string, string>;
|
|
14
|
+
engine: Liquid;
|
|
15
|
+
tokens: TokenEntry[];
|
|
16
|
+
ipAllowlist: string[];
|
|
17
|
+
maxUploadBytes: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const v1Routes: FastifyPluginAsync<V1RouteOptions>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { batchRoutes } from "./batch.js";
|
|
2
|
+
import { capabilitiesRoutes } from "./capabilities.js";
|
|
3
|
+
import { contentRoutes } from "./content.js";
|
|
4
|
+
import { draftsRoutes } from "./drafts.js";
|
|
5
|
+
import { gitRoutes } from "./git.js";
|
|
6
|
+
import { mediaRoutes } from "./media.js";
|
|
7
|
+
import { menusRoutes } from "./menus.js";
|
|
8
|
+
import { previewRevisionRoutes } from "./preview-revision.js";
|
|
9
|
+
import { previewRoutes } from "./preview.js";
|
|
10
|
+
import { publishRoutes } from "./publish.js";
|
|
11
|
+
import { redirectsRoutes } from "./redirects.js";
|
|
12
|
+
import { searchRoutes } from "./search.js";
|
|
13
|
+
import { themeRoutes } from "./theme.js";
|
|
14
|
+
import { ipAllowlistGuard } from "../services/ip-allowlist.js";
|
|
15
|
+
// The /v1 aggregator: buildServer registers this once with
|
|
16
|
+
// { prefix: '/v1', ...V1RouteOptions }, and every route-group plugin
|
|
17
|
+
// (capabilities.ts, preview.ts, and later content/drafts/publish/etc.)
|
|
18
|
+
// registers itself here with a bare, unprefixed path. Each route-group
|
|
19
|
+
// plugin is registered plainly (never wrapped in fastify-plugin's
|
|
20
|
+
// fp()), so it keeps its own encapsulation scope - required for Group
|
|
21
|
+
// B's auth preHandler hooks to gate only the routes that need them.
|
|
22
|
+
// Plugins that don't need site data (capabilities.ts) simply ignore
|
|
23
|
+
// the passed-through opts.
|
|
24
|
+
export const v1Routes = async (fastify, opts) => {
|
|
25
|
+
// A coarse "is this client's network address allowed to talk to the
|
|
26
|
+
// API at all" gate, applied to the entire /v1 surface uniformly
|
|
27
|
+
// (including capabilities.ts and every GET route) - orthogonal to
|
|
28
|
+
// requireScope's per-route, per-scope exemptions. Added on this
|
|
29
|
+
// fastify instance (v1Routes's own parameter, never wrapped in fp())
|
|
30
|
+
// before any nested register() call, so it cascades to every plugin
|
|
31
|
+
// registered as a child below but never to publicRoutes/assetsRoutes
|
|
32
|
+
// (siblings registered directly on the root app, outside this
|
|
33
|
+
// encapsulation context) - verified with a real request, not just
|
|
34
|
+
// asserted.
|
|
35
|
+
fastify.addHook('onRequest', ipAllowlistGuard(opts.ipAllowlist));
|
|
36
|
+
fastify.register(capabilitiesRoutes, { maxUploadBytes: opts.maxUploadBytes });
|
|
37
|
+
// Fastify passes the *entire* options object a plugin was registered
|
|
38
|
+
// with - including `prefix` - through as this plugin's own second
|
|
39
|
+
// argument. Passing `opts` straight through to a nested register()
|
|
40
|
+
// call would carry that `prefix: '/v1'` along with it, double-
|
|
41
|
+
// applying the prefix (confirmed empirically: routes ended up
|
|
42
|
+
// registered at /v1/v1/preview/*). Re-pack only the site-data
|
|
43
|
+
// fields, never `opts` itself, for any nested plugin registration.
|
|
44
|
+
fastify.register(previewRoutes, {
|
|
45
|
+
config: opts.config,
|
|
46
|
+
themeTemplates: opts.themeTemplates,
|
|
47
|
+
layouts: opts.layouts,
|
|
48
|
+
engine: opts.engine,
|
|
49
|
+
tokens: opts.tokens,
|
|
50
|
+
});
|
|
51
|
+
fastify.register(previewRevisionRoutes, {
|
|
52
|
+
config: opts.config,
|
|
53
|
+
themeTemplates: opts.themeTemplates,
|
|
54
|
+
layouts: opts.layouts,
|
|
55
|
+
engine: opts.engine,
|
|
56
|
+
tokens: opts.tokens,
|
|
57
|
+
});
|
|
58
|
+
// contentRoutes needs no themeTemplates (it never renders anything),
|
|
59
|
+
// so it gets its own narrower options shape rather than the full
|
|
60
|
+
// bundle out of convenience - matching capabilities.ts's precedent
|
|
61
|
+
// of registering with only what it actually uses.
|
|
62
|
+
fastify.register(contentRoutes, { config: opts.config, tokens: opts.tokens });
|
|
63
|
+
fastify.register(draftsRoutes, {
|
|
64
|
+
config: opts.config,
|
|
65
|
+
themeSchemas: opts.themeSchemas,
|
|
66
|
+
tokens: opts.tokens,
|
|
67
|
+
});
|
|
68
|
+
fastify.register(publishRoutes, {
|
|
69
|
+
config: opts.config,
|
|
70
|
+
themeSchemas: opts.themeSchemas,
|
|
71
|
+
tokens: opts.tokens,
|
|
72
|
+
});
|
|
73
|
+
fastify.register(batchRoutes, {
|
|
74
|
+
config: opts.config,
|
|
75
|
+
themeSchemas: opts.themeSchemas,
|
|
76
|
+
tokens: opts.tokens,
|
|
77
|
+
});
|
|
78
|
+
fastify.register(gitRoutes, { config: opts.config, tokens: opts.tokens });
|
|
79
|
+
fastify.register(mediaRoutes, { config: opts.config, tokens: opts.tokens, maxUploadBytes: opts.maxUploadBytes });
|
|
80
|
+
fastify.register(searchRoutes, { config: opts.config, tokens: opts.tokens });
|
|
81
|
+
fastify.register(redirectsRoutes, { config: opts.config, tokens: opts.tokens });
|
|
82
|
+
fastify.register(menusRoutes, { config: opts.config, tokens: opts.tokens });
|
|
83
|
+
fastify.register(themeRoutes, { themeSchemas: opts.themeSchemas, pageTemplates: opts.pageTemplates, tokens: opts.tokens });
|
|
84
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { openLocalFsMediaDriver } from "../media/drivers/local-fs-driver.js";
|
|
2
|
+
import { mimeTypeFor } from "../services/mime-types.js";
|
|
3
|
+
async function handleMediaRequest(request, reply, config) {
|
|
4
|
+
const relativePath = request.params['*'];
|
|
5
|
+
// Never read: Fastify doesn't require query params to be declared or
|
|
6
|
+
// consumed, so a Shopify-style ?width=1500 suffix
|
|
7
|
+
// (docs/cms-build-plan.md's Phase 4) is automatically inert with no
|
|
8
|
+
// code needed to "ignore" it - this route is the MVP passthrough
|
|
9
|
+
// driver, real resizing is separately-scoped future work.
|
|
10
|
+
const bytes = await openLocalFsMediaDriver(config.mediaRoot).get(relativePath);
|
|
11
|
+
if (bytes === null) {
|
|
12
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${relativePath}"` });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// nosniff: mimeTypeFor() falls back to application/octet-stream for
|
|
16
|
+
// anything outside its small known-extension table (uploads aren't
|
|
17
|
+
// restricted to that table, only to the images-only allowlist in
|
|
18
|
+
// media.ts), and without this header some browsers will still
|
|
19
|
+
// content-sniff an octet-stream response - the same stored-content
|
|
20
|
+
// risk class SVG rejection already exists to avoid for that specific
|
|
21
|
+
// case.
|
|
22
|
+
// Access-Control-Allow-Origin: * - same reasoning as assets.ts:
|
|
23
|
+
// fonts specifically enforce CORS unconditionally, and the admin's
|
|
24
|
+
// preview route proxies a site's HTML into its own origin (with a
|
|
25
|
+
// <base href> fix), making genuinely cross-origin browser requests
|
|
26
|
+
// for what looks like a same-origin path. Consistent with this
|
|
27
|
+
// route already being deliberately unauthenticated and public.
|
|
28
|
+
reply
|
|
29
|
+
.header('X-Content-Type-Options', 'nosniff')
|
|
30
|
+
.header('Access-Control-Allow-Origin', '*')
|
|
31
|
+
.type(mimeTypeFor(relativePath))
|
|
32
|
+
.send(bytes);
|
|
33
|
+
}
|
|
34
|
+
// Deliberately and permanently unauthenticated, same reasoning as
|
|
35
|
+
// assets.ts: uploaded media must be fetchable by any visitor's browser
|
|
36
|
+
// without a token, same as the public website itself. Registered
|
|
37
|
+
// without a /v1 prefix, directly on app in server.ts, alongside
|
|
38
|
+
// assetsRoutes/publicRoutes.
|
|
39
|
+
//
|
|
40
|
+
// Reads go through MediaStorageDriver.get() rather than raw fs calls
|
|
41
|
+
// (unlike assets.ts, which touches fs directly since theme assets are
|
|
42
|
+
// always local) - the one deliberate deviation from assets.ts's own
|
|
43
|
+
// shape, since a future object-storage driver needs this route to
|
|
44
|
+
// stay storage-agnostic. sanitisePath is called inside the driver
|
|
45
|
+
// itself (local-fs-driver.ts), not here - see that file's own comment.
|
|
46
|
+
export const mediaPublicRoutes = async (fastify, opts) => {
|
|
47
|
+
fastify.get('/media/*', async (request, reply) => handleMediaRequest(request, reply, opts.config));
|
|
48
|
+
};
|
|
@@ -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,84 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import multipart from '@fastify/multipart';
|
|
3
|
+
import { ManageMediaError, deleteMedia, listMedia, putMedia } from "../media/manage-media.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
|
+
// Images only - confirmed with the user, not a general document
|
|
8
|
+
// library. Checked against the *original* uploaded filename, not the
|
|
9
|
+
// client-supplied mimetype header (trivially spoofable) and not the
|
|
10
|
+
// stored content-addressed filename (built only after this check
|
|
11
|
+
// passes, from the same already-validated extension). .svg is
|
|
12
|
+
// rejected regardless of this list even though it's technically an
|
|
13
|
+
// image format - docs/cms-build-plan.md's own "SVG rejected outright,
|
|
14
|
+
// not sanitised" decision.
|
|
15
|
+
const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
16
|
+
function sendManageMediaError(reply, error) {
|
|
17
|
+
if (error.reason === 'not-found') {
|
|
18
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
async function handleListMedia(_request, reply, config) {
|
|
24
|
+
reply.send(await listMedia(config));
|
|
25
|
+
}
|
|
26
|
+
async function handleUploadMedia(request, reply, config) {
|
|
27
|
+
const data = await request.file();
|
|
28
|
+
if (!data) {
|
|
29
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected a multipart file upload' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const extension = extname(data.filename).toLowerCase();
|
|
33
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
34
|
+
reply.code(415).send({
|
|
35
|
+
statusCode: 415,
|
|
36
|
+
error: 'Unsupported Media Type',
|
|
37
|
+
message: `"${extension}" is not an accepted image type`,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// No try/catch around toBuffer(): @fastify/multipart's own
|
|
42
|
+
// RequestFileTooLargeError already carries .statusCode = 413, so it
|
|
43
|
+
// passes straight through server.ts's existing global error handler
|
|
44
|
+
// unchanged - the same "just throw and let the existing handler map
|
|
45
|
+
// it" pattern already used for AuthError elsewhere. busboy enforces
|
|
46
|
+
// limits.fileSize *during* the stream (rejecting once the cap is
|
|
47
|
+
// hit, never silently truncating), not after buffering an unbounded
|
|
48
|
+
// body into memory first - this is what "capped at the stream
|
|
49
|
+
// level" actually means in practice.
|
|
50
|
+
const bytes = await data.toBuffer();
|
|
51
|
+
const result = await putMedia(config, data.filename, bytes);
|
|
52
|
+
reply.code(201).send(result);
|
|
53
|
+
}
|
|
54
|
+
async function handleDeleteMedia(request, reply, config) {
|
|
55
|
+
const filename = request.params['*'];
|
|
56
|
+
try {
|
|
57
|
+
await deleteMedia(config, filename);
|
|
58
|
+
reply.code(204).send();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof PathSafetyError) {
|
|
62
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No media file at "${filename}"` });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof ManageMediaError) {
|
|
66
|
+
sendManageMediaError(reply, error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export const mediaRoutes = async (fastify, opts) => {
|
|
73
|
+
// Registered inside this plugin's own encapsulation scope (media.ts
|
|
74
|
+
// is registered plainly, never wrapped in fastify-plugin's fp() -
|
|
75
|
+
// matching this codebase's house rule so Group B's auth preHandler
|
|
76
|
+
// hooks keep working), not globally in server.ts - confines the
|
|
77
|
+
// multipart content-type parser and .file()/.files() decorations to
|
|
78
|
+
// media routes only, leaving every other route file's default JSON
|
|
79
|
+
// body parsing completely untouched.
|
|
80
|
+
await fastify.register(multipart, { limits: { fileSize: opts.maxUploadBytes } });
|
|
81
|
+
fastify.get('/media', { preHandler: requireScope(opts.tokens, 'media') }, async (request, reply) => handleListMedia(request, reply, opts.config));
|
|
82
|
+
fastify.post('/media', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleUploadMedia(request, reply, opts.config));
|
|
83
|
+
fastify.delete('/media/*', { preHandler: requireScope(opts.tokens, 'media'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteMedia(request, reply, opts.config));
|
|
84
|
+
};
|
|
@@ -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,77 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { ManageMenuError, saveMenu } from "../services/manage-menus.js";
|
|
3
|
+
import { PathSafetyError } from "../services/path-safety.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
|
+
// The body is wrapped ({ content, message, author }), not raw menu
|
|
10
|
+
// content the way PUT /v1/drafts/:path is: unlike a draft save, this
|
|
11
|
+
// commits immediately, so it needs the same message/author metadata
|
|
12
|
+
// every other commit-creating write in this API takes in its body
|
|
13
|
+
// (redirects, publish, delete, move) - a header-based convention would
|
|
14
|
+
// be a new, inconsistent pattern for no reason.
|
|
15
|
+
function parseSaveMenuBody(body) {
|
|
16
|
+
if (typeof body !== 'object' || body === null) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const { content, message, author } = body;
|
|
20
|
+
if (content === undefined || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { content, message, author };
|
|
24
|
+
}
|
|
25
|
+
async function handleSaveMenu(request, reply, config) {
|
|
26
|
+
const relativePath = request.params['*'];
|
|
27
|
+
// A static per-request property (is the header present at all on
|
|
28
|
+
// *this* request), not shared mutable state - no TOCTOU risk in
|
|
29
|
+
// checking it here, before the queue, matching drafts.ts's own
|
|
30
|
+
// precedent exactly (the ETag comparison itself must happen inside
|
|
31
|
+
// the queued job, this presence check does not).
|
|
32
|
+
const ifMatch = request.headers['if-match'];
|
|
33
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
34
|
+
reply.code(428).send({
|
|
35
|
+
statusCode: 428,
|
|
36
|
+
error: 'Precondition Required',
|
|
37
|
+
message: 'An If-Match header is required to save a menu',
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const parsed = parseSaveMenuBody(request.body);
|
|
42
|
+
if (!parsed) {
|
|
43
|
+
reply.code(400).send({
|
|
44
|
+
statusCode: 400,
|
|
45
|
+
error: 'Bad Request',
|
|
46
|
+
message: 'Expected { content: object, message: string, author: { name, email } }',
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const newEtag = await saveMenu(config, relativePath, parsed.content, ifMatch, parsed.message, parsed.author);
|
|
52
|
+
reply.header('etag', newEtag).send({ ok: true });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof PathSafetyError) {
|
|
56
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No menu at "${relativePath}"` });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof ManageMenuError && error.reason === 'conflict') {
|
|
60
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
|
|
64
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// GET/DELETE/move for menus/ paths deliberately stay on the existing
|
|
71
|
+
// generic /v1/content routes (Group N) - only this write path is new.
|
|
72
|
+
// Its own namespace (/v1/menus, not wedged into /v1/content), matching
|
|
73
|
+
// how redirects got /v1/redirects rather than overloading /v1/content
|
|
74
|
+
// with type-conditional write behaviour.
|
|
75
|
+
export const menusRoutes = async (fastify, opts) => {
|
|
76
|
+
fastify.put('/menus/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveMenu(request, reply, opts.config));
|
|
77
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { Liquid } from 'liquidjs';
|
|
3
|
+
import type { SiteConfig } from '../config.ts';
|
|
4
|
+
import type { ThemeTemplates } from '../renderer/theme-templates.ts';
|
|
5
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
6
|
+
export interface PreviewRevisionRouteOptions {
|
|
7
|
+
config: SiteConfig;
|
|
8
|
+
themeTemplates: ThemeTemplates;
|
|
9
|
+
layouts: Record<string, string>;
|
|
10
|
+
engine: Liquid;
|
|
11
|
+
tokens: TokenEntry[];
|
|
12
|
+
}
|
|
13
|
+
export declare const previewRevisionRoutes: FastifyPluginAsync<PreviewRevisionRouteOptions>;
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
|
|
7
|
+
import { requireScope } from "../services/token-auth.js";
|
|
8
|
+
import { urlToPagePath } from "../services/urls.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/toPostsRenderPath (contentRoot-relative), the path
|
|
24
|
+
// built here is prefixed with "content/".
|
|
25
|
+
function toRevisionPath(pagesRelativePath) {
|
|
26
|
+
return join('content', 'pages', pagesRelativePath);
|
|
27
|
+
}
|
|
28
|
+
function toPostsRevisionPath(postsRelativePath) {
|
|
29
|
+
return join('content', 'posts', postsRelativePath);
|
|
30
|
+
}
|
|
31
|
+
async function handlePreviewRevisionRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
32
|
+
const { ref } = request.params;
|
|
33
|
+
const url = `/${request.params['*']}`;
|
|
34
|
+
if (!isValidGitRef(ref)) {
|
|
35
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: `Invalid ref: "${ref}"` });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const isPost = isBlogUrl(url);
|
|
40
|
+
let repoRelativePath;
|
|
41
|
+
if (isPost) {
|
|
42
|
+
const relativePath = urlToPostPath(url);
|
|
43
|
+
if (relativePath === null) {
|
|
44
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
repoRelativePath = toPostsRevisionPath(relativePath);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
repoRelativePath = toRevisionPath(urlToPagePath(url));
|
|
51
|
+
}
|
|
52
|
+
const raw = readFileAtRevision(config, ref, repoRelativePath);
|
|
53
|
+
const page = parsePageContent(raw.toString('utf-8'));
|
|
54
|
+
const html = await renderLoadedPage(page, config, themeTemplates, layouts, engine);
|
|
55
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof PathSafetyError) {
|
|
59
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (error instanceof GitShowError) {
|
|
63
|
+
const code = error.reason === 'invalid-ref' ? 400 : 404;
|
|
64
|
+
reply.code(code).send({
|
|
65
|
+
statusCode: code,
|
|
66
|
+
error: code === 400 ? 'Bad Request' : 'Not Found',
|
|
67
|
+
message: error.message,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (error instanceof SyntaxError) {
|
|
72
|
+
reply.code(404).send({
|
|
73
|
+
statusCode: 404,
|
|
74
|
+
error: 'Not Found',
|
|
75
|
+
message: `No renderable page at "${url}" at revision "${ref}"`,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (error instanceof PageRenderError) {
|
|
80
|
+
if (error.reason === 'page-not-found') {
|
|
81
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// missing-section-type/missing-block-type/missing-layout/
|
|
85
|
+
// template-error: a deliberate divergence from preview.ts and
|
|
86
|
+
// public.ts, which let these reasons bubble to an uncaught 500.
|
|
87
|
+
// For those routes, a broken theme/content combination reachable
|
|
88
|
+
// today is genuinely an operational bug worth alerting on. Here,
|
|
89
|
+
// "today's theme doesn't have a section type this old revision
|
|
90
|
+
// used" is an expected, user-facing outcome (themes/layouts/menus
|
|
91
|
+
// are never revision-pinned, only the page body is - see
|
|
92
|
+
// render-page.ts's renderLoadedPage comment), not a bug - the
|
|
93
|
+
// admin needs a stable 4xx it can catch and turn into "this
|
|
94
|
+
// revision can't be previewed with the current theme" copy,
|
|
95
|
+
// not a raw 500.
|
|
96
|
+
reply.code(422).send({
|
|
97
|
+
statusCode: 422,
|
|
98
|
+
error: 'Unprocessable Entity',
|
|
99
|
+
message: error.message,
|
|
100
|
+
reason: error.reason,
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const previewRevisionRoutes = async (fastify, opts) => {
|
|
108
|
+
fastify.get('/preview-revision/:ref/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRevisionRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
|
|
109
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { Liquid } from 'liquidjs';
|
|
3
|
+
import type { SiteConfig } from '../config.ts';
|
|
4
|
+
import type { ThemeTemplates } from '../renderer/theme-templates.ts';
|
|
5
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
6
|
+
export interface PreviewRouteOptions {
|
|
7
|
+
config: SiteConfig;
|
|
8
|
+
themeTemplates: ThemeTemplates;
|
|
9
|
+
layouts: Record<string, string>;
|
|
10
|
+
engine: Liquid;
|
|
11
|
+
tokens: TokenEntry[];
|
|
12
|
+
}
|
|
13
|
+
export declare const previewRoutes: FastifyPluginAsync<PreviewRouteOptions>;
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
import { urlToPagePath } from "../services/urls.js";
|
|
7
|
+
// Same pagesRoot/contentRoot seam as public.ts - see that file's
|
|
8
|
+
// toRenderPath comment. Preview never consults redirects.json:
|
|
9
|
+
// redirects are a public-URL concept, and an editor previewing a
|
|
10
|
+
// specific page path isn't redirected.
|
|
11
|
+
function toRenderPath(pagesRelativePath) {
|
|
12
|
+
return join('pages', pagesRelativePath);
|
|
13
|
+
}
|
|
14
|
+
// Same seam as toRenderPath above, for posts.
|
|
15
|
+
function toPostsRenderPath(postsRelativePath) {
|
|
16
|
+
return join('posts', postsRelativePath);
|
|
17
|
+
}
|
|
18
|
+
async function handlePreviewRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
19
|
+
const url = `/${request.params['*']}`;
|
|
20
|
+
try {
|
|
21
|
+
// /blog is a permanently reserved namespace, same as public.ts -
|
|
22
|
+
// no redirects.json fallback here either way (preview never
|
|
23
|
+
// consults it, per this file's own established comment above).
|
|
24
|
+
if (isBlogUrl(url)) {
|
|
25
|
+
const relativePath = urlToPostPath(url);
|
|
26
|
+
if (relativePath === null) {
|
|
27
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(relativePath), 'preview');
|
|
31
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const relativePath = urlToPagePath(url);
|
|
35
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(relativePath), 'preview');
|
|
36
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof PathSafetyError) {
|
|
40
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof PageRenderError && error.reason === 'page-not-found') {
|
|
44
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export const previewRoutes = async (fastify, opts) => {
|
|
51
|
+
fastify.get('/preview/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handlePreviewRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
|
|
52
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { Liquid } from 'liquidjs';
|
|
3
|
+
import type { SiteConfig } from '../config.ts';
|
|
4
|
+
import type { ThemeTemplates } from '../renderer/theme-templates.ts';
|
|
5
|
+
export interface PublicRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
themeTemplates: ThemeTemplates;
|
|
8
|
+
layouts: Record<string, string>;
|
|
9
|
+
engine: Liquid;
|
|
10
|
+
}
|
|
11
|
+
export declare const publicRoutes: FastifyPluginAsync<PublicRouteOptions>;
|