@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,62 @@
|
|
|
1
|
+
import type { SqlDriver } from '../repo/sql';
|
|
2
|
+
|
|
3
|
+
// Guest sign-in storage: one pending code per address and one session per
|
|
4
|
+
// signed-in device. Only SHA-256 hashes of codes and tokens are stored.
|
|
5
|
+
|
|
6
|
+
/** guest app sessions end after this long unused */
|
|
7
|
+
export const GUEST_IDLE_MS = 90 * 86_400_000;
|
|
8
|
+
|
|
9
|
+
export interface GuestSession { id: string; email: string; deviceName: string; createdAt: number; lastSeenAt: number }
|
|
10
|
+
const session = (r: Record<string, unknown>): GuestSession => ({ id: String(r.id), email: String(r.email), deviceName: String(r.device_name ?? ''), createdAt: Number(r.created_at), lastSeenAt: Number(r.last_seen_at) });
|
|
11
|
+
|
|
12
|
+
export class GuestStore {
|
|
13
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
14
|
+
/** replaces any earlier code for the address (a new request voids the old code) */
|
|
15
|
+
async saveCode(email: string, codeHash: string, expiresAt: number, now = Date.now()) {
|
|
16
|
+
await (await this.driver()).run(`INSERT INTO guest_codes (email, code_hash, attempts, expires_at, created_at) VALUES (?, ?, 0, ?, ?)
|
|
17
|
+
ON CONFLICT(email) DO UPDATE SET code_hash = excluded.code_hash, attempts = 0, expires_at = excluded.expires_at, created_at = excluded.created_at`, [email, codeHash, expiresAt, now]);
|
|
18
|
+
}
|
|
19
|
+
async code(email: string) {
|
|
20
|
+
const r = (await (await this.driver()).all('SELECT code_hash, attempts, expires_at, created_at FROM guest_codes WHERE email = ?', [email]))[0];
|
|
21
|
+
return r ? { codeHash: String(r.code_hash), attempts: Number(r.attempts), expiresAt: Number(r.expires_at), createdAt: Number(r.created_at) } : null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* counts one verification attempt in a single statement, so concurrent
|
|
25
|
+
* guesses can't exceed `maxTries`; returns the code hash it was counted
|
|
26
|
+
* against, or null when there is no live code with tries left
|
|
27
|
+
*/
|
|
28
|
+
async attempt(email: string, maxTries: number, now = Date.now()) {
|
|
29
|
+
const r = (await (await this.driver()).all('UPDATE guest_codes SET attempts = attempts + 1 WHERE email = ? AND attempts < ? AND expires_at > ? RETURNING code_hash, attempts', [email, maxTries, now]))[0];
|
|
30
|
+
return r ? { codeHash: String(r.code_hash), attempts: Number(r.attempts) } : null;
|
|
31
|
+
}
|
|
32
|
+
/** deletes this exact code; true for exactly one caller, so a code signs in once */
|
|
33
|
+
async consumeCode(email: string, codeHash: string) {
|
|
34
|
+
return (await (await this.driver()).all('DELETE FROM guest_codes WHERE email = ? AND code_hash = ? RETURNING email', [email, codeHash])).length > 0;
|
|
35
|
+
}
|
|
36
|
+
async deleteCode(email: string) { await (await this.driver()).run('DELETE FROM guest_codes WHERE email = ?', [email]); }
|
|
37
|
+
|
|
38
|
+
/** keeps the newest `keep` sessions per address so old devices age out */
|
|
39
|
+
async createSession(s: { id: string; tokenHash: string; email: string; deviceName: string }, now = Date.now(), keep = 20) {
|
|
40
|
+
const d = await this.driver();
|
|
41
|
+
await d.batch([
|
|
42
|
+
{ sql: 'INSERT INTO guest_sessions (id, token_hash, email, device_name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)', args: [s.id, s.tokenHash, s.email, s.deviceName, now, now] },
|
|
43
|
+
{ sql: 'DELETE FROM guest_sessions WHERE email = ? AND id NOT IN (SELECT id FROM guest_sessions WHERE email = ? ORDER BY last_seen_at DESC LIMIT ?)', args: [s.email, s.email, keep] },
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
async sessionByHash(tokenHash: string) {
|
|
47
|
+
const r = (await (await this.driver()).all('SELECT * FROM guest_sessions WHERE token_hash = ?', [tokenHash]))[0];
|
|
48
|
+
return r ? session(r) : null;
|
|
49
|
+
}
|
|
50
|
+
async touch(id: string, now = Date.now()) { await (await this.driver()).run('UPDATE guest_sessions SET last_seen_at = ? WHERE id = ?', [now, id]); }
|
|
51
|
+
async deleteSession(id: string) { await (await this.driver()).run('DELETE FROM guest_sessions WHERE id = ?', [id]); }
|
|
52
|
+
/** every device signs out (owner switch "Sign out all") */
|
|
53
|
+
async deleteAll() { await (await this.driver()).run('DELETE FROM guest_sessions'); }
|
|
54
|
+
async count(since = 0) { return Number((await (await this.driver()).all('SELECT COUNT(*) AS n FROM guest_sessions WHERE last_seen_at >= ?', [since]))[0]?.n ?? 0); }
|
|
55
|
+
/** drops expired codes and sessions unused for `idleMs` */
|
|
56
|
+
async purge(idleMs: number, now = Date.now()) {
|
|
57
|
+
await (await this.driver()).batch([
|
|
58
|
+
{ sql: 'DELETE FROM guest_codes WHERE expires_at <= ?', args: [now] },
|
|
59
|
+
{ sql: 'DELETE FROM guest_sessions WHERE last_seen_at <= ?', args: [now - idleMs] },
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { Booking } from '../schema';
|
|
4
|
+
import { ApiError, readJson } from '../api';
|
|
5
|
+
import { sha256Hex } from '../api-keys';
|
|
6
|
+
import { randomToken, safeEqual } from '../editor/auth';
|
|
7
|
+
import { CALENDAR, bookingsEnabled, manageUrl } from '../calendar/config';
|
|
8
|
+
import { encrypt, encryptionAvailable, resolveConfig, validAddress } from '../email/config';
|
|
9
|
+
import { kickEmail } from '../email/service';
|
|
10
|
+
import { guestChanges } from '../booking/service';
|
|
11
|
+
import { siteOrigin } from '../seo';
|
|
12
|
+
import { waitUntilFrom } from '../webhooks';
|
|
13
|
+
import { pageQuery, queryParams } from './http';
|
|
14
|
+
import { failureLock } from './access';
|
|
15
|
+
import type { PublicContext } from './route';
|
|
16
|
+
import { GUEST_IDLE_MS } from './guest-store';
|
|
17
|
+
export { GUEST_IDLE_MS };
|
|
18
|
+
|
|
19
|
+
// Guest sign-in for "My bookings": the visitor proves they read an inbox.
|
|
20
|
+
// POST /guest/code emails a 6-digit code (10 minutes, 5 tries, only its hash
|
|
21
|
+
// is stored); POST /guest/token trades email + code for a per-device token
|
|
22
|
+
// (opaque, hashed at rest, ends after 90 days unused). The token reads that
|
|
23
|
+
// address's bookings and nothing else — it never reaches staff routes.
|
|
24
|
+
// Replies never say whether an address has bookings.
|
|
25
|
+
|
|
26
|
+
export const GUEST_PREFIX = 'acms_guest_';
|
|
27
|
+
const CODE_MS = 10 * 60_000;
|
|
28
|
+
const MAX_TRIES = 5;
|
|
29
|
+
// wrong codes per address per day before sign-in for it pauses (guessing 6 digits stays hopeless)
|
|
30
|
+
const EMAIL_FAILURES_PER_DAY = 10;
|
|
31
|
+
const SITE_CODES_PER_HOUR = 200;
|
|
32
|
+
const TOKEN = /^acms_guest_[0-9a-f]{64}$/;
|
|
33
|
+
|
|
34
|
+
/** guest sign-in needs the owner switch, working email and an encryption key */
|
|
35
|
+
export async function guestSignInReady(repo: ContentRepository, locals?: unknown) {
|
|
36
|
+
const settings = await repo.getSettings();
|
|
37
|
+
if (!settings.mobileAccess.guestSignIn || !CALENDAR.enabled) return false;
|
|
38
|
+
const c = resolveConfig(await repo.email.config(), locals);
|
|
39
|
+
return !c.error && c.provider !== 'none' && c.config.enabled && encryptionAvailable(locals);
|
|
40
|
+
}
|
|
41
|
+
async function requireGuest(c: PublicContext) {
|
|
42
|
+
if (!await guestSignInReady(c.repo, c.locals)) throw new ApiError(404, 'This feature is turned off.');
|
|
43
|
+
}
|
|
44
|
+
const emailField = z.string().trim().toLowerCase().max(254).refine(validAddress, 'Enter a valid email address.');
|
|
45
|
+
const hash = (email: string, code: string) => sha256Hex(`guest-code:${email}:${code}`);
|
|
46
|
+
function sixDigits() {
|
|
47
|
+
// rejection sampling keeps every code equally likely
|
|
48
|
+
const buf = new Uint32Array(1);
|
|
49
|
+
do crypto.getRandomValues(buf); while (buf[0] >= 4_294_000_000);
|
|
50
|
+
return String(buf[0] % 1_000_000).padStart(6, '0');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const codeInput = z.object({ email: emailField }).strict();
|
|
54
|
+
export async function requestCode(c: PublicContext) {
|
|
55
|
+
await requireGuest(c);
|
|
56
|
+
const { email } = codeInput.parse(await readJson(c.request, 1000));
|
|
57
|
+
if (!await c.repo.email.rateLimit(`guest:code:ip:${await sha256Hex(c.address)}`, 20, 15 * 60_000)) throw new ApiError(429, 'Too many requests. Try again in a few minutes.', 900);
|
|
58
|
+
// site-wide ceiling: rotating client addresses can't turn this into a mail cannon
|
|
59
|
+
if (!await c.repo.email.rateLimit('guest:code:site', SITE_CODES_PER_HOUR, 3_600_000)) throw new ApiError(429, 'Sign-in is busy right now. Try again in a little while.', 3600);
|
|
60
|
+
const emailKey = await sha256Hex(email);
|
|
61
|
+
// an address that already got 3 codes, or that ran out of guesses today, quietly gets no more
|
|
62
|
+
if (!await c.repo.email.rateLimited(`guest:fail:email:${emailKey}`, EMAIL_FAILURES_PER_DAY)
|
|
63
|
+
&& await c.repo.email.rateLimit(`guest:code:email:${emailKey}`, 3, 15 * 60_000)) {
|
|
64
|
+
const now = Date.now(), code = sixDigits(), dedupe = `guest-code:${crypto.randomUUID()}`;
|
|
65
|
+
await c.repo.guests.saveCode(email, await hash(email, code), now + CODE_MS, now);
|
|
66
|
+
const config = await c.repo.email.config();
|
|
67
|
+
await c.repo.email.enqueue({ dedupe, kind: 'guest-code', recipient: email, configVersion: config?.version ?? 0, expiresAt: now + CODE_MS,
|
|
68
|
+
payload: JSON.stringify({ issuedAt: now, code: encrypt(code, `guest-code:${dedupe}`, c.locals) }) });
|
|
69
|
+
await kickEmail(c.repo, c.locals, waitUntilFrom(c.locals));
|
|
70
|
+
}
|
|
71
|
+
return { sent: true as const, expiresInMinutes: CODE_MS / 60_000 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const tokenInput = z.object({
|
|
75
|
+
email: emailField, code: z.string().regex(/^\d{6}$/, 'Enter the 6-digit code.'),
|
|
76
|
+
// shown to the owner ("Ana's iPhone"); optional
|
|
77
|
+
deviceName: z.string().trim().max(80).default(''),
|
|
78
|
+
}).strict();
|
|
79
|
+
export async function exchangeCode(c: PublicContext) {
|
|
80
|
+
await requireGuest(c);
|
|
81
|
+
const body = tokenInput.parse(await readJson(c.request, 1000));
|
|
82
|
+
const lock = await failureLock(c.repo, 'guest-code', c.address, 30);
|
|
83
|
+
await lock.check();
|
|
84
|
+
// per address, across every code: new codes reset the 5 tries, not this budget
|
|
85
|
+
const failKey = `guest:fail:email:${await sha256Hex(body.email)}`;
|
|
86
|
+
if (await c.repo.email.rateLimited(failKey, EMAIL_FAILURES_PER_DAY)) {
|
|
87
|
+
await c.repo.guests.deleteCode(body.email);
|
|
88
|
+
throw new ApiError(400, 'That code is wrong or has expired. Check the latest email or ask for a new code.');
|
|
89
|
+
}
|
|
90
|
+
// the try is counted and the code consumed atomically: concurrent requests
|
|
91
|
+
// can't share one code or squeeze in extra guesses (spent codes stay until
|
|
92
|
+
// purged; attempt() already refuses them)
|
|
93
|
+
const tried = await c.repo.guests.attempt(body.email, MAX_TRIES);
|
|
94
|
+
const ok = !!tried && safeEqual(tried.codeHash, await hash(body.email, body.code)) && await c.repo.guests.consumeCode(body.email, tried.codeHash);
|
|
95
|
+
if (!ok) {
|
|
96
|
+
await lock.miss();
|
|
97
|
+
await c.repo.email.rateLimit(failKey, EMAIL_FAILURES_PER_DAY, 86_400_000);
|
|
98
|
+
throw new ApiError(400, 'That code is wrong or has expired. Check the latest email or ask for a new code.');
|
|
99
|
+
}
|
|
100
|
+
const token = randomToken(GUEST_PREFIX);
|
|
101
|
+
await c.repo.guests.createSession({ id: crypto.randomUUID(), tokenHash: await sha256Hex(token), email: body.email, deviceName: body.deviceName });
|
|
102
|
+
return { accessToken: token, tokenType: 'Bearer' as const, email: body.email, idleDays: GUEST_IDLE_MS / 86_400_000 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** the signed-in guest behind `Authorization: Bearer acms_guest_…` */
|
|
106
|
+
export async function authenticateGuest(c: PublicContext) {
|
|
107
|
+
await requireGuest(c);
|
|
108
|
+
const raw = /^Bearer (\S+)$/.exec(c.request.headers.get('authorization') ?? '')?.[1] ?? '';
|
|
109
|
+
const lock = await failureLock(c.repo, 'guest-token', c.address);
|
|
110
|
+
await lock.check();
|
|
111
|
+
const session = TOKEN.test(raw) ? await c.repo.guests.sessionByHash(await sha256Hex(raw)) : null;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
if (!session || session.lastSeenAt <= now - GUEST_IDLE_MS) {
|
|
114
|
+
await lock.miss();
|
|
115
|
+
if (session) await c.repo.guests.deleteSession(session.id);
|
|
116
|
+
throw new ApiError(401, 'Sign in again.');
|
|
117
|
+
}
|
|
118
|
+
// sliding expiry without a write on every request
|
|
119
|
+
if (now - session.lastSeenAt > 3_600_000) await c.repo.guests.touch(session.id, now);
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const myBookingsQuery = z.object({ when: z.enum(['upcoming', 'past']).default('upcoming'), ...pageQuery }).strict();
|
|
124
|
+
export async function myBookings(c: PublicContext) {
|
|
125
|
+
const q = myBookingsQuery.parse(queryParams(c.url));
|
|
126
|
+
const session = await authenticateGuest(c);
|
|
127
|
+
const settings = await c.repo.getSettings();
|
|
128
|
+
const origin = siteOrigin(settings, c.url);
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const kinds: Booking['kind'][] = bookingsEnabled() ? ['booking', 'rsvp'] : ['rsvp'];
|
|
131
|
+
const start = (q.page - 1) * q.limit;
|
|
132
|
+
// filtered, ordered and paged in SQL (one extra row says whether there is a next page)
|
|
133
|
+
const [list, resources, links, events] = await Promise.all([
|
|
134
|
+
c.repo.listBookings({ email: session.email, kinds, ...(q.when === 'upcoming' ? { from: now } : { endedBy: now, newestFirst: true }), limit: q.limit + 1, offset: start }),
|
|
135
|
+
c.repo.listResources(), c.repo.listBookingLinks(), c.repo.listEvents(),
|
|
136
|
+
]);
|
|
137
|
+
const res = new Map(resources.map((r) => [r.id, r])), lnk = new Map(links.map((l) => [l.id, l])), ev = new Map(events.map((e) => [e.id, e]));
|
|
138
|
+
return {
|
|
139
|
+
email: session.email,
|
|
140
|
+
items: list.slice(0, q.limit).map((b) => {
|
|
141
|
+
const resource = res.get(b.resourceId), link = lnk.get(b.linkId) ?? null, event = ev.get(b.eventId);
|
|
142
|
+
return {
|
|
143
|
+
id: b.id, reference: b.id.slice(0, 8).toUpperCase(), kind: b.kind as 'booking' | 'rsvp', status: b.status,
|
|
144
|
+
start: new Date(b.start).toISOString(), end: new Date(b.end).toISOString(), seats: b.seats,
|
|
145
|
+
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
146
|
+
// use with the Booking-Token header on /bookings/manage/*
|
|
147
|
+
manageToken: b.manageToken, manageUrl: origin + manageUrl(b.manageToken),
|
|
148
|
+
can: guestChanges(b, link, now),
|
|
149
|
+
};
|
|
150
|
+
}),
|
|
151
|
+
pagination: { page: q.page, limit: q.limit, nextPage: list.length > q.limit ? q.page + 1 : null },
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function revokeGuest(c: PublicContext) {
|
|
156
|
+
const session = await authenticateGuest(c);
|
|
157
|
+
await c.repo.guests.deleteSession(session.id);
|
|
158
|
+
return { signedOut: true as const };
|
|
159
|
+
}
|
|
160
|
+
|
package/src/lib/mobile/http.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { BookingError } from '../booking/service';
|
|
|
4
4
|
import { sha256Hex } from '../api-keys';
|
|
5
5
|
import type { ContentRepository } from '../repo/types';
|
|
6
6
|
|
|
7
|
-
const headers = {
|
|
7
|
+
export const headers = {
|
|
8
8
|
'Access-Control-Allow-Origin': '*',
|
|
9
9
|
'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
10
10
|
// Authorization carries editor-app bearer tokens; no route reads cookies
|
|
11
|
-
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match',
|
|
11
|
+
'Access-Control-Allow-Headers': 'Authorization, Booking-Token, Content-Type, Idempotency-Key, If-None-Match',
|
|
12
12
|
'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
|
|
13
13
|
'X-Content-Type-Options': 'nosniff',
|
|
14
14
|
'Cache-Control': 'no-store',
|
|
@@ -25,9 +25,10 @@ export async function publicResponse(request: Request, data: unknown, raw = fals
|
|
|
25
25
|
res.headers.set('ETag', etag);
|
|
26
26
|
// Revalidate even fresh-looking content: unpublishing must take effect.
|
|
27
27
|
res.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
|
28
|
-
|
|
29
|
-
return match ? new Response(null, { status: 304, headers: res.headers }) : res;
|
|
28
|
+
return etagMatches(request, etag) ? new Response(null, { status: 304, headers: res.headers }) : res;
|
|
30
29
|
}
|
|
30
|
+
export const etagMatches = (request: Request, etag: string) =>
|
|
31
|
+
request.headers.get('if-none-match')?.split(',').some((s) => s.trim().replace(/^W\//, '') === etag || s.trim() === '*') ?? false;
|
|
31
32
|
export function failure(error: unknown) {
|
|
32
33
|
const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
|
|
33
34
|
const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
|
|
@@ -35,7 +36,7 @@ export function failure(error: unknown) {
|
|
|
35
36
|
code: codes[status] ?? 'internal_error',
|
|
36
37
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
37
38
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
38
|
-
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After':
|
|
39
|
+
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
|
39
40
|
...(status === 401 ? { 'WWW-Authenticate': 'Bearer realm="acms", error="invalid_token"' } : {}) } });
|
|
40
41
|
}
|
|
41
42
|
export async function limitRequest(repo: ContentRepository, address: string, write = false) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { Link } from '../schema';
|
|
4
|
+
import { siteOrigin } from '../seo';
|
|
5
|
+
import { socialIconPath } from '../socialIcons';
|
|
6
|
+
import { safeUrl } from './content';
|
|
7
|
+
import { queryParams } from './http';
|
|
8
|
+
import { requireAccess } from './access';
|
|
9
|
+
|
|
10
|
+
// The links page (link-in-bio) for apps: the same visible blocks in the same
|
|
11
|
+
// order. Buttons point at /go/<id> so taps still count; raw HTML embeds stay
|
|
12
|
+
// website-only and arrive as `embed` with the page URL to open instead.
|
|
13
|
+
|
|
14
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
15
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
16
|
+
|
|
17
|
+
function icon(l: Link, origin: string) {
|
|
18
|
+
const value = l.icon.trim();
|
|
19
|
+
if (!value) return null;
|
|
20
|
+
if (value.startsWith('<svg')) return { type: 'svg' as const, value };
|
|
21
|
+
if (value === 'favicon') {
|
|
22
|
+
try { return { type: 'image' as const, value: `https://www.google.com/s2/favicons?domain=${new URL(l.url).hostname}&sz=64` }; } catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
if (value.startsWith('http') || value.startsWith('/')) { const url = safeUrl(value, origin); return url ? { type: 'image' as const, value: url } : null; }
|
|
25
|
+
return { type: 'emoji' as const, value: value.slice(0, 16) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shown(l: Link) {
|
|
29
|
+
if (l.meta?.hidden) return false;
|
|
30
|
+
if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
|
|
31
|
+
if (l.kind === 'heading') return Boolean(l.label.trim());
|
|
32
|
+
if (l.kind === 'text' || l.kind === 'html') return Boolean(l.html.trim());
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function item(l: Link, origin: string, page: string) {
|
|
37
|
+
const style = { color: HEX.test(str(l.meta.color)) ? str(l.meta.color) : null, textColor: HEX.test(str(l.meta.textColor)) ? str(l.meta.textColor) : null };
|
|
38
|
+
switch (l.kind) {
|
|
39
|
+
case 'heading': return { id: l.id, kind: 'heading' as const, label: l.label };
|
|
40
|
+
case 'text': return { id: l.id, kind: 'text' as const, text: l.html };
|
|
41
|
+
case 'spacer': return { id: l.id, kind: 'spacer' as const, size: (['s', 'm', 'l'].includes(str(l.meta.size)) ? str(l.meta.size) : 'm') as 's' | 'm' | 'l' };
|
|
42
|
+
case 'live': return { id: l.id, kind: 'live' as const };
|
|
43
|
+
case 'html': return { id: l.id, kind: 'embed' as const, url: page };
|
|
44
|
+
case 'email': return { id: l.id, kind: 'email' as const, label: l.label || l.url, url: `mailto:${l.url.trim()}`, icon: icon(l, origin), ...style };
|
|
45
|
+
case 'phone': return { id: l.id, kind: 'phone' as const, label: l.label || l.url, url: `tel:${l.url.replace(/[^+\d]/g, '')}`, icon: icon(l, origin), ...style };
|
|
46
|
+
case 'vcard': {
|
|
47
|
+
const m = l.meta;
|
|
48
|
+
return { id: l.id, kind: 'contact' as const, label: l.label || 'Save contact', icon: icon(l, origin), ...style,
|
|
49
|
+
contact: { firstName: str(m.firstName), lastName: str(m.lastName), organization: str(m.org), phone: str(m.phone), email: str(m.email), url: safeUrl(str(m.url), origin) } };
|
|
50
|
+
}
|
|
51
|
+
default: return { id: l.id, kind: 'link' as const, label: l.label, url: `${origin}/go/${encodeURIComponent(l.id)}`, destination: safeUrl(l.url, origin), icon: icon(l, origin), ...style };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getLinks(repo: ContentRepository, url: URL) {
|
|
56
|
+
z.object({}).strict().parse(queryParams(url));
|
|
57
|
+
const settings = await requireAccess(repo, 'links');
|
|
58
|
+
const origin = siteOrigin(settings, url);
|
|
59
|
+
const [links, nav] = await Promise.all([repo.listLinks(), repo.listNavItems()]);
|
|
60
|
+
const page = `${origin}/links`;
|
|
61
|
+
return {
|
|
62
|
+
name: settings.linksName || settings.siteTitle,
|
|
63
|
+
bio: settings.linksBio || settings.tagline,
|
|
64
|
+
avatar: safeUrl(settings.linksAvatar, origin),
|
|
65
|
+
url: page,
|
|
66
|
+
socials: nav.filter((n) => n.area === 'social').map((n) => ({ label: n.label, url: safeUrl(n.url, origin, true), iconPath: socialIconPath(n.url) })).filter((n) => n.url),
|
|
67
|
+
items: links.filter(shown).map((l) => item(l, origin, page)).filter((i) => !('url' in i) || i.url),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Booking, BookingLink, CalEvent, Resource, Settings } from '../schema';
|
|
3
|
+
import { ApiError, readJson } from '../api';
|
|
4
|
+
import { bookingsEnabled, eventUrl, manageUrl } from '../calendar/config';
|
|
5
|
+
import { eventTimeZone, locationLine } from '../calendar/events';
|
|
6
|
+
import { resourceZone, linkLengths } from '../booking/availability';
|
|
7
|
+
import { bookingIcs } from '../booking/emails';
|
|
8
|
+
import { guestCancel, guestChanges, guestMoveLink, guestReschedule } from '../booking/service';
|
|
9
|
+
import { siteOrigin } from '../seo';
|
|
10
|
+
import { waitUntilFrom } from '../webhooks';
|
|
11
|
+
import { isoDay, queryParams } from './http';
|
|
12
|
+
import { requireCalendar, availabilityFor } from './calendar';
|
|
13
|
+
import { requireAccess, failureLock } from './access';
|
|
14
|
+
import type { PublicContext } from './route';
|
|
15
|
+
|
|
16
|
+
// A booker's own booking, from an app: the private token every confirmation
|
|
17
|
+
// email links to is the credential. It travels in the Booking-Token header,
|
|
18
|
+
// never the URL, so it stays out of access logs and analytics. Unknown tokens
|
|
19
|
+
// count against the caller's address (20 misses / 10 min → locked out).
|
|
20
|
+
|
|
21
|
+
export const BOOKING_TOKEN_HEADER = 'Booking-Token';
|
|
22
|
+
const TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
23
|
+
|
|
24
|
+
interface Managed { booking: Booking; settings: Settings; resource: Resource | null; link: BookingLink | null; event: CalEvent | null; origin: string }
|
|
25
|
+
|
|
26
|
+
async function managed(c: PublicContext): Promise<Managed> {
|
|
27
|
+
requireCalendar();
|
|
28
|
+
const settings = await requireAccess(c.repo, 'bookingChanges');
|
|
29
|
+
const token = c.request.headers.get(BOOKING_TOKEN_HEADER) ?? '';
|
|
30
|
+
if (!token) throw new ApiError(400, `Send the booking’s token in the ${BOOKING_TOKEN_HEADER} header.`);
|
|
31
|
+
const lock = await failureLock(c.repo, 'booking-token', c.address);
|
|
32
|
+
await lock.check();
|
|
33
|
+
const booking = TOKEN.test(token) ? await c.repo.getBookingByToken(token) : null;
|
|
34
|
+
if (!booking || booking.kind === 'block' || (!bookingsEnabled() && booking.kind !== 'rsvp')) {
|
|
35
|
+
await lock.miss();
|
|
36
|
+
throw new ApiError(404, 'Booking not found.');
|
|
37
|
+
}
|
|
38
|
+
const [resource, link, event] = await Promise.all([
|
|
39
|
+
booking.resourceId ? c.repo.getResource(booking.resourceId) : null,
|
|
40
|
+
booking.linkId ? c.repo.getBookingLink(booking.linkId) : null,
|
|
41
|
+
booking.eventId ? c.repo.getEvent(booking.eventId) : null,
|
|
42
|
+
]);
|
|
43
|
+
return { booking, settings, resource, link, event, origin: siteOrigin(settings, c.url) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function view(m: Managed, booking = m.booking) {
|
|
47
|
+
const { resource, link, event, settings, origin } = m;
|
|
48
|
+
const can = guestChanges(booking, link);
|
|
49
|
+
return {
|
|
50
|
+
booking: {
|
|
51
|
+
id: booking.id, reference: booking.id.slice(0, 8).toUpperCase(), kind: booking.kind as 'booking' | 'rsvp', status: booking.status,
|
|
52
|
+
start: new Date(booking.start).toISOString(), end: new Date(booking.end).toISOString(),
|
|
53
|
+
allDay: event ? event.allDay : link?.mode === 'day',
|
|
54
|
+
timezone: event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC',
|
|
55
|
+
seats: booking.seats, name: booking.name, email: booking.email, phone: booking.phone, answers: booking.answers,
|
|
56
|
+
createdAt: new Date(booking.createdAt).toISOString(),
|
|
57
|
+
},
|
|
58
|
+
title: event ? event.title : [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
|
|
59
|
+
location: event ? locationLine(event.location) : resource?.name ?? '',
|
|
60
|
+
resource: resource ? { slug: resource.slug, name: resource.name } : null,
|
|
61
|
+
link: link ? { slug: link.slug, name: link.name, mode: link.mode, lengths: linkLengths(link) } : null,
|
|
62
|
+
event: event ? { slug: event.slug, title: event.title, url: origin + eventUrl(event.slug) } : null,
|
|
63
|
+
// the web page for this booking (the same private link the emails carry)
|
|
64
|
+
manageUrl: origin + manageUrl(booking.manageToken),
|
|
65
|
+
can,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const flow = (m: Managed, c: PublicContext) => ({ origin: m.origin, locals: c.locals, waitUntil: waitUntilFrom(c.locals) });
|
|
69
|
+
|
|
70
|
+
export const getManaged = async (c: PublicContext) => view(await managed(c));
|
|
71
|
+
|
|
72
|
+
export async function cancelManaged(c: PublicContext) {
|
|
73
|
+
const m = await managed(c);
|
|
74
|
+
return view(m, await guestCancel(c.repo, m.booking, flow(m, c)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const manageAvailabilityQuery = z.object({
|
|
78
|
+
from: isoDay.optional(), days: z.coerce.number().int().min(1).max(31).default(7), minutes: z.coerce.number().int().min(1).max(44640).optional(),
|
|
79
|
+
}).strict();
|
|
80
|
+
export async function managedAvailability(c: PublicContext) {
|
|
81
|
+
const q = manageAvailabilityQuery.parse(queryParams(c.url));
|
|
82
|
+
const m = await managed(c);
|
|
83
|
+
if (!guestChanges(m.booking, m.link).reschedule || !m.resource || !m.link) throw new ApiError(409, 'This booking can’t be moved.');
|
|
84
|
+
// only times the move itself would accept (outside the change cutoff)
|
|
85
|
+
return availabilityFor(c.repo, m.resource, guestMoveLink(m.link), m.settings, q, m.booking.id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const rescheduleInput = z.object({
|
|
89
|
+
start: z.string().datetime({ offset: true }),
|
|
90
|
+
// range/day links: the new length; default keeps the current length
|
|
91
|
+
minutes: z.number().int().min(1).max(44640).optional(),
|
|
92
|
+
}).strict();
|
|
93
|
+
export async function rescheduleManaged(c: PublicContext) {
|
|
94
|
+
const body = rescheduleInput.parse(await readJson(c.request, 2000));
|
|
95
|
+
const m = await managed(c);
|
|
96
|
+
const start = Date.parse(body.start);
|
|
97
|
+
const minutes = body.minutes ?? Math.round((m.booking.end - m.booking.start) / 60_000);
|
|
98
|
+
// availability rules check everything else (hours, notice, overlaps)
|
|
99
|
+
if (m.link?.mode === 'range' && body.minutes !== undefined && !linkLengths(m.link).includes(minutes)) throw new ApiError(400, 'Pick one of the booking type’s lengths.');
|
|
100
|
+
return view(m, await guestReschedule(c.repo, m.booking, start, start + minutes * 60_000, flow(m, c)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function managedIcs(c: PublicContext) {
|
|
104
|
+
z.object({}).strict().parse(queryParams(c.url));
|
|
105
|
+
const m = await managed(c);
|
|
106
|
+
const v = view(m);
|
|
107
|
+
const body = bookingIcs(m.booking, v.title, v.location, v.manageUrl, new URL(m.origin).host, m.booking.status === 'cancelled' ? 'CANCEL' : 'REQUEST', { allDay: v.booking.allDay });
|
|
108
|
+
return new Response(body, { headers: {
|
|
109
|
+
'Content-Type': 'text/calendar; charset=utf-8', 'Content-Disposition': 'attachment; filename="booking.ics"',
|
|
110
|
+
'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff',
|
|
111
|
+
} });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ManagedBooking = ReturnType<typeof view>;
|
|
@@ -5,6 +5,10 @@ import { contentQuery, contentDetailQuery } from './content';
|
|
|
5
5
|
import { eventsQuery, availabilityQuery } from './calendar';
|
|
6
6
|
import { bookingInput } from './bookings';
|
|
7
7
|
import { pagesQuery } from './pages';
|
|
8
|
+
import { searchQuery } from './search';
|
|
9
|
+
import { manageAvailabilityQuery, rescheduleInput } from './manage';
|
|
10
|
+
import { formResponseInput } from './forms';
|
|
11
|
+
import { codeInput, tokenInput, myBookingsQuery } from './guest';
|
|
8
12
|
import { deviceInput, deviceUpdate } from '../push/api';
|
|
9
13
|
import { pageQuery, queryParams } from './http';
|
|
10
14
|
import * as schemas from './schemas';
|
|
@@ -24,7 +28,7 @@ const readHeaders = {
|
|
|
24
28
|
};
|
|
25
29
|
const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [status, {
|
|
26
30
|
description: status === 429 ? 'Read limit exceeded (300/minute/client address).' : 'Request failed.', content: media(ref('Error')),
|
|
27
|
-
...(status === 429 ? { headers: { 'Retry-After': {
|
|
31
|
+
...(status === 429 ? { headers: { 'Retry-After': { description: 'Seconds to wait: 60 for per-minute limits, longer for hourly, daily and lockout limits.', schema: { type: 'string' } } } } : {}),
|
|
28
32
|
}]));
|
|
29
33
|
function parameters(query: z.AnyZodObject) {
|
|
30
34
|
return Object.entries(query.shape).map(([name, schema]) => ({ name, in: 'query', required: !(schema as z.ZodTypeAny).isOptional(), schema: jsonSchema(schema as z.ZodTypeAny) }));
|
|
@@ -37,13 +41,16 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
37
41
|
['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
|
|
38
42
|
['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
|
|
39
43
|
['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
|
|
40
|
-
['/content', 'Published content in the requested language', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
|
|
44
|
+
['/content', 'Published content in the requested language; q (requires search) returns ranked matches, posts before pages, up to 200 per kind', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
|
|
41
45
|
['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
|
|
46
|
+
['/search', 'Ranked matches across posts, pages and events (top limit per group); requires search', searchQuery, ref('SearchResults')],
|
|
42
47
|
['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
|
|
43
48
|
['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
|
|
44
49
|
['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
|
|
45
50
|
['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
|
|
46
51
|
['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
|
|
52
|
+
['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
|
|
53
|
+
['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
|
|
47
54
|
['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
|
|
48
55
|
];
|
|
49
56
|
for (const [path, summary, query, schema, cache = true] of reads) {
|
|
@@ -72,6 +79,35 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
72
79
|
},
|
|
73
80
|
options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
|
|
74
81
|
};
|
|
82
|
+
// private reads and writes: no-store, credential in a header, never the URL
|
|
83
|
+
const bookingToken = { name: 'Booking-Token', in: 'header', required: true, description: 'The booking’s private token (manageToken from POST /bookings or GET /guest/bookings). Unknown tokens count against the caller: 20 misses in 10 minutes locks the address out for a while.', schema: { type: 'string' } };
|
|
84
|
+
const priv = (summary: string, response: unknown, o: { query?: z.AnyZodObject; body?: z.ZodTypeAny; header?: unknown; guest?: boolean; status?: number; description?: string; write?: boolean } = {}) => ({
|
|
85
|
+
summary, ...(o.description ? { description: o.description } : {}),
|
|
86
|
+
...(o.guest ? { security: [{ guestToken: [] }] } : {}),
|
|
87
|
+
parameters: [...(o.query ? parameters(o.query) : []), ...(o.header ? [o.header] : [])],
|
|
88
|
+
...(o.body ? { requestBody: { required: true, content: media(jsonSchema(o.body)) } } : {}),
|
|
89
|
+
responses: { ...errors, ...(o.write ? Object.fromEntries([409, 410, 413, 415].map(code => [code, { description: 'Request rejected.', content: media(ref('Error')) }])) : {}),
|
|
90
|
+
...(o.guest ? { 401: { description: 'Missing, expired or signed-out guest token: sign in again.', content: media(ref('Error')) } } : {}),
|
|
91
|
+
[o.status ?? 200]: { description: 'Success.', headers: { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } }, content: media(envelope(response)) } },
|
|
92
|
+
});
|
|
93
|
+
const cors = { options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } } };
|
|
94
|
+
const manageNote = 'Requires the calendar and the owner switch bookingChanges. Same rules and emails as the booking’s web page; a booking type’s change cutoff closes guest changes that many hours before the start.';
|
|
95
|
+
paths['/api/v1/bookings/manage'] = { get: priv('The booker’s own booking: status, time, place, what they may still change', ref('ManagedBooking'), { header: bookingToken, description: manageNote }), ...cors };
|
|
96
|
+
paths['/api/v1/bookings/manage/cancel'] = { post: priv('Cancel the booking (repeating is harmless)', ref('ManagedBooking'), { header: bookingToken, write: true, description: manageNote }), ...cors };
|
|
97
|
+
paths['/api/v1/bookings/manage/availability'] = { get: priv('Open times for moving the booking (its own time counts as free)', ref('Availability'), { header: bookingToken, query: manageAvailabilityQuery, description: manageNote }), ...cors };
|
|
98
|
+
paths['/api/v1/bookings/manage/reschedule'] = { post: priv('Move the booking to a new start (room bookings, not RSVPs)', ref('ManagedBooking'), { header: bookingToken, body: rescheduleInput, write: true, description: manageNote }), ...cors };
|
|
99
|
+
paths['/api/v1/bookings/manage/ics'] = { get: { summary: 'Calendar file for the booking (text/calendar, no v1 envelope)', parameters: [bookingToken],
|
|
100
|
+
responses: { ...errors, 200: { description: 'An iCalendar file.', content: { 'text/calendar': { schema: { type: 'string' } } } } } }, ...cors };
|
|
101
|
+
paths['/api/v1/forms/{id}/responses'] = { post: { ...priv('Send a response to a published form', ref('FormReceipt'), { body: formResponseInput, write: true, status: 201,
|
|
102
|
+
description: 'Same validation as the web form. Idempotency-Key (UUID v4) is the response id: retries never duplicate. 10 responses per address per form per minute, plus the owner’s daily ceiling per form (429). version from GET /forms/{id}: a changed form answers 409. Leave website empty (spam trap).' }),
|
|
103
|
+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid' } }] }, ...cors };
|
|
104
|
+
const guestNote = 'Guest sign-in needs the calendar, working email, EMAIL_ENCRYPTION_KEY and the owner switch guestSignIn (site.features.guestSignIn); 404 otherwise.';
|
|
105
|
+
paths['/api/v1/guest/code'] = { post: priv('Email a 6-digit sign-in code (always answers the same, whether or not the address has bookings)', ref('GuestCodeSent'), { body: codeInput, write: true, status: 202,
|
|
106
|
+
description: `${guestNote} Codes last 10 minutes; a new request voids the old code. 3 codes per address and 20 per client address per 15 minutes.` }), ...cors };
|
|
107
|
+
paths['/api/v1/guest/token'] = { post: priv('Trade email + code for a guest token for this device', ref('GuestToken'), { body: tokenInput, write: true,
|
|
108
|
+
description: `${guestNote} 5 tries per code; 30 failures per client address in 10 minutes locks it out. Keep the token in secure storage; it ends after 90 days unused.` }), ...cors };
|
|
109
|
+
paths['/api/v1/guest/bookings'] = { get: priv('Bookings and RSVPs made with the signed-in email (upcoming or past)', ref('GuestBookings'), { query: myBookingsQuery, guest: true, description: guestNote }), ...cors };
|
|
110
|
+
paths['/api/v1/guest/revoke'] = { post: priv('Sign this device out', ref('GuestSignedOut'), { guest: true, write: true }), ...cors };
|
|
75
111
|
const writeErrors = { ...errors, 429: { ...errors[429], description: 'Write limit exceeded (20/minute/client address).' },
|
|
76
112
|
...Object.fromEntries([413, 415].map(code => [code, { description: 'Request rejected.', content: media(ref('Error')) }])) };
|
|
77
113
|
const noStore = { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } };
|
|
@@ -101,11 +137,11 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
|
|
|
101
137
|
};
|
|
102
138
|
Object.assign(paths, editorPaths(jsonSchema));
|
|
103
139
|
return {
|
|
104
|
-
openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication. Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
|
|
140
|
+
openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication, except a booking’s own routes (/bookings/manage/*: its private token in the Booking-Token header) and guest sign-in (/guest/*: a guest token from an emailed code). Owner switches in Admin → Mobile app turn app features off (404). Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
|
|
105
141
|
tags: [{ name: 'Public', description: 'Visitor apps: no authentication.' }, { name: 'Editor', description: 'Editor apps: device sign-in and the admin API.' }],
|
|
106
142
|
paths, components: {
|
|
107
143
|
schemas: Object.fromEntries([...Object.entries(schemas), ...Object.entries(editorSchemas)].map(([name, schema]) => [name, jsonSchema(schema as z.ZodTypeAny)])),
|
|
108
|
-
securitySchemes: editorSecuritySchemes,
|
|
144
|
+
securitySchemes: { ...editorSecuritySchemes, guestToken: { type: 'http', scheme: 'bearer', bearerFormat: 'acms_guest_…', description: 'Guest token from POST /api/v1/guest/token. Reads that email’s bookings only.' } },
|
|
109
145
|
},
|
|
110
146
|
};
|
|
111
147
|
}
|
package/src/lib/mobile/route.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
3
|
import { getRepo } from '../repo';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { userConfig } from 'virtual:acms-config';
|
|
5
|
+
import { limitRequest, failure, response, headers as baseHeaders, etagMatches } from './http';
|
|
6
|
+
import { API_REVALIDATE } from '../cache/policy';
|
|
7
|
+
import { CACHE } from '../cache/config';
|
|
8
|
+
import { memoryGet, memorySet } from '../cache/memory';
|
|
9
|
+
|
|
10
|
+
type Load = (repo: ContentRepository, url: URL, id: string, locals: unknown) => Promise<unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* A public, cacheable read. `timeless` routes depend only on stored content;
|
|
13
|
+
* the rest (upcoming events, dynamic page blocks, search) also change with
|
|
14
|
+
* the clock, so their ETag rolls over every cache.maxAge seconds too.
|
|
15
|
+
*/
|
|
16
|
+
export function readRoute(load: Load, cache = true, raw = false, opts: { timeless?: boolean } = {}): APIRoute {
|
|
6
17
|
return async context => {
|
|
7
18
|
try {
|
|
8
19
|
const repo = await getRepo(context.locals);
|
|
9
20
|
let address = 'unknown';
|
|
10
21
|
try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
11
22
|
await limitRequest(repo, address);
|
|
12
|
-
const
|
|
13
|
-
const res = cache ? await
|
|
23
|
+
const id = context.params.id ?? context.params.slug ?? '';
|
|
24
|
+
const res = cache ? await versionedResponse(repo, context, () => load(repo, context.url, id, context.locals), raw, opts.timeless)
|
|
25
|
+
: response(await load(repo, context.url, id, context.locals));
|
|
14
26
|
return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
|
|
15
27
|
} catch (error) {
|
|
16
28
|
const res = failure(error);
|
|
@@ -18,3 +30,50 @@ export function readRoute(load: (repo: ContentRepository, url: URL, id: string)
|
|
|
18
30
|
}
|
|
19
31
|
};
|
|
20
32
|
}
|
|
33
|
+
|
|
34
|
+
const CORE = String((userConfig as { coreVersion?: string }).coreVersion ?? '0');
|
|
35
|
+
// The ETag comes from the content version (plus the framework version, and
|
|
36
|
+
// a time window for clock-dependent routes), so a matching If-None-Match is
|
|
37
|
+
// answered 304 before any content is loaded or serialized.
|
|
38
|
+
async function versionedResponse(repo: ContentRepository, context: { request: Request; url: URL; locals: unknown }, build: () => Promise<unknown>, raw: boolean, timeless = false) {
|
|
39
|
+
const { version } = await repo.getCacheState();
|
|
40
|
+
const window = timeless ? '' : `.${Math.floor(Date.now() / (Math.max(CACHE.maxAge, 1) * 1000))}`;
|
|
41
|
+
const etag = `"c${version}${window}-${CORE}"`;
|
|
42
|
+
const h = new Headers(baseHeaders);
|
|
43
|
+
h.set('ETag', etag);
|
|
44
|
+
h.set('Cache-Control', API_REVALIDATE);
|
|
45
|
+
h.set('Content-Type', 'application/json');
|
|
46
|
+
if (etagMatches(context.request, etag)) return new Response(null, { status: 304, headers: h });
|
|
47
|
+
// Node only: Workers isolates are short-lived and many
|
|
48
|
+
const memory = CACHE.enabled && CACHE.memory && !(context.locals as { runtime?: unknown } | undefined)?.runtime;
|
|
49
|
+
const key = `${etag} ${context.url.href}`;
|
|
50
|
+
let body = memory ? memoryGet(key) : undefined;
|
|
51
|
+
if (body === undefined) {
|
|
52
|
+
const data = await build();
|
|
53
|
+
body = JSON.stringify(raw ? data : { version: 1, data });
|
|
54
|
+
if (memory) memorySet(key, body);
|
|
55
|
+
}
|
|
56
|
+
return new Response(body, { status: 200, headers: h });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PublicContext { repo: ContentRepository; request: Request; url: URL; address: string; params: Record<string, string | undefined>; locals: unknown }
|
|
60
|
+
/**
|
|
61
|
+
* A private or write route on the public API: rate limited per client
|
|
62
|
+
* address (reads 300/min, writes 20/min), never cached, v1 envelope.
|
|
63
|
+
* Handlers may return a Response (files, custom status).
|
|
64
|
+
*/
|
|
65
|
+
export function privateRoute(handler: (c: PublicContext) => Promise<unknown>, opts: { write?: boolean; status?: number } = {}): APIRoute {
|
|
66
|
+
return async context => {
|
|
67
|
+
try {
|
|
68
|
+
const repo = await getRepo(context.locals);
|
|
69
|
+
let address = 'unknown';
|
|
70
|
+
try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
|
|
71
|
+
await limitRequest(repo, address, opts.write);
|
|
72
|
+
const data = await handler({ repo, request: context.request, url: context.url, address, params: context.params, locals: context.locals });
|
|
73
|
+
if (data instanceof Response) return data;
|
|
74
|
+
return response(data, opts.status ?? 200);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return failure(error);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|