@acms-subir/core 0.2.0 → 0.3.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 (254) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -0,0 +1,114 @@
1
+ import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
3
+ import { renderMarkdown } from './markdown';
4
+ import { slugify, type Page, type Post } from './schema';
5
+ import { CONTENT } from './content';
6
+ import type { ContentRepository } from './repo/types';
7
+ import { ApiError } from './api';
8
+ import { EventToolInputs } from './calendar/agent-schema';
9
+ import { runEventTool, type AgentContext, type EventToolInput } from './calendar/agent';
10
+ import { BookingToolInputs, BOOKING_TOOL_NAMES, runBookingTool } from './booking/agent';
11
+
12
+ const Kind = z.enum(['posts', 'pages']);
13
+ const fields = {
14
+ title: z.string().trim().min(1).max(300).optional(),
15
+ content: z.string().max(100_000).optional(),
16
+ excerpt: z.string().max(2000).optional(),
17
+ tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
18
+ seoTitle: z.string().max(300).optional(), seoDesc: z.string().max(2000).optional(),
19
+ };
20
+ export const AgentInput = z.discriminatedUnion('tool', [
21
+ z.object({ tool: z.literal('list_content'), arguments: z.object({ kind: Kind, collection: z.string().optional(), query: z.string().max(200).optional(), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(25) }).strict() }).strict(),
22
+ z.object({ tool: z.literal('get_content'), arguments: z.object({ kind: Kind, id: z.string().min(1) }).strict() }).strict(),
23
+ z.object({ tool: z.literal('create_draft'), arguments: z.object({ ...fields, kind: Kind, title: z.string().trim().min(1).max(300), slug: z.string().max(300).optional(), collection: z.string().optional() }).strict() }).strict(),
24
+ z.object({ tool: z.literal('update_content'), arguments: z.object({ ...fields, kind: Kind, id: z.string().min(1) }).strict() }).strict(),
25
+ z.object({ tool: z.literal('publish_content'), arguments: z.object({ kind: Kind, id: z.string().min(1) }).strict() }).strict(),
26
+ ...EventToolInputs,
27
+ ...BookingToolInputs,
28
+ ]);
29
+ export function renderAgentMarkdown(markdown: string): string {
30
+ const html = sanitizeHtml(renderMarkdown(markdown), {
31
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
32
+ allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, img: ['src', 'alt', 'title'], code: ['class'] },
33
+ allowedSchemes: ['http', 'https', 'mailto'], allowProtocolRelative: false,
34
+ });
35
+ // Editors/public templates fall back to legacy Markdown when bodyHtml is
36
+ // empty. Keep that fallback from reintroducing stripped HTML-only input.
37
+ return html || (markdown ? '<p></p>' : '');
38
+ }
39
+ const editUrl = (kind: 'posts' | 'pages', doc: { id: string; collection?: string }) => kind === 'pages' ? `/admin/pages/${doc.id}` : `/admin/c/${doc.collection}/${doc.id}`;
40
+
41
+ export async function runAgentTool(repo: ContentRepository, raw: unknown, allowPublish: boolean, ctx: AgentContext = { origin: '' }) {
42
+ const parsed = AgentInput.parse(raw);
43
+ if ((BOOKING_TOOL_NAMES as readonly string[]).includes(parsed.tool)) {
44
+ return runBookingTool(repo, parsed as never, allowPublish, ctx);
45
+ }
46
+ if (parsed.tool.endsWith('_event') || parsed.tool === 'list_events' || parsed.tool === 'list_calendars') {
47
+ return runEventTool(repo, parsed as EventToolInput, allowPublish, ctx);
48
+ }
49
+ const input = parsed as Extract<typeof parsed, { tool: 'list_content' | 'get_content' | 'create_draft' | 'update_content' | 'publish_content' }>;
50
+ const args = input.arguments;
51
+ const isPost = args.kind === 'posts';
52
+ if (input.tool === 'list_content') {
53
+ const a = input.arguments;
54
+ if (a.collection && !CONTENT.collections[a.collection]) throw new ApiError(400, 'Unknown collection.');
55
+ let docs: (Page | Post)[] = isPost ? await repo.listPosts(a.collection) : await repo.listPages();
56
+ if (a.query) docs = docs.filter((d) => `${d.title} ${d.slug}`.toLowerCase().includes(a.query!.toLowerCase()));
57
+ return { total: docs.length, offset: a.offset, items: docs.slice(a.offset, a.offset + a.limit).map((d) => ({ id: d.id, title: d.title, slug: d.slug, status: d.status, updatedAt: d.updatedAt, hasPendingDraft: !!d.draft, editUrl: editUrl(a.kind, d) })) };
58
+ }
59
+ if (input.tool === 'create_draft') {
60
+ const a = input.arguments;
61
+ if (isPost && !CONTENT.collections[a.collection ?? 'posts']) throw new ApiError(400, 'Unknown collection.');
62
+ if (!isPost && (a.tags !== undefined || a.excerpt !== undefined || a.collection !== undefined)) throw new ApiError(400, 'Tags, excerpts, and collections apply only to posts.');
63
+ const base = slugify(a.slug || a.title) || 'untitled';
64
+ let slug = base;
65
+ for (let n = 2; isPost ? await repo.getPostBySlug(slug, a.collection ?? 'posts') : await repo.getPageBySlug(slug); n++) slug = `${base}-${n}`;
66
+ const data = contentFields(a);
67
+ const doc = isPost ? await repo.createPost({ title: a.title, slug, collection: a.collection }) : await repo.createPage({ title: a.title, slug });
68
+ if (isPost) { await repo.updatePost(doc.id, data); if (a.tags) await repo.ensureTags(a.tags); }
69
+ else await repo.updatePage(doc.id, pageFields(data));
70
+ return { id: doc.id, slug, status: 'draft', editUrl: editUrl(a.kind, doc) };
71
+ }
72
+ const a = input.arguments;
73
+ const doc = isPost ? await repo.getPost(a.id) : await repo.getPage(a.id);
74
+ if (!doc) throw new ApiError(404, 'Content not found.');
75
+ if (input.tool === 'get_content') return { ...doc, editUrl: editUrl(a.kind, doc) };
76
+ if (input.tool === 'publish_content') {
77
+ if (!allowPublish) throw new ApiError(403, 'Agent publishing is disabled. An owner can enable it in Settings.');
78
+ // Existing editor drafts can rename content; agents never publish those
79
+ // implicitly because doing so requires redirect/homepage handling.
80
+ if (doc.draft && Object.keys(doc.draft).some((key) => !['title', 'body', 'bodyHtml', 'bodyJson', 'excerpt', 'tags', 'seoTitle', 'seoDesc', 'savedAt'].includes(key))) {
81
+ throw new ApiError(409, 'This draft contains editor layout or routing changes. Publish it from the admin editor.');
82
+ }
83
+ if (isPost) {
84
+ if (doc.draft) await repo.applyPostDraft(doc.id);
85
+ const updated = await repo.getPost(doc.id);
86
+ if (updated?.tags.length) await repo.ensureTags(updated.tags);
87
+ await repo.updatePost(doc.id, { status: 'published', scheduledAt: null });
88
+ } else {
89
+ if (doc.draft) await repo.applyPageDraft(doc.id);
90
+ await repo.updatePage(doc.id, { status: 'published' });
91
+ }
92
+ return { id: doc.id, status: 'published', editUrl: editUrl(a.kind, doc) };
93
+ }
94
+ const changes = input.arguments;
95
+ if (!isPost && (changes.tags !== undefined || changes.excerpt !== undefined)) throw new ApiError(400, 'Tags and excerpts apply only to posts.');
96
+ const data = contentFields(changes);
97
+ if (!Object.keys(data).length) throw new ApiError(400, 'Provide at least one content field to update.');
98
+ const staged = doc.status === 'published' || doc.status === 'scheduled';
99
+ if (isPost) {
100
+ if (staged) await repo.savePostDraft(doc.id, data); else await repo.updatePost(doc.id, data);
101
+ if (changes.tags) await repo.ensureTags(changes.tags);
102
+ } else {
103
+ if (staged) await repo.savePageDraft(doc.id, pageFields(data)); else await repo.updatePage(doc.id, pageFields(data));
104
+ }
105
+ return { id: doc.id, status: doc.status, staged, editUrl: editUrl(a.kind, doc) };
106
+ }
107
+ function contentFields(args: z.infer<z.ZodObject<typeof fields>>) {
108
+ const { content, ...rest } = z.object(fields).parse(args);
109
+ return { ...rest, ...(content !== undefined ? { body: content, bodyJson: '', bodyHtml: renderAgentMarkdown(content) } : {}) };
110
+ }
111
+ function pageFields(data: ReturnType<typeof contentFields>) {
112
+ const { body: _body, tags: _tags, excerpt: _excerpt, ...rest } = data;
113
+ return rest;
114
+ }
@@ -0,0 +1,58 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { env } from './env';
3
+
4
+ import type { AiConfig } from './ai-schema';
5
+
6
+ // Only owners configure this destination. Private addresses are intentional
7
+ // for Node deployments on a tailnet. Never accept per-generation URLs.
8
+ export function normalizeAiUrl(provider: AiConfig['provider'], raw: string): string {
9
+ if (provider === 'openai') return 'https://api.openai.com/v1';
10
+ let url: URL;
11
+ try { url = new URL(raw.trim()); } catch { throw new Error('Enter a full API URL starting with https:// or http://.'); }
12
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
13
+ throw new Error('API URL must use HTTP(S), without credentials, query parameters, or a fragment.');
14
+ }
15
+ let path = url.pathname.replace(/\/+$/, '');
16
+ if (/\/(management\.html|v0\/management)(\/|$)/i.test(path)) throw new Error('Use the proxy API base URL, not the CPAMC management URL.');
17
+ if (/\/(chat\/completions|responses|models)$/.test(path)) throw new Error('Enter the API base URL, not an individual endpoint.');
18
+ // Custom providers may serve at the root, /v1, /api/v4, or another prefix.
19
+ // Use the supplied base exactly; never guess a provider's API version.
20
+ url.pathname = path;
21
+ return url.href.replace(/\/+$/, '');
22
+ }
23
+ function encryptionKey(): Buffer {
24
+ const secret = env('SESSION_SECRET');
25
+ if (!secret) throw new Error('SESSION_SECRET is required to store AI credentials.');
26
+ return createHash('sha256').update(`acms:ai:v1:${secret}`).digest();
27
+ }
28
+ export function encryptApiKey(value: string): string {
29
+ if (!value) return '';
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv);
32
+ const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
33
+ return [iv, cipher.getAuthTag(), ciphertext].map((v) => v.toString('base64url')).join('.');
34
+ }
35
+ export function decryptApiKey(value: string): string {
36
+ if (!value) return '';
37
+ try {
38
+ const parts = value.split('.');
39
+ if (parts.length !== 3) throw new Error();
40
+ const [iv, tag, ciphertext] = parts.map((v) => Buffer.from(v, 'base64url'));
41
+ const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), iv);
42
+ decipher.setAuthTag(tag);
43
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
44
+ } catch { throw new Error('The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
45
+ }
46
+ export const hashAgentKey = (key: string) => createHash('sha256').update(key).digest('hex');
47
+ export function validAgentKey(key: string, hash: string): boolean {
48
+ if (!key || !/^[a-f0-9]{64}$/.test(hash)) return false;
49
+ return timingSafeEqual(Buffer.from(hashAgentKey(key), 'hex'), Buffer.from(hash, 'hex'));
50
+ }
51
+ export function generateAgentKey() {
52
+ const token = `acms_agent_${randomBytes(32).toString('hex')}`;
53
+ return { token, keyHash: hashAgentKey(token), keyPrefix: token.slice(0, 18) };
54
+ }
55
+ export function publicAiConfig(ai: AiConfig) {
56
+ const { encryptedKey, ...config } = ai;
57
+ return { ...config, hasApiKey: !!encryptedKey };
58
+ }
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+
3
+ // Read legacy provider names as Custom without changing saved URLs or keys.
4
+ export const AiProvider = z.enum(['openai', 'custom', 'cliproxy', 'tailscale'])
5
+ .transform((provider): 'openai' | 'custom' => provider === 'openai' ? 'openai' : 'custom');
6
+
7
+ export const AiConfig = z.object({
8
+ enabled: z.boolean().default(false),
9
+ provider: AiProvider.default('openai'),
10
+ baseUrl: z.string().default('https://api.openai.com/v1'),
11
+ model: z.string().default(''),
12
+ encryptedKey: z.string().default(''),
13
+ }).default({});
14
+ export type AiConfig = z.infer<typeof AiConfig>;
15
+ export const AgentConfig = z.object({
16
+ enabled: z.boolean().default(false),
17
+ keyHash: z.string().default(''),
18
+ keyPrefix: z.string().default(''),
19
+ allowPublish: z.boolean().default(false),
20
+ }).default({});
21
+ export type AgentConfig = z.infer<typeof AgentConfig>;
22
+
package/src/lib/ai.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ import type { AiConfig } from './ai-schema';
3
+ import { decryptApiKey, normalizeAiUrl } from './ai-config';
4
+ import { ApiError, readResponseJson } from './api';
5
+ import { CleanupInput, cleanupInstructions } from './documents/cleanup';
6
+ import { francAll } from 'franc-min';
7
+
8
+ async function providerRequest(config: AiConfig, path: string, payload?: unknown): Promise<unknown> {
9
+ const baseUrl = normalizeAiUrl(config.provider, config.baseUrl);
10
+ let key: string;
11
+ try { key = decryptApiKey(config.encryptedKey); }
12
+ catch { throw new ApiError(409, 'The saved AI key cannot be decrypted. Re-enter it in Settings.'); }
13
+ if (config.provider === 'openai' && !key) throw new ApiError(400, 'Add your OpenAI API key in Settings.');
14
+ let response: Response;
15
+ try {
16
+ response = await fetch(`${baseUrl}/${path}`, {
17
+ method: payload ? 'POST' : 'GET', redirect: 'error', signal: AbortSignal.timeout(payload ? 90_000 : 15_000),
18
+ headers: { Accept: 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}), ...(payload ? { 'Content-Type': 'application/json' } : {}) },
19
+ ...(payload ? { body: JSON.stringify(payload) } : {}),
20
+ });
21
+ } catch {
22
+ throw new ApiError(502, new URL(baseUrl).hostname.endsWith('.ts.net')
23
+ ? 'Cannot reach the Tailscale proxy. The Node server must be on your tailnet; Cloudflare needs a publicly reachable endpoint. Check the URL, Tailscale access, and proxy service.'
24
+ : 'Cannot reach the AI provider. Check the API URL and network connection.');
25
+ }
26
+ if (!response.ok) {
27
+ await response.body?.cancel();
28
+ const detail = response.status === 401 || response.status === 403 ? 'Check the client API key and provider access.'
29
+ : response.status === 429 ? 'Provider quota or rate limit reached. Try again later.'
30
+ : response.status === 404 ? 'Check the API base URL and selected model.' : 'Check the provider service and model.';
31
+ throw new ApiError(502, `AI provider returned HTTP ${response.status}. ${detail}`);
32
+ }
33
+ try { return await readResponseJson(response, 2_000_000); }
34
+ catch { throw new ApiError(502, 'Provider returned an invalid or oversized JSON response. Check that the URL serves the AI API.'); }
35
+ }
36
+ export async function listAiModels(config: AiConfig) {
37
+ const result = z.object({ data: z.array(z.object({ id: z.string() })) }).safeParse(await providerRequest(config, 'models'));
38
+ if (!result.success) throw new ApiError(502, 'Provider did not return an OpenAI-compatible model list.');
39
+ return [...new Set(result.data.data.map((m) => m.id))].sort();
40
+ }
41
+ async function requestCleanupText(config: AiConfig, instruction: string, input: string) {
42
+ if (!config.enabled) throw new ApiError(409, 'AI is disabled. Enable it in Settings → AI & agents.');
43
+ if (!config.model) throw new ApiError(400, 'Choose an AI model in Settings.');
44
+ if (config.provider === 'openai') {
45
+ const raw = await providerRequest(config, 'responses', { model: config.model, instructions: instruction, input, store: false, max_output_tokens: 16384 });
46
+ const result = z.object({ status: z.string().optional(), output: z.array(z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })).optional() })).optional() }).safeParse(raw);
47
+ if (!result.success || result.data.status === 'incomplete' || result.data.status === 'failed') throw new ApiError(502, 'The provider could not complete the cleanup. Try a smaller document or a model with a larger output limit.');
48
+ const text = result.data.output?.flatMap((o) => o.content ?? []).filter((c) => c.type === 'output_text').map((c) => c.text ?? '').join('\n').trim();
49
+ if (!text) throw new ApiError(502, 'The provider returned no cleaned text. Try another model.');
50
+ return text;
51
+ }
52
+ const raw = await providerRequest(config, 'chat/completions', { model: config.model, messages: [{ role: 'system', content: instruction }, { role: 'user', content: input }], stream: false, max_tokens: 16384 });
53
+ const result = z.object({ choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }), finish_reason: z.string().nullish() })) }).safeParse(raw);
54
+ const choice = result.success ? result.data.choices[0] : null;
55
+ if (!choice?.message.content?.trim() || choice.finish_reason === 'length') throw new ApiError(502, 'The provider returned empty or incomplete text. Try another model or a smaller document.');
56
+ return choice.message.content.trim();
57
+ }
58
+
59
+
60
+ // This is a conservative guard, not a language guarantee: short/ambiguous or
61
+ // mixed-language documents still need review. Reject clear full-document
62
+ // language switches rather than silently accepting a translated result.
63
+ function languageChanged(source: string, cleaned: string): boolean {
64
+ if (source.length < 160 || cleaned.length < 160) return false;
65
+ const before = francAll(source, { minLength: 160 });
66
+ const after = francAll(cleaned, { minLength: 160 });
67
+ if (before[0]?.[0] === 'und' || after[0]?.[0] === 'und' || before[0]?.[0] === after[0]?.[0]) return false;
68
+ const beforeInAfter = after.find(([lang]) => lang === before[0]?.[0])?.[1] ?? 0;
69
+ const afterInBefore = before.find(([lang]) => lang === after[0]?.[0])?.[1] ?? 0;
70
+ return beforeInAfter < 0.7 && afterInBefore < 0.7;
71
+ }
72
+
73
+ export async function cleanDocument(config: AiConfig, raw: unknown) {
74
+ const input = CleanupInput.parse(raw);
75
+ const response = await requestCleanupText(config, cleanupInstructions(input), JSON.stringify({
76
+ cleanupNotes: input.notes, sourceDocument: input.source,
77
+ }));
78
+ let parsed: unknown;
79
+ try { parsed = JSON.parse(response.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim()); }
80
+ catch { throw new ApiError(502, 'The provider returned an unreadable cleanup result. Your original document is unchanged. Try again.'); }
81
+ const result = z.object({ text: z.string().trim().min(1).max(100_000), warnings: z.array(z.string().max(1000)).max(30).default([]) }).safeParse(parsed);
82
+ if (!result.success) throw new ApiError(502, 'The provider returned an invalid cleanup result. Your original document is unchanged.');
83
+ if (!input.translate && languageChanged(input.source, result.data.text)) {
84
+ throw new ApiError(502, 'The cleanup appears to change the document language without permission. The result was rejected; retry or use another model.');
85
+ }
86
+ if (!input.translate && result.data.text.replace(/\s+/g, ' ').length < input.source.replace(/\s+/g, ' ').length * 0.55) {
87
+ throw new ApiError(502, 'The cleanup appears to omit too much source content. The result was rejected. Try a smaller document or another model.');
88
+ }
89
+ return { ...result.data, model: config.model };
90
+ }
@@ -0,0 +1,65 @@
1
+ // Scoped API keys for the integration APIs (/api/calendar/*, and bookings
2
+ // later). A key is shown once at creation; only its SHA-256 hash is stored.
3
+ // The agent key from Settings → AI & agents also authenticates here and
4
+ // counts as every scope, so existing agent setups keep working.
5
+ import type { ContentRepository } from './repo/types';
6
+ import type { ApiScope } from './schema';
7
+ import { ApiError } from './api';
8
+ import { validAgentKey } from './ai-config';
9
+
10
+ export const API_KEY_PREFIX = 'acms_key_';
11
+
12
+ const hex = (bytes: ArrayBuffer | Uint8Array) =>
13
+ [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
14
+
15
+ export async function sha256Hex(value: string): Promise<string> {
16
+ return hex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
17
+ }
18
+
19
+ export function generateApiKey(): string {
20
+ return API_KEY_PREFIX + hex(crypto.getRandomValues(new Uint8Array(24)));
21
+ }
22
+
23
+ // creates the record and returns the plaintext key — the only time it exists
24
+ export async function issueApiKey(repo: ContentRepository, name: string, scopes: ApiScope[]) {
25
+ const key = generateApiKey();
26
+ const record = await repo.createApiKey({
27
+ name: name.trim() || 'API key',
28
+ prefix: key.slice(0, API_KEY_PREFIX.length + 6),
29
+ keyHash: await sha256Hex(key),
30
+ scopes: [...new Set(scopes)],
31
+ });
32
+ return { key, record };
33
+ }
34
+
35
+ export interface ApiPrincipal {
36
+ via: 'key' | 'agent';
37
+ name: string;
38
+ scopes: Set<ApiScope> | 'all';
39
+ }
40
+
41
+ export const hasScope = (p: ApiPrincipal, scope: ApiScope) => p.scopes === 'all' || p.scopes.has(scope);
42
+
43
+ // null = no (or an unknown) bearer token; callers decide whether anonymous
44
+ // access is fine (public calendars) or a 401
45
+ export async function authenticate(request: Request, repo: ContentRepository): Promise<ApiPrincipal | null> {
46
+ const auth = request.headers.get('authorization') ?? '';
47
+ const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
48
+ if (!token) return null;
49
+ if (token.startsWith(API_KEY_PREFIX)) {
50
+ const key = await repo.findApiKeyByHash(await sha256Hex(token));
51
+ return key ? { via: 'key', name: key.name, scopes: new Set(key.scopes) } : null;
52
+ }
53
+ const { agent } = await repo.getSettings();
54
+ if (agent.enabled && validAgentKey(token, agent.keyHash)) return { via: 'agent', name: 'Agent key', scopes: 'all' };
55
+ return null;
56
+ }
57
+
58
+ export async function requireScope(request: Request, repo: ContentRepository, scope: ApiScope): Promise<ApiPrincipal> {
59
+ const principal = await authenticate(request, repo);
60
+ if (!principal) {
61
+ throw new ApiError(401, 'Send an API key: Authorization: Bearer <key>.');
62
+ }
63
+ if (!hasScope(principal, scope)) throw new ApiError(403, `This key is missing the ${scope} scope.`);
64
+ return principal;
65
+ }
package/src/lib/api.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { validateSession, OWNER_ENV, SESSION_COOKIE } from './auth';
3
+ import type { ContentRepository } from './repo/types';
4
+
5
+ export class ApiError extends Error {
6
+ constructor(public status: number, message: string) { super(message); }
7
+ }
8
+ export const json = (body: unknown, status = 200) => Response.json(body, {
9
+ status, headers: { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' },
10
+ });
11
+ export function apiError(error: unknown): Response {
12
+ if (error instanceof ApiError) return json({ error: error.message }, error.status);
13
+ if (error instanceof z.ZodError) return json({ error: 'Invalid request', issues: error.issues.map(({ path, message }) => ({ path, message })) }, 400);
14
+ return json({ error: 'Request failed. Please try again.' }, 500);
15
+ }
16
+ export async function readJson(request: Request, maxBytes = 300_000): Promise<unknown> {
17
+ if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new ApiError(415, 'Use Content-Type: application/json.');
18
+ return readResponseJson(request, maxBytes);
19
+ }
20
+ export async function readResponseJson(source: Request | Response, maxBytes: number): Promise<unknown> {
21
+ const bytes = await readResponseBytes(source, maxBytes);
22
+ try { return JSON.parse(new TextDecoder().decode(bytes)); }
23
+ catch { throw new ApiError(400, 'Body must be valid JSON.'); }
24
+ }
25
+ export async function readResponseBytes(source: Request | Response, maxBytes: number): Promise<Uint8Array> {
26
+ if (Number(source.headers.get('content-length')) > maxBytes) throw new ApiError(413, 'Payload too large.');
27
+ const reader = source.body?.getReader();
28
+ if (!reader) throw new ApiError(400, 'JSON body is required.');
29
+ const chunks: Uint8Array[] = [];
30
+ let length = 0;
31
+ try {
32
+ while (true) {
33
+ const { done, value } = await reader.read();
34
+ if (done) break;
35
+ length += value.byteLength;
36
+ if (length > maxBytes) { await reader.cancel(); throw new ApiError(413, 'Payload too large.'); }
37
+ chunks.push(value);
38
+ }
39
+ } finally { reader.releaseLock(); }
40
+ const bytes = new Uint8Array(length);
41
+ let offset = 0;
42
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
43
+ return bytes;
44
+ }
45
+ export async function requireSession(
46
+ request: Request, cookies: { get(name: string): { value: string } | undefined }, repo: ContentRepository, ownerOnly = false,
47
+ ) {
48
+ const id = (await validateSession(cookies.get(SESSION_COOKIE)?.value, repo));
49
+ if (!id) throw new ApiError(401, 'Sign in to continue.');
50
+ const user = id === OWNER_ENV ? null : await repo.getUser(id);
51
+ if (id !== OWNER_ENV && (!user || (ownerOnly && user.role !== 'owner'))) throw new ApiError(403, 'Owner access is required.');
52
+ const origin = request.headers.get('origin');
53
+ if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') {
54
+ throw new ApiError(403, 'Cross-origin requests are not allowed.');
55
+ }
56
+ }
package/src/lib/auth.ts CHANGED
@@ -15,7 +15,8 @@ function hmac(payload: string): string {
15
15
  }
16
16
 
17
17
  function safeEqual(a: string, b: string): boolean {
18
- return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
18
+ const left = Buffer.from(a), right = Buffer.from(b);
19
+ return left.length === right.length && timingSafeEqual(left, right);
19
20
  }
20
21
 
21
22
  // ---- sessions -------------------------------------------------------------
@@ -27,26 +28,33 @@ function safeEqual(a: string, b: string): boolean {
27
28
 
28
29
  export const OWNER_ENV = '__owner_env__';
29
30
 
30
- export function createSessionToken(userId: string = OWNER_ENV): string {
31
+ export function createSessionToken(userId: string = OWNER_ENV, version = 0): string {
31
32
  const exp = Date.now() + WEEK_MS;
32
33
  if (userId === OWNER_ENV) return `${exp}.${hmac(String(exp))}`;
33
- return `u.${userId}.${exp}.${hmac(`${userId}.${exp}`)}`;
34
+ return `v.${userId}.${version}.${exp}.${hmac(`${userId}.${version}.${exp}`)}`;
34
35
  }
35
36
 
36
37
  // Returns the session's user id (OWNER_ENV for env-password sessions) or
37
38
  // null. Truthy/falsy compatible with the old boolean signature.
38
39
  export function verifySessionToken(token: string | undefined): string | null {
39
40
  if (!token) return null;
41
+ if (token.startsWith('v.')) {
42
+ const parts = token.split('.');
43
+ if (parts.length !== 5) return null;
44
+ const [, id, version, exp, sig] = parts;
45
+ if (!id || !/^\d+$/.test(version) || !Number.isFinite(Number(exp)) || Number(exp) < Date.now()) return null;
46
+ return safeEqual(sig, hmac(`${id}.${version}.${exp}`)) ? id : null;
47
+ }
40
48
  if (token.startsWith('u.')) {
41
49
  const parts = token.split('.');
42
50
  if (parts.length !== 4) return null;
43
51
  const [, userId, expStr, sig] = parts;
44
- if (!userId || Number(expStr) < Date.now()) return null;
52
+ if (!userId || !Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
45
53
  return safeEqual(sig, hmac(`${userId}.${expStr}`)) ? userId : null;
46
54
  }
47
55
  const [expStr, sig] = token.split('.');
48
56
  if (!expStr || !sig) return null;
49
- if (Number(expStr) < Date.now()) return null;
57
+ if (!Number.isFinite(Number(expStr)) || Number(expStr) < Date.now()) return null;
50
58
  return safeEqual(sig, hmac(expStr)) ? OWNER_ENV : null;
51
59
  }
52
60
 
@@ -92,3 +100,15 @@ export async function verifyPassword(password: string, stored: string): Promise<
92
100
  }
93
101
 
94
102
  export const SESSION_COOKIE = COOKIE;
103
+
104
+ // All authorization paths must consult the database, not just the signature.
105
+ export async function validateSession(token: string | undefined, localsOrRepo?: unknown): Promise<string | null> {
106
+ const id = verifySessionToken(token);
107
+ if (!id || id === OWNER_ENV) return id;
108
+ const { getRepo } = await import('./repo');
109
+ const repo = localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo
110
+ ? localsOrRepo as import('./repo/types').ContentRepository : await getRepo(localsOrRepo);
111
+ const version = await repo.email.sessionVersion(id);
112
+ const expected = token!.startsWith('v.') ? Number(token!.split('.')[2]) : 0;
113
+ return version !== null && version === expected ? id : null;
114
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { Page, Post, Media, Link, Tag, User, NavItem, Redirect, Settings, Calendar, CalEvent, Resource, BookingLink, Booking } from '../schema';
3
+ import type { ContentRepository } from '../repo/types';
4
+
5
+ export const BookingRequest = z.object({ keyHash: z.string().regex(/^[a-f0-9]{64}$/), requestHash: z.string().regex(/^[a-f0-9]{64}$/), bookingId: z.string().min(1), createdAt: z.number().int().nonnegative() });
6
+ export type BookingRequest = z.infer<typeof BookingRequest>;
7
+ export const BackupContent = z.object({
8
+ format: z.literal('acms-backup'), version: z.number().int().min(1).max(5), exportedAt: z.string().optional(),
9
+ settings: Settings.partial(), pages: z.array(Page), posts: z.array(Post), media: z.array(Media), links: z.array(Link),
10
+ tags: z.array(Tag), navItems: z.array(NavItem), users: z.array(User), redirects: z.array(Redirect).default([]),
11
+ calendars: z.array(Calendar).default([]), events: z.array(CalEvent).default([]), resources: z.array(Resource).default([]),
12
+ bookingLinks: z.array(BookingLink).default([]), bookings: z.array(Booking).default([]), bookingRequests: z.array(BookingRequest).optional(),
13
+ }).superRefine((data, ctx) => {
14
+ if (data.version >= 5 && !data.bookingRequests) ctx.addIssue({ code: 'custom', message: 'Version 5 requires booking retry records.' });
15
+ const keys = new Set<string>();
16
+ for (const r of data.bookingRequests ?? []) {
17
+ if (keys.has(r.keyHash)) ctx.addIssue({ code: 'custom', message: 'Duplicate booking retry key.' });
18
+ keys.add(r.keyHash);
19
+ }
20
+ for (const m of data.media) for (const key of [m.key, ...m.variants.map(v => v.key)]) {
21
+ if (!key || key.startsWith('/') || key.includes('..') || key.includes('\\') || /[\x00-\x1f]/.test(key)) ctx.addIssue({ code: 'custom', message: 'Unsafe media key.' });
22
+ }
23
+ });
24
+ export async function exportContent(repo: ContentRepository, now = Date.now()) {
25
+ const [settings, pages, posts, media, links, tags, navItems, users, redirects, calendars, events, resources, bookingLinks, bookingState] = await Promise.all([
26
+ repo.getSettings(), repo.listPages(), repo.listPosts(), repo.listMedia(), repo.listLinks(), repo.listTags(), repo.listNavItems(),
27
+ repo.listUsers(), repo.listRedirects(), repo.listCalendars(), repo.listEvents(), repo.listResources(), repo.listBookingLinks(), repo.exportBookingState(),
28
+ ]);
29
+ return {
30
+ format: 'acms-backup' as const, version: 5, exportedAt: new Date(now).toISOString(),
31
+ settings: { ...settings, live: { ...settings.live, mode: 'off' as const, manual: { watchUrl: '', title: 'We’re live', until: 0 } }, ai: { ...settings.ai, enabled: false, encryptedKey: '' }, agent: { ...settings.agent, enabled: false, keyHash: '', keyPrefix: '', allowPublish: false } },
32
+ pages, posts, media, links, tags, navItems, users, redirects,
33
+ calendars: calendars.map(c => ({ ...c, feedToken: '' })), events,
34
+ resources: resources.map(r => ({ ...r, feedToken: '' })), bookingLinks, ...bookingState,
35
+ };
36
+ }
@@ -1,3 +1,4 @@
1
+ import { crc32 } from './zip';
1
2
  // Minimal ZIP reader for acms backups. Backups are written by zip.ts with
2
3
  // the store method (no compression), so this only needs to walk the central
3
4
  // directory and slice entries out — deflated archives from other tools are
@@ -13,11 +14,14 @@ export function unzip(buf: ArrayBuffer): Map<string, Uint8Array> {
13
14
  if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
14
15
  }
15
16
  if (eocd < 0) throw new Error('Not a ZIP file.');
17
+ if (view.getUint16(eocd + 4, true) || view.getUint16(eocd + 6, true)) throw new Error('Split ZIP archives are not supported.');
16
18
  const count = view.getUint16(eocd + 10, true);
17
19
  let off = view.getUint32(eocd + 16, true);
20
+ if (off + view.getUint32(eocd + 12, true) !== eocd) throw new Error('Corrupt ZIP directory size.');
18
21
 
19
22
  const entries = new Map<string, Uint8Array>();
20
23
  for (let n = 0; n < count; n++) {
24
+ if (off + 46 > eocd) throw new Error('Truncated ZIP directory.');
21
25
  if (view.getUint32(off, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
22
26
  const method = view.getUint16(off + 10, true);
23
27
  const compSize = view.getUint32(off + 20, true);
@@ -25,13 +29,20 @@ export function unzip(buf: ArrayBuffer): Map<string, Uint8Array> {
25
29
  const extraLen = view.getUint16(off + 30, true);
26
30
  const commentLen = view.getUint16(off + 32, true);
27
31
  const headerOff = view.getUint32(off + 42, true);
32
+ if (off + 46 + nameLen + extraLen + commentLen > eocd || headerOff + 30 > off) throw new Error('Truncated ZIP entry.');
33
+ if (view.getUint16(off + 8, true) & 1) throw new Error('Encrypted ZIP entries are not supported.');
28
34
  const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
29
35
  if (method !== 0) throw new Error('Compressed ZIP entries are not supported — use an unmodified acms backup.');
36
+ if (!name || name.startsWith('/') || name.split('/').includes('..') || name.includes('\\') || entries.has(name)) throw new Error('Unsafe or duplicate ZIP entry.');
37
+ if (compSize !== view.getUint32(off + 24, true) || view.getUint32(headerOff, true) !== 0x04034b50) throw new Error('Corrupt ZIP entry.');
30
38
  // local header repeats name/extra lengths; data starts after them
31
39
  const localNameLen = view.getUint16(headerOff + 26, true);
32
40
  const localExtraLen = view.getUint16(headerOff + 28, true);
33
41
  const dataStart = headerOff + 30 + localNameLen + localExtraLen;
34
- entries.set(name, bytes.subarray(dataStart, dataStart + compSize));
42
+ if (dataStart + compSize > view.getUint32(eocd + 16, true)) throw new Error('Truncated ZIP data.');
43
+ const data = bytes.subarray(dataStart, dataStart + compSize);
44
+ if (crc32(data) !== view.getUint32(off + 16, true)) throw new Error('ZIP checksum mismatch. Download a fresh backup.');
45
+ entries.set(name, data);
35
46
  off += 46 + nameLen + extraLen + commentLen;
36
47
  }
37
48
  return entries;
@@ -19,7 +19,7 @@ const CRC_TABLE = (() => {
19
19
  return t;
20
20
  })();
21
21
 
22
- function crc32(data: Uint8Array): number {
22
+ export function crc32(data: Uint8Array): number {
23
23
  let c = 0xffffffff;
24
24
  for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
25
25
  return (c ^ 0xffffffff) >>> 0;