@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,30 @@
|
|
|
1
|
+
import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
2
|
+
async function handleAssetRequest(request, reply, config) {
|
|
3
|
+
const relativePath = request.params['*'];
|
|
4
|
+
const match = findStaticFile(config.assetsRoot, relativePath);
|
|
5
|
+
if (!match) {
|
|
6
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No asset at "${relativePath}"` });
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
// A synchronous buffered read, not a stream: reply.send(stream) was
|
|
10
|
+
// verified empirically to produce an empty body in this Fastify
|
|
11
|
+
// version (confirmed both via .inject() and a real socket, isolated
|
|
12
|
+
// down to a minimal repro) - a real, environment-specific problem,
|
|
13
|
+
// not a mistake in this route's own logic. No Range/If-Range support
|
|
14
|
+
// in this pass either way (nothing has asked for large-file
|
|
15
|
+
// streaming yet), so buffering the whole file is not a meaningful
|
|
16
|
+
// regression. sendStaticFile (services/static-file.ts) does this
|
|
17
|
+
// read, shared with routes/public.ts's root-mirror check.
|
|
18
|
+
sendStaticFile(reply, match);
|
|
19
|
+
}
|
|
20
|
+
// Deliberately and permanently unauthenticated: static assets (CSS/JS/
|
|
21
|
+
// images) must be fetchable by any visitor's browser without a token,
|
|
22
|
+
// same as the public website itself. Registered without a /v1 prefix,
|
|
23
|
+
// directly on app in server.ts, alongside publicRoutes. No guard
|
|
24
|
+
// against the public catch-all's own /* wildcard is needed here - this
|
|
25
|
+
// is the more specific static-prefixed route winning cleanly (verified
|
|
26
|
+
// empirically), not the reverse case that needed public.ts's /v1/*
|
|
27
|
+
// guard.
|
|
28
|
+
export const assetsRoutes = async (fastify, opts) => {
|
|
29
|
+
fastify.get('/assets/*', async (request, reply) => handleAssetRequest(request, reply, opts.config));
|
|
30
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeSchemas } from '../services/validation.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface BatchRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
themeSchemas: ThemeSchemas;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const batchRoutes: FastifyPluginAsync<BatchRouteOptions>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { BatchError, runBatch } from "../services/batch.js";
|
|
2
|
+
import { isValidCommitAuthor } from "../services/git.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
|
+
function isPlainObject(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
function parseOperation(value) {
|
|
13
|
+
if (!isPlainObject(value)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const { type } = value;
|
|
17
|
+
switch (type) {
|
|
18
|
+
case 'draft-write': {
|
|
19
|
+
const { path, content, expectedEtag } = value;
|
|
20
|
+
if (!isNonEmptyString(path) || !isPlainObject(content) || !isNonEmptyString(expectedEtag)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { type, path, content, expectedEtag };
|
|
24
|
+
}
|
|
25
|
+
case 'draft-discard': {
|
|
26
|
+
const { path } = value;
|
|
27
|
+
if (!isNonEmptyString(path)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { type, path };
|
|
31
|
+
}
|
|
32
|
+
case 'content-delete': {
|
|
33
|
+
const { path, redirectTo } = value;
|
|
34
|
+
if (!isNonEmptyString(path) || (redirectTo !== undefined && !isNonEmptyString(redirectTo))) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { type, path, redirectTo };
|
|
38
|
+
}
|
|
39
|
+
case 'move': {
|
|
40
|
+
const { from, to } = value;
|
|
41
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { type, from, to };
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parsePublish(value) {
|
|
51
|
+
if (!isPlainObject(value)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const { paths } = value;
|
|
55
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { relativePaths: paths };
|
|
59
|
+
}
|
|
60
|
+
const BATCH_BODY_SHAPE = 'Expected { operations: BatchOperation[], publish?: { paths: string[] }, message: string, author: { name, email } }';
|
|
61
|
+
function parseBatchBody(body) {
|
|
62
|
+
if (!isPlainObject(body)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const { operations, publish, message, author } = body;
|
|
66
|
+
if (!Array.isArray(operations)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const parsedOperations = [];
|
|
70
|
+
for (const raw of operations) {
|
|
71
|
+
const operation = parseOperation(raw);
|
|
72
|
+
if (!operation) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
parsedOperations.push(operation);
|
|
76
|
+
}
|
|
77
|
+
let parsedPublish;
|
|
78
|
+
if (publish !== undefined) {
|
|
79
|
+
const result = parsePublish(publish);
|
|
80
|
+
if (!result) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
parsedPublish = result;
|
|
84
|
+
}
|
|
85
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { operations: parsedOperations, publish: parsedPublish, message, author };
|
|
89
|
+
}
|
|
90
|
+
// Maps every BatchReason to a status code, mirroring the same mapping
|
|
91
|
+
// each originating service's own route already applies (publish.ts's
|
|
92
|
+
// replyForPublishError, content.ts's move/delete handlers, drafts.ts's
|
|
93
|
+
// conflict/validation-failed handling) - kept as one switch here since
|
|
94
|
+
// batch.ts's BatchReason is a superset drawn from all four services.
|
|
95
|
+
function replyForBatchError(reply, error) {
|
|
96
|
+
switch (error.reason) {
|
|
97
|
+
case 'conflict':
|
|
98
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
99
|
+
return;
|
|
100
|
+
case 'destination-exists':
|
|
101
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
102
|
+
return;
|
|
103
|
+
case 'has-children':
|
|
104
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
105
|
+
return;
|
|
106
|
+
case 'validation-failed':
|
|
107
|
+
case 'duplicate-path':
|
|
108
|
+
case 'redirect-cycle':
|
|
109
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
110
|
+
return;
|
|
111
|
+
case 'page-not-found':
|
|
112
|
+
case 'source-not-found':
|
|
113
|
+
case 'draft-not-found':
|
|
114
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
115
|
+
return;
|
|
116
|
+
default:
|
|
117
|
+
// write-failed / commit-failed / rollback-failed: no client-side
|
|
118
|
+
// fix applies, fall through to the global handler's sanitised 500.
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export const batchRoutes = async (fastify, opts) => {
|
|
123
|
+
fastify.post('/batch', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
124
|
+
const parsed = parseBatchBody(request.body);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: BATCH_BODY_SHAPE });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await runBatch(opts.config, opts.themeSchemas, parsed.operations, parsed.publish, parsed.message, parsed.author);
|
|
131
|
+
reply.send({ ok: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
135
|
+
// through to the global handler's sanitised 500, the same gap
|
|
136
|
+
// every other route touching a :path already guards against.
|
|
137
|
+
if (error instanceof PathSafetyError) {
|
|
138
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (error instanceof BatchError) {
|
|
142
|
+
replyForBatchError(reply, error);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
|
|
4
|
+
import { CAPABILITIES_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
|
+
import { DRIVER_NAME } from "../search/drivers/node-sqlite-driver.js";
|
|
6
|
+
// The agent's own bundled package.json, not site data - the same
|
|
7
|
+
// import.meta.dirname-relative pattern already established in
|
|
8
|
+
// validation.ts/startup-checks.ts for the agent's own files.
|
|
9
|
+
function readAgentVersion() {
|
|
10
|
+
const packageJsonPath = join(import.meta.dirname, '..', '..', 'package.json');
|
|
11
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
12
|
+
return packageJson.version ?? '0.0.0';
|
|
13
|
+
}
|
|
14
|
+
export const capabilitiesRoutes = async (fastify, opts) => {
|
|
15
|
+
fastify.get('/capabilities', { config: CAPABILITIES_RATE_LIMIT }, async () => ({
|
|
16
|
+
agentVersion: readAgentVersion(),
|
|
17
|
+
contentSchemaVersion: CURRENT_SCHEMA_VERSION,
|
|
18
|
+
sqliteDriver: DRIVER_NAME,
|
|
19
|
+
// Named distinctly from site.config.json's own "media.maxUploadBytes"
|
|
20
|
+
// key so this response reads unambiguously next to its siblings -
|
|
21
|
+
// lets the admin's own client-side upload check validate against
|
|
22
|
+
// the same limit the server will actually enforce (@fastify/multipart's
|
|
23
|
+
// limits.fileSize in media.ts), rather than a separately hardcoded
|
|
24
|
+
// guess that could drift.
|
|
25
|
+
maxMediaUploadBytes: opts.maxUploadBytes,
|
|
26
|
+
}));
|
|
27
|
+
};
|
|
@@ -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 ContentRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const contentRoutes: FastifyPluginAsync<ContentRouteOptions>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { ContentReadError, listContent, readContentFile } from "../services/content-read.js";
|
|
2
|
+
import { DeleteContentError, deleteContent } from "../services/delete-content.js";
|
|
3
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
4
|
+
import { MoveError, movePage } from "../services/move.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 isNonEmptyString(value) {
|
|
9
|
+
return typeof value === 'string' && value.length > 0;
|
|
10
|
+
}
|
|
11
|
+
// content/drafts/ and content/redirects.json sit inside contentRoot
|
|
12
|
+
// (Group N) but are not page-shaped content reachable through the
|
|
13
|
+
// generic content routes - drafts have their own dedicated /v1/drafts/*
|
|
14
|
+
// surface with its own semantics, and redirects.json is agent-managed
|
|
15
|
+
// data with its own /v1/redirects surface. Without this check, a
|
|
16
|
+
// client request resolving into either would either leak an unpublished
|
|
17
|
+
// draft's raw bytes through the "live content" read endpoint, or
|
|
18
|
+
// (for DELETE) unlink a file that was never git-tracked and blow up in
|
|
19
|
+
// commitPaths with a spurious 500 - both real bugs the nesting would
|
|
20
|
+
// introduce, not present when drafts/redirects.json were siteRoot
|
|
21
|
+
// siblings of contentRoot.
|
|
22
|
+
function isReservedContentPath(relativePath) {
|
|
23
|
+
return relativePath === 'redirects.json' || relativePath === 'drafts' || relativePath.startsWith('drafts/');
|
|
24
|
+
}
|
|
25
|
+
async function handleReadContent(request, reply, config) {
|
|
26
|
+
const relativePath = request.params['*'];
|
|
27
|
+
if (isReservedContentPath(relativePath)) {
|
|
28
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const { bytes, etag } = readContentFile(config.contentRoot, relativePath);
|
|
33
|
+
reply.header('etag', etag).type('application/json; charset=utf-8').send(bytes);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// PathSafetyError has no .statusCode - left uncaught it would fall
|
|
37
|
+
// through to the global handler's generic sanitised 500, matching
|
|
38
|
+
// the gap preview.ts's precedent already guards against.
|
|
39
|
+
if (error instanceof PathSafetyError || (error instanceof ContentReadError && error.reason === 'not-found')) {
|
|
40
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function parseDeleteContentBody(body) {
|
|
47
|
+
if (typeof body !== 'object' || body === null) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const { redirectTo, message, author } = body;
|
|
51
|
+
if (redirectTo !== undefined && !isNonEmptyString(redirectTo)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return { redirectTo, message, author };
|
|
58
|
+
}
|
|
59
|
+
async function handleDeleteContent(request, reply, config) {
|
|
60
|
+
const relativePath = request.params['*'];
|
|
61
|
+
if (isReservedContentPath(relativePath)) {
|
|
62
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const parsed = parseDeleteContentBody(request.body);
|
|
66
|
+
if (!parsed) {
|
|
67
|
+
reply.code(400).send({
|
|
68
|
+
statusCode: 400,
|
|
69
|
+
error: 'Bad Request',
|
|
70
|
+
message: 'Expected { redirectTo?: string, message: string, author: { name, email } }',
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await deleteContent(config, relativePath, parsed.redirectTo, parsed.message, parsed.author);
|
|
76
|
+
reply.code(204).send();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof PathSafetyError) {
|
|
80
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No content at "${relativePath}"` });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (error instanceof DeleteContentError) {
|
|
84
|
+
if (error.reason === 'page-not-found') {
|
|
85
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (error.reason === 'has-children') {
|
|
89
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (error.reason === 'redirect-cycle' || error.reason === 'invalid-redirect-target') {
|
|
93
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function parseMoveContentBody(body) {
|
|
101
|
+
if (typeof body !== 'object' || body === null) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const { from, to, message, author, createRedirect } = body;
|
|
105
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (createRedirect !== undefined && typeof createRedirect !== 'boolean') {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { from, to, message, author, createRedirect };
|
|
115
|
+
}
|
|
116
|
+
async function handleMoveContent(request, reply, config) {
|
|
117
|
+
const parsed = parseMoveContentBody(request.body);
|
|
118
|
+
if (!parsed) {
|
|
119
|
+
reply.code(400).send({
|
|
120
|
+
statusCode: 400,
|
|
121
|
+
error: 'Bad Request',
|
|
122
|
+
message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await movePage(config, parsed.from, parsed.to, parsed.message, parsed.author, {
|
|
128
|
+
createRedirect: parsed.createRedirect,
|
|
129
|
+
});
|
|
130
|
+
reply.send({ ok: true });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof PathSafetyError) {
|
|
134
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${parsed.from}"` });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (error instanceof MoveError) {
|
|
138
|
+
if (error.reason === 'source-not-found') {
|
|
139
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (error.reason === 'destination-exists') {
|
|
143
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function handleListContent(request, reply, config) {
|
|
151
|
+
const { type, prefix, draftStatus } = request.query;
|
|
152
|
+
const entries = listContent(config, { type, prefix, draftStatus });
|
|
153
|
+
reply.send(entries);
|
|
154
|
+
}
|
|
155
|
+
export const contentRoutes = async (fastify, opts) => {
|
|
156
|
+
fastify.get('/content', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleListContent(request, reply, opts.config));
|
|
157
|
+
fastify.get('/content/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleReadContent(request, reply, opts.config));
|
|
158
|
+
fastify.delete('/content/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDeleteContent(request, reply, opts.config));
|
|
159
|
+
// A static path, distinct from the GET/DELETE /content/* wildcard
|
|
160
|
+
// above by method as well as by not matching the wildcard's own
|
|
161
|
+
// prefix shape at all (POST here, GET/DELETE there) - no routing
|
|
162
|
+
// collision, per Group C's own verified precedent that Fastify
|
|
163
|
+
// prefers static/exact matches over a wildcard regardless of
|
|
164
|
+
// registration order.
|
|
165
|
+
fastify.post('/content/move', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleMoveContent(request, reply, opts.config));
|
|
166
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeSchemas } from '../services/validation.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface DraftsRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
themeSchemas: ThemeSchemas;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const draftsRoutes: FastifyPluginAsync<DraftsRouteOptions>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ContentReadError, readContentFile } from "../services/content-read.js";
|
|
2
|
+
import { DraftError, discardDraft, saveDraft } from "../services/drafts.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
|
+
async function handleReadDraft(request, reply, config) {
|
|
7
|
+
const relativePath = request.params['*'];
|
|
8
|
+
try {
|
|
9
|
+
const { bytes, etag } = readContentFile(config.draftsRoot, relativePath);
|
|
10
|
+
reply.header('etag', etag).type('application/json; charset=utf-8').send(bytes);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error instanceof PathSafetyError || (error instanceof ContentReadError && error.reason === 'not-found')) {
|
|
14
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function handleSaveDraft(request, reply, config, themeSchemas) {
|
|
21
|
+
const relativePath = request.params['*'];
|
|
22
|
+
// A static per-request property (is the header present at all on
|
|
23
|
+
// *this* request), not shared mutable state - no TOCTOU risk in
|
|
24
|
+
// checking it here, before the queue, unlike the ETag comparison
|
|
25
|
+
// itself (E2/E4/E6), which must happen inside saveDraftJob.
|
|
26
|
+
const ifMatch = request.headers['if-match'];
|
|
27
|
+
if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
|
|
28
|
+
reply.code(428).send({
|
|
29
|
+
statusCode: 428,
|
|
30
|
+
error: 'Precondition Required',
|
|
31
|
+
message: 'An If-Match header is required to save a draft',
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const newEtag = await saveDraft(config, themeSchemas, relativePath, request.body, ifMatch);
|
|
37
|
+
reply.header('etag', newEtag).send({ ok: true });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof PathSafetyError) {
|
|
41
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (error instanceof DraftError && error.reason === 'conflict') {
|
|
45
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (error instanceof DraftError && error.reason === 'validation-failed') {
|
|
49
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message, errors: error.errors });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function handleDiscardDraft(request, reply, config) {
|
|
56
|
+
const relativePath = request.params['*'];
|
|
57
|
+
try {
|
|
58
|
+
await discardDraft(config, relativePath);
|
|
59
|
+
reply.code(204).send();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error instanceof PathSafetyError) {
|
|
63
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No draft at "${relativePath}"` });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const draftsRoutes = async (fastify, opts) => {
|
|
70
|
+
fastify.get('/drafts/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleReadDraft(request, reply, opts.config));
|
|
71
|
+
fastify.put('/drafts/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveDraft(request, reply, opts.config, opts.themeSchemas));
|
|
72
|
+
fastify.delete('/drafts/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleDiscardDraft(request, reply, opts.config));
|
|
73
|
+
};
|
|
@@ -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 GitRouteOptions {
|
|
5
|
+
config: SiteConfig;
|
|
6
|
+
tokens: TokenEntry[];
|
|
7
|
+
}
|
|
8
|
+
export declare const gitRoutes: FastifyPluginAsync<GitRouteOptions>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { commitWorkingTreeChanges } from "../services/git-commit.js";
|
|
2
|
+
import { GitShowError, getCommitLog, readFileAtRevision } from "../services/git-history.js";
|
|
3
|
+
import { RevertError, revertPaths } from "../services/git-revert.js";
|
|
4
|
+
import { isValidCommitAuthor, isValidGitRef } from "../services/git.js";
|
|
5
|
+
import { mimeTypeFor } from "../services/mime-types.js";
|
|
6
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
7
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
8
|
+
import { requireScope } from "../services/token-auth.js";
|
|
9
|
+
function isNonEmptyString(value) {
|
|
10
|
+
return typeof value === 'string' && value.length > 0;
|
|
11
|
+
}
|
|
12
|
+
async function handleGitLog(request, reply, config) {
|
|
13
|
+
const { path, limit: rawLimit } = request.query;
|
|
14
|
+
let limit;
|
|
15
|
+
if (rawLimit !== undefined) {
|
|
16
|
+
const parsed = Number(rawLimit);
|
|
17
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
18
|
+
reply.code(400).send({
|
|
19
|
+
statusCode: 400,
|
|
20
|
+
error: 'Bad Request',
|
|
21
|
+
message: '"limit" must be a positive integer',
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
limit = parsed;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const result = getCommitLog(config, { path, limit });
|
|
29
|
+
reply.send(result);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
33
|
+
// through to the global handler's sanitised 500, the same gap
|
|
34
|
+
// every other route touching a :path already guards against.
|
|
35
|
+
if (error instanceof PathSafetyError) {
|
|
36
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function handleGitShow(request, reply, config) {
|
|
43
|
+
const { ref } = request.params;
|
|
44
|
+
const relativePath = request.params['*'];
|
|
45
|
+
if (!isValidGitRef(ref)) {
|
|
46
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: `Invalid ref: "${ref}"` });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileAtRevision(config, ref, relativePath);
|
|
51
|
+
reply.type(mimeTypeFor(relativePath)).send(content);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof PathSafetyError) {
|
|
55
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof GitShowError) {
|
|
59
|
+
// Two distinct codes, not collapsed to one 404: unlike public.ts's
|
|
60
|
+
// deliberate traversal-vs-miss collapse (justified there because
|
|
61
|
+
// that route is unauthenticated, where leaking which case
|
|
62
|
+
// occurred would aid enumeration), this route sits behind
|
|
63
|
+
// requireScope already - there's no equivalent risk, and
|
|
64
|
+
// collapsing here would only make an admin's diff/history UI
|
|
65
|
+
// worse for no security benefit.
|
|
66
|
+
const code = error.reason === 'invalid-ref' ? 400 : 404;
|
|
67
|
+
reply.code(code).send({
|
|
68
|
+
statusCode: code,
|
|
69
|
+
error: code === 400 ? 'Bad Request' : 'Not Found',
|
|
70
|
+
message: error.message,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function parseRevertBody(body) {
|
|
78
|
+
if (typeof body !== 'object' || body === null) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const { ref, paths, message, author } = body;
|
|
82
|
+
if (!isNonEmptyString(ref)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return { ref, paths, message, author };
|
|
92
|
+
}
|
|
93
|
+
async function handleGitRevert(request, reply, config) {
|
|
94
|
+
const parsed = parseRevertBody(request.body);
|
|
95
|
+
if (!parsed) {
|
|
96
|
+
reply.code(400).send({
|
|
97
|
+
statusCode: 400,
|
|
98
|
+
error: 'Bad Request',
|
|
99
|
+
message: 'Expected { ref: string, paths: string[], message: string, author: { name, email } }',
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await revertPaths(config, parsed.ref, parsed.paths, parsed.message, parsed.author);
|
|
105
|
+
reply.send({ ok: true });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error instanceof PathSafetyError) {
|
|
109
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof RevertError) {
|
|
113
|
+
if (error.reason === 'invalid-ref') {
|
|
114
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (error.reason === 'path-not-found-at-ref') {
|
|
118
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// write-failed/commit-failed/rollback-failed: no client-side fix
|
|
122
|
+
// applies, fall through to the global handler's sanitised 500.
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function parseCommitBody(body) {
|
|
128
|
+
if (typeof body !== 'object' || body === null) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const { message, author } = body;
|
|
132
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return { message, author };
|
|
136
|
+
}
|
|
137
|
+
async function handleGitCommit(request, reply, config) {
|
|
138
|
+
const parsed = parseCommitBody(request.body);
|
|
139
|
+
if (!parsed) {
|
|
140
|
+
reply.code(400).send({
|
|
141
|
+
statusCode: 400,
|
|
142
|
+
error: 'Bad Request',
|
|
143
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Any GitOperationError here is not caught - it rethrows to the
|
|
148
|
+
// global error handler's sanitised 500, same as every other route's
|
|
149
|
+
// uncaught commitPaths failure.
|
|
150
|
+
const result = await commitWorkingTreeChanges(config, parsed.message, parsed.author);
|
|
151
|
+
reply.send({ ok: true, committed: result === 'committed' });
|
|
152
|
+
}
|
|
153
|
+
export const gitRoutes = async (fastify, opts) => {
|
|
154
|
+
fastify.get('/git/log', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleGitLog(request, reply, opts.config));
|
|
155
|
+
fastify.get('/git/show/:ref/*', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleGitShow(request, reply, opts.config));
|
|
156
|
+
fastify.post('/git/revert', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleGitRevert(request, reply, opts.config));
|
|
157
|
+
fastify.post('/git/commit', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleGitCommit(request, reply, opts.config));
|
|
158
|
+
};
|