@micacms/runtime 0.5.0-rc.2 → 0.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +3 -1
  2. package/dist/app.js +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/config.d.ts +13 -13
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +9 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/context.d.ts +2 -0
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +9 -3
  11. package/dist/context.js.map +1 -1
  12. package/dist/http.d.ts +1 -3
  13. package/dist/http.d.ts.map +1 -1
  14. package/dist/http.js +2 -9
  15. package/dist/http.js.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -3
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/errors.d.ts +1 -2
  21. package/dist/lib/errors.d.ts.map +1 -1
  22. package/dist/lib/errors.js +0 -2
  23. package/dist/lib/errors.js.map +1 -1
  24. package/dist/lib/id.d.ts +0 -10
  25. package/dist/lib/id.d.ts.map +1 -1
  26. package/dist/lib/id.js +0 -50
  27. package/dist/lib/id.js.map +1 -1
  28. package/dist/lib/rate-limit.d.ts +0 -2
  29. package/dist/lib/rate-limit.d.ts.map +1 -1
  30. package/dist/lib/rate-limit.js +0 -3
  31. package/dist/lib/rate-limit.js.map +1 -1
  32. package/dist/middleware/index.d.ts +1 -1
  33. package/dist/middleware/index.js +2 -2
  34. package/dist/middleware/index.js.map +1 -1
  35. package/dist/routes/admin-content.d.ts.map +1 -1
  36. package/dist/routes/admin-content.js +4 -3
  37. package/dist/routes/admin-content.js.map +1 -1
  38. package/dist/routes/admin-media.d.ts.map +1 -1
  39. package/dist/routes/admin-media.js +6 -1
  40. package/dist/routes/admin-media.js.map +1 -1
  41. package/dist/routes/admin-system.d.ts.map +1 -1
  42. package/dist/routes/admin-system.js +6 -4
  43. package/dist/routes/admin-system.js.map +1 -1
  44. package/dist/schema.d.ts +15 -3
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +63 -10
  47. package/dist/schema.js.map +1 -1
  48. package/dist/services/changes.d.ts.map +1 -1
  49. package/dist/services/changes.js +89 -13
  50. package/dist/services/changes.js.map +1 -1
  51. package/dist/services/content-store.d.ts +6 -1
  52. package/dist/services/content-store.d.ts.map +1 -1
  53. package/dist/services/content-store.js +25 -15
  54. package/dist/services/content-store.js.map +1 -1
  55. package/dist/services/staging.d.ts +2 -0
  56. package/dist/services/staging.d.ts.map +1 -1
  57. package/dist/services/staging.js +15 -4
  58. package/dist/services/staging.js.map +1 -1
  59. package/dist/workspace/tree.d.ts +6 -0
  60. package/dist/workspace/tree.d.ts.map +1 -1
  61. package/dist/workspace/tree.js +28 -0
  62. package/dist/workspace/tree.js.map +1 -1
  63. package/package.json +12 -4
  64. package/src/app.ts +127 -0
  65. package/src/config.ts +113 -0
  66. package/src/context.ts +167 -0
  67. package/src/http.ts +46 -0
  68. package/src/index.ts +141 -0
  69. package/src/lib/errors.ts +84 -0
  70. package/src/lib/file-type.ts +108 -0
  71. package/src/lib/id.ts +6 -0
  72. package/src/lib/logger.ts +87 -0
  73. package/src/lib/ports.ts +17 -0
  74. package/src/lib/rate-limit.ts +72 -0
  75. package/src/lib/schema-stats.ts +183 -0
  76. package/src/lib/version.ts +29 -0
  77. package/src/middleware/index.ts +234 -0
  78. package/src/routes/admin-content.ts +197 -0
  79. package/src/routes/admin-media.ts +96 -0
  80. package/src/routes/admin-spa.ts +82 -0
  81. package/src/routes/admin-staging.ts +126 -0
  82. package/src/routes/admin-system.ts +146 -0
  83. package/src/routes/health.ts +67 -0
  84. package/src/schema.ts +217 -0
  85. package/src/server.ts +68 -0
  86. package/src/services/changes.ts +605 -0
  87. package/src/services/content-store.ts +492 -0
  88. package/src/services/media.ts +229 -0
  89. package/src/services/staging.ts +330 -0
  90. package/src/workspace/developer.ts +46 -0
  91. package/src/workspace/files.ts +243 -0
  92. package/src/workspace/git.ts +108 -0
  93. package/src/workspace/locks.ts +103 -0
  94. package/src/workspace/policy.ts +77 -0
  95. package/src/workspace/tree.ts +314 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Middleware.
3
+ *
4
+ * Ordering matters and is asserted in `app.ts`:
5
+ * requestContext → guards → securityHeaders → errorBoundary → rateLimit
6
+ * → originGuard → the auth surface's gate → route
7
+ *
8
+ * The gate itself is not here. Whether a request is anybody — a session
9
+ * middleware, `allowAll()` — belongs to the composing app, which is what
10
+ * keeps this package authentication-neutral.
11
+ */
12
+
13
+ import { ContentPathError } from "@micacms/core";
14
+ import type { ErrorHandler, MiddlewareHandler } from "hono";
15
+ import { bodyLimit } from "hono/body-limit";
16
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
17
+
18
+ import { ApiError, badRequest } from "../lib/errors.js";
19
+ import { clientIp, type Env } from "../http.js";
20
+ import { randomToken } from "../lib/id.js";
21
+
22
+ /** Attaches the container, a request id and a scoped logger; logs the result. */
23
+ export function requestContext(ctx: Env["Variables"]["ctx"]): MiddlewareHandler<Env> {
24
+ return async (c, next) => {
25
+ // A client's id is logged and echoed, so only a short, plain one is kept (#150).
26
+ const offered = c.req.header("x-request-id");
27
+ const requestId = offered && /^[A-Za-z0-9_-]{1,64}$/.test(offered) ? offered : randomToken(8);
28
+ const logger = ctx.logger.child({ requestId });
29
+
30
+ c.set("ctx", ctx);
31
+ c.set("requestId", requestId);
32
+ c.set("logger", logger);
33
+ c.header("x-request-id", requestId);
34
+
35
+ const startedAt = performance.now();
36
+ await next();
37
+ const duration = Math.round(performance.now() - startedAt);
38
+
39
+ const line = {
40
+ method: c.req.method,
41
+ path: new URL(c.req.url).pathname,
42
+ status: c.res.status,
43
+ duration,
44
+ };
45
+
46
+ /**
47
+ * "Am I signed in?" — "No."
48
+ *
49
+ * `/auth/me` is the SPA's first request on every page load, and a 401 is
50
+ * one of its two correct answers, not a rejection worth a warning. Logging
51
+ * it as one puts a scary line in the terminal of everybody who has simply
52
+ * opened the editor while signed out.
53
+ */
54
+ const expected401 = c.res.status === 401 && line.path === "/api/auth/me";
55
+
56
+ if (c.res.status >= 500) logger.error("Request failed", line);
57
+ else if (c.res.status >= 400 && !expected401) logger.warn("Request rejected", line);
58
+ else logger.debug("Request", line);
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Same-origin only. `blob:` and `data:` images cover previews; a route that
64
+ * needs a stricter policy, such as the sandbox on media files, sets its own.
65
+ */
66
+ export const CONTENT_SECURITY_POLICY = [
67
+ "default-src 'self'",
68
+ "img-src 'self' blob: data:",
69
+ "object-src 'none'",
70
+ "base-uri 'none'",
71
+ "frame-ancestors 'none'",
72
+ ].join("; ");
73
+
74
+ export function securityHeaders(): MiddlewareHandler<Env> {
75
+ return async (c, next) => {
76
+ await next();
77
+
78
+ if (!c.res.headers.has("content-security-policy")) {
79
+ c.header("content-security-policy", CONTENT_SECURITY_POLICY);
80
+ }
81
+ // Only for an https public URL: HSTS over plain HTTP is ignored, and on
82
+ // localhost it would pin a developer's other projects to https.
83
+ if (c.get("ctx").config.MICA_PUBLIC_URL.startsWith("https://")) {
84
+ c.header("strict-transport-security", "max-age=31536000");
85
+ }
86
+
87
+ c.header("x-content-type-options", "nosniff");
88
+ c.header("referrer-policy", "same-origin");
89
+ c.header("x-frame-options", "DENY");
90
+ // The admin is a first-party SPA; nothing here needs to be embedded.
91
+ c.header("permissions-policy", "geolocation=(), microphone=(), camera=()");
92
+ };
93
+ }
94
+
95
+ /** Files in one upload request; the Media view sends a selection in one request. */
96
+ export const MAX_FILES_PER_UPLOAD = 10;
97
+
98
+ /** Far more than any entry's JSON, and small enough to refuse a flood early. */
99
+ export const MAX_JSON_BYTES = 2 * 1024 * 1024;
100
+
101
+ /**
102
+ * Refuses an oversized body before a handler reads it into memory (#148). An
103
+ * upload may carry `MAX_FILES_PER_UPLOAD` files of `MICA_MAX_UPLOAD_BYTES`, plus
104
+ * room for the multipart framing; every other request gets `MAX_JSON_BYTES`.
105
+ */
106
+ export function requestSizeLimit(): MiddlewareHandler<Env> {
107
+ return async (c, next) => {
108
+ const { config } = c.get("ctx");
109
+ const upload = c.req.method === "POST" && c.req.path === "/api/admin/media";
110
+ const maxSize = upload
111
+ ? config.MICA_MAX_UPLOAD_BYTES * MAX_FILES_PER_UPLOAD + 1024 * 1024
112
+ : MAX_JSON_BYTES;
113
+
114
+ return bodyLimit({
115
+ maxSize,
116
+ onError: (ctx) => {
117
+ const refused = new ApiError(
118
+ "payload_too_large",
119
+ upload
120
+ ? "The upload is too large. Upload fewer or smaller files at a time."
121
+ : "The request is too large.",
122
+ );
123
+ return ctx.json(refused.toBody(), 413);
124
+ },
125
+ })(c, next);
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Converts thrown errors into the API error envelope. Deliberate failures
131
+ * (`ApiError`) are reported as-is; anything else is a bug and becomes a bare
132
+ * 500, with the stack kept in the log rather than sent to the client.
133
+ *
134
+ * Registered with `app.onError`, not as middleware: Hono's `compose` wraps each
135
+ * handler individually and routes a throw to `onError` at that level, so a
136
+ * try/catch in an outer middleware would never see it.
137
+ */
138
+ export function errorHandler(): ErrorHandler<Env> {
139
+ return (error, c) => {
140
+ const logger = c.get("logger") ?? c.get("ctx").logger;
141
+
142
+ if (error instanceof ApiError) {
143
+ logger.debug("Handled error", { code: error.code, message: error.message });
144
+ return c.json(error.toBody(), error.status as ContentfulStatusCode, error.headers);
145
+ }
146
+
147
+ // A path that could leave the content root is the client's mistake, not a bug.
148
+ if (error instanceof ContentPathError) {
149
+ logger.warn("Refused content path", { message: error.message });
150
+ const refused = badRequest(error.message);
151
+ return c.json(refused.toBody(), refused.status as ContentfulStatusCode);
152
+ }
153
+
154
+ logger.error("Unhandled error", {
155
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
156
+ path: new URL(c.req.url).pathname,
157
+ });
158
+
159
+ return c.json(
160
+ {
161
+ error: {
162
+ code: "internal_error" as const,
163
+ message: "Something went wrong. Please try again.",
164
+ requestId: c.get("requestId"),
165
+ },
166
+ },
167
+ 500,
168
+ );
169
+ };
170
+ }
171
+
172
+ /** Coarse per-IP limit, applied to the whole API surface. */
173
+ export function rateLimit(): MiddlewareHandler<Env> {
174
+ return async (c, next) => {
175
+ const { limiters } = c.get("ctx");
176
+ const result = limiters.api.check(clientIp(c));
177
+
178
+ if (!result.allowed) {
179
+ throw new ApiError("rate_limited", "Too many requests. Please slow down.", {
180
+ headers: { "retry-after": String(result.retryAfter) },
181
+ });
182
+ }
183
+
184
+ await next();
185
+ };
186
+ }
187
+
188
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
189
+
190
+ const normaliseOrigin = (value: string): string => {
191
+ try {
192
+ return new URL(value).origin;
193
+ } catch {
194
+ return value;
195
+ }
196
+ };
197
+
198
+ export function allowedOrigins(config: {
199
+ MICA_PUBLIC_URL: string;
200
+ adminOrigins: string[];
201
+ }): string[] {
202
+ return [...new Set([config.MICA_PUBLIC_URL, ...config.adminOrigins].map(normaliseOrigin))];
203
+ }
204
+
205
+ /**
206
+ * First CSRF layer: the browser sets `Origin` on every state-changing request
207
+ * and a page on another site cannot forge it.
208
+ *
209
+ * Applied to the whole API — including sign-in, which has no session yet and
210
+ * so cannot use the token check below.
211
+ */
212
+ export function originGuard(): MiddlewareHandler<Env> {
213
+ return async (c, next) => {
214
+ if (SAFE_METHODS.has(c.req.method)) {
215
+ await next();
216
+ return;
217
+ }
218
+
219
+ const { config } = c.get("ctx");
220
+ const origin = c.req.header("origin");
221
+
222
+ if (origin) {
223
+ if (!allowedOrigins(config).includes(normaliseOrigin(origin))) {
224
+ throw new ApiError("forbidden", "Request origin is not allowed.");
225
+ }
226
+ } else if (config.isProduction) {
227
+ // A browser always sends Origin on writes; its absence means the request
228
+ // did not come from one. Tolerated in development for curl-based testing.
229
+ throw new ApiError("forbidden", "Missing Origin header.");
230
+ }
231
+
232
+ await next();
233
+ };
234
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Admin routes for entries and singletons. The content store holds every rule;
3
+ * these routes only parse and serialize. The API path is the repository path,
4
+ * Git is the history, and `revision` makes concurrent edits safe.
5
+ */
6
+
7
+ import { ReadOnlyError, ValidationError } from "../services/content-store.js";
8
+ import { ConflictError } from "../workspace/files.js";
9
+ import { collectionEntryPath, compareEntries, entryTitle, LayoutError, singletonPath } from "@micacms/core";
10
+ import { Hono } from "hono";
11
+ import { z } from "zod";
12
+
13
+ import { ApiError, badRequest, notFound } from "../lib/errors.js";
14
+ import type { Env } from "../http.js";
15
+
16
+ const saveSchema = z.object({
17
+ data: z.record(z.string(), z.unknown()),
18
+ slug: z.string().max(120).optional(),
19
+ /**
20
+ * The revision the editor opened. `null` asserts that the entry does not
21
+ * exist, so two parallel creates cannot overwrite.
22
+ */
23
+ revision: z.string().nullable().optional(),
24
+ });
25
+
26
+ const reorderSchema = z.object({ slugs: z.array(z.string().min(1)).max(2000) });
27
+
28
+ async function jsonBody(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
29
+ return c.req.json().catch(() => {
30
+ throw badRequest("The request body must be valid JSON.");
31
+ });
32
+ }
33
+
34
+ /** Maps a conflict to 409 and a validation failure to 422; other errors go to the error handler. */
35
+ async function handled<T>(run: () => Promise<T>): Promise<T> {
36
+ try {
37
+ return await run();
38
+ } catch (error) {
39
+ if (error instanceof ConflictError || error instanceof ReadOnlyError || error instanceof LayoutError) {
40
+ throw new ApiError("conflict", error.message);
41
+ }
42
+ if (error instanceof ValidationError) {
43
+ throw new ApiError("validation_failed", error.message, { issues: error.issues });
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ export function adminContentRoutes(): Hono<Env> {
50
+ const app = new Hono<Env>();
51
+
52
+ app.get("/collections/:collection/entries", async (c) => {
53
+ const { content } = c.get("ctx").services;
54
+ const collection = c.req.param("collection");
55
+ const def = content.requireCollection(collection);
56
+
57
+ const entries = await handled(() => content.list(collection));
58
+ const search = c.req.query("search")?.trim().toLowerCase();
59
+
60
+ const items = entries
61
+ .map((entry) => ({
62
+ slug: entry.slug,
63
+ position: entry.position,
64
+ title: entryTitle(def, entry.data),
65
+ data: entry.data,
66
+ }))
67
+ // Content trees are small, so an in-memory substring match is sufficient.
68
+ .filter((item) => !search || JSON.stringify(item.data).toLowerCase().includes(search))
69
+ .sort(compareEntries(def));
70
+
71
+ return c.json({ items, total: items.length });
72
+ });
73
+
74
+ app.post("/collections/:collection/entries", async (c) => {
75
+ const { content } = c.get("ctx").services;
76
+ const parsed = saveSchema.safeParse(await jsonBody(c));
77
+ if (!parsed.success) throw badRequest("The entry payload is malformed.");
78
+
79
+ const saved = await handled(() =>
80
+ content.save(c.req.param("collection"), {
81
+ data: parsed.data.data,
82
+ expectedRevision: parsed.data.revision ?? null,
83
+ }),
84
+ );
85
+
86
+ return c.json(saved, 201);
87
+ });
88
+
89
+ app.post("/collections/:collection/reorder", async (c) => {
90
+ const { content } = c.get("ctx").services;
91
+ const parsed = reorderSchema.safeParse(await jsonBody(c));
92
+ if (!parsed.success) throw badRequest("Expected a list of entry slugs.");
93
+
94
+ await handled(() => content.reorder(c.req.param("collection"), parsed.data.slugs));
95
+ return c.json({ ok: true });
96
+ });
97
+
98
+ /** Server-side slug generation, so the URL preview matches what is saved. */
99
+ app.get("/collections/:collection/slug", async (c) => {
100
+ const { content } = c.get("ctx").services;
101
+ const source = c.req.query("source") ?? "";
102
+ if (!source.trim()) return c.json({ slug: "" });
103
+
104
+ return c.json({
105
+ slug: await handled(() =>
106
+ content.suggestSlug(c.req.param("collection"), source, c.req.query("except")),
107
+ ),
108
+ });
109
+ });
110
+
111
+ app.get("/collections/:collection/entries/:slug", async (c) => {
112
+ const { content } = c.get("ctx").services;
113
+ const collection = c.req.param("collection");
114
+ const slug = c.req.param("slug");
115
+
116
+ const entry = await handled(() => content.get(collection, slug));
117
+ if (!entry) throw notFound("This entry no longer exists.");
118
+
119
+ const def = content.requireCollection(collection);
120
+
121
+ return c.json({
122
+ collection,
123
+ slug: entry.slug,
124
+ position: entry.position,
125
+ title: entryTitle(def, entry.data),
126
+ data: entry.data,
127
+ // From the model, so the editor need not know when an entry is a directory.
128
+ path: collectionEntryPath(def.fields, collection, entry.slug),
129
+ revision: await content.revisionOf(collection, slug),
130
+ });
131
+ });
132
+
133
+ app.put("/collections/:collection/entries/:slug", async (c) => {
134
+ const { content } = c.get("ctx").services;
135
+ const parsed = saveSchema.safeParse(await jsonBody(c));
136
+ if (!parsed.success) throw badRequest("The entry payload is malformed.");
137
+
138
+ const saved = await handled(() =>
139
+ content.save(c.req.param("collection"), {
140
+ data: parsed.data.data,
141
+ slug: c.req.param("slug"),
142
+ expectedRevision: parsed.data.revision,
143
+ }),
144
+ );
145
+
146
+ return c.json(saved);
147
+ });
148
+
149
+ app.delete("/collections/:collection/entries/:slug", async (c) => {
150
+ const { content } = c.get("ctx").services;
151
+
152
+ await handled(() =>
153
+ content.remove(c.req.param("collection"), c.req.param("slug")),
154
+ );
155
+
156
+ return c.json({ ok: true });
157
+ });
158
+
159
+ /** Entries elsewhere that point at this one — shown before a delete. */
160
+ app.get("/collections/:collection/entries/:slug/references", async (c) => {
161
+ const { content } = c.get("ctx").services;
162
+ const references = await handled(() =>
163
+ content.findReferences(c.req.param("collection"), c.req.param("slug")),
164
+ );
165
+
166
+ return c.json({ references });
167
+ });
168
+
169
+ app.get("/singletons/:name", async (c) => {
170
+ const { content } = c.get("ctx").services;
171
+ const name = c.req.param("name");
172
+ const def = content.requireSingleton(name);
173
+
174
+ return c.json({
175
+ name,
176
+ label: def.label,
177
+ path: singletonPath(def.fields, name),
178
+ data: await handled(() => content.getSingleton(name)),
179
+ revision: await content.revisionOfSingleton(name),
180
+ });
181
+ });
182
+
183
+ app.put("/singletons/:name", async (c) => {
184
+ const { content } = c.get("ctx").services;
185
+ const body = (await jsonBody(c)) as { data?: unknown; revision?: string | null };
186
+
187
+ const saved = await handled(() =>
188
+ content.saveSingleton(c.req.param("name"), body?.data ?? {}, {
189
+ expectedRevision: body?.revision,
190
+ }),
191
+ );
192
+
193
+ return c.json(saved);
194
+ });
195
+
196
+ return app;
197
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Admin media routes. Media is files in the content repository, and there is no delete endpoint.
3
+ * An upload is written on its own, before the entry save, because the editor needs its path first.
4
+ */
5
+
6
+ import { Hono } from "hono";
7
+
8
+ import { ApiError, badRequest, notFound } from "../lib/errors.js";
9
+ import { isImageType } from "../lib/file-type.js";
10
+ import { MAX_FILES_PER_UPLOAD } from "../middleware/index.js";
11
+ import type { Env } from "../http.js";
12
+
13
+ export function adminMediaRoutes(): Hono<Env> {
14
+ const app = new Hono<Env>();
15
+
16
+ app.get("/", async (c) => {
17
+ const { media } = c.get("ctx").services;
18
+ return c.json({ items: await media.list() });
19
+ });
20
+
21
+ app.post("/", async (c) => {
22
+ const ctx = c.get("ctx");
23
+ const form = await c.req.formData().catch(() => {
24
+ throw badRequest("Expected a multipart upload.");
25
+ });
26
+
27
+ // The cast picks one of the two structurally different `File` types (Node's and undici's).
28
+ const files = form
29
+ .getAll("file")
30
+ .filter((value) => typeof value !== "string") as unknown as File[];
31
+
32
+ if (files.length === 0) throw badRequest("No file was uploaded.");
33
+ if (files.length > MAX_FILES_PER_UPLOAD) {
34
+ throw badRequest(`Upload at most ${MAX_FILES_PER_UPLOAD} files at a time.`);
35
+ }
36
+
37
+ const accept = form.get("accept");
38
+ // An entry's own `media/` directory or the shared one; the media service refuses anything else.
39
+ const directory = typeof form.get("directory") === "string" ? String(form.get("directory")) : undefined;
40
+ // The media service accepts `/gallery/x/media` too; take the owner from the path it will write to.
41
+ const owner = (directory ?? "").replace(/^[/\\]+/, "").split(/[/\\]/)[0] ?? "";
42
+ const readOnly = ctx.site.readOnly.get(`collections.${owner}`) ?? ctx.site.readOnly.get(`singletons.${owner}`);
43
+ if (readOnly) throw new ApiError("conflict", readOnly);
44
+
45
+ const prepared = await Promise.all(
46
+ files.map((file) =>
47
+ ctx.services.media.prepare({
48
+ file,
49
+ directory,
50
+ accept: accept === "image" || accept === "document" ? accept : "any",
51
+ }),
52
+ ),
53
+ );
54
+
55
+ await ctx.site.contentRepository.apply({
56
+ changes: prepared.map((item) => ({
57
+ type: "write" as const,
58
+ path: item.file.path,
59
+ contents: item.contents,
60
+ })),
61
+ });
62
+
63
+ return c.json({ items: prepared.map((item) => item.file) }, 201);
64
+ });
65
+
66
+ /**
67
+ * Serves a file to the editor, behind the admin gate. The site build reads
68
+ * the repository, not this route.
69
+ */
70
+ app.get("/file/*", async (c) => {
71
+ const { media } = c.get("ctx").services;
72
+ const mediaPath = c.req.path.replace(/^.*\/file\//, "");
73
+
74
+ let decoded: string;
75
+ try {
76
+ decoded = decodeURIComponent(mediaPath);
77
+ } catch {
78
+ throw badRequest("The file path is not valid.");
79
+ }
80
+
81
+ const found = await media.read(decoded);
82
+ if (!found) throw notFound("No such file.");
83
+
84
+ return c.body(found.bytes as unknown as ArrayBuffer, 200, {
85
+ // Text without a charset is decoded by guessing; the repository's text is UTF-8.
86
+ "content-type": found.mimeType.startsWith("text/") ? `${found.mimeType}; charset=utf-8` : found.mimeType,
87
+ "cache-control": "private, max-age=60",
88
+ // A file opened directly runs nothing and reaches nothing on this origin.
89
+ "content-security-policy": "sandbox; default-src 'none'",
90
+ // An image is shown as a preview; anything else is downloaded, never rendered here.
91
+ ...(isImageType(found.mimeType) ? {} : { "content-disposition": "attachment" }),
92
+ });
93
+ });
94
+
95
+ return app;
96
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Serves the built administration SPA.
3
+ *
4
+ * Small enough to do directly rather than pull in a static-file middleware,
5
+ * and doing it here means the caching rules are explicit: hashed asset files
6
+ * are immutable, `index.html` never is — otherwise a deploy leaves editors on
7
+ * a stale bundle pointing at assets that no longer exist.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import fsp from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { Readable } from "node:stream";
14
+
15
+ import { Hono, type Context } from "hono";
16
+
17
+ import type { Env } from "../http.js";
18
+
19
+ const CONTENT_TYPES: Record<string, string> = {
20
+ ".html": "text/html; charset=utf-8",
21
+ ".js": "text/javascript; charset=utf-8",
22
+ ".css": "text/css; charset=utf-8",
23
+ ".json": "application/json; charset=utf-8",
24
+ ".svg": "image/svg+xml",
25
+ ".png": "image/png",
26
+ ".jpg": "image/jpeg",
27
+ ".webp": "image/webp",
28
+ ".ico": "image/x-icon",
29
+ ".woff2": "font/woff2",
30
+ ".woff": "font/woff",
31
+ ".map": "application/json; charset=utf-8",
32
+ };
33
+
34
+ export function adminAssetsExist(directory: string): boolean {
35
+ return fs.existsSync(path.join(directory, "index.html"));
36
+ }
37
+
38
+ export function adminSpaRoutes(directory: string): Hono<Env> {
39
+ const app = new Hono<Env>();
40
+ const root = path.resolve(directory);
41
+
42
+ const send = async (c: Context<Env>, filePath: string, immutable: boolean) => {
43
+ const stat = await fsp.stat(filePath);
44
+ const extension = path.extname(filePath).toLowerCase();
45
+
46
+ return c.body(Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>, 200, {
47
+ "content-type": CONTENT_TYPES[extension] ?? "application/octet-stream",
48
+ "content-length": String(stat.size),
49
+ "cache-control": immutable
50
+ ? "public, max-age=31536000, immutable"
51
+ : "no-cache, must-revalidate",
52
+ });
53
+ };
54
+
55
+ app.get("/*", async (c) => {
56
+ const url = new URL(c.req.url);
57
+ const relative = decodeURIComponent(url.pathname.replace(/^\/admin\/?/, ""));
58
+ const target = path.join(root, relative);
59
+
60
+ // Never serve outside the build directory, whatever the path contains.
61
+ if (relative && !path.relative(root, target).startsWith("..")) {
62
+ try {
63
+ const stat = await fsp.stat(target);
64
+ if (stat.isFile()) {
65
+ // Vite emits hashed filenames under /assets, so those are immutable.
66
+ return await send(c, target, relative.startsWith("assets/"));
67
+ }
68
+ } catch {
69
+ // Fall through to the SPA entry point.
70
+ }
71
+ }
72
+
73
+ // Any other path belongs to the client-side router.
74
+ try {
75
+ return await send(c, path.join(root, "index.html"), false);
76
+ } catch {
77
+ return c.text("The admin interface has not been built. Run `rush build`.", 503);
78
+ }
79
+ });
80
+
81
+ return app;
82
+ }