@acms-subir/core 0.3.9 → 0.3.11
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 +51 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +17 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -0
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/cli/preview.mjs +1 -1
- package/deploy/Dockerfile +5 -1
- package/deploy/compose.yml +21 -0
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +23 -2
- package/docs/theme-authoring.md +6 -0
- package/integration.mjs +74 -4
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +11 -31
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/components/ThemeTransfer.tsx +211 -0
- package/src/components/auth/ClerkSession.astro +80 -0
- package/src/components/auth/ReverifyForms.astro +61 -0
- package/src/components/auth/clerk.ts +169 -0
- package/src/components/import/ImportExport.tsx +16 -6
- package/src/components/import/runner.ts +3 -0
- package/src/layouts/Admin.astro +42 -12
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +8 -4
- package/src/lib/auth/accounts.ts +94 -0
- package/src/lib/auth/config.ts +22 -0
- package/src/lib/auth/login-limit.ts +15 -0
- package/src/lib/auth/request.ts +161 -0
- package/src/lib/auth/store.ts +72 -0
- package/src/lib/auth.ts +3 -0
- package/src/lib/backup/archive.ts +26 -0
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/backup/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/cache/config.ts +7 -0
- package/src/lib/cache/memory.ts +39 -0
- package/src/lib/cache/policy.ts +89 -0
- package/src/lib/cache/purge.ts +89 -0
- package/src/lib/calendar/admin.ts +41 -0
- package/src/lib/designPreview.ts +2 -2
- package/src/lib/dynamicBlocks.ts +1 -1
- package/src/lib/editor/auth.ts +35 -5
- package/src/lib/editor/http.ts +4 -1
- package/src/lib/editor/me.ts +4 -0
- package/src/lib/editor/openapi.ts +49 -1
- package/src/lib/editor/roles.ts +20 -0
- package/src/lib/editor/schedule.ts +340 -0
- package/src/lib/editor/schemas.ts +36 -1
- package/src/lib/editor/site-data.ts +110 -0
- package/src/lib/editor/store.ts +13 -7
- package/src/lib/editor/token-route.ts +2 -1
- package/src/lib/email/accounts.ts +4 -0
- package/src/lib/email/service.ts +9 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +8 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/forms/store.ts +5 -0
- package/src/lib/mobile/access-admin.ts +34 -0
- package/src/lib/mobile/access.ts +28 -0
- package/src/lib/mobile/calendar.ts +26 -13
- package/src/lib/mobile/content.ts +21 -1
- package/src/lib/mobile/forms.ts +77 -0
- package/src/lib/mobile/guest-store.ts +62 -0
- package/src/lib/mobile/guest.ts +160 -0
- package/src/lib/mobile/http.ts +6 -5
- package/src/lib/mobile/links.ts +69 -0
- package/src/lib/mobile/manage.ts +114 -0
- package/src/lib/mobile/openapi.ts +40 -4
- package/src/lib/mobile/route.ts +63 -4
- package/src/lib/mobile/schemas.ts +52 -1
- package/src/lib/mobile/search.ts +95 -0
- package/src/lib/mobile/site.ts +8 -2
- package/src/lib/monitoring.ts +171 -0
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +104 -0
- package/src/lib/repo/sql.ts +122 -44
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +31 -3
- package/src/lib/scheduler.ts +14 -0
- package/src/lib/schema.ts +35 -1
- package/src/lib/theme-transfer.ts +163 -0
- package/src/lib/theme.ts +5 -0
- package/src/lib/webhooks.ts +7 -2
- package/src/middleware.ts +41 -11
- package/src/pages/[...slug].astro +3 -2
- package/src/pages/account/[action].astro +6 -0
- package/src/pages/admin/app-auth.astro +21 -7
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/design.astro +2 -1
- package/src/pages/admin/import.astro +5 -3
- package/src/pages/admin/index.astro +3 -2
- package/src/pages/admin/integrations.astro +3 -2
- package/src/pages/admin/login.astro +236 -25
- package/src/pages/admin/logout.ts +16 -2
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +43 -5
- package/src/pages/admin/users.astro +350 -59
- package/src/pages/api/backup.ts +10 -26
- package/src/pages/api/design/export.ts +72 -0
- package/src/pages/api/design/import.ts +41 -0
- package/src/pages/api/email/settings.ts +9 -5
- package/src/pages/api/health.ts +17 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
- package/src/pages/api/live/settings.ts +2 -2
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/mobile/access.ts +22 -0
- package/src/pages/api/monitoring.ts +22 -0
- package/src/pages/api/ops/status.ts +2 -1
- package/src/pages/api/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
- package/src/pages/api/v1/admin/bookings/index.ts +10 -2
- package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
- package/src/pages/api/v1/admin/calendars/index.ts +12 -0
- package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
- package/src/pages/api/v1/admin/forms/index.ts +6 -0
- package/src/pages/api/v1/admin/links.ts +13 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
- package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
- package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
- package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
- package/src/pages/api/v1/admin/resources/index.ts +12 -0
- package/src/pages/api/v1/admin/schedule.ts +7 -0
- package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
- package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
- package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
- package/src/pages/api/v1/bookings/manage/index.ts +6 -0
- package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/forms/[id]/index.ts +7 -0
- package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
- package/src/pages/api/v1/guest/bookings.ts +6 -0
- package/src/pages/api/v1/guest/code.ts +6 -0
- package/src/pages/api/v1/guest/revoke.ts +6 -0
- package/src/pages/api/v1/guest/token.ts +6 -0
- package/src/pages/api/v1/links.ts +7 -0
- package/src/pages/api/v1/openapi.json.ts +1 -1
- package/src/pages/api/v1/push/topics.ts +1 -1
- package/src/pages/api/v1/resources.ts +1 -1
- package/src/pages/api/v1/search.ts +7 -0
- package/src/pages/api/v1/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +169 -16
- package/src/styles/email-settings.css +6 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { runtimeValue } from '../email/config';
|
|
4
|
+
import { backupArchive } from './archive';
|
|
5
|
+
|
|
6
|
+
// Scheduled off-site backups: the scheduler writes the same ZIP as Admin →
|
|
7
|
+
// Backup to object storage every BACKUP_EVERY_HOURS (default 24) and keeps
|
|
8
|
+
// the newest BACKUP_KEEP (default 14). Targets, first match wins:
|
|
9
|
+
// - an R2 bucket bound as BACKUPS (Cloudflare Workers)
|
|
10
|
+
// - any S3-compatible bucket: BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET,
|
|
11
|
+
// BACKUP_S3_ACCESS_KEY_ID, BACKUP_S3_SECRET_ACCESS_KEY, BACKUP_S3_REGION
|
|
12
|
+
// (R2's S3 API, Oracle Object Storage, AWS S3, MinIO…)
|
|
13
|
+
// The archive is built in memory, so on Workers the site (content + media)
|
|
14
|
+
// must fit well inside the isolate's memory limit.
|
|
15
|
+
|
|
16
|
+
export interface BackupTarget {
|
|
17
|
+
describe: string;
|
|
18
|
+
put(key: string, body: Uint8Array): Promise<void>;
|
|
19
|
+
list(prefix: string): Promise<string[]>;
|
|
20
|
+
delete(key: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
23
|
+
|
|
24
|
+
const LEASE_MS = 30 * 60_000;
|
|
25
|
+
const RETRY_MS = 15 * 60_000;
|
|
26
|
+
export const BACKUP_NAME = /^acms-backup-\d{8}T\d{6}Z\.zip$/;
|
|
27
|
+
export const backupKey = (prefix: string, now: number) =>
|
|
28
|
+
`${prefix}acms-backup-${new Date(now).toISOString().replace(/[:-]|\.\d{3}/g, '')}.zip`;
|
|
29
|
+
|
|
30
|
+
// ---- S3 (SigV4, path-style: works for R2, Oracle and AWS alike) ----------
|
|
31
|
+
|
|
32
|
+
const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
|
|
33
|
+
const hmac = (key: string | Buffer, data: string) => createHmac('sha256', key).update(data).digest();
|
|
34
|
+
const rfc3986 = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
35
|
+
|
|
36
|
+
export interface S3Credentials { accessKeyId: string; secretAccessKey: string; region: string }
|
|
37
|
+
/** SigV4 headers for one S3 request (host, x-amz-date, x-amz-content-sha256, authorization). */
|
|
38
|
+
export function signS3(method: string, url: URL, payloadHash: string, creds: S3Credentials, now = new Date(), extra: Record<string, string> = {}): Headers {
|
|
39
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''), day = amzDate.slice(0, 8);
|
|
40
|
+
const values: Record<string, string> = { host: url.host, 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate };
|
|
41
|
+
for (const [k, v] of Object.entries(extra)) values[k.toLowerCase()] = v;
|
|
42
|
+
const names = Object.keys(values).sort(), signed = names.join(';');
|
|
43
|
+
const path = url.pathname.split('/').map((p) => rfc3986(decodeURIComponent(p))).join('/');
|
|
44
|
+
const query = [...url.searchParams].map(([k, v]) => [rfc3986(k), rfc3986(v)]).sort(([a, av], [b, bv]) => a < b ? -1 : a > b ? 1 : av < bv ? -1 : 1).map(([k, v]) => `${k}=${v}`).join('&');
|
|
45
|
+
const canonical = [method, path, query, names.map((n) => `${n}:${values[n].trim()}\n`).join(''), signed, payloadHash].join('\n');
|
|
46
|
+
const scope = `${day}/${creds.region}/s3/aws4_request`;
|
|
47
|
+
const key = hmac(hmac(hmac(hmac(`AWS4${creds.secretAccessKey}`, day), creds.region), 's3'), 'aws4_request');
|
|
48
|
+
const signature = hmac(key, `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${sha256(canonical)}`).toString('hex');
|
|
49
|
+
const headers = new Headers(values);
|
|
50
|
+
headers.delete('host'); // fetch sets it
|
|
51
|
+
headers.set('authorization', `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`);
|
|
52
|
+
return headers;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const xmlText = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
56
|
+
|
|
57
|
+
export function s3Target(endpoint: string, bucket: string, creds: S3Credentials, fetcher: Fetch = fetch): BackupTarget {
|
|
58
|
+
const base = endpoint.replace(/\/+$/, '');
|
|
59
|
+
const objectUrl = (key: string) => new URL(`${base}/${rfc3986(bucket)}/${key.split('/').map(rfc3986).join('/')}`);
|
|
60
|
+
async function send(method: string, url: URL, body?: Uint8Array, extra: Record<string, string> = {}) {
|
|
61
|
+
const headers = signS3(method, url, sha256(body ?? ''), creds, new Date(), extra);
|
|
62
|
+
const res = await fetcher(url.href, { method, headers, body: body as BodyInit | undefined, signal: AbortSignal.timeout(10 * 60_000) });
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const text = await res.text().catch(() => '');
|
|
65
|
+
const code = text.match(/<Code>([^<]*)<\/Code>/)?.[1];
|
|
66
|
+
throw new Error(`S3 ${method} failed (${res.status}${code ? ` ${code}` : ''})`);
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
describe: `${new URL(base).host}/${bucket}`,
|
|
72
|
+
async put(key, body) { await send('PUT', objectUrl(key), body, { 'content-type': 'application/zip' }); },
|
|
73
|
+
async list(prefix) {
|
|
74
|
+
const keys: string[] = [];
|
|
75
|
+
let token = '';
|
|
76
|
+
for (let page = 0; page < 100; page++) {
|
|
77
|
+
const url = new URL(`${base}/${rfc3986(bucket)}`);
|
|
78
|
+
url.searchParams.set('list-type', '2');
|
|
79
|
+
url.searchParams.set('prefix', prefix);
|
|
80
|
+
if (token) url.searchParams.set('continuation-token', token);
|
|
81
|
+
const xml = await (await send('GET', url)).text();
|
|
82
|
+
for (const m of xml.matchAll(/<Key>([^<]*)<\/Key>/g)) keys.push(xmlText(m[1]));
|
|
83
|
+
token = /<IsTruncated>true<\/IsTruncated>/.test(xml) ? xmlText(xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/)?.[1] ?? '') : '';
|
|
84
|
+
if (!token) break;
|
|
85
|
+
}
|
|
86
|
+
return keys;
|
|
87
|
+
},
|
|
88
|
+
async delete(key) { await send('DELETE', objectUrl(key)); },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- R2 binding ------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
interface R2Like {
|
|
95
|
+
put(key: string, body: Uint8Array, opts?: unknown): Promise<unknown>;
|
|
96
|
+
list(opts: { prefix: string; cursor?: string }): Promise<{ objects: Array<{ key: string }>; truncated: boolean; cursor?: string }>;
|
|
97
|
+
delete(key: string): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
export function r2Target(bucket: R2Like): BackupTarget {
|
|
100
|
+
return {
|
|
101
|
+
describe: 'R2 binding BACKUPS',
|
|
102
|
+
async put(key, body) { await bucket.put(key, body, { httpMetadata: { contentType: 'application/zip' } }); },
|
|
103
|
+
async list(prefix) {
|
|
104
|
+
const keys: string[] = [];
|
|
105
|
+
let cursor: string | undefined;
|
|
106
|
+
for (let page = 0; page < 100; page++) {
|
|
107
|
+
const r = await bucket.list({ prefix, cursor });
|
|
108
|
+
keys.push(...r.objects.map((o) => o.key));
|
|
109
|
+
if (!r.truncated) break;
|
|
110
|
+
cursor = r.cursor;
|
|
111
|
+
}
|
|
112
|
+
return keys;
|
|
113
|
+
},
|
|
114
|
+
async delete(key) { await bucket.delete(key); },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- configuration + the scheduler job --------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface OffsiteConfig { target: BackupTarget; prefix: string; keep: number; everyMs: number }
|
|
121
|
+
export function offsiteConfig(locals?: unknown, fetcher?: Fetch): OffsiteConfig | null {
|
|
122
|
+
const v = (k: string) => runtimeValue(k, locals).trim();
|
|
123
|
+
const binding = (locals as { runtime?: { env?: { BACKUPS?: R2Like } } } | undefined)?.runtime?.env?.BACKUPS;
|
|
124
|
+
const s3 = { endpoint: v('BACKUP_S3_ENDPOINT'), bucket: v('BACKUP_S3_BUCKET'), accessKeyId: v('BACKUP_S3_ACCESS_KEY_ID'), secretAccessKey: v('BACKUP_S3_SECRET_ACCESS_KEY') };
|
|
125
|
+
const anyS3 = Object.values(s3).some(Boolean);
|
|
126
|
+
if (!binding?.put && !anyS3) return null;
|
|
127
|
+
const keep = Number(v('BACKUP_KEEP') || 14), hours = Number(v('BACKUP_EVERY_HOURS') || 24);
|
|
128
|
+
if (!Number.isInteger(keep) || keep < 1 || keep > 1000) throw new Error('BACKUP_KEEP must be a whole number from 1 to 1000.');
|
|
129
|
+
if (!Number.isFinite(hours) || hours < 1 || hours > 24 * 31) throw new Error('BACKUP_EVERY_HOURS must be between 1 and 744.');
|
|
130
|
+
let prefix = v('BACKUP_PREFIX') || 'acms-backups/';
|
|
131
|
+
if (!prefix.endsWith('/')) prefix += '/';
|
|
132
|
+
let target: BackupTarget;
|
|
133
|
+
if (binding?.put) target = r2Target(binding);
|
|
134
|
+
else {
|
|
135
|
+
const missing = Object.entries(s3).filter(([, x]) => !x).map(([k]) => `BACKUP_S3_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`);
|
|
136
|
+
if (missing.length) throw new Error(`Off-site backups need ${missing.join(', ')}.`);
|
|
137
|
+
let url: URL;
|
|
138
|
+
try { url = new URL(s3.endpoint); } catch { throw new Error('BACKUP_S3_ENDPOINT must be an https:// URL.'); }
|
|
139
|
+
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) throw new Error('BACKUP_S3_ENDPOINT must be an https:// URL.');
|
|
140
|
+
target = s3Target(url.origin, s3.bucket, { accessKeyId: s3.accessKeyId, secretAccessKey: s3.secretAccessKey, region: v('BACKUP_S3_REGION') || 'auto' }, fetcher);
|
|
141
|
+
}
|
|
142
|
+
return { target, prefix, keep, everyMs: hours * 3_600_000 };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scheduler job. Returns 1 when it wrote a backup, 0 when none was due or
|
|
147
|
+
* nothing is configured. Throws when a backup failed; the lease then holds
|
|
148
|
+
* retries off for 15 minutes so a broken bucket isn't hammered every minute.
|
|
149
|
+
*/
|
|
150
|
+
export async function runOffsiteBackup(repo: ContentRepository, locals?: unknown, opts: { now?: number; fetch?: Fetch } = {}): Promise<number> {
|
|
151
|
+
const config = offsiteConfig(locals, opts.fetch);
|
|
152
|
+
if (!config) return 0;
|
|
153
|
+
const now = opts.now ?? Date.now();
|
|
154
|
+
if (!await repo.backups.claim(now, config.everyMs, LEASE_MS)) return 0;
|
|
155
|
+
const key = backupKey(config.prefix, now);
|
|
156
|
+
try {
|
|
157
|
+
const body = new Uint8Array(await new Response(await backupArchive(repo, locals, now)).arrayBuffer());
|
|
158
|
+
await config.target.put(key, body);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
await repo.backups.fail(now, e instanceof Error ? e.message : 'Backup failed', RETRY_MS).catch(() => {});
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
await repo.backups.succeed(now, key);
|
|
164
|
+
// keep the newest N of our own files; anything else under the prefix stays
|
|
165
|
+
try {
|
|
166
|
+
const ours = (await config.target.list(config.prefix)).filter((k) => BACKUP_NAME.test(k.slice(config.prefix.length))).sort();
|
|
167
|
+
for (const old of ours.slice(0, Math.max(0, ours.length - config.keep))) await config.target.delete(old);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// the backup itself landed; the next run prunes again
|
|
170
|
+
console.error('acms backup: pruning old backups failed', e);
|
|
171
|
+
}
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
|
|
3
|
+
// Scheduled off-site backups: when the last one ran, and a lease so two
|
|
4
|
+
// overlapping scheduler runs (a slow upload, two containers) never upload
|
|
5
|
+
// the same backup twice.
|
|
6
|
+
export interface BackupStatus { lastAt: number; leaseUntil: number; lastKey: string; lastError: string; failedAt: number }
|
|
7
|
+
export class BackupStore {
|
|
8
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
9
|
+
async status(): Promise<BackupStatus> {
|
|
10
|
+
const r = (await (await this.driver()).all('SELECT * FROM backup_state WHERE id = 1'))[0] ?? {};
|
|
11
|
+
return { lastAt: Number(r.last_at ?? 0), leaseUntil: Number(r.lease_until ?? 0), lastKey: String(r.last_key ?? ''), lastError: String(r.last_error ?? ''), failedAt: Number(r.failed_at ?? 0) };
|
|
12
|
+
}
|
|
13
|
+
/** take the lease when a backup is due (last success older than everyMs, no live lease) */
|
|
14
|
+
async claim(now: number, everyMs: number, leaseMs: number): Promise<boolean> {
|
|
15
|
+
const rows = await (await this.driver()).all(
|
|
16
|
+
'UPDATE backup_state SET lease_until = ? WHERE id = 1 AND lease_until <= ? AND last_at <= ? RETURNING id',
|
|
17
|
+
[now + leaseMs, now, now - everyMs],
|
|
18
|
+
);
|
|
19
|
+
return rows.length > 0;
|
|
20
|
+
}
|
|
21
|
+
async succeed(now: number, key: string) {
|
|
22
|
+
await (await this.driver()).run("UPDATE backup_state SET last_at = ?, last_key = ?, lease_until = 0, last_error = '' WHERE id = 1", [now, key]);
|
|
23
|
+
}
|
|
24
|
+
/** keep the failure, and hold the lease until the retry time */
|
|
25
|
+
async fail(now: number, error: string, retryMs: number) {
|
|
26
|
+
await (await this.driver()).run('UPDATE backup_state SET lease_until = ?, last_error = ?, failed_at = ? WHERE id = 1', [now + retryMs, error.slice(0, 500), now]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -147,7 +147,8 @@ export function slotsForDay(ctx: AvailabilityContext, date: string, minutes?: nu
|
|
|
147
147
|
return out;
|
|
148
148
|
}
|
|
149
149
|
const step = Math.max(5, ctx.link.step) * MIN;
|
|
150
|
-
|
|
150
|
+
// a requested length must be one the booking type offers (a 30-minute slot can't be held for 8 hours)
|
|
151
|
+
const lengths = minutes ? linkLengths(ctx.link).filter((l) => l === minutes) : linkLengths(ctx.link);
|
|
151
152
|
const out: Slot[] = [];
|
|
152
153
|
const [y, m, d] = date.split('-').map(Number);
|
|
153
154
|
for (const [a, b] of dayRanges(ctx.resource, ctx.link, date)) {
|
|
@@ -195,10 +196,22 @@ export function nextOpenings(ctx: AvailabilityContext, count: number, horizonDay
|
|
|
195
196
|
return out;
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Day bookings run midnight to midnight in the resource zone, so across a DST
|
|
201
|
+
* change a "1 day" span lasts 23 or 25 hours. Callers send start + n × 1440
|
|
202
|
+
* minutes; this snaps the end to the real local midnight (other modes unchanged).
|
|
203
|
+
*/
|
|
204
|
+
export function spanEnd(resource: Resource, link: BookingLink, siteTimezone: string, start: number, end: number): number {
|
|
205
|
+
if (link.mode !== 'day') return end;
|
|
206
|
+
const tz = resourceZone(resource, siteTimezone);
|
|
207
|
+
return localMidnight(addDaysYmd(ymd(start, tz), Math.max(1, Math.round((end - start) / DAY))), tz);
|
|
208
|
+
}
|
|
209
|
+
|
|
198
210
|
// server-side check before an insert (the DB guard backs it up for races)
|
|
199
211
|
export function isBookable(ctx: AvailabilityContext, start: number, end: number): boolean {
|
|
200
212
|
const tz = resourceZone(ctx.resource, ctx.siteTimezone);
|
|
201
|
-
|
|
213
|
+
// day spans are counted in local days (a 25-hour DST day is still 1440)
|
|
214
|
+
const minutes = ctx.link.mode === 'day' ? Math.round((end - start) / DAY) * 1440 : Math.round((end - start) / MIN);
|
|
202
215
|
return slotsForDay(ctx, ymd(start, tz), minutes).some((s) => s.start === start && s.end === end);
|
|
203
216
|
}
|
|
204
217
|
|
|
@@ -12,7 +12,7 @@ import { kickEmail } from '../email/service';
|
|
|
12
12
|
import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
|
|
13
13
|
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
14
14
|
import { bookingEmail, whenText, type BookingEmail } from './emails';
|
|
15
|
-
import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
|
|
15
|
+
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
16
16
|
import { decideStatus, linkApproval } from './approval';
|
|
17
17
|
import { eventHolds } from './holds';
|
|
18
18
|
|
|
@@ -165,6 +165,54 @@ export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'>
|
|
|
165
165
|
|
|
166
166
|
// ---- workflows ---------------------------------------------------------------
|
|
167
167
|
|
|
168
|
+
// App access limit: new bookings + RSVPs per address per day, on every public
|
|
169
|
+
// channel. Staff adding a booking by hand is never capped.
|
|
170
|
+
async function checkEmailCap(repo: ContentRepository, email: string, source: Booking['source'] | undefined, settings: Settings, now = Date.now()) {
|
|
171
|
+
const cap = settings.mobileAccess.bookingsPerEmailDay;
|
|
172
|
+
if (!cap || source === 'admin') return;
|
|
173
|
+
if (await repo.countBookingsByEmail(email, now - 86_400_000) >= cap) throw new BookingError(429, 'That’s the most bookings one email address can make in a day. Please try again tomorrow or contact us.');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* What the booker may still do themselves (manage page, apps). A booking
|
|
178
|
+
* type's change cutoff closes guest changes that many hours before the
|
|
179
|
+
* start; staff can always act from the admin.
|
|
180
|
+
*/
|
|
181
|
+
export function guestChanges(b: Booking, link: BookingLink | null, now = Date.now()) {
|
|
182
|
+
const active = b.status === 'pending' || b.status === 'confirmed';
|
|
183
|
+
const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
|
|
184
|
+
const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
|
|
185
|
+
const open = active && b.end > now && !closed;
|
|
186
|
+
return {
|
|
187
|
+
cancel: open,
|
|
188
|
+
reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
189
|
+
// hours before the start when guest changes close; 0 = until it ends
|
|
190
|
+
cutoffHours: cutoff,
|
|
191
|
+
closed,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function guestCancel(repo: ContentRepository, b: Booking, opts: FlowOptions): Promise<Booking> {
|
|
196
|
+
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
197
|
+
if (b.status === 'cancelled') return b;
|
|
198
|
+
const can = guestChanges(b, link, opts.now);
|
|
199
|
+
if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
|
|
200
|
+
return setBookingStatus(repo, b, 'cancelled', opts);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** the link as a guest moving a booking sees it: no new time inside the change cutoff */
|
|
204
|
+
export const guestMoveLink = (link: BookingLink): BookingLink =>
|
|
205
|
+
link.changeCutoff > 0 ? { ...link, minNotice: Math.max(link.minNotice, link.changeCutoff * 60) } : link;
|
|
206
|
+
|
|
207
|
+
export async function guestReschedule(repo: ContentRepository, b: Booking, start: number, end: number, opts: FlowOptions): Promise<Booking> {
|
|
208
|
+
const link = b.linkId ? await repo.getBookingLink(b.linkId) : null;
|
|
209
|
+
const can = guestChanges(b, link, opts.now);
|
|
210
|
+
if (!can.reschedule) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to move it.` : 'This booking can’t be moved.');
|
|
211
|
+
// the new time must respect the cutoff too
|
|
212
|
+
if (link && link.changeCutoff > 0 && start - (opts.now ?? Date.now()) < link.changeCutoff * 3_600_000) throw new BookingError(409, `Pick a time at least ${link.changeCutoff} hours from now.`);
|
|
213
|
+
return rescheduleBooking(repo, b, start, end, opts);
|
|
214
|
+
}
|
|
215
|
+
|
|
168
216
|
export async function createResourceBooking(
|
|
169
217
|
repo: ContentRepository,
|
|
170
218
|
args: { resource: Resource; link: BookingLink; start: number; end: number; booker: BookerInput; source?: Booking['source']; status?: BookingStatus },
|
|
@@ -173,7 +221,9 @@ export async function createResourceBooking(
|
|
|
173
221
|
const { resource, link } = args;
|
|
174
222
|
if (!link.active) throw new BookingError(410, 'This booking link is turned off.');
|
|
175
223
|
const settings = await repo.getSettings();
|
|
224
|
+
args = { ...args, end: spanEnd(resource, link, settings.timezone, args.start, args.end) };
|
|
176
225
|
const booker = validateBooker(link, args.booker);
|
|
226
|
+
await checkEmailCap(repo, booker.email, args.source, settings, opts.now);
|
|
177
227
|
// staff adding a booking by hand may ignore notice windows, never overlaps
|
|
178
228
|
const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
|
|
179
229
|
if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
|
|
@@ -219,6 +269,7 @@ export async function createRsvp(
|
|
|
219
269
|
if (args.end <= (opts.now ?? Date.now())) throw new BookingError(410, 'This event has already happened.');
|
|
220
270
|
const seats = Math.max(1, Math.min(e.rsvp.maxSeats || 1, Math.floor(args.seats) || 1));
|
|
221
271
|
const booker = validateBooker(null, args.booker);
|
|
272
|
+
await checkEmailCap(repo, booker.email, args.source, await repo.getSettings(), opts.now);
|
|
222
273
|
const formResponse = e.rsvp.form ? await collectResponse(e.rsvp.form, args.booker.answers ?? {}, args.booker.formVersion) : undefined;
|
|
223
274
|
if (formResponse) booker.answers = displayAnswers(formResponse);
|
|
224
275
|
const b = await repo.createBooking({
|
|
@@ -264,6 +315,7 @@ export async function rescheduleBooking(repo: ContentRepository, b: Booking, sta
|
|
|
264
315
|
if (b.status !== 'pending' && b.status !== 'confirmed') throw new BookingError(409, 'Only active bookings can be moved.');
|
|
265
316
|
const [resource, link, settings] = await Promise.all([repo.getResource(b.resourceId), repo.getBookingLink(b.linkId), repo.getSettings()]);
|
|
266
317
|
if (!resource || !link) throw new BookingError(404, 'This booking’s room or link no longer exists.');
|
|
318
|
+
end = spanEnd(resource, link, settings.timezone, start, end);
|
|
267
319
|
if (!opts.skipRules) {
|
|
268
320
|
const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now(), b.id);
|
|
269
321
|
if (!isBookable(ctx, start, end)) throw new BookingError(409, 'That time isn’t available.');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ cache }) resolved by integration.mjs. Mutable so tests can flip it.
|
|
4
|
+
export const CACHE: { enabled: boolean; maxAge: number; staleWhileRevalidate: number; memory: boolean } = {
|
|
5
|
+
enabled: true, maxAge: 60, staleWhileRevalidate: 86400, memory: true,
|
|
6
|
+
...((userConfig as { cache?: Partial<typeof CACHE> }).cache ?? {}),
|
|
7
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// In-process cache of public app API bodies (Node: one long-lived server).
|
|
2
|
+
// Keys carry the content version, so a change makes old entries unreachable;
|
|
3
|
+
// the purge loop also drops them all. Bounded by entries and bytes, oldest
|
|
4
|
+
// out first — the sustained memory workload must stay flat.
|
|
5
|
+
|
|
6
|
+
const MAX_ENTRIES = 500;
|
|
7
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const store = new Map<string, string>();
|
|
9
|
+
let bytes = 0;
|
|
10
|
+
|
|
11
|
+
export function memoryGet(key: string): string | undefined {
|
|
12
|
+
const hit = store.get(key);
|
|
13
|
+
if (hit === undefined) return undefined;
|
|
14
|
+
// refresh recency
|
|
15
|
+
store.delete(key);
|
|
16
|
+
store.set(key, hit);
|
|
17
|
+
return hit;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function memorySet(key: string, body: string) {
|
|
21
|
+
const size = body.length * 2;
|
|
22
|
+
if (size > MAX_BYTES / 8) return; // one huge body must not flush everything else
|
|
23
|
+
const old = store.get(key);
|
|
24
|
+
if (old !== undefined) { bytes -= old.length * 2; store.delete(key); }
|
|
25
|
+
store.set(key, body);
|
|
26
|
+
bytes += size;
|
|
27
|
+
for (const [k, v] of store) {
|
|
28
|
+
if (store.size <= MAX_ENTRIES && bytes <= MAX_BYTES) break;
|
|
29
|
+
store.delete(k);
|
|
30
|
+
bytes -= v.length * 2;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearMemoryCache() {
|
|
35
|
+
store.clear();
|
|
36
|
+
bytes = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const memoryStats = () => ({ entries: store.size, bytes });
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CACHE } from './config';
|
|
2
|
+
import { CALENDAR } from '../calendar/config';
|
|
3
|
+
import { I18N } from '../i18n';
|
|
4
|
+
|
|
5
|
+
// The one place that decides whether a response may be stored by a shared
|
|
6
|
+
// cache (Cloudflare, a CDN). Public GET pages and the public app API are
|
|
7
|
+
// cacheable; anything personal, interactive or staff-only is private.
|
|
8
|
+
|
|
9
|
+
export const PRIVATE_CACHE_CONTROL = 'private, no-store';
|
|
10
|
+
export const publicCacheControl = () => `public, s-maxage=${CACHE.maxAge}, stale-while-revalidate=${CACHE.staleWhileRevalidate}`;
|
|
11
|
+
// publicResponse's own header: browsers/apps always revalidate by ETag. The
|
|
12
|
+
// policy upgrades it with s-maxage so a CDN can share it.
|
|
13
|
+
export const API_REVALIDATE = 'public, max-age=0, must-revalidate';
|
|
14
|
+
|
|
15
|
+
// Sign-in state lives in these cookies (built-in session, Clerk, design
|
|
16
|
+
// preview). Any of them means the page may be personalized.
|
|
17
|
+
const PRIVATE_COOKIE = /(?:^|;\s*)(?:acms_session|acms_preview|__session(?:_[^=;]+)?|__client_uat(?:_[^=;]+)?|__clerk_db_jwt(?:_[^=;]+)?)=/;
|
|
18
|
+
const CACHEABLE_STATUS = new Set([200, 203, 301, 308, 404, 410]);
|
|
19
|
+
// Paths that set their own long-lived headers (hashed media, built assets).
|
|
20
|
+
const OWN_HEADERS = /^\/(?:media|_astro)\//;
|
|
21
|
+
|
|
22
|
+
function privatePrefixes() {
|
|
23
|
+
return [
|
|
24
|
+
'/admin', '/preview', '/account', '/go', '/forms', '/_actions', '/_server-islands', '/embed/book',
|
|
25
|
+
...(CALENDAR.enabled ? [CALENDAR.bookBase] : []),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
// Public app API routes that are personal, transactional or write-only.
|
|
29
|
+
const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms)(?:\/|$)/;
|
|
30
|
+
|
|
31
|
+
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}.`);
|
|
32
|
+
|
|
33
|
+
/** strip a non-default locale prefix: /es/events/x is cached like /events/x */
|
|
34
|
+
function barePath(pathname: string) {
|
|
35
|
+
if (!I18N.enabled) return pathname;
|
|
36
|
+
for (const l of I18N.locales) {
|
|
37
|
+
if (l !== I18N.defaultLocale && (pathname === `/${l}` || pathname.startsWith(`/${l}/`))) return pathname.slice(l.length + 1) || '/';
|
|
38
|
+
}
|
|
39
|
+
return pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPrivateRequest(request: Request, pathname = new URL(request.url).pathname): boolean {
|
|
43
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return true;
|
|
44
|
+
if (request.headers.has('authorization')) return true;
|
|
45
|
+
if (PRIVATE_COOKIE.test(request.headers.get('cookie') ?? '')) return true;
|
|
46
|
+
const path = barePath(pathname);
|
|
47
|
+
if (path.startsWith('/api/')) return !path.startsWith('/api/v1/') || PRIVATE_API.test(path);
|
|
48
|
+
return privatePrefixes().some((p) => under(path, p));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const warned = new Set<string>();
|
|
52
|
+
function warnCookie(pathname: string) {
|
|
53
|
+
if (warned.has(pathname) || warned.size >= 100) return;
|
|
54
|
+
warned.add(pathname);
|
|
55
|
+
console.warn(`acms cache: ${pathname} set a cookie on a public page, so it was served private (not cached)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Set Cache-Control on a finished response. Routes that already chose a
|
|
60
|
+
* private/no-store policy, or their own public one, are left alone; the
|
|
61
|
+
* policy only fills the default and never loosens a route's choice.
|
|
62
|
+
*/
|
|
63
|
+
export function applyCachePolicy(request: Request, response: Response, pathname = new URL(request.url).pathname): Response {
|
|
64
|
+
if (!CACHE.enabled) return response;
|
|
65
|
+
if (OWN_HEADERS.test(pathname)) return response;
|
|
66
|
+
const current = response.headers.get('cache-control');
|
|
67
|
+
const set = (value: string) => {
|
|
68
|
+
try { response.headers.set('Cache-Control', value); return response; }
|
|
69
|
+
catch {
|
|
70
|
+
// immutable headers (a proxied/fetched response): copy it
|
|
71
|
+
const copy = new Response(response.body, response);
|
|
72
|
+
copy.headers.set('Cache-Control', value);
|
|
73
|
+
return copy;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
if (isPrivateRequest(request, pathname)) {
|
|
77
|
+
if (current && /no-store/.test(current) && !/public/.test(current)) return response;
|
|
78
|
+
return set(PRIVATE_CACHE_CONTROL);
|
|
79
|
+
}
|
|
80
|
+
// a route that decided for itself (no-store live status, immutable files)
|
|
81
|
+
if (current && current !== API_REVALIDATE) return response;
|
|
82
|
+
if (!CACHEABLE_STATUS.has(response.status)) return current ? response : set(PRIVATE_CACHE_CONTROL);
|
|
83
|
+
// one stray cookie would hand one visitor's cookie to everyone
|
|
84
|
+
if (response.headers.has('set-cookie')) {
|
|
85
|
+
warnCookie(pathname);
|
|
86
|
+
return set(PRIVATE_CACHE_CONTROL);
|
|
87
|
+
}
|
|
88
|
+
return set(publicCacheControl());
|
|
89
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { runtimeValue } from '../email/config';
|
|
3
|
+
import { CACHE } from './config';
|
|
4
|
+
import { clearMemoryCache } from './memory';
|
|
5
|
+
|
|
6
|
+
// Clearing shared caches when content changes. Each install is one site, so
|
|
7
|
+
// a change clears everything. The database records the last version cleared
|
|
8
|
+
// (cache_purged_version); anything newer is cleared on the next pass:
|
|
9
|
+
// - right after a write request (middleware, in the background)
|
|
10
|
+
// - the once-a-minute traffic sweep and every scheduler run, which retry a
|
|
11
|
+
// purge that failed (Cloudflare API down, rate limited)
|
|
12
|
+
// Targets: Workers Cache (ctx.cache, Cloudflare deployments) and the
|
|
13
|
+
// Cloudflare zone API (CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN, Docker
|
|
14
|
+
// behind the Cloudflare proxy). The Node memory cache is keyed by version
|
|
15
|
+
// and simply dropped.
|
|
16
|
+
|
|
17
|
+
type Locals = { runtime?: { scheduled?: boolean; ctx?: { cache?: { purge(o: { purgeEverything?: boolean }): Promise<{ success: boolean; errors?: Array<{ message: string }> }> } } } };
|
|
18
|
+
export type PurgeFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
// A burst of saves clears once; the scheduler (≤ 1 min) picks up the rest.
|
|
21
|
+
const MIN_INTERVAL_MS = 10_000;
|
|
22
|
+
let lastAttempt = 0;
|
|
23
|
+
let inflight: Promise<number> | null = null;
|
|
24
|
+
|
|
25
|
+
// The adapter gives pages a wrapper ctx with only waitUntil. worker.ts tags
|
|
26
|
+
// each request with the real ExecutionContext; the middleware copies its
|
|
27
|
+
// Workers Cache handle onto locals.runtime.ctx for the purge.
|
|
28
|
+
export const EXECUTION_CONTEXT = Symbol.for('acms.executionContext');
|
|
29
|
+
export function bindExecutionContext(request: Request, locals: unknown) {
|
|
30
|
+
const exec = Reflect.get(request, EXECUTION_CONTEXT) as { cache?: unknown } | undefined;
|
|
31
|
+
const ctx = (locals as { runtime?: { ctx?: Record<string, unknown> } } | undefined)?.runtime?.ctx;
|
|
32
|
+
if (exec?.cache && ctx && !ctx.cache) ctx.cache = exec.cache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function purgeTargets(locals?: unknown) {
|
|
36
|
+
const workers = (locals as Locals | undefined)?.runtime?.ctx?.cache;
|
|
37
|
+
const zone = runtimeValue('CLOUDFLARE_ZONE_ID', locals).trim();
|
|
38
|
+
const token = runtimeValue('CLOUDFLARE_API_TOKEN', locals).trim();
|
|
39
|
+
return { workers: workers && typeof workers.purge === 'function' ? workers : null, zone: zone && token ? { zone, token } : null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function purgeZone(zone: string, token: string, fetcher: PurgeFetch) {
|
|
43
|
+
const res = await fetcher(`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(zone)}/purge_cache`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ purge_everything: true }),
|
|
47
|
+
signal: AbortSignal.timeout(10_000),
|
|
48
|
+
});
|
|
49
|
+
const body = await res.json().catch(() => null) as { success?: boolean; errors?: Array<{ message?: string }> } | null;
|
|
50
|
+
if (!res.ok || !body?.success) throw new Error(`Cloudflare purge failed (${res.status}${body?.errors?.[0]?.message ? `: ${body.errors[0].message}` : ''})`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear caches if content changed since the last successful purge. Returns
|
|
55
|
+
* 1 when it purged, 0 when nothing was due (or throttled). Throws when a
|
|
56
|
+
* purge failed, so the scheduler job reports it and the next run retries.
|
|
57
|
+
*/
|
|
58
|
+
export function syncCachePurge(repo: ContentRepository, locals?: unknown, opts: { force?: boolean; fetch?: PurgeFetch; now?: number } = {}): Promise<number> {
|
|
59
|
+
if (!CACHE.enabled) return Promise.resolve(0);
|
|
60
|
+
const now = opts.now ?? Date.now();
|
|
61
|
+
// another request is already purging; never await its promise (Workers
|
|
62
|
+
// forbid I/O across requests) — a newer version is picked up next pass
|
|
63
|
+
if (inflight) return Promise.resolve(0);
|
|
64
|
+
if (!opts.force && now - lastAttempt < MIN_INTERVAL_MS) return Promise.resolve(0);
|
|
65
|
+
const run = (async () => {
|
|
66
|
+
const { version, purged } = await repo.getCacheState();
|
|
67
|
+
if (version <= purged) return 0;
|
|
68
|
+
lastAttempt = now;
|
|
69
|
+
clearMemoryCache();
|
|
70
|
+
const targets = purgeTargets(locals);
|
|
71
|
+
// A Workers cron run may not carry ctx.cache even when Workers Cache is
|
|
72
|
+
// on. Leave the version unpurged so the next page request (which has
|
|
73
|
+
// the fetch context) clears it, instead of recording a purge that
|
|
74
|
+
// never reached the cache.
|
|
75
|
+
if (!targets.workers && (locals as Locals | undefined)?.runtime?.scheduled) return 0;
|
|
76
|
+
if (targets.workers) {
|
|
77
|
+
const result = await targets.workers.purge({ purgeEverything: true });
|
|
78
|
+
if (!result.success) throw new Error(`Workers Cache purge failed${result.errors?.[0]?.message ? `: ${result.errors[0].message}` : ''}`);
|
|
79
|
+
}
|
|
80
|
+
if (targets.zone) await purgeZone(targets.zone.zone, targets.zone.token, opts.fetch ?? fetch);
|
|
81
|
+
await repo.markCachePurged(version);
|
|
82
|
+
return 1;
|
|
83
|
+
})();
|
|
84
|
+
inflight = run;
|
|
85
|
+
return run.finally(() => { if (inflight === run) inflight = null; });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** test hook: forget the throttle between cases */
|
|
89
|
+
export function resetPurgeThrottle() { lastAttempt = 0; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { Calendar } from '../schema';
|
|
3
|
+
import { slugify } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { normalizeCalendarSrc } from './src';
|
|
6
|
+
import { safeColor } from './events';
|
|
7
|
+
|
|
8
|
+
// Create or edit a calendar: the admin's Calendar settings and editor apps
|
|
9
|
+
// share these rules (feed URL normalization, unique slug, safe color).
|
|
10
|
+
export interface CalendarInput {
|
|
11
|
+
name: string; slug?: string; color?: string; description?: string;
|
|
12
|
+
kind?: 'native' | 'feed'; feedUrl?: string; visibility?: Calendar['visibility'];
|
|
13
|
+
}
|
|
14
|
+
export async function saveCalendar(repo: ContentRepository, id: string | undefined, data: CalendarInput): Promise<Calendar> {
|
|
15
|
+
const existing = id ? await repo.getCalendar(id) : null;
|
|
16
|
+
if (id && !existing) throw new ApiError(404, 'Calendar not found.');
|
|
17
|
+
const kind = existing?.kind ?? data.kind ?? 'native';
|
|
18
|
+
let feedUrl = existing?.feedUrl ?? '';
|
|
19
|
+
if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
|
|
20
|
+
const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
|
|
21
|
+
if (!normalized) throw new ApiError(400, 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
22
|
+
feedUrl = normalized;
|
|
23
|
+
}
|
|
24
|
+
const base = slugify(data.slug || data.name);
|
|
25
|
+
let slug = base;
|
|
26
|
+
for (let n = 2; ; n++) {
|
|
27
|
+
const clash = await repo.getCalendarBySlug(slug);
|
|
28
|
+
if (!clash || clash.id === id) break;
|
|
29
|
+
slug = `${base}-${n}`;
|
|
30
|
+
}
|
|
31
|
+
const fields = {
|
|
32
|
+
name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
|
|
33
|
+
description: data.description ?? existing?.description ?? '', feedUrl,
|
|
34
|
+
visibility: data.visibility ?? existing?.visibility ?? 'public',
|
|
35
|
+
};
|
|
36
|
+
if (existing) {
|
|
37
|
+
await repo.updateCalendar(existing.id, fields);
|
|
38
|
+
return (await repo.getCalendar(existing.id))!;
|
|
39
|
+
}
|
|
40
|
+
return repo.createCalendar({ ...fields, kind });
|
|
41
|
+
}
|
package/src/lib/designPreview.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AstroCookies } from 'astro';
|
|
2
2
|
import type { NavItem, Settings } from './schema';
|
|
3
3
|
import { draftNavItems, pickDesignDraft, shareEpoch, toNavItems } from './drafts';
|
|
4
|
-
import {
|
|
4
|
+
import { validatePreviewSession } from './auth/request';
|
|
5
5
|
import { verifyDesignToken } from './preview';
|
|
6
6
|
import { getRepo } from './repo';
|
|
7
7
|
|
|
@@ -86,7 +86,7 @@ async function compute(url: URL, cookies: AstroCookies, locals: unknown, liveSet
|
|
|
86
86
|
return { role: 'reviewer', source: 'draft', active: !showLive, showLive, ended: false, draft, expiresAt: exp };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
if (!(await
|
|
89
|
+
if (!(await validatePreviewSession(cookies, locals))) return NONE;
|
|
90
90
|
if (c.startsWith('v.')) {
|
|
91
91
|
const v = await repo.getDesignVersion(c.slice(2));
|
|
92
92
|
if (!v) return { ...NONE, role: 'admin' };
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -162,7 +162,7 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
if (hasForms) {
|
|
165
|
-
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter(t => t.includes('data-dyn="form"')))];
|
|
165
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter(t => t.includes('data-dyn="form"')))];
|
|
166
166
|
await Promise.all(tags.map(async tag => {
|
|
167
167
|
const form = await repo.forms.get(attr(tag, 'form'));
|
|
168
168
|
// A same-origin frame gives each visitor an uncached form/receipt even
|