@minamocms/framework 0.0.0-stage → 0.10.1

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