@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,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Link } from '../schema';
|
|
3
|
+
import { LinkKind } from '../schema';
|
|
4
|
+
import { ApiError } from '../api';
|
|
5
|
+
import { displayAnswers } from '../forms/service';
|
|
6
|
+
import type { EditorContext } from './http';
|
|
7
|
+
import { isAdmin, requireAdmin } from './roles';
|
|
8
|
+
|
|
9
|
+
// Forms and the links page from editor apps. Any staff account sees which
|
|
10
|
+
// forms exist and how many responses they have; reading responses (personal
|
|
11
|
+
// details) takes an administrator or owner. The links page is editable by
|
|
12
|
+
// staff, except raw HTML blocks and inline SVG icons, which run on the
|
|
13
|
+
// public page and so need an administrator or owner.
|
|
14
|
+
|
|
15
|
+
const iso = (n: number | null) => (n === null ? null : new Date(n).toISOString());
|
|
16
|
+
|
|
17
|
+
export async function listFormsFor(c: EditorContext) {
|
|
18
|
+
const [forms, stats] = await Promise.all([c.repo.forms.list(), c.repo.forms.stats()]);
|
|
19
|
+
return {
|
|
20
|
+
canReadResponses: isAdmin(c.who),
|
|
21
|
+
forms: forms.filter((f) => f.kind === 'form').map((f) => ({
|
|
22
|
+
id: f.id, name: f.name, title: f.definition.title || f.name, purpose: f.purpose, status: f.status, hasDraft: Boolean(f.draft),
|
|
23
|
+
responses: stats.get(f.id)?.count ?? 0, lastResponseAt: iso(stats.get(f.id)?.last ?? null), updatedAt: iso(f.updatedAt),
|
|
24
|
+
})),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ResponsesQuery = z.object({
|
|
29
|
+
q: z.string().max(100).optional(), since: z.string().datetime({ offset: true }).optional(),
|
|
30
|
+
page: z.coerce.number().int().min(1).max(10000).default(1), limit: z.coerce.number().int().min(1).max(100).default(25),
|
|
31
|
+
}).strict();
|
|
32
|
+
export async function formResponsesFor(c: EditorContext, id: string, q: z.infer<typeof ResponsesQuery>) {
|
|
33
|
+
requireAdmin(c.who, 'read form responses');
|
|
34
|
+
const form = await c.repo.forms.get(id);
|
|
35
|
+
if (!form || form.kind !== 'form') throw new ApiError(404, 'Form not found.');
|
|
36
|
+
const rows = await c.repo.forms.searchSubmissions(id, { q: q.q, since: q.since ? Date.parse(q.since) : undefined, limit: q.limit + 1, offset: (q.page - 1) * q.limit });
|
|
37
|
+
return {
|
|
38
|
+
form: { id: form.id, name: form.name },
|
|
39
|
+
items: rows.slice(0, q.limit).map((s) => ({
|
|
40
|
+
id: s.id, createdAt: iso(s.createdAt)!,
|
|
41
|
+
// labelled answers from the questions as they were when submitted
|
|
42
|
+
answers: Object.entries(displayAnswers(s.response)).map(([label, value]) => ({ label, value })),
|
|
43
|
+
})),
|
|
44
|
+
pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- links page ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const linkView = (l: Link) => ({ id: l.id, kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta, clicks: l.clicks });
|
|
51
|
+
|
|
52
|
+
export async function getLinksFor(c: EditorContext) {
|
|
53
|
+
// revision first: a change landing between the reads leaves it stale (the
|
|
54
|
+
// next save gets a 409), never newer than the blocks it is sent with
|
|
55
|
+
const revision = await c.repo.linksRevision();
|
|
56
|
+
const [settings, links] = await Promise.all([c.repo.getSettings(), c.repo.listLinks()]);
|
|
57
|
+
return { revision, name: settings.linksName, bio: settings.linksBio, avatar: settings.linksAvatar, canEditHtml: isAdmin(c.who), items: links.map(linkView) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const Item = z.object({
|
|
61
|
+
// omit for a new block; an existing id keeps its click count
|
|
62
|
+
id: z.string().max(100).optional(),
|
|
63
|
+
kind: LinkKind.default('link'), label: z.string().max(200).default(''), url: z.string().max(2000).default(''),
|
|
64
|
+
icon: z.string().max(5000).default(''), html: z.string().max(20_000).default(''),
|
|
65
|
+
meta: z.record(z.unknown()).default({}).refine((m) => JSON.stringify(m).length <= 4000, 'Block settings are too large.'),
|
|
66
|
+
}).strict();
|
|
67
|
+
export const LinksPut = z.object({
|
|
68
|
+
// from GET /admin/links; a stale revision is a 409 and nothing is saved
|
|
69
|
+
revision: z.number().int().min(0),
|
|
70
|
+
name: z.string().max(120).optional(), bio: z.string().max(500).optional(),
|
|
71
|
+
avatar: z.string().max(1000).refine((v) => !v || /^(https?:\/\/|\/)/.test(v), 'Use an http(s) or site path image.').optional(),
|
|
72
|
+
// the whole page in display order; blocks left out are removed
|
|
73
|
+
items: z.array(Item).max(200),
|
|
74
|
+
}).strict();
|
|
75
|
+
|
|
76
|
+
const urlKinds = new Set(['link']);
|
|
77
|
+
function checkItem(i: z.infer<typeof Item>) {
|
|
78
|
+
const url = i.url.trim();
|
|
79
|
+
// http(s) or a path on this site; "//host" and "/\\host" would leave it (open redirect through /go)
|
|
80
|
+
if (urlKinds.has(i.kind) && url && !/^https?:\/\//i.test(url) && !/^\/(?![\/\\])/.test(url)) throw new ApiError(400, `“${i.label || 'Link'}” needs an http(s) address.`);
|
|
81
|
+
}
|
|
82
|
+
// inline SVG icons render as markup on the public page, exactly like HTML blocks
|
|
83
|
+
const isMarkup = (icon: string) => icon.trim().toLowerCase().startsWith('<svg');
|
|
84
|
+
|
|
85
|
+
export async function putLinksFor(c: EditorContext, body: z.infer<typeof LinksPut>) {
|
|
86
|
+
const current = await c.repo.listLinks();
|
|
87
|
+
const byId = new Map(current.map((l) => [l.id, l]));
|
|
88
|
+
const admin = isAdmin(c.who);
|
|
89
|
+
const markupChanged = (i: z.infer<typeof Item>) => {
|
|
90
|
+
const old = i.id ? byId.get(i.id) : undefined;
|
|
91
|
+
const html = i.kind === 'html' && (!old || old.kind !== 'html' || old.html !== i.html);
|
|
92
|
+
const svg = isMarkup(i.icon) && (!old || old.icon !== i.icon);
|
|
93
|
+
return html || svg;
|
|
94
|
+
};
|
|
95
|
+
const ids = body.items.map((i) => i.id).filter(Boolean);
|
|
96
|
+
if (new Set(ids).size !== ids.length) throw new ApiError(400, 'Each block can appear once.');
|
|
97
|
+
for (const i of body.items) {
|
|
98
|
+
if (i.id && !byId.has(i.id)) throw new ApiError(409, 'The links page changed since you opened it. Reload and try again.');
|
|
99
|
+
checkItem(i);
|
|
100
|
+
if (!admin && markupChanged(i)) requireAdmin(c.who, 'add or change HTML blocks or SVG icons');
|
|
101
|
+
}
|
|
102
|
+
// one atomic write: a failure leaves the page as it was. Blocks left out
|
|
103
|
+
// are removed (fine for anyone, even HTML: it only takes code away); a
|
|
104
|
+
// block whose kind changed is replaced
|
|
105
|
+
await c.repo.replaceLinks(body.revision, body.items.map((i) => {
|
|
106
|
+
const old = i.id ? byId.get(i.id) : undefined;
|
|
107
|
+
return { kind: i.kind, label: i.label, url: i.url, icon: i.icon, html: i.html, meta: i.meta, keep: old && old.kind === i.kind ? old.id : undefined };
|
|
108
|
+
}), { linksName: body.name, linksBio: body.bio, linksAvatar: body.avatar });
|
|
109
|
+
return getLinksFor(c);
|
|
110
|
+
}
|
package/src/lib/editor/store.ts
CHANGED
|
@@ -4,10 +4,12 @@ import type { SqlDriver } from '../repo/sql';
|
|
|
4
4
|
type Row = Record<string, unknown>;
|
|
5
5
|
|
|
6
6
|
export interface EditorDevice {
|
|
7
|
+
authProvider?: string;
|
|
7
8
|
id: string; userId: string; name: string; clientId: string; sessionVersion: number;
|
|
8
9
|
createdAt: number; lastUsedAt: number; revokedAt: number | null; revokedReason: string;
|
|
9
10
|
}
|
|
10
11
|
export interface EditorCode {
|
|
12
|
+
authProvider?: string;
|
|
11
13
|
hash: string; userId: string; clientId: string; redirectUri: string; challenge: string;
|
|
12
14
|
deviceName: string; sessionVersion: number; createdAt: number; expiresAt: number; usedAt: number | null; deviceId: string | null;
|
|
13
15
|
}
|
|
@@ -19,11 +21,11 @@ export interface EditorAccess {
|
|
|
19
21
|
export interface EditorActivity { id: string; createdAt: number; userId: string; deviceId: string; action: string; target: string }
|
|
20
22
|
|
|
21
23
|
function device(r: Row): EditorDevice {
|
|
22
|
-
return { id: String(r.id), userId: String(r.user_id), name: String(r.name), clientId: String(r.client_id), sessionVersion: Number(r.session_version),
|
|
24
|
+
return { authProvider: String(r.auth_provider ?? 'builtin'), id: String(r.id), userId: String(r.user_id), name: String(r.name), clientId: String(r.client_id), sessionVersion: Number(r.session_version),
|
|
23
25
|
createdAt: Number(r.created_at), lastUsedAt: Number(r.last_used_at), revokedAt: r.revoked_at == null ? null : Number(r.revoked_at), revokedReason: String(r.revoked_reason ?? '') };
|
|
24
26
|
}
|
|
25
27
|
function code(r: Row): EditorCode {
|
|
26
|
-
return { hash: String(r.hash), userId: String(r.user_id), clientId: String(r.client_id), redirectUri: String(r.redirect_uri), challenge: String(r.challenge),
|
|
28
|
+
return { authProvider: String(r.auth_provider ?? 'builtin'), hash: String(r.hash), userId: String(r.user_id), clientId: String(r.client_id), redirectUri: String(r.redirect_uri), challenge: String(r.challenge),
|
|
27
29
|
deviceName: String(r.device_name), sessionVersion: Number(r.session_version), createdAt: Number(r.created_at), expiresAt: Number(r.expires_at),
|
|
28
30
|
usedAt: r.used_at == null ? null : Number(r.used_at), deviceId: r.device_id == null ? null : String(r.device_id) };
|
|
29
31
|
}
|
|
@@ -38,8 +40,12 @@ export class EditorStore {
|
|
|
38
40
|
|
|
39
41
|
// ---- authorization codes ----
|
|
40
42
|
async createCode(c: Omit<EditorCode, 'usedAt' | 'deviceId' | 'createdAt'>, now: number) {
|
|
41
|
-
await this.run(`INSERT INTO editor_codes (hash,user_id,client_id,redirect_uri,challenge,device_name,session_version,created_at,expires_at,used_at,device_id)
|
|
42
|
-
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)`, [c.hash, c.userId, c.clientId, c.redirectUri, c.challenge, c.deviceName, c.sessionVersion, now, c.expiresAt]);
|
|
43
|
+
await this.run(`INSERT INTO editor_codes (hash,user_id,client_id,redirect_uri,challenge,device_name,session_version,created_at,expires_at,used_at,device_id,auth_provider)
|
|
44
|
+
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL,?)`, [c.hash, c.userId, c.clientId, c.redirectUri, c.challenge, c.deviceName, c.sessionVersion, now, c.expiresAt, c.authProvider ?? 'builtin']);
|
|
45
|
+
}
|
|
46
|
+
async getCode(hash: string): Promise<EditorCode | null> {
|
|
47
|
+
const rows = await this.all('SELECT * FROM editor_codes WHERE hash=?', [hash]);
|
|
48
|
+
return rows[0] ? code(rows[0]) : null;
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Burn a code (single use, even when the rest of the exchange fails).
|
|
@@ -55,10 +61,10 @@ export class EditorStore {
|
|
|
55
61
|
|
|
56
62
|
// ---- devices + tokens ----
|
|
57
63
|
/** a new device and its first token pair, atomically */
|
|
58
|
-
async createDevice(d: Pick<EditorDevice, 'id' | 'userId' | 'name' | 'clientId' | 'sessionVersion'>, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number) {
|
|
64
|
+
async createDevice(d: Pick<EditorDevice, 'id' | 'userId' | 'name' | 'clientId' | 'sessionVersion' | 'authProvider'>, tokens: { access: string; accessExpires: number; refresh: string; refreshExpires: number }, now: number) {
|
|
59
65
|
await (await this.driver()).batch([
|
|
60
|
-
{ sql: `INSERT INTO editor_devices (id,user_id,name,client_id,session_version,created_at,last_used_at,revoked_at,revoked_reason) VALUES (?,?,?,?,?,?,?,NULL,'')`,
|
|
61
|
-
args: [d.id, d.userId, d.name, d.clientId, d.sessionVersion, now, now] },
|
|
66
|
+
{ sql: `INSERT INTO editor_devices (id,user_id,name,client_id,session_version,created_at,last_used_at,revoked_at,revoked_reason,auth_provider) VALUES (?,?,?,?,?,?,?,NULL,'',?)`,
|
|
67
|
+
args: [d.id, d.userId, d.name, d.clientId, d.sessionVersion, now, now, d.authProvider ?? 'builtin'] },
|
|
62
68
|
...this.tokenRows(d.id, tokens, now),
|
|
63
69
|
]);
|
|
64
70
|
}
|
|
@@ -40,6 +40,7 @@ const address = (context: APIContext) => { try { return context.clientAddress ||
|
|
|
40
40
|
function oauthFailure(error: unknown) {
|
|
41
41
|
if (error instanceof OAuthError) return reply({ error: error.error, error_description: error.message }, error.status);
|
|
42
42
|
if (error instanceof ApiError) {
|
|
43
|
+
if (error.status === 503) return reply({ error: 'temporarily_unavailable', error_description: error.message }, 503, { 'Retry-After': '10' });
|
|
43
44
|
if (error.status === 429) return reply({ error: 'rate_limited', error_description: error.message }, 429, { 'Retry-After': '60' });
|
|
44
45
|
if (error.status === 404) return reply({ error: 'not_found', error_description: error.message }, 404);
|
|
45
46
|
return reply({ error: 'invalid_request', error_description: error.message }, error.status);
|
|
@@ -54,7 +55,7 @@ export async function tokenRoute(context: APIContext) {
|
|
|
54
55
|
if (context.url.search) throw new OAuthError(400, 'invalid_request', 'Send parameters in the request body, not the URL.');
|
|
55
56
|
const repo = await getRepo(context.locals);
|
|
56
57
|
if (!await repo.email.rateLimit(`editor:token:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
|
|
57
|
-
return reply(await tokenGrant(repo, await readForm(context.request)));
|
|
58
|
+
return reply(await tokenGrant(repo, await readForm(context.request), Date.now(), context.locals));
|
|
58
59
|
} catch (error) { return oauthFailure(error); }
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTH } from '../auth/config';
|
|
1
2
|
import type { Role } from '../schema';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import type { ContentRepository } from '../repo/types';
|
|
@@ -23,12 +24,14 @@ export async function accountForm(request: Request): Promise<URLSearchParams> {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export async function reauthenticate(repo:ContentRepository, userId:string, password:string) {
|
|
27
|
+
if (AUTH.provider !== 'builtin') return false;
|
|
26
28
|
if (!password || password.length>1024) return false;
|
|
27
29
|
if (userId===OWNER_ENV) return checkPassword(password);
|
|
28
30
|
const user=await repo.getUser(userId); if(!user)return false;
|
|
29
31
|
const auth=await repo.getUserAuth(user.email);return !!auth && verifyPassword(password,auth.passHash);
|
|
30
32
|
}
|
|
31
33
|
export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:Role;expiresDays?:number}, locals?:unknown) {
|
|
34
|
+
if (AUTH.provider !== 'builtin') throw new Error('Account access is managed by Clerk.');
|
|
32
35
|
const email=data.email.trim().toLowerCase();
|
|
33
36
|
if(!validAddress(email))throw new Error('Enter a valid email address.');
|
|
34
37
|
const auth=await repo.getUserAuth(email);
|
|
@@ -45,6 +48,7 @@ export async function issueAccountToken(repo:ContentRepository, data:{purpose:'i
|
|
|
45
48
|
await repo.email.saveToken(token,job);
|
|
46
49
|
}
|
|
47
50
|
export async function redeemAccountToken(repo:ContentRepository,raw:string,password:string,purpose:'invite'|'reset'):Promise<boolean> {
|
|
51
|
+
if (AUTH.provider !== 'builtin') return false;
|
|
48
52
|
if(!/^[a-zA-Z0-9_-]{43}$/.test(raw))return false;
|
|
49
53
|
if(password.length<12||password.length>128)throw new Error('Use a password between 12 and 128 characters.');
|
|
50
54
|
const token=await repo.email.tokenByHash(sha256(raw));
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin, EmailLinkError } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, guestCodeMail, EmailLinkError } from './templates';
|
|
5
5
|
import { bookingMail } from '../booking/service';
|
|
6
6
|
import type { EmailJob, Mail } from './types';
|
|
7
7
|
|
|
8
8
|
async function render(repo: ContentRepository, job: EmailJob, locals?: unknown, from = ''): Promise<Mail | null> {
|
|
9
9
|
const settings = await repo.getSettings();
|
|
10
10
|
if (job.kind === 'test') return accountMail('test', job.recipient, settings.siteTitle);
|
|
11
|
+
if (job.kind === 'guest-code') {
|
|
12
|
+
// only the latest, unexpired code is worth sending
|
|
13
|
+
const payload = JSON.parse(job.payload) as { issuedAt: number; code: string };
|
|
14
|
+
const current = await repo.guests.code(job.recipient);
|
|
15
|
+
if (!current || current.createdAt !== payload.issuedAt || current.expiresAt <= Date.now()) return null;
|
|
16
|
+
return guestCodeMail(job.recipient, settings.siteTitle, decrypt<string>(payload.code, `guest-code:${job.dedupe}`, locals));
|
|
17
|
+
}
|
|
11
18
|
const origin = canonicalOrigin(settings.siteUrl);
|
|
12
19
|
if (job.kind === 'booking') {
|
|
13
20
|
const payload = JSON.parse(job.payload);
|
|
@@ -25,6 +32,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
|
|
|
25
32
|
return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
|
|
26
33
|
}
|
|
27
34
|
if (job.kind === 'welcome' || job.kind === 'password-changed') return accountMail(job.kind, job.recipient, settings.siteTitle, `${origin}/admin/login`);
|
|
35
|
+
if (job.kind === 'email-changed') return accountMail(job.kind, job.recipient, settings.siteTitle);
|
|
28
36
|
return null;
|
|
29
37
|
}
|
|
30
38
|
export async function dispatchEmail(repo: ContentRepository, locals?: unknown, limit = 10): Promise<number> {
|
package/src/lib/email/store.ts
CHANGED
|
@@ -112,9 +112,19 @@ export class EmailStore {
|
|
|
112
112
|
count=CASE WHEN expires_at <= ? THEN 1 ELSE count+1 END, expires_at=CASE WHEN expires_at <= ? THEN ? ELSE expires_at END RETURNING count`, [key,now+windowMs,now,now,now+windowMs]);
|
|
113
113
|
return Number(rows[0].count) <= max;
|
|
114
114
|
}
|
|
115
|
+
/** true when key's current window already holds max hits (checks without counting) */
|
|
116
|
+
async rateLimited(key: string, max: number): Promise<boolean> {
|
|
117
|
+
const rows = await (await this.getDriver()).all('SELECT count FROM email_rate_limits WHERE key=? AND expires_at > ?', [key, Date.now()]);
|
|
118
|
+
return rows.length > 0 && Number(rows[0].count) >= max;
|
|
119
|
+
}
|
|
115
120
|
async recordScheduler(report: { startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> }) {
|
|
116
121
|
await (await this.getDriver()).run("INSERT INTO email_meta(key,value) VALUES ('scheduler-heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [JSON.stringify(report)]);
|
|
117
122
|
}
|
|
123
|
+
/** mail that should have gone out before `before` but is still waiting (a stuck queue) */
|
|
124
|
+
async stuckJobs(before: number): Promise<number> {
|
|
125
|
+
const rows = await (await this.getDriver()).all("SELECT COUNT(*) AS n FROM email_jobs WHERE state IN ('queued','retrying') AND next_at < ?", [before]);
|
|
126
|
+
return Number(rows[0]?.n ?? 0);
|
|
127
|
+
}
|
|
118
128
|
async schedulerStatus(): Promise<{ startedAt: number; finishedAt: number; ok: boolean; jobs: Record<string, { ok: boolean; count?: number }> } | null> {
|
|
119
129
|
const rows = await (await this.getDriver()).all("SELECT value FROM email_meta WHERE key='scheduler-heartbeat'");
|
|
120
130
|
return rows[0] ? JSON.parse(String(rows[0].value)) : null;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { Mail } from './types';
|
|
2
2
|
export const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]!));
|
|
3
|
-
export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|'test', to: string, site: string, url = ''): Mail {
|
|
3
|
+
export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|'email-changed'|'test', to: string, site: string, url = ''): Mail {
|
|
4
4
|
const copy = {
|
|
5
5
|
invite: ['You’re invited', `You’ve been invited to join ${site}. Choose your own password to accept.`, 'Accept invitation'],
|
|
6
6
|
reset: ['Reset your password', 'Use this single-use link within 30 minutes to choose a new password. If you did not request this, ignore this message.', 'Choose a new password'],
|
|
7
7
|
welcome: ['Welcome', `Your account at ${site} is ready.`, 'Sign in'],
|
|
8
8
|
'password-changed': ['Your password changed', 'Your password was changed and previous sessions were signed out. If this was not you, contact the site owner.', 'Sign in'],
|
|
9
|
+
'email-changed': ['Your sign-in email changed', `The email for your account at ${site} was changed to a new address, and previous sessions were signed out. If this was not expected, contact the site owner.`, ''],
|
|
9
10
|
test: ['Email connection test', `This is a transactional email test from ${site}.`, ''],
|
|
10
11
|
}[kind];
|
|
11
12
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
|
|
12
13
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
|
|
13
14
|
}
|
|
15
|
+
/** a guest's app sign-in code; no link, so it works on any device */
|
|
16
|
+
export function guestCodeMail(to: string, site: string, code: string): Mail {
|
|
17
|
+
const intro = `Enter this code in the ${site} app to see your bookings. It expires in 10 minutes. If you did not ask for it, ignore this message.`;
|
|
18
|
+
return { to, subject: `${code} is your sign-in code · ${site}`, text: ['Your sign-in code', '', code, '', intro].join('\n'),
|
|
19
|
+
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">Your sign-in code</h1><p style="font:700 32px/1.2 ui-monospace,Menlo,monospace;letter-spacing:.2em">${escape(code)}</p><p>${escape(intro)}</p></div></body></html>` };
|
|
20
|
+
}
|
|
14
21
|
export class EmailLinkError extends Error {}
|
|
15
22
|
export function canonicalOrigin(value: string): string {
|
|
16
23
|
const raw = value.trim();
|
package/src/lib/email/types.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipi
|
|
|
23
23
|
export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
|
|
24
24
|
export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number }
|
|
25
25
|
export interface PublicEmailSettings {
|
|
26
|
+
authProvider?: 'builtin' | 'clerk';
|
|
26
27
|
version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
|
|
27
28
|
encryptionAvailable: boolean; environmentLocked: boolean; credentialFields: string[];
|
|
28
29
|
linksReady: boolean; linkError: string;
|
package/src/lib/forms/store.ts
CHANGED
|
@@ -25,6 +25,11 @@ export class FormStore {
|
|
|
25
25
|
async submissions(formId?: string, limit = 100, offset = 0) {
|
|
26
26
|
return (await (await this.driver()).all(`SELECT * FROM form_submissions ${formId ? 'WHERE form_id=?' : ''} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...(formId ? [formId] : []), limit, offset])).map(r => FormSubmission.parse({ id: r.id, formId: r.form_id, response: JSON.parse(String(r.response)), createdAt: r.created_at }));
|
|
27
27
|
}
|
|
28
|
+
/** the response this request id already saved to this form, if any (as stored JSON) */
|
|
29
|
+
async receipt(formId: string, requestId: string) {
|
|
30
|
+
const r = (await (await this.driver()).all('SELECT response FROM form_submissions WHERE id=? AND form_id=?', [requestId, formId]))[0];
|
|
31
|
+
return r ? String(r.response) : null;
|
|
32
|
+
}
|
|
28
33
|
async submit(formId: string, response: FormResponse, requestId: string) {
|
|
29
34
|
response = FormResponse.parse(response);
|
|
30
35
|
const db = await this.driver();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentRepository } from '../repo/types';
|
|
3
|
+
import { MobileAccess } from '../schema';
|
|
4
|
+
import { CALENDAR, bookingsEnabled } from '../calendar/config';
|
|
5
|
+
import { encryptionAvailable, resolveConfig } from '../email/config';
|
|
6
|
+
import { GUEST_IDLE_MS } from './guest-store';
|
|
7
|
+
|
|
8
|
+
// Admin → Mobile app → App access (owner only): switches and limits for the
|
|
9
|
+
// public app routes, plus "Sign out all" for guest sessions.
|
|
10
|
+
|
|
11
|
+
export async function appAccessState(repo: ContentRepository, locals?: unknown) {
|
|
12
|
+
const settings = await repo.getSettings();
|
|
13
|
+
const email = resolveConfig(await repo.email.config(), locals);
|
|
14
|
+
const emailReady = !email.error && email.provider !== 'none' && email.config.enabled;
|
|
15
|
+
return {
|
|
16
|
+
access: settings.mobileAccess,
|
|
17
|
+
modules: { calendar: CALENDAR.enabled, bookings: bookingsEnabled() },
|
|
18
|
+
// why guest sign-in can't run yet, if it can't
|
|
19
|
+
guestSignIn: {
|
|
20
|
+
blocker: !CALENDAR.enabled ? 'calendar' as const : !emailReady ? 'email' as const : !encryptionAvailable(locals) ? 'encryption' as const : null,
|
|
21
|
+
devices: await repo.guests.count(Date.now() - GUEST_IDLE_MS),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export type AppAccessState = Awaited<ReturnType<typeof appAccessState>>;
|
|
26
|
+
|
|
27
|
+
export const AppAccessInput = z.discriminatedUnion('action', [
|
|
28
|
+
z.object({ action: z.literal('save'), access: MobileAccess.partial().strict() }).strict(),
|
|
29
|
+
z.object({ action: z.literal('signOutGuests') }).strict(),
|
|
30
|
+
]);
|
|
31
|
+
export async function appAccessAction(repo: ContentRepository, input: z.infer<typeof AppAccessInput>) {
|
|
32
|
+
if (input.action === 'signOutGuests') { await repo.guests.deleteAll(); return; }
|
|
33
|
+
await repo.patchMobileAccess(input.access);
|
|
34
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import type { MobileAccess, Settings } from '../schema';
|
|
3
|
+
import { ApiError } from '../api';
|
|
4
|
+
import { sha256Hex } from '../api-keys';
|
|
5
|
+
|
|
6
|
+
type Switch = Exclude<{ [K in keyof MobileAccess]: MobileAccess[K] extends boolean ? K : never }[keyof MobileAccess], undefined>;
|
|
7
|
+
|
|
8
|
+
/** Owner switch (Admin → Mobile app → App access); off answers 404 like a disabled module. */
|
|
9
|
+
export async function requireAccess(repo: ContentRepository, key: Switch): Promise<Settings> {
|
|
10
|
+
const settings = await repo.getSettings();
|
|
11
|
+
if (!settings.mobileAccess[key]) throw new ApiError(404, 'This feature is turned off.');
|
|
12
|
+
return settings;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lockout for secret guessing: only failures count, per client address.
|
|
17
|
+
* `check` before the lookup (already locked → 429), `miss` after a failure.
|
|
18
|
+
*/
|
|
19
|
+
export async function failureLock(repo: ContentRepository, scope: string, address: string, max = 20, windowMs = 10 * 60_000) {
|
|
20
|
+
const key = `lock:${scope}:${await sha256Hex(address)}`;
|
|
21
|
+
// without a real client address everyone would share one bucket, and a few
|
|
22
|
+
// junk requests would lock every visitor out; per-target limits still apply
|
|
23
|
+
if (!address || address === 'unknown') return { async check() {}, async miss() {} };
|
|
24
|
+
return {
|
|
25
|
+
async check() { if (await repo.email.rateLimited(key, max)) throw new ApiError(429, 'Too many failed attempts. Try again in a few minutes.', windowMs / 1000); },
|
|
26
|
+
async miss() { await repo.email.rateLimit(key, max, windowMs); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|