@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.
- package/CHANGELOG.md +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
limit?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function emitWebhook(
|
|
57
|
+
repo: ContentRepository,
|
|
58
|
+
event: WebhookEventType,
|
|
59
|
+
data: Record<string, unknown>,
|
|
60
|
+
opts: EmitOptions = {}
|
|
61
|
+
): Promise<WebhookDelivery[]> {
|
|
62
|
+
const hooks = (await repo.listWebhooks()).filter((h) => h.active && (h.events.includes(event) || h.events.includes('*')));
|
|
63
|
+
if (!hooks.length) return [];
|
|
64
|
+
const now = opts.now ?? Date.now();
|
|
65
|
+
const rows: WebhookDelivery[] = hooks.map((h) => {
|
|
66
|
+
const id = crypto.randomUUID();
|
|
67
|
+
return {
|
|
68
|
+
id, webhookId: h.id, event,
|
|
69
|
+
payload: JSON.stringify({ id, type: event, createdAt: new Date(now).toISOString(), data }),
|
|
70
|
+
status: 'pending', attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
await repo.createDeliveries(rows);
|
|
74
|
+
const work = Promise.all(rows.map((d) => attemptDelivery(repo, d, hooks.find((h) => h.id === d.webhookId)!, opts))).catch(() => {});
|
|
75
|
+
if (opts.waitUntil) opts.waitUntil(work);
|
|
76
|
+
else await work;
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function attemptDelivery(repo: ContentRepository, d: WebhookDelivery, hook: Webhook, opts: EmitOptions = {}): Promise<boolean> {
|
|
81
|
+
const now = opts.now ?? Date.now();
|
|
82
|
+
const claimed = await repo.claimDelivery(d.id, now);
|
|
83
|
+
if (!claimed) return false;
|
|
84
|
+
d = claimed;
|
|
85
|
+
const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
|
|
86
|
+
const ts = Math.floor(now / 1000);
|
|
87
|
+
let code: number | null = null;
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetcher(hook.url, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
redirect: 'manual',
|
|
92
|
+
headers: {
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
'user-agent': 'acms-webhooks/1.0',
|
|
95
|
+
'x-acms-event': d.event,
|
|
96
|
+
'x-acms-delivery': d.id,
|
|
97
|
+
'x-acms-timestamp': String(ts),
|
|
98
|
+
'x-acms-signature': await signPayload(hook.secret, ts, d.payload),
|
|
99
|
+
},
|
|
100
|
+
body: d.payload,
|
|
101
|
+
signal: AbortSignal.timeout(8000),
|
|
102
|
+
});
|
|
103
|
+
code = res.status;
|
|
104
|
+
await res.body?.cancel().catch(() => {});
|
|
105
|
+
} catch {
|
|
106
|
+
code = null; // unreachable / timeout
|
|
107
|
+
}
|
|
108
|
+
const attempts = d.attempts + 1;
|
|
109
|
+
const ok = code !== null && code >= 200 && code < 300;
|
|
110
|
+
const exhausted = attempts >= MAX_ATTEMPTS;
|
|
111
|
+
await repo.recordDelivery(d.id, {
|
|
112
|
+
status: ok ? 'delivered' : exhausted ? 'failed' : 'pending',
|
|
113
|
+
attempts,
|
|
114
|
+
nextAt: ok || exhausted ? null : now + BACKOFF_MIN[Math.min(attempts - 1, BACKOFF_MIN.length - 1)] * 60_000,
|
|
115
|
+
responseCode: code,
|
|
116
|
+
deliveredAt: ok ? now : null,
|
|
117
|
+
});
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// retry sweep: due pending deliveries, oldest first, bounded per run
|
|
122
|
+
export async function processDueDeliveries(repo: ContentRepository, opts: EmitOptions = {}): Promise<number> {
|
|
123
|
+
const now = opts.now ?? Date.now();
|
|
124
|
+
const due = await repo.listDueDeliveries(now, opts.limit ?? 25);
|
|
125
|
+
if (!due.length) return 0;
|
|
126
|
+
const hooks = new Map((await repo.listWebhooks()).map((h) => [h.id, h]));
|
|
127
|
+
let n = 0;
|
|
128
|
+
const deliver = async (d: WebhookDelivery) => {
|
|
129
|
+
const hook = hooks.get(d.webhookId);
|
|
130
|
+
if (!hook || !hook.active) {
|
|
131
|
+
await repo.recordDelivery(d.id, { status: 'failed', attempts: d.attempts, nextAt: null, responseCode: null, deliveredAt: null });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Each claim gets a fresh clock, even after earlier batches time out.
|
|
135
|
+
if (await attemptDelivery(repo, d, hook, opts)) n++;
|
|
136
|
+
};
|
|
137
|
+
// Bound network concurrency, while avoiding a string of eight-second
|
|
138
|
+
// endpoint timeouts holding up a request or exhausting waitUntil time.
|
|
139
|
+
for (let i = 0; i < due.length; i += 5) await Promise.all(due.slice(i, i + 5).map(deliver));
|
|
140
|
+
return n;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// the request's background-work hook, when the runtime has one (Workers)
|
|
144
|
+
export function waitUntilFrom(locals: unknown): ((p: Promise<unknown>) => void) | undefined {
|
|
145
|
+
const ctx = (locals as { runtime?: { ctx?: { waitUntil?: (p: Promise<unknown>) => void } } } | undefined)?.runtime?.ctx;
|
|
146
|
+
return ctx?.waitUntil ? (p) => ctx.waitUntil!(p) : undefined;
|
|
147
|
+
}
|
package/src/lib/widgets.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface WidgetMeta {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const widgetMeta: Record<WidgetType, WidgetMeta> = {
|
|
16
|
+
live: { label: 'Live video', description: 'Your shared live broadcast (set up in Integrations)', group: 'Content', icon: 'M3 5h12v14H3zM15 10l6-4v12l-6-4' },
|
|
16
17
|
hero: {
|
|
17
18
|
label: 'Hero',
|
|
18
19
|
description: 'Headline, subtitle + CTA',
|
package/src/middleware.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
|
-
import {
|
|
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 (!
|
|
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).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,25 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
76
99
|
|
|
77
100
|
const response = await next();
|
|
78
101
|
|
|
102
|
+
// Database writes enqueue publication/media notifications atomically.
|
|
103
|
+
// Flush after mutations (including Astro actions), once their transaction
|
|
104
|
+
// has committed. The scheduler remains the durable fallback on failure.
|
|
105
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(context.request.method)) {
|
|
106
|
+
const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
|
|
107
|
+
const wait = waitUntilFrom(context.locals);
|
|
108
|
+
if (wait) wait(deliveries); else await deliveries;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// a preview render must never be cached where a visitor could get it
|
|
112
|
+
if (hasPreviewCookie(context.cookies)) {
|
|
113
|
+
try {
|
|
114
|
+
response.headers.set('Cache-Control', 'private, no-store');
|
|
115
|
+
response.headers.append('Vary', 'Cookie');
|
|
116
|
+
} catch {
|
|
117
|
+
// immutable headers (e.g. a proxied response): nothing to cache-bust
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
// Redirects fire only when the request would otherwise 404, so a rule can
|
|
80
122
|
// never shadow real content. The table is cached per isolate for a minute.
|
|
81
123
|
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,8 +1,9 @@
|
|
|
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 {
|
|
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';
|
|
@@ -13,15 +14,21 @@ const repo = await getRepo(Astro.locals);
|
|
|
13
14
|
const page = slug ? await repo.getPageBySlug(slug) : null;
|
|
14
15
|
|
|
15
16
|
// Drafts are visible only to a logged-in admin (doubles as draft preview).
|
|
16
|
-
const isAdmin =
|
|
17
|
+
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
17
18
|
if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
18
19
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
20
|
}
|
|
20
21
|
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
21
22
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
22
|
-
const settings = effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies);
|
|
23
|
+
const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
24
|
+
// Live state must not survive in a cached page after its snapshot expires.
|
|
25
|
+
if (settings.live.mode !== 'off' || page?.blocks.some(block => block.type === 'live') ||
|
|
26
|
+
(page && settings.sectionPages?.[page.id]?.sections.some(section => section.type === 'live'))) {
|
|
27
|
+
Astro.response.headers.set('Cache-Control', 'no-store');
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
-
const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
|
|
31
|
+
const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !== 'content' ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
|
|
25
32
|
---
|
|
26
33
|
|
|
27
34
|
<Site
|
|
@@ -35,7 +42,9 @@ const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplate
|
|
|
35
42
|
Draft preview — only you can see this page.
|
|
36
43
|
</p>
|
|
37
44
|
)}
|
|
38
|
-
{
|
|
45
|
+
{settings.sectionPages?.[page.id]?.mode === 'sections' ? (
|
|
46
|
+
<ThemeSections page={page} settings={settings} />
|
|
47
|
+
) : PageTemplate ? (
|
|
39
48
|
<PageTemplate page={page} html={bodyHtml} settings={settings} />
|
|
40
49
|
) : page.bodyHtml ? (
|
|
41
50
|
<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>
|
|
@@ -7,6 +7,7 @@ import { env } from '../../../../lib/env';
|
|
|
7
7
|
import { BRAND } from '../../../../lib/brand';
|
|
8
8
|
import { CONTENT, collectionUrl } from '../../../../lib/content';
|
|
9
9
|
import { mergePostDraft } from '../../../../lib/drafts';
|
|
10
|
+
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
10
11
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
11
12
|
import '../../../../styles/admin.css';
|
|
12
13
|
import '../../../../styles/editor.css';
|
|
@@ -42,7 +43,8 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
|
|
|
42
43
|
<html lang="en">
|
|
43
44
|
<head>
|
|
44
45
|
<meta charset="utf-8" />
|
|
45
|
-
<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 />
|
|
46
48
|
<meta name="robots" content="noindex" />
|
|
47
49
|
<title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
|
|
48
50
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -58,6 +60,7 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
|
|
|
58
60
|
sharePath={sharePath}
|
|
59
61
|
livePath={livePath}
|
|
60
62
|
templates={templates}
|
|
63
|
+
site={{ title: settings.siteTitle, url: settings.siteUrl }}
|
|
61
64
|
/>
|
|
62
65
|
</body>
|
|
63
66
|
</html>
|