@acms-subir/core 0.2.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.
- package/CHANGELOG.md +78 -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/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -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 +324 -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 +52 -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/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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- 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 +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -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 +221 -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/links.astro +3 -3
- 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 +64 -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/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/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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -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/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 +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -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 +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import sanitizeHtml from 'sanitize-html';
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
import type { Page, Post } from '../schema';
|
|
5
|
+
import { publicTags } from '../schema';
|
|
6
|
+
import { CONTENT, collectionUrl } from '../content';
|
|
7
|
+
import { I18N, localeHomeUrl } from '../i18n';
|
|
8
|
+
import { siteOrigin } from '../seo';
|
|
9
|
+
import { renderMarkdown } from '../markdown';
|
|
10
|
+
import { blocksToHtml } from '../blocksToHtml';
|
|
11
|
+
import { ApiError } from '../api';
|
|
12
|
+
import { pageQuery, queryParams } from './http';
|
|
13
|
+
|
|
14
|
+
export const contentQuery = z.object({
|
|
15
|
+
...pageQuery, kind: z.enum(['post', 'page']).optional(),
|
|
16
|
+
collection: z.string().min(1).max(80).optional(), locale: z.string().min(1).max(35).optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
export function safeUrl(value: string, origin: string, link = false): string {
|
|
19
|
+
if (!value) return '';
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(value, origin);
|
|
22
|
+
return (['http:', 'https:', ...(link ? ['mailto:', 'tel:'] : [])].includes(url.protocol) && !url.username && !url.password) ? url.href : '';
|
|
23
|
+
} catch { return ''; }
|
|
24
|
+
}
|
|
25
|
+
export function mobileHtml(html: string, origin: string) {
|
|
26
|
+
return sanitizeHtml(html, {
|
|
27
|
+
allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
|
|
28
|
+
allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
|
|
29
|
+
allowedSchemes: ['https', 'http', 'mailto', 'tel'],
|
|
30
|
+
allowedSchemesByTag: { img: ['https', 'http'] },
|
|
31
|
+
allowProtocolRelative: false,
|
|
32
|
+
transformTags: {
|
|
33
|
+
a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: safeUrl(attrs.href ?? '', origin, true) } }),
|
|
34
|
+
img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: safeUrl(attrs.src ?? '', origin) } }),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const iso = (n: number | null) => n === null ? null : new Date(n).toISOString();
|
|
39
|
+
function dto(doc: Page | Post, kind: 'page' | 'post', origin: string, detail: boolean) {
|
|
40
|
+
const post = kind === 'post' ? doc as Post : null;
|
|
41
|
+
const page = kind === 'page' ? doc as Page : null;
|
|
42
|
+
const result = {
|
|
43
|
+
id: doc.id, kind, slug: doc.slug, title: doc.title,
|
|
44
|
+
collection: post?.collection ?? null,
|
|
45
|
+
locale: page?.locale || I18N.defaultLocale || null,
|
|
46
|
+
url: safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin),
|
|
47
|
+
excerpt: post?.excerpt ?? page!.seoDesc,
|
|
48
|
+
tags: post ? publicTags(post.tags) : [],
|
|
49
|
+
image: post ? safeUrl(post.cover, origin) : '', imageAlt: post?.coverAlt ?? '',
|
|
50
|
+
publishedAt: post ? iso(post.publishedAt) : null,
|
|
51
|
+
createdAt: iso(doc.createdAt), updatedAt: iso(doc.updatedAt),
|
|
52
|
+
};
|
|
53
|
+
if (!detail) return result;
|
|
54
|
+
// An intentionally empty canonical editor body must not revive legacy text.
|
|
55
|
+
const html = doc.bodyJson ? doc.bodyHtml : doc.bodyHtml || (post ? renderMarkdown(post.body) : blocksToHtml(page!.blocks));
|
|
56
|
+
return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url), dynamicWidgets: 'website-only' as const } };
|
|
57
|
+
}
|
|
58
|
+
function visible(doc: Page | Post, kind: 'page' | 'post') {
|
|
59
|
+
return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
|
|
60
|
+
}
|
|
61
|
+
export async function listContent(repo: ContentRepository, url: URL) {
|
|
62
|
+
const q = contentQuery.parse(queryParams(url));
|
|
63
|
+
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
64
|
+
if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
|
|
65
|
+
if (q.locale && !I18N.locales.includes(q.locale)) throw new ApiError(400, 'Unknown locale.');
|
|
66
|
+
const origin = siteOrigin(await repo.getSettings(), url);
|
|
67
|
+
const rows = await repo.listPublicContent({ ...q, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
|
|
68
|
+
const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
|
|
69
|
+
const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
|
|
70
|
+
return doc && visible(doc, r.kind) ? dto(doc, r.kind, origin, false) : null;
|
|
71
|
+
}));
|
|
72
|
+
return { items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
|
|
73
|
+
}
|
|
74
|
+
export async function getContent(repo: ContentRepository, url: URL, id: string) {
|
|
75
|
+
const q = z.object({ kind: z.enum(['post', 'page']).optional() }).strict().parse(queryParams(url));
|
|
76
|
+
// IDs are UUIDs, but restored databases may contain arbitrary legacy IDs.
|
|
77
|
+
if (!id || id.length > 200) throw new ApiError(404, 'Content not found.');
|
|
78
|
+
const post = q.kind === 'page' ? null : await repo.getPost(id);
|
|
79
|
+
const page = q.kind === 'post' ? null : await repo.getPage(id);
|
|
80
|
+
if (!q.kind && post && page) throw new ApiError(400, 'Specify kind for this content ID.');
|
|
81
|
+
const kind = post ? 'post' : 'page';
|
|
82
|
+
const doc = post ?? page;
|
|
83
|
+
if (!doc || !visible(doc, kind)) throw new ApiError(404, 'Content not found.');
|
|
84
|
+
return dto(doc, kind, siteOrigin(await repo.getSettings(), url), true);
|
|
85
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ApiError } from '../api';
|
|
3
|
+
import { BookingError } from '../booking/service';
|
|
4
|
+
import { sha256Hex } from '../api-keys';
|
|
5
|
+
import type { ContentRepository } from '../repo/types';
|
|
6
|
+
|
|
7
|
+
const headers = {
|
|
8
|
+
'Access-Control-Allow-Origin': '*',
|
|
9
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
|
|
10
|
+
'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key, If-None-Match',
|
|
11
|
+
'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
|
|
12
|
+
'X-Content-Type-Options': 'nosniff',
|
|
13
|
+
'Cache-Control': 'no-store',
|
|
14
|
+
};
|
|
15
|
+
export const preflight = () => new Response(null, { status: 204, headers });
|
|
16
|
+
export const response = (data: unknown, status = 200, extra: HeadersInit = {}) => {
|
|
17
|
+
const h = new Headers(headers);
|
|
18
|
+
new Headers(extra).forEach((value, key) => h.set(key, value));
|
|
19
|
+
return Response.json({ version: 1, data }, { status, headers: h });
|
|
20
|
+
};
|
|
21
|
+
export async function publicResponse(request: Request, data: unknown) {
|
|
22
|
+
const res = response(data);
|
|
23
|
+
const etag = `"${await sha256Hex(await res.clone().text())}"`;
|
|
24
|
+
res.headers.set('ETag', etag);
|
|
25
|
+
// Revalidate even fresh-looking content: unpublishing must take effect.
|
|
26
|
+
res.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
27
|
+
const match = request.headers.get('if-none-match')?.split(',').some((s) => s.trim().replace(/^W\//, '') === etag || s.trim() === '*');
|
|
28
|
+
return match ? new Response(null, { status: 304, headers: res.headers }) : res;
|
|
29
|
+
}
|
|
30
|
+
export function failure(error: unknown) {
|
|
31
|
+
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
32
|
+
const codes: Record<number, string> = { 400: 'invalid_request', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited' };
|
|
33
|
+
return Response.json({ version: 1, error: {
|
|
34
|
+
code: codes[status] ?? 'internal_error',
|
|
35
|
+
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
36
|
+
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
37
|
+
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}) } });
|
|
38
|
+
}
|
|
39
|
+
export async function limitRequest(repo: ContentRepository, address: string, write = false) {
|
|
40
|
+
const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
|
|
41
|
+
if (!await repo.email.rateLimit(key, write ? 20 : 300, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
42
|
+
}
|
|
43
|
+
export function queryParams(url: URL) {
|
|
44
|
+
const result: Record<string, string> = {};
|
|
45
|
+
for (const [key, value] of url.searchParams) {
|
|
46
|
+
if (key in result) throw new ApiError(400, `Duplicate query parameter: ${key}.`);
|
|
47
|
+
result[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export const pageQuery = {
|
|
52
|
+
page: z.coerce.number().int().min(1).max(10000).default(1),
|
|
53
|
+
limit: z.coerce.number().int().min(1).max(50).default(20),
|
|
54
|
+
};
|
|
55
|
+
export const isoDay = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((s) => {
|
|
56
|
+
const n = Date.parse(s); return Number.isFinite(n) && new Date(n).toISOString().slice(0, 10) === s;
|
|
57
|
+
}, 'Use a valid calendar date.');
|
|
58
|
+
|
|
59
|
+
export function methodNotAllowed(allow = 'GET, HEAD, OPTIONS') {
|
|
60
|
+
const res = failure(new ApiError(405, 'Method not allowed.'));
|
|
61
|
+
res.headers.set('Allow', allow);
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { getRepo } from '../repo';
|
|
4
|
+
import { limitRequest, failure, publicResponse, response } from './http';
|
|
5
|
+
export function readRoute(load: (repo: ContentRepository, url: URL, id: string) => Promise<unknown>, cache = true): APIRoute {
|
|
6
|
+
return async context => {
|
|
7
|
+
try {
|
|
8
|
+
const repo = await getRepo(context.locals);
|
|
9
|
+
let address = 'unknown';
|
|
10
|
+
try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
11
|
+
await limitRequest(repo, address);
|
|
12
|
+
const data = await load(repo, context.url, context.params.id ?? '');
|
|
13
|
+
const res = cache ? await publicResponse(context.request, data) : response(data);
|
|
14
|
+
return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const res = failure(error);
|
|
17
|
+
return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
package/src/lib/ops.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { runtimeValue } from './email/config';
|
|
3
|
+
export function authorizedRunner(request: Request, locals?: unknown): boolean {
|
|
4
|
+
const expected = runtimeValue('EMAIL_RUNNER_SECRET', locals);
|
|
5
|
+
const supplied = request.headers.get('authorization')?.match(/^Bearer (.+)$/)?.[1] ?? '';
|
|
6
|
+
const a = Buffer.from(expected), b = Buffer.from(supplied);
|
|
7
|
+
return expected.length >= 32 && a.length === b.length && timingSafeEqual(a, b);
|
|
8
|
+
}
|
package/src/lib/preview.ts
CHANGED
|
@@ -12,7 +12,7 @@ function sig(payload: string): string {
|
|
|
12
12
|
return createHmac('sha256', secret).update(`preview.${payload}`).digest('hex');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function createPreviewToken(type: 'post' | 'page', id: string, ttlMs = WEEK_MS): string {
|
|
15
|
+
export function createPreviewToken(type: 'post' | 'page' | 'event', id: string, ttlMs = WEEK_MS): string {
|
|
16
16
|
const exp = Date.now() + ttlMs;
|
|
17
17
|
return `${exp}.${sig(`${type}.${id}.${exp}`)}`;
|
|
18
18
|
}
|
|
@@ -26,3 +26,23 @@ export function verifyPreviewToken(type: string, id: string, token: string | und
|
|
|
26
26
|
if (s.length !== expected.length) return false;
|
|
27
27
|
return timingSafeEqual(Buffer.from(s), Buffer.from(expected));
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
// Design share links: ?preview=<token> opens preview mode for anyone holding
|
|
31
|
+
// it. Token = <epoch>.<exp>.<sig>; `epoch` is the draft's shareEpoch, so
|
|
32
|
+
// "Revoke all" (a fresh UUID) kills every earlier link, and publishing or
|
|
33
|
+
// discarding the draft ends them all (no draft left to show).
|
|
34
|
+
export function createDesignToken(epoch: string | number, ttlMs = WEEK_MS): { token: string; expiresAt: number } {
|
|
35
|
+
const exp = Date.now() + ttlMs;
|
|
36
|
+
return { token: `${epoch}.${exp}.${sig(`design.${epoch}.${exp}`)}`, expiresAt: exp };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// the token's expiry when valid for `epoch`, else null
|
|
40
|
+
export function verifyDesignToken(token: string | undefined, epoch: string | number): number | null {
|
|
41
|
+
if (!token) return null;
|
|
42
|
+
const [epochStr, expStr, s] = token.split('.');
|
|
43
|
+
if (!epochStr || !expStr || !s) return null;
|
|
44
|
+
if (epochStr !== String(epoch) || !Number.isFinite(Number(expStr)) || Number(expStr) < Date.now() || epoch === 'legacy-disabled') return null;
|
|
45
|
+
const expected = sig(`design.${epochStr}.${expStr}`);
|
|
46
|
+
if (s.length !== expected.length) return null;
|
|
47
|
+
return timingSafeEqual(Buffer.from(s), Buffer.from(expected)) ? Number(expStr) : null;
|
|
48
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EMAIL_MIGRATION } from '../email/migration';
|
|
1
2
|
// Versioned schema migrations shared by the two SQLite-dialect adapters
|
|
2
3
|
// (D1 and libSQL). Applied versions are recorded in the `migrations` table;
|
|
3
4
|
// each adapter's runner applies whatever is newer, in order, at startup.
|
|
@@ -278,6 +279,222 @@ export const MIGRATIONS: Migration[] = [
|
|
|
278
279
|
version: 25,
|
|
279
280
|
statements: [`ALTER TABLE settings ADD COLUMN analytics TEXT DEFAULT '{}'`],
|
|
280
281
|
},
|
|
282
|
+
{
|
|
283
|
+
version: 26,
|
|
284
|
+
statements: [
|
|
285
|
+
`ALTER TABLE settings ADD COLUMN ai TEXT NOT NULL DEFAULT '{}'`,
|
|
286
|
+
`ALTER TABLE settings ADD COLUMN agent TEXT NOT NULL DEFAULT '{}'`,
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
// Calendar module: calendars (native + live ICS feeds), events with
|
|
291
|
+
// wall-clock times + recurrence, and the integration plumbing shared by
|
|
292
|
+
// events and (later) bookings: scoped API keys and signed webhooks with
|
|
293
|
+
// a retry queue. calendar_ids / tags / exdates / location / cta / fields
|
|
294
|
+
// are JSON columns, same as posts.tags. Event search rows reuse the
|
|
295
|
+
// search_index FTS table with kind = 'event'.
|
|
296
|
+
version: 27,
|
|
297
|
+
statements: [
|
|
298
|
+
`CREATE TABLE IF NOT EXISTS calendars (
|
|
299
|
+
id TEXT PRIMARY KEY,
|
|
300
|
+
slug TEXT UNIQUE NOT NULL,
|
|
301
|
+
name TEXT NOT NULL,
|
|
302
|
+
color TEXT NOT NULL DEFAULT '#4f46e5',
|
|
303
|
+
description TEXT NOT NULL DEFAULT '',
|
|
304
|
+
kind TEXT NOT NULL DEFAULT 'native',
|
|
305
|
+
feed_url TEXT NOT NULL DEFAULT '',
|
|
306
|
+
visibility TEXT NOT NULL DEFAULT 'public',
|
|
307
|
+
feed_token TEXT NOT NULL DEFAULT '',
|
|
308
|
+
ord INTEGER NOT NULL DEFAULT 0,
|
|
309
|
+
created_at INTEGER NOT NULL,
|
|
310
|
+
updated_at INTEGER NOT NULL
|
|
311
|
+
)`,
|
|
312
|
+
`CREATE TABLE IF NOT EXISTS events (
|
|
313
|
+
id TEXT PRIMARY KEY,
|
|
314
|
+
slug TEXT UNIQUE NOT NULL,
|
|
315
|
+
title TEXT NOT NULL,
|
|
316
|
+
body_json TEXT NOT NULL DEFAULT '',
|
|
317
|
+
body_html TEXT NOT NULL DEFAULT '',
|
|
318
|
+
excerpt TEXT NOT NULL DEFAULT '',
|
|
319
|
+
cover TEXT NOT NULL DEFAULT '',
|
|
320
|
+
cover_alt TEXT NOT NULL DEFAULT '',
|
|
321
|
+
seo_title TEXT NOT NULL DEFAULT '',
|
|
322
|
+
seo_desc TEXT NOT NULL DEFAULT '',
|
|
323
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
324
|
+
featured INTEGER NOT NULL DEFAULT 0,
|
|
325
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
326
|
+
template TEXT NOT NULL DEFAULT '',
|
|
327
|
+
starts TEXT NOT NULL,
|
|
328
|
+
ends TEXT NOT NULL DEFAULT '',
|
|
329
|
+
all_day INTEGER NOT NULL DEFAULT 0,
|
|
330
|
+
timezone TEXT NOT NULL DEFAULT '',
|
|
331
|
+
rrule TEXT NOT NULL DEFAULT '',
|
|
332
|
+
exdates TEXT NOT NULL DEFAULT '[]',
|
|
333
|
+
cancelled INTEGER NOT NULL DEFAULT 0,
|
|
334
|
+
location TEXT NOT NULL DEFAULT '{}',
|
|
335
|
+
cta TEXT NOT NULL DEFAULT '{}',
|
|
336
|
+
calendar_ids TEXT NOT NULL DEFAULT '[]',
|
|
337
|
+
fields TEXT NOT NULL DEFAULT '{}',
|
|
338
|
+
source_uid TEXT NOT NULL DEFAULT '',
|
|
339
|
+
start_at INTEGER NOT NULL,
|
|
340
|
+
end_at INTEGER NOT NULL,
|
|
341
|
+
draft TEXT,
|
|
342
|
+
published_at INTEGER,
|
|
343
|
+
scheduled_at INTEGER,
|
|
344
|
+
created_at INTEGER NOT NULL,
|
|
345
|
+
updated_at INTEGER NOT NULL
|
|
346
|
+
)`,
|
|
347
|
+
`CREATE INDEX IF NOT EXISTS events_start_at ON events (start_at)`,
|
|
348
|
+
`CREATE INDEX IF NOT EXISTS events_status ON events (status)`,
|
|
349
|
+
`CREATE TABLE IF NOT EXISTS api_keys (
|
|
350
|
+
id TEXT PRIMARY KEY,
|
|
351
|
+
name TEXT NOT NULL,
|
|
352
|
+
prefix TEXT NOT NULL,
|
|
353
|
+
key_hash TEXT UNIQUE NOT NULL,
|
|
354
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
355
|
+
last_used_at INTEGER,
|
|
356
|
+
created_at INTEGER NOT NULL
|
|
357
|
+
)`,
|
|
358
|
+
`CREATE TABLE IF NOT EXISTS webhooks (
|
|
359
|
+
id TEXT PRIMARY KEY,
|
|
360
|
+
url TEXT NOT NULL,
|
|
361
|
+
secret TEXT NOT NULL,
|
|
362
|
+
events TEXT NOT NULL DEFAULT '[]',
|
|
363
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
364
|
+
last_status INTEGER,
|
|
365
|
+
last_at INTEGER,
|
|
366
|
+
created_at INTEGER NOT NULL
|
|
367
|
+
)`,
|
|
368
|
+
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
369
|
+
id TEXT PRIMARY KEY,
|
|
370
|
+
webhook_id TEXT NOT NULL,
|
|
371
|
+
event TEXT NOT NULL,
|
|
372
|
+
payload TEXT NOT NULL,
|
|
373
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
374
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
375
|
+
next_at INTEGER,
|
|
376
|
+
response_code INTEGER,
|
|
377
|
+
created_at INTEGER NOT NULL,
|
|
378
|
+
delivered_at INTEGER
|
|
379
|
+
)`,
|
|
380
|
+
`CREATE INDEX IF NOT EXISTS webhook_deliveries_due ON webhook_deliveries (status, next_at)`,
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
// Bookings: resources (rooms, people, vans…), their booking links, and the
|
|
385
|
+
// bookings themselves (also staff time blocks and event RSVPs). Weekly
|
|
386
|
+
// hours / overrides / questions are JSON. The (resource_id, start_at)
|
|
387
|
+
// index serves the overlap check every booking insert runs; hold_start /
|
|
388
|
+
// hold_end are the booked time widened by the link's buffers, so the
|
|
389
|
+
// overlap test is a single range comparison.
|
|
390
|
+
version: 28,
|
|
391
|
+
statements: [
|
|
392
|
+
`CREATE TABLE IF NOT EXISTS resources (
|
|
393
|
+
id TEXT PRIMARY KEY,
|
|
394
|
+
slug TEXT UNIQUE NOT NULL,
|
|
395
|
+
name TEXT NOT NULL,
|
|
396
|
+
kind TEXT NOT NULL DEFAULT 'Room',
|
|
397
|
+
description TEXT NOT NULL DEFAULT '',
|
|
398
|
+
photo TEXT NOT NULL DEFAULT '',
|
|
399
|
+
capacity INTEGER,
|
|
400
|
+
amenities TEXT NOT NULL DEFAULT '[]',
|
|
401
|
+
timezone TEXT NOT NULL DEFAULT '',
|
|
402
|
+
hours TEXT NOT NULL DEFAULT '{}',
|
|
403
|
+
overrides TEXT NOT NULL DEFAULT '[]',
|
|
404
|
+
busy_feeds TEXT NOT NULL DEFAULT '[]',
|
|
405
|
+
notify_emails TEXT NOT NULL DEFAULT '[]',
|
|
406
|
+
visibility TEXT NOT NULL DEFAULT 'public',
|
|
407
|
+
show_on_calendar INTEGER NOT NULL DEFAULT 0,
|
|
408
|
+
feed_token TEXT NOT NULL DEFAULT '',
|
|
409
|
+
ord INTEGER NOT NULL DEFAULT 0,
|
|
410
|
+
created_at INTEGER NOT NULL,
|
|
411
|
+
updated_at INTEGER NOT NULL
|
|
412
|
+
)`,
|
|
413
|
+
`CREATE TABLE IF NOT EXISTS booking_links (
|
|
414
|
+
id TEXT PRIMARY KEY,
|
|
415
|
+
resource_id TEXT NOT NULL,
|
|
416
|
+
slug TEXT NOT NULL,
|
|
417
|
+
name TEXT NOT NULL,
|
|
418
|
+
description TEXT NOT NULL DEFAULT '',
|
|
419
|
+
mode TEXT NOT NULL DEFAULT 'slots',
|
|
420
|
+
duration INTEGER NOT NULL DEFAULT 60,
|
|
421
|
+
lengths TEXT NOT NULL DEFAULT '[]',
|
|
422
|
+
min_days INTEGER NOT NULL DEFAULT 1,
|
|
423
|
+
max_days INTEGER NOT NULL DEFAULT 1,
|
|
424
|
+
step INTEGER NOT NULL DEFAULT 30,
|
|
425
|
+
buffer_before INTEGER NOT NULL DEFAULT 0,
|
|
426
|
+
buffer_after INTEGER NOT NULL DEFAULT 0,
|
|
427
|
+
min_notice INTEGER NOT NULL DEFAULT 120,
|
|
428
|
+
max_advance INTEGER NOT NULL DEFAULT 90,
|
|
429
|
+
per_day_cap INTEGER NOT NULL DEFAULT 0,
|
|
430
|
+
hours TEXT,
|
|
431
|
+
auto_confirm INTEGER NOT NULL DEFAULT 1,
|
|
432
|
+
visibility TEXT NOT NULL DEFAULT 'public',
|
|
433
|
+
ask_phone INTEGER NOT NULL DEFAULT 0,
|
|
434
|
+
questions TEXT NOT NULL DEFAULT '[]',
|
|
435
|
+
confirm_message TEXT NOT NULL DEFAULT '',
|
|
436
|
+
redirect_url TEXT NOT NULL DEFAULT '',
|
|
437
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
438
|
+
ord INTEGER NOT NULL DEFAULT 0,
|
|
439
|
+
created_at INTEGER NOT NULL,
|
|
440
|
+
updated_at INTEGER NOT NULL,
|
|
441
|
+
UNIQUE (resource_id, slug)
|
|
442
|
+
)`,
|
|
443
|
+
`CREATE TABLE IF NOT EXISTS bookings (
|
|
444
|
+
id TEXT PRIMARY KEY,
|
|
445
|
+
kind TEXT NOT NULL DEFAULT 'booking',
|
|
446
|
+
resource_id TEXT NOT NULL DEFAULT '',
|
|
447
|
+
link_id TEXT NOT NULL DEFAULT '',
|
|
448
|
+
event_id TEXT NOT NULL DEFAULT '',
|
|
449
|
+
status TEXT NOT NULL,
|
|
450
|
+
start_at INTEGER NOT NULL,
|
|
451
|
+
end_at INTEGER NOT NULL,
|
|
452
|
+
hold_start INTEGER NOT NULL,
|
|
453
|
+
hold_end INTEGER NOT NULL,
|
|
454
|
+
timezone TEXT NOT NULL DEFAULT '',
|
|
455
|
+
name TEXT NOT NULL DEFAULT '',
|
|
456
|
+
email TEXT NOT NULL DEFAULT '',
|
|
457
|
+
phone TEXT NOT NULL DEFAULT '',
|
|
458
|
+
seats INTEGER NOT NULL DEFAULT 1,
|
|
459
|
+
answers TEXT NOT NULL DEFAULT '{}',
|
|
460
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
461
|
+
manage_token TEXT NOT NULL DEFAULT '',
|
|
462
|
+
source TEXT NOT NULL DEFAULT 'web',
|
|
463
|
+
reminder_sent_at INTEGER,
|
|
464
|
+
created_at INTEGER NOT NULL,
|
|
465
|
+
updated_at INTEGER NOT NULL
|
|
466
|
+
)`,
|
|
467
|
+
`CREATE INDEX IF NOT EXISTS bookings_resource_start ON bookings (resource_id, start_at)`,
|
|
468
|
+
`CREATE INDEX IF NOT EXISTS bookings_event_start ON bookings (event_id, start_at)`,
|
|
469
|
+
`CREATE INDEX IF NOT EXISTS bookings_manage ON bookings (manage_token)`,
|
|
470
|
+
`ALTER TABLE events ADD COLUMN rsvp TEXT NOT NULL DEFAULT '{}'`,
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
EMAIL_MIGRATION,
|
|
474
|
+
{ version: 30, statements: [
|
|
475
|
+
`CREATE TABLE mobile_booking_requests (key_hash TEXT PRIMARY KEY, request_hash TEXT NOT NULL, booking_id TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
476
|
+
] },
|
|
477
|
+
// design history: every Publish design snapshots the outgoing live design
|
|
478
|
+
// (theme, overrides, brand images, navigation) so it can be restored
|
|
479
|
+
{ version: 31, statements: [
|
|
480
|
+
`CREATE TABLE IF NOT EXISTS design_versions (
|
|
481
|
+
id TEXT PRIMARY KEY,
|
|
482
|
+
created_at INTEGER NOT NULL,
|
|
483
|
+
author TEXT NOT NULL DEFAULT '',
|
|
484
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
485
|
+
snapshot TEXT NOT NULL
|
|
486
|
+
)`,
|
|
487
|
+
`CREATE INDEX IF NOT EXISTS design_versions_created ON design_versions (created_at)`,
|
|
488
|
+
] },
|
|
489
|
+
{ version: 32, statements: [
|
|
490
|
+
`ALTER TABLE settings ADD COLUMN section_pages TEXT NOT NULL DEFAULT '{}'`,
|
|
491
|
+
`ALTER TABLE settings ADD COLUMN design_revision INTEGER NOT NULL DEFAULT 0`,
|
|
492
|
+
`CREATE TABLE IF NOT EXISTS design_audit (
|
|
493
|
+
id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, actor TEXT NOT NULL,
|
|
494
|
+
action TEXT NOT NULL, expected_revision INTEGER NOT NULL,
|
|
495
|
+
actual_revision INTEGER NOT NULL CHECK (expected_revision = actual_revision)
|
|
496
|
+
)`,
|
|
497
|
+
] },
|
|
281
498
|
];
|
|
282
499
|
|
|
283
500
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|