@acms-subir/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +106 -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 -315
  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/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -0,0 +1,139 @@
1
+ // Signed outgoing webhooks. emitWebhook() queues one delivery row per
2
+ // subscribed endpoint and tries it immediately (in the background where the
3
+ // runtime allows); failures retry with backoff from the cron sweep
4
+ // (processDueDeliveries, run by the Worker's scheduled handler and by the
5
+ // middleware sweep on Node). Every request carries:
6
+ // X-Acms-Event: event.published
7
+ // X-Acms-Delivery: <delivery id>
8
+ // X-Acms-Timestamp: <unix seconds>
9
+ // X-Acms-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">
10
+ // Receivers verify by recomputing the HMAC with the endpoint secret and
11
+ // rejecting stale timestamps.
12
+ import type { ContentRepository } from './repo/types';
13
+ import type { Webhook, WebhookDelivery, WebhookEventType } from './schema';
14
+ import { isPrivateHost } from './net';
15
+
16
+ export const MAX_ATTEMPTS = 4;
17
+ // minutes until attempt n+1 (after the first immediate try)
18
+ const BACKOFF_MIN = [1, 10, 60];
19
+
20
+ const hex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
21
+
22
+ export async function signPayload(secret: string, timestamp: number, body: string): Promise<string> {
23
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
24
+ return `sha256=${hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`)))}`;
25
+ }
26
+
27
+ export function generateWebhookSecret(): string {
28
+ return `whsec_${hex(crypto.getRandomValues(new Uint8Array(24)).buffer)}`;
29
+ }
30
+
31
+ // Endpoint URLs must be public http(s): a webhook must never become a way to
32
+ // make the server call its own network. Localhost is allowed only in dev.
33
+ export function validateWebhookUrl(raw: string, allowPrivate = false): string {
34
+ let url: URL;
35
+ try {
36
+ url = new URL(raw.trim());
37
+ } catch {
38
+ throw new Error('Enter a full URL starting with https://.');
39
+ }
40
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Webhook URLs must use http(s).');
41
+ if (url.username || url.password) throw new Error('Put credentials in the receiver, not the URL.');
42
+ if (!allowPrivate && isPrivateHost(url.hostname)) throw new Error('Webhook URLs must point at a public host.');
43
+ return url.href;
44
+ }
45
+
46
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
47
+
48
+ export interface EmitOptions {
49
+ now?: number;
50
+ fetcher?: Fetcher;
51
+ // Workers: ctx.waitUntil, so the response isn't held for delivery
52
+ waitUntil?: (p: Promise<unknown>) => void;
53
+ }
54
+
55
+ export async function emitWebhook(
56
+ repo: ContentRepository,
57
+ event: WebhookEventType,
58
+ data: Record<string, unknown>,
59
+ opts: EmitOptions = {}
60
+ ): Promise<WebhookDelivery[]> {
61
+ const hooks = (await repo.listWebhooks()).filter((h) => h.active && (h.events.includes(event) || h.events.includes('*')));
62
+ if (!hooks.length) return [];
63
+ const now = opts.now ?? Date.now();
64
+ const rows: WebhookDelivery[] = hooks.map((h) => {
65
+ const id = crypto.randomUUID();
66
+ return {
67
+ id, webhookId: h.id, event,
68
+ payload: JSON.stringify({ id, type: event, createdAt: new Date(now).toISOString(), data }),
69
+ status: 'pending', attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null,
70
+ };
71
+ });
72
+ await repo.createDeliveries(rows);
73
+ const work = Promise.all(rows.map((d) => attemptDelivery(repo, d, hooks.find((h) => h.id === d.webhookId)!, opts))).catch(() => {});
74
+ if (opts.waitUntil) opts.waitUntil(work);
75
+ else await work;
76
+ return rows;
77
+ }
78
+
79
+ export async function attemptDelivery(repo: ContentRepository, d: WebhookDelivery, hook: Webhook, opts: EmitOptions = {}): Promise<void> {
80
+ const now = opts.now ?? Date.now();
81
+ const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
82
+ const ts = Math.floor(now / 1000);
83
+ let code: number | null = null;
84
+ try {
85
+ const res = await fetcher(hook.url, {
86
+ method: 'POST',
87
+ redirect: 'manual',
88
+ headers: {
89
+ 'content-type': 'application/json',
90
+ 'user-agent': 'acms-webhooks/1.0',
91
+ 'x-acms-event': d.event,
92
+ 'x-acms-delivery': d.id,
93
+ 'x-acms-timestamp': String(ts),
94
+ 'x-acms-signature': await signPayload(hook.secret, ts, d.payload),
95
+ },
96
+ body: d.payload,
97
+ signal: AbortSignal.timeout(8000),
98
+ });
99
+ code = res.status;
100
+ await res.body?.cancel().catch(() => {});
101
+ } catch {
102
+ code = null; // unreachable / timeout
103
+ }
104
+ const attempts = d.attempts + 1;
105
+ const ok = code !== null && code >= 200 && code < 300;
106
+ const exhausted = attempts >= MAX_ATTEMPTS;
107
+ await repo.recordDelivery(d.id, {
108
+ status: ok ? 'delivered' : exhausted ? 'failed' : 'pending',
109
+ attempts,
110
+ nextAt: ok || exhausted ? null : now + BACKOFF_MIN[Math.min(attempts - 1, BACKOFF_MIN.length - 1)] * 60_000,
111
+ responseCode: code,
112
+ deliveredAt: ok ? now : null,
113
+ });
114
+ }
115
+
116
+ // retry sweep: due pending deliveries, oldest first, bounded per run
117
+ export async function processDueDeliveries(repo: ContentRepository, opts: EmitOptions = {}): Promise<number> {
118
+ const now = opts.now ?? Date.now();
119
+ const due = await repo.listDueDeliveries(now, 25);
120
+ if (!due.length) return 0;
121
+ const hooks = new Map((await repo.listWebhooks()).map((h) => [h.id, h]));
122
+ let n = 0;
123
+ for (const d of due) {
124
+ const hook = hooks.get(d.webhookId);
125
+ if (!hook || !hook.active) {
126
+ await repo.recordDelivery(d.id, { status: 'failed', attempts: d.attempts, nextAt: null, responseCode: null, deliveredAt: null });
127
+ continue;
128
+ }
129
+ await attemptDelivery(repo, d, hook, { ...opts, now });
130
+ n++;
131
+ }
132
+ return n;
133
+ }
134
+
135
+ // the request's background-work hook, when the runtime has one (Workers)
136
+ export function waitUntilFrom(locals: unknown): ((p: Promise<unknown>) => void) | undefined {
137
+ const ctx = (locals as { runtime?: { ctx?: { waitUntil?: (p: Promise<unknown>) => void } } } | undefined)?.runtime?.ctx;
138
+ return ctx?.waitUntil ? (p) => ctx.waitUntil!(p) : undefined;
139
+ }
package/src/middleware.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
- import { verifySessionToken, SESSION_COOKIE } from './lib/auth';
2
+ import { validateSession, SESSION_COOKIE } from './lib/auth';
3
3
  import { getRepo } from './lib/repo';
4
4
  import { CONTENT } from './lib/content';
5
+ import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
6
+ import { dispatchEmail } from './lib/email/service';
7
+ import { sendDueReminders } from './lib/booking/service';
8
+ import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
5
9
 
6
10
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
7
11
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -56,19 +60,38 @@ export const onRequest = defineMiddleware(async (context, next) => {
56
60
  return context.redirect(pathname.replace(/\/+$/, '') + context.url.search, 301);
57
61
  }
58
62
 
63
+ // design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
64
+ // ?preview=off) become a cookie, then the clean URL — so every link clicked
65
+ // afterwards stays in preview mode
66
+ const previewTo = await previewParamRedirect(context.url, context.request.method, context.cookies, async () =>
67
+ Boolean(await validateSession(context.cookies.get(SESSION_COOKIE)?.value, context.locals)));
68
+ if (previewTo) return context.redirect(previewTo, 302);
69
+
59
70
  if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
60
71
  const token = context.cookies.get(SESSION_COOKIE)?.value;
61
- if (!verifySessionToken(token)) {
72
+ if (!(await validateSession(token, context.locals))) {
62
73
  return context.redirect('/admin/login');
63
74
  }
64
75
  }
65
76
 
66
77
  const now = Date.now();
67
- if (now - lastPublishCheck > 60_000 && !pathname.startsWith('/media/')) {
78
+ if (now - lastPublishCheck > 60_000 && !pathname.startsWith('/media/') && !pathname.startsWith('/account/') && !pathname.startsWith('/api/email/')) {
68
79
  lastPublishCheck = now;
69
80
  try {
70
81
  const repo = await getRepo(context.locals);
71
82
  await repo.publishDuePosts(now);
83
+ // webhook retries ride the same once-a-minute sweep (Node has no
84
+ // cron) — in the background, never holding up this request
85
+ const emails = dispatchEmail(repo, context.locals, 2).catch(() => 0);
86
+ const wait = waitUntilFrom(context.locals);
87
+ if (wait) wait(emails); else await emails;
88
+ const retries = processDueDeliveries(repo, { now }).catch(() => {});
89
+ if (wait) wait(retries); else await retries;
90
+ // booking reminders (Node has no cron): site URL, else this request's origin
91
+ const reminders = repo.getSettings()
92
+ .then((s) => sendDueReminders(repo, { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now }))
93
+ .catch(() => {});
94
+ if (wait) wait(reminders); else await reminders;
72
95
  } catch {
73
96
  // never let the publish sweep break a request
74
97
  }
@@ -76,6 +99,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
76
99
 
77
100
  const response = await next();
78
101
 
102
+ // a preview render must never be cached where a visitor could get it
103
+ if (hasPreviewCookie(context.cookies)) {
104
+ try {
105
+ response.headers.set('Cache-Control', 'private, no-store');
106
+ response.headers.append('Vary', 'Cookie');
107
+ } catch {
108
+ // immutable headers (e.g. a proxied response): nothing to cache-bust
109
+ }
110
+ }
111
+
79
112
  // Redirects fire only when the request would otherwise 404, so a rule can
80
113
  // never shadow real content. The table is cached per isolate for a minute.
81
114
  if (response.status === 404 && !pathname.startsWith('/admin') && !pathname.startsWith('/api')) {
@@ -0,0 +1,23 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import { SEARCH } from '../lib/search-config';
4
+ // Rendered for every unmatched URL and for routes that return an empty 404.
5
+ Astro.response.status = 404;
6
+ ---
7
+
8
+ <Site title="Page not found" noindex>
9
+ <div class="not-found">
10
+ <div class="empty-state">
11
+ <span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6M8.5 11h5" /></svg></span>
12
+ <h1>Page not found</h1>
13
+ <span>It moved, or it never existed.</span>
14
+ {SEARCH.enabled && (
15
+ <form action="/search" method="get" role="search">
16
+ <input type="search" name="q" placeholder="Search the site" aria-label="Search the site" />
17
+ <button class="btn primary sm" type="submit">Search</button>
18
+ </form>
19
+ )}
20
+ <a class="btn secondary sm" href="/">Go home</a>
21
+ </div>
22
+ </div>
23
+ </Site>
@@ -1,25 +1,28 @@
1
1
  ---
2
+ import ThemeSections from '../components/ThemeSections.astro';
2
3
  import Site from '../layouts/Site.astro';
3
4
  import WidgetRenderer from '../components/WidgetRenderer.astro';
4
5
  import { getRepo } from '../lib/repo';
5
- import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
6
+ import { validateSession, SESSION_COOKIE } from '../lib/auth';
6
7
  import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
8
  import { highlightCodeHtml } from '../lib/highlightCode';
8
9
  import { resolveDesign } from '../lib/theme';
10
+ import { effectiveSettings } from '../lib/designPreview';
9
11
 
10
12
  const { slug } = Astro.params;
11
13
  const repo = await getRepo(Astro.locals);
12
14
  const page = slug ? await repo.getPageBySlug(slug) : null;
13
15
 
14
16
  // Drafts are visible only to a logged-in admin (doubles as draft preview).
15
- const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
17
+ const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
16
18
  if (!page || (page.status !== 'published' && !isAdmin)) {
17
19
  return new Response(null, { status: 404, statusText: 'Not found' });
18
20
  }
19
21
  const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
20
- const settings = await repo.getSettings();
22
+ // ?designPreview=1 (admin-only) renders the staged Design-screen changes
23
+ const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
21
24
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
- const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
25
+ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !== 'content' ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
23
26
  ---
24
27
 
25
28
  <Site
@@ -33,7 +36,9 @@ const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplate
33
36
  Draft preview — only you can see this page.
34
37
  </p>
35
38
  )}
36
- {PageTemplate ? (
39
+ {settings.sectionPages?.[page.id]?.mode === 'sections' ? (
40
+ <ThemeSections page={page} settings={settings} />
41
+ ) : PageTemplate ? (
37
42
  <PageTemplate page={page} html={bodyHtml} settings={settings} />
38
43
  ) : page.bodyHtml ? (
39
44
  <article class="page-body" set:html={bodyHtml} />
@@ -0,0 +1,53 @@
1
+ ---
2
+ import { getRepo } from '../../lib/repo';
3
+ import { accountForm, issueAccountToken, redeemAccountToken } from '../../lib/email/accounts';
4
+ import { sha256 } from '../../lib/email/config';
5
+ import { dispatchEmail, kickEmail } from '../../lib/email/service';
6
+ import { waitUntilFrom } from '../../lib/webhooks';
7
+ import { SESSION_COOKIE } from '../../lib/auth';
8
+ import '../../styles/admin.css';
9
+ const action=Astro.params.action||'';
10
+ if(!['invite','reset','forgot'].includes(action))return new Response('Not found',{status:404});
11
+ Astro.response.headers.set('Cache-Control','no-store');
12
+ Astro.response.headers.set('Referrer-Policy','no-referrer');
13
+ Astro.response.headers.set('X-Robots-Tag','noindex');
14
+ const repo=await getRepo(Astro.locals);
15
+ const token=Astro.url.searchParams.get('token')||'';
16
+ let error='',success='';
17
+ if(Astro.request.method==='POST'){
18
+ try{
19
+ if(Astro.request.headers.get('origin')!==Astro.url.origin)throw new Error('Please submit this form from this site.');
20
+ if(Number(Astro.request.headers.get('content-length'))>10000)throw new Error('Form too large.');
21
+ const form=await accountForm(Astro.request);
22
+ const address=String(form.get('email')||'').trim().toLowerCase();
23
+ const ip=Astro.clientAddress||'unknown';
24
+ const allowed=await repo.email.rateLimit(`account-ip:${sha256(ip)}`,20,15*60000);
25
+ if(action==='forgot'){
26
+ const accountAllowed=await repo.email.rateLimit(`reset:${sha256(address)}`,3,15*60000);
27
+ if(allowed&&accountAllowed){
28
+ try { await issueAccountToken(repo,{purpose:'reset',email:address},Astro.locals); const wait=waitUntilFrom(Astro.locals); if(wait)wait(dispatchEmail(repo,Astro.locals,1).catch(()=>0)); }
29
+ catch { /* Generic response also covers unavailable email configuration. */ }
30
+ }
31
+ success='If an account exists for that address, a password reset email will be sent. Check your inbox.';
32
+ }else{
33
+ if(!allowed)throw new Error('Too many attempts. Please try again later.');
34
+ const password=String(form.get('password')||'');
35
+ if(password!==String(form.get('confirm')||''))throw new Error('The passwords do not match.');
36
+ if(!await redeemAccountToken(repo,token,password,action as 'invite'|'reset'))throw new Error('This link has expired, was used, or was revoked. Request a new link.');
37
+ Astro.cookies.delete(SESSION_COOKIE,{path:'/'});
38
+ success=action==='invite'?'Your account is ready. Sign in with your email and new password.':'Your password has changed. Sign in again with your new password.';
39
+ await kickEmail(repo,Astro.locals,waitUntilFrom(Astro.locals));
40
+ }
41
+ }catch(e){error=e instanceof Error?e.message:'Unable to process this request.';}
42
+ }
43
+ const liveToken=action==='forgot'?true:/^[a-zA-Z0-9_-]{43}$/.test(token)&&await repo.email.tokenByHash(sha256(token));
44
+ const heading=action==='forgot'?'Forgot your password?':action==='invite'?'Accept your invitation':'Choose a new password';
45
+ ---
46
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="robots" content="noindex"/><meta name="referrer" content="no-referrer"/><title>{heading}</title></head>
47
+ <body><main class="login-wrap"><div class="card card-pad login-card"><h1>{heading}</h1>
48
+ {error&&<p class="flash error" role="alert">{error}</p>}
49
+ {success?<p class="flash" role="status">{success}</p>:!liveToken?<p>This link has expired or is no longer available. Ask the owner for a new invitation or request a password reset.</p>:<form method="post">
50
+ {action==='forgot'?<label class="field"><span>Email address</span><input name="email" type="email" autocomplete="email" required maxlength="254"/></label>:<><label class="field"><span>New password</span><input name="password" type="password" autocomplete="new-password" minlength="12" maxlength="128" required/></label><label class="field"><span>Confirm password</span><input name="confirm" type="password" autocomplete="new-password" minlength="12" maxlength="128" required/></label><p class="hint">Use at least 12 characters. This link can only be used once.</p></>}
51
+ <button class="btn primary block" type="submit">{action==='forgot'?'Request password reset':action==='invite'?'Accept invitation':'Change password'}</button>
52
+ </form>}
53
+ <p><a href="/admin/login">Back to sign in</a></p></div></main></body></html>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import Admin from '../../layouts/Admin.astro';
3
+ import AiCleaner from '../../components/AiCleaner';
4
+ import { CONTENT } from '../../lib/content';
5
+ const initialKind = Astro.url.searchParams.get('kind') === 'pages' ? 'pages' : 'posts';
6
+ const requestedCollection = Astro.url.searchParams.get('collection') ?? 'posts';
7
+ const initialCollection = CONTENT.collections[requestedCollection] ? requestedCollection : 'posts';
8
+ const collections = Object.entries(CONTENT.collections).map(([key, value]) => ({ key, label: value.plural }));
9
+ ---
10
+ <Admin title="Create from document" section="ai" wide>
11
+ <AiCleaner client:load initialKind={initialKind} initialCollection={initialCollection} collections={collections} />
12
+ </Admin>
@@ -5,7 +5,9 @@ import { createPreviewToken } from '../../../../lib/preview';
5
5
  import { resolveDesign } from '../../../../lib/theme';
6
6
  import { env } from '../../../../lib/env';
7
7
  import { BRAND } from '../../../../lib/brand';
8
- import { CONTENT } from '../../../../lib/content';
8
+ import { CONTENT, collectionUrl } from '../../../../lib/content';
9
+ import { mergePostDraft } from '../../../../lib/drafts';
10
+ import ThemeScript from '../../../../components/ThemeScript.astro';
9
11
  import PostEditor from '../../../../components/editor/PostEditor';
10
12
  import '../../../../styles/admin.css';
11
13
  import '../../../../styles/editor.css';
@@ -22,9 +24,14 @@ const post = await repo.getPost(id);
22
24
  if (!post) return new Response(null, { status: 404 });
23
25
  if (post.collection !== key) return Astro.redirect(`/admin/c/${post.collection}/${post.id}`);
24
26
 
27
+ // The editor hydrates from the pending draft when one exists (live posts
28
+ // stage their edits until Update); the live URL stays on the live slug.
29
+ const editable = mergePostDraft(post);
30
+ const livePath = collectionUrl(post.collection, post.slug);
31
+
25
32
  // Canonical content is the TipTap doc (as HTML). Legacy markdown posts are
26
33
  // converted on first open and become rich content on next save.
27
- const initialHTML = post.bodyHtml || (post.body ? renderMarkdown(post.body) : '');
34
+ const initialHTML = editable.bodyHtml || (editable.body ? renderMarkdown(editable.body) : '');
28
35
  const previewPath = `/preview/post/${post.id}`;
29
36
  const sharePath = `${previewPath}?token=${createPreviewToken('post', post.id)}`;
30
37
  const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
@@ -36,7 +43,8 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
36
43
  <html lang="en">
37
44
  <head>
38
45
  <meta charset="utf-8" />
39
- <meta name="viewport" content="width=device-width, initial-scale=1" />
46
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
47
+ <ThemeScript />
40
48
  <meta name="robots" content="noindex" />
41
49
  <title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
42
50
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -45,12 +53,14 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
45
53
  <body>
46
54
  <PostEditor
47
55
  client:only="react"
48
- post={post}
56
+ post={editable}
49
57
  initialHTML={initialHTML}
50
58
  unsplashEnabled={unsplashEnabled}
51
59
  previewPath={previewPath}
52
60
  sharePath={sharePath}
61
+ livePath={livePath}
53
62
  templates={templates}
63
+ site={{ title: settings.siteTitle, url: settings.siteUrl }}
54
64
  />
55
65
  </body>
56
66
  </html>