@acms-subir/core 0.3.9 → 0.3.10
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 +33 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +7 -1
- package/cli/mobile-doctor.mjs +9 -0
- package/cli/monitor.mjs +189 -0
- package/docs/mobile.md +23 -2
- package/integration.mjs +35 -4
- package/package.json +1 -1
- package/src/actions/index.ts +6 -26
- package/src/components/AppAccess.tsx +117 -0
- package/src/components/MonitoringSettings.tsx +112 -0
- package/src/layouts/Admin.astro +2 -3
- package/src/lib/api.ts +2 -1
- package/src/lib/backup/content.ts +1 -1
- package/src/lib/booking/availability.ts +15 -2
- package/src/lib/booking/service.ts +53 -1
- package/src/lib/calendar/admin.ts +41 -0
- 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/email/service.ts +8 -1
- package/src/lib/email/store.ts +10 -0
- package/src/lib/email/templates.ts +6 -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 +2 -2
- 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 +24 -2
- 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/repo/migrations.ts +28 -0
- package/src/lib/repo/sql.ts +66 -20
- package/src/lib/repo/types.ts +19 -2
- package/src/lib/scheduler.ts +7 -0
- package/src/lib/schema.ts +35 -1
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/mobile.astro +6 -2
- package/src/pages/admin/settings.astro +12 -0
- package/src/pages/api/health.ts +17 -0
- 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/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/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/search.ts +7 -0
- package/src/pages/book/[resource]/[link].astro +3 -3
- package/src/pages/book/manage/[token].astro +11 -7
- package/src/styles/admin.css +66 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { BookingLink, CalEvent, Calendar, Resource, Settings } from '../schema';
|
|
3
4
|
import { ApiError } from '../api';
|
|
4
5
|
import { CALENDAR, bookingsEnabled, resourceUrl, bookingLinkUrl } from '../calendar/config';
|
|
5
6
|
import { calendarToJson, eventToJson } from '../calendar/api';
|
|
@@ -27,7 +28,10 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
27
28
|
requireCalendar();
|
|
28
29
|
const q = eventsQuery.parse(queryParams(url));
|
|
29
30
|
const locale = mobileLocale(q.locale);
|
|
30
|
-
const
|
|
31
|
+
const term = q.q?.trim() ?? '';
|
|
32
|
+
const [settings, calendars, events, indexed] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(),
|
|
33
|
+
// the search index's matches (body text, word prefixes), so "more" from /search continues here
|
|
34
|
+
term ? repo.searchPublished(term, { limit: 200, collections: [] }).then((r) => new Set(r.events.map(({ doc }) => doc.id))) : null]);
|
|
31
35
|
// days are the site's calendar days (a New York site's "today" starts at midnight there, not UTC)
|
|
32
36
|
const tz = settings.timezone || 'UTC';
|
|
33
37
|
// a plain day is the site's midnight; a date-time is that exact instant
|
|
@@ -41,25 +45,30 @@ export async function listEvents(repo: ContentRepository, url: URL) {
|
|
|
41
45
|
if (q.calendar && !cal) throw new ApiError(404, 'Calendar not found.');
|
|
42
46
|
const publicCalendars = new Map(calendars.filter(c => c.visibility === 'public' || c.id === cal?.id).map(c => [c.id, c]));
|
|
43
47
|
const visible = events.map(e => localizeDoc(e, locale ?? undefined)).filter(e => (cal ? e.calendarIds.includes(cal.id) && eventIsPublic(e, all) : eventIsListed(e, all))
|
|
44
|
-
&& (!
|
|
48
|
+
&& (!term || indexed!.has(e.id) || `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(term.toLowerCase())));
|
|
45
49
|
const occurrences = expandEvents(visible, settings, { displayTz: settings.timezone || 'UTC', now: Date.now(), windowStartMs: from, windowEndMs: to });
|
|
46
50
|
occurrences.sort((a, b) => a.start - b.start || (a.event.id < b.event.id ? -1 : a.event.id > b.event.id ? 1 : 0));
|
|
47
51
|
const start = (q.page - 1) * q.limit;
|
|
48
52
|
const origin = siteOrigin(settings, url);
|
|
49
53
|
return {
|
|
50
54
|
locale, timezone: settings.timezone || 'UTC', from: new Date(from).toISOString(), to: new Date(to).toISOString(),
|
|
51
|
-
occurrences: occurrences.slice(start, start + q.limit).map(o => {
|
|
52
|
-
|
|
53
|
-
event
|
|
54
|
-
|
|
55
|
-
event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(o.event.fields, f.key)).map(f => [f.key, o.event.fields[f.key]]));
|
|
56
|
-
event.image = safeUrl(o.event.cover, origin);
|
|
57
|
-
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
58
|
-
return { id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay, event };
|
|
59
|
-
}),
|
|
55
|
+
occurrences: occurrences.slice(start, start + q.limit).map(o => ({
|
|
56
|
+
id: `${o.event.id}:${o.start}`, start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(), allDay: o.allDay,
|
|
57
|
+
event: publicEventDto(o.event, origin, publicCalendars, settings.timezone, locale),
|
|
58
|
+
})),
|
|
60
59
|
pagination: { page: q.page, limit: q.limit, nextPage: occurrences.length > start + q.limit ? q.page + 1 : null },
|
|
61
60
|
};
|
|
62
61
|
}
|
|
62
|
+
// e is already localized; publicCalendars limits which calendars are named
|
|
63
|
+
export function publicEventDto(e: CalEvent, origin: string, publicCalendars: Map<string, Calendar>, siteTz: string, locale: string | null) {
|
|
64
|
+
const event = eventToJson(e, origin, publicCalendars, siteTz);
|
|
65
|
+
event.url = localizedUrl(event.url, origin, locale);
|
|
66
|
+
// Only configured public event fields; no undeclared imported metadata.
|
|
67
|
+
event.fields = Object.fromEntries(CALENDAR.eventFields.filter(f => Object.hasOwn(e.fields, f.key)).map(f => [f.key, e.fields[f.key]]));
|
|
68
|
+
event.image = safeUrl(e.cover, origin);
|
|
69
|
+
event.cta = { ...event.cta, href: localizedUrl(safeUrl(event.cta.href, origin, true), origin, locale) };
|
|
70
|
+
return event;
|
|
71
|
+
}
|
|
63
72
|
export async function listResources(repo: ContentRepository, url: URL) {
|
|
64
73
|
requireCalendar(true);
|
|
65
74
|
const q = z.object(pageQuery).strict().parse(queryParams(url));
|
|
@@ -85,8 +94,12 @@ export async function getAvailability(repo: ContentRepository, url: URL) {
|
|
|
85
94
|
const resource = await repo.getResourceBySlug(q.resource);
|
|
86
95
|
const link = resource ? await repo.getBookingLinkBySlug(resource.id, q.link) : null;
|
|
87
96
|
if (!resource || !link || !link.active) throw new ApiError(404, 'Resource or booking link not found.');
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
return availabilityFor(repo, resource, link, await repo.getSettings(), q);
|
|
98
|
+
}
|
|
99
|
+
/** open slots for a link; excludeBookingId frees that booking's own time (moving it) */
|
|
100
|
+
export async function availabilityFor(repo: ContentRepository, resource: Resource, link: BookingLink, settings: Settings,
|
|
101
|
+
q: { from?: string; days: number; minutes?: number }, excludeBookingId?: string) {
|
|
102
|
+
const ctx = await availabilityContext(repo, resource, link, settings, Date.now(), excludeBookingId);
|
|
90
103
|
const timezone = resource.timezone || settings.timezone || 'UTC';
|
|
91
104
|
const from = q.from ?? ymd(Date.now(), timezone);
|
|
92
105
|
return { timezone, mode: link.mode, lengths: linkLengths(link), autoConfirm: confirmsInstantly(linkApproval(link, settings)),
|
|
@@ -4,7 +4,8 @@ import type { ContentRepository } from '../repo/types';
|
|
|
4
4
|
import type { Page, Post } from '../schema';
|
|
5
5
|
import { publicTags } from '../schema';
|
|
6
6
|
import { CONTENT, collectionUrl } from '../content';
|
|
7
|
-
import { I18N, localeHomeUrl } from '../i18n';
|
|
7
|
+
import { I18N, localeHomeUrl, normalizePageLocale } from '../i18n';
|
|
8
|
+
import { SEARCH } from '../search-config';
|
|
8
9
|
import { siteOrigin } from '../seo';
|
|
9
10
|
import { renderMarkdown } from '../markdown';
|
|
10
11
|
import { blocksToHtml } from '../blocksToHtml';
|
|
@@ -16,6 +17,7 @@ import { localeQuery, mobileLocale, localizedUrl } from './locale';
|
|
|
16
17
|
export const contentQuery = z.object({
|
|
17
18
|
...pageQuery, kind: z.enum(['post', 'page']).optional(),
|
|
18
19
|
collection: z.string().min(1).max(80).optional(), locale: localeQuery,
|
|
20
|
+
q: z.string().max(100).optional(),
|
|
19
21
|
}).strict();
|
|
20
22
|
export function safeUrl(value: string, origin: string, link = false): string {
|
|
21
23
|
if (!value) return '';
|
|
@@ -61,12 +63,30 @@ export function contentDto(raw: Page | Post, kind: 'page' | 'post', origin: stri
|
|
|
61
63
|
function visible(doc: Page | Post, kind: 'page' | 'post') {
|
|
62
64
|
return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
|
|
63
65
|
}
|
|
66
|
+
// most matches a search can page through, per kind
|
|
67
|
+
export const SEARCH_WINDOW = 200;
|
|
64
68
|
export async function listContent(repo: ContentRepository, url: URL) {
|
|
65
69
|
const q = contentQuery.parse(queryParams(url));
|
|
66
70
|
if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
|
|
67
71
|
if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
|
|
68
72
|
const locale = mobileLocale(q.locale);
|
|
69
73
|
const origin = siteOrigin(await repo.getSettings(), url);
|
|
74
|
+
const term = q.q?.trim();
|
|
75
|
+
if (term) {
|
|
76
|
+
if (!SEARCH.enabled) throw new ApiError(400, 'Search is disabled on this site.');
|
|
77
|
+
// ranked matches, posts before pages; deep pages stop at SEARCH_WINDOW
|
|
78
|
+
const end = q.page * q.limit;
|
|
79
|
+
if (end - q.limit >= SEARCH_WINDOW) return { locale, items: [], pagination: { page: q.page, limit: q.limit, nextPage: null } };
|
|
80
|
+
const found = await repo.searchPublished(term, { locale: normalizePageLocale(locale ?? ''), limit: Math.min(end + 1, SEARCH_WINDOW), collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) });
|
|
81
|
+
const hits = [
|
|
82
|
+
...(q.kind === 'page' ? [] : found.posts.map(({ doc }) => ({ doc, kind: 'post' as const }))),
|
|
83
|
+
...(q.kind === 'post' || q.collection ? [] : found.pages.map(({ doc }) => ({ doc, kind: 'page' as const }))),
|
|
84
|
+
];
|
|
85
|
+
return {
|
|
86
|
+
locale, items: hits.slice(end - q.limit, end).map(({ doc, kind }) => contentDto(doc, kind, origin, false, locale)),
|
|
87
|
+
pagination: { page: q.page, limit: q.limit, nextPage: hits.length > end && end < SEARCH_WINDOW ? q.page + 1 : null },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
70
90
|
const rows = await repo.listPublicContent({ ...q, locale: undefined, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
|
|
71
91
|
const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
|
|
72
92
|
const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import type { SavedForm } from '../forms/schema';
|
|
4
|
+
import { collectResponse, formVersion } from '../forms/service';
|
|
5
|
+
import { ApiError, readJson } from '../api';
|
|
6
|
+
import { sha256Hex } from '../api-keys';
|
|
7
|
+
import { queryParams } from './http';
|
|
8
|
+
import { requireAccess } from './access';
|
|
9
|
+
import type { PublicContext } from './route';
|
|
10
|
+
|
|
11
|
+
// Published general forms for apps: read the questions, send answers. The
|
|
12
|
+
// same validation and storage as the web form (/forms/<id>), plus app
|
|
13
|
+
// limits: an Idempotency-Key per response (retries never duplicate), 10
|
|
14
|
+
// responses per address per form per minute, and a per-form daily ceiling
|
|
15
|
+
// from Admin → Mobile app so one form can't be flooded.
|
|
16
|
+
|
|
17
|
+
const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
18
|
+
|
|
19
|
+
async function publishedForm(repo: ContentRepository, id: string): Promise<SavedForm> {
|
|
20
|
+
// ids are UUIDs; anything else can't be a form
|
|
21
|
+
const form = id && id.length <= 100 ? await repo.forms.get(id) : null;
|
|
22
|
+
if (!form || form.kind !== 'form' || form.purpose !== 'general' || form.status === 'draft') throw new ApiError(404, 'Form not found.');
|
|
23
|
+
if (form.status === 'archived') throw new ApiError(410, 'This form is no longer accepting responses.');
|
|
24
|
+
return form;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getForm(repo: ContentRepository, url: URL, id: string) {
|
|
28
|
+
z.object({}).strict().parse(queryParams(url));
|
|
29
|
+
await requireAccess(repo, 'forms');
|
|
30
|
+
const form = await publishedForm(repo, id);
|
|
31
|
+
const d = form.definition;
|
|
32
|
+
return {
|
|
33
|
+
id: form.id, version: await formVersion(d),
|
|
34
|
+
title: d.title || form.name, intro: d.intro, submitLabel: d.submitLabel || 'Submit', confirmation: d.confirmation,
|
|
35
|
+
fields: d.fields.map((f) => ({
|
|
36
|
+
id: f.id, type: f.type, label: f.label, required: f.required, help: f.help, options: f.options,
|
|
37
|
+
placeholder: f.placeholder ?? '', width: f.width ?? 'full', allowOther: f.allowOther ?? false,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const formResponseInput = z.object({
|
|
43
|
+
// field id → answer; checkbox groups send a list. "Other" answers send the typed text.
|
|
44
|
+
answers: z.record(z.union([z.string().max(2000), z.array(z.string().max(2000)).max(51)])).refine((a) => Object.keys(a).length <= 30, 'Too many answers.'),
|
|
45
|
+
// the version from GET /forms/{id}; a changed form answers 409 instead of saving
|
|
46
|
+
version: z.string().max(100).optional(),
|
|
47
|
+
// spam trap: apps leave it out or empty
|
|
48
|
+
website: z.string().max(200).optional(),
|
|
49
|
+
}).strict();
|
|
50
|
+
|
|
51
|
+
export async function submitFormResponse(c: PublicContext) {
|
|
52
|
+
const settings = await requireAccess(c.repo, 'forms');
|
|
53
|
+
const id = c.params.id ?? '';
|
|
54
|
+
const key = c.request.headers.get('idempotency-key') ?? '';
|
|
55
|
+
if (!UUID4.test(key)) throw new ApiError(400, 'Send a random UUID v4 in Idempotency-Key and reuse it only for retries.');
|
|
56
|
+
const body = formResponseInput.parse(await readJson(c.request, 50_000));
|
|
57
|
+
if (body.website) throw new ApiError(400, 'Could not submit this form.');
|
|
58
|
+
const form = await publishedForm(c.repo, id);
|
|
59
|
+
if (!await c.repo.email.rateLimit(`forms:app:${await sha256Hex(`${form.id}:${c.address}`)}`, 10, 60_000)) throw new ApiError(429, 'Too many responses. Please try again in a minute.');
|
|
60
|
+
const answers = Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => x.trim()).filter(Boolean).join('\n') : v]));
|
|
61
|
+
// the key is the stored response id: a retry of a saved response answers
|
|
62
|
+
// as before and costs nothing (also after the form changed); the same key
|
|
63
|
+
// with different answers is a 409
|
|
64
|
+
const prior = await c.repo.forms.receipt(form.id, key.toLowerCase());
|
|
65
|
+
if (prior) {
|
|
66
|
+
const unchanged = JSON.stringify((JSON.parse(prior) as { definition: unknown }).definition) === JSON.stringify(form.definition);
|
|
67
|
+
if (unchanged && JSON.stringify(await collectResponse(form.definition, answers, body.version)) !== prior)
|
|
68
|
+
throw new ApiError(409, 'This request was already submitted with different answers. Reload to send a new response.');
|
|
69
|
+
return { received: true as const, confirmation: form.definition.confirmation };
|
|
70
|
+
}
|
|
71
|
+
const response = await collectResponse(form.definition, answers, body.version);
|
|
72
|
+
// only valid new responses count toward the daily ceiling
|
|
73
|
+
const cap = settings.mobileAccess.responsesPerFormDay;
|
|
74
|
+
if (cap && !await c.repo.email.rateLimit(`forms:day:${form.id}`, cap, 86_400_000)) throw new ApiError(429, 'This form has had a lot of responses today. Please try again tomorrow.', 86_400);
|
|
75
|
+
await c.repo.forms.submit(form.id, response, key.toLowerCase());
|
|
76
|
+
return { received: true as const, confirmation: form.definition.confirmation };
|
|
77
|
+
}
|
|
@@ -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
|
@@ -8,7 +8,7 @@ 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',
|
|
@@ -35,7 +35,7 @@ export function failure(error: unknown) {
|
|
|
35
35
|
code: codes[status] ?? 'internal_error',
|
|
36
36
|
message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
|
|
37
37
|
...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
|
|
38
|
-
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After':
|
|
38
|
+
} }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': String((error as ApiError).retryAfter ?? 60) } : {}),
|
|
39
39
|
...(status === 401 ? { 'WWW-Authenticate': 'Bearer realm="acms", error="invalid_token"' } : {}) } });
|
|
40
40
|
}
|
|
41
41
|
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>;
|