@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.
Files changed (161) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +17 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/mobile-doctor.mjs +9 -0
  8. package/cli/monitor.mjs +189 -0
  9. package/cli/preview.mjs +1 -1
  10. package/deploy/Dockerfile +5 -1
  11. package/deploy/compose.yml +21 -0
  12. package/docs/live-video.md +1 -1
  13. package/docs/mobile.md +23 -2
  14. package/docs/theme-authoring.md +6 -0
  15. package/integration.mjs +74 -4
  16. package/migrate.mjs +5 -0
  17. package/package.json +9 -11
  18. package/src/actions/index.ts +11 -31
  19. package/src/components/AppAccess.tsx +117 -0
  20. package/src/components/DesignManager.tsx +29 -10
  21. package/src/components/EmailSettings.tsx +53 -11
  22. package/src/components/MonitoringSettings.tsx +112 -0
  23. package/src/components/ThemeTransfer.tsx +211 -0
  24. package/src/components/auth/ClerkSession.astro +80 -0
  25. package/src/components/auth/ReverifyForms.astro +61 -0
  26. package/src/components/auth/clerk.ts +169 -0
  27. package/src/components/import/ImportExport.tsx +16 -6
  28. package/src/components/import/runner.ts +3 -0
  29. package/src/layouts/Admin.astro +42 -12
  30. package/src/layouts/Site.astro +6 -1
  31. package/src/lib/api.ts +8 -4
  32. package/src/lib/auth/accounts.ts +94 -0
  33. package/src/lib/auth/config.ts +22 -0
  34. package/src/lib/auth/login-limit.ts +15 -0
  35. package/src/lib/auth/request.ts +161 -0
  36. package/src/lib/auth/store.ts +72 -0
  37. package/src/lib/auth.ts +3 -0
  38. package/src/lib/backup/archive.ts +26 -0
  39. package/src/lib/backup/content.ts +1 -1
  40. package/src/lib/backup/offsite.ts +173 -0
  41. package/src/lib/backup/store.ts +28 -0
  42. package/src/lib/booking/availability.ts +15 -2
  43. package/src/lib/booking/service.ts +53 -1
  44. package/src/lib/cache/config.ts +7 -0
  45. package/src/lib/cache/memory.ts +39 -0
  46. package/src/lib/cache/policy.ts +89 -0
  47. package/src/lib/cache/purge.ts +89 -0
  48. package/src/lib/calendar/admin.ts +41 -0
  49. package/src/lib/designPreview.ts +2 -2
  50. package/src/lib/dynamicBlocks.ts +1 -1
  51. package/src/lib/editor/auth.ts +35 -5
  52. package/src/lib/editor/http.ts +4 -1
  53. package/src/lib/editor/me.ts +4 -0
  54. package/src/lib/editor/openapi.ts +49 -1
  55. package/src/lib/editor/roles.ts +20 -0
  56. package/src/lib/editor/schedule.ts +340 -0
  57. package/src/lib/editor/schemas.ts +36 -1
  58. package/src/lib/editor/site-data.ts +110 -0
  59. package/src/lib/editor/store.ts +13 -7
  60. package/src/lib/editor/token-route.ts +2 -1
  61. package/src/lib/email/accounts.ts +4 -0
  62. package/src/lib/email/service.ts +9 -1
  63. package/src/lib/email/store.ts +10 -0
  64. package/src/lib/email/templates.ts +8 -1
  65. package/src/lib/email/types.ts +1 -0
  66. package/src/lib/forms/store.ts +5 -0
  67. package/src/lib/mobile/access-admin.ts +34 -0
  68. package/src/lib/mobile/access.ts +28 -0
  69. package/src/lib/mobile/calendar.ts +26 -13
  70. package/src/lib/mobile/content.ts +21 -1
  71. package/src/lib/mobile/forms.ts +77 -0
  72. package/src/lib/mobile/guest-store.ts +62 -0
  73. package/src/lib/mobile/guest.ts +160 -0
  74. package/src/lib/mobile/http.ts +6 -5
  75. package/src/lib/mobile/links.ts +69 -0
  76. package/src/lib/mobile/manage.ts +114 -0
  77. package/src/lib/mobile/openapi.ts +40 -4
  78. package/src/lib/mobile/route.ts +63 -4
  79. package/src/lib/mobile/schemas.ts +52 -1
  80. package/src/lib/mobile/search.ts +95 -0
  81. package/src/lib/mobile/site.ts +8 -2
  82. package/src/lib/monitoring.ts +171 -0
  83. package/src/lib/net.ts +82 -14
  84. package/src/lib/repo/migrate-runner.ts +96 -0
  85. package/src/lib/repo/migrations.ts +104 -0
  86. package/src/lib/repo/sql.ts +122 -44
  87. package/src/lib/repo/sqlite.ts +14 -1
  88. package/src/lib/repo/types.ts +31 -3
  89. package/src/lib/scheduler.ts +14 -0
  90. package/src/lib/schema.ts +35 -1
  91. package/src/lib/theme-transfer.ts +163 -0
  92. package/src/lib/theme.ts +5 -0
  93. package/src/lib/webhooks.ts +7 -2
  94. package/src/middleware.ts +41 -11
  95. package/src/pages/[...slug].astro +3 -2
  96. package/src/pages/account/[action].astro +6 -0
  97. package/src/pages/admin/app-auth.astro +21 -7
  98. package/src/pages/admin/calendar/resources/link.astro +2 -0
  99. package/src/pages/admin/design.astro +2 -1
  100. package/src/pages/admin/import.astro +5 -3
  101. package/src/pages/admin/index.astro +3 -2
  102. package/src/pages/admin/integrations.astro +3 -2
  103. package/src/pages/admin/login.astro +236 -25
  104. package/src/pages/admin/logout.ts +16 -2
  105. package/src/pages/admin/mobile.astro +6 -2
  106. package/src/pages/admin/settings.astro +43 -5
  107. package/src/pages/admin/users.astro +350 -59
  108. package/src/pages/api/backup.ts +10 -26
  109. package/src/pages/api/design/export.ts +72 -0
  110. package/src/pages/api/design/import.ts +41 -0
  111. package/src/pages/api/email/settings.ts +9 -5
  112. package/src/pages/api/health.ts +17 -0
  113. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  114. package/src/pages/api/live/settings.ts +2 -2
  115. package/src/pages/api/media/upload.ts +2 -2
  116. package/src/pages/api/mobile/access.ts +22 -0
  117. package/src/pages/api/monitoring.ts +22 -0
  118. package/src/pages/api/ops/status.ts +2 -1
  119. package/src/pages/api/push/admin.ts +3 -2
  120. package/src/pages/api/restore.ts +6 -3
  121. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +7 -1
  122. package/src/pages/api/v1/admin/bookings/index.ts +10 -2
  123. package/src/pages/api/v1/admin/calendars/[id].ts +16 -0
  124. package/src/pages/api/v1/admin/calendars/index.ts +12 -0
  125. package/src/pages/api/v1/admin/forms/[id]/responses.ts +7 -0
  126. package/src/pages/api/v1/admin/forms/index.ts +6 -0
  127. package/src/pages/api/v1/admin/links.ts +13 -0
  128. package/src/pages/api/v1/admin/resources/[id]/blocks/[blockId].ts +10 -0
  129. package/src/pages/api/v1/admin/resources/[id]/blocks/index.ts +12 -0
  130. package/src/pages/api/v1/admin/resources/[id]/index.ts +17 -0
  131. package/src/pages/api/v1/admin/resources/[id]/links/[linkId].ts +16 -0
  132. package/src/pages/api/v1/admin/resources/[id]/links/index.ts +12 -0
  133. package/src/pages/api/v1/admin/resources/index.ts +12 -0
  134. package/src/pages/api/v1/admin/schedule.ts +7 -0
  135. package/src/pages/api/v1/bookings/manage/availability.ts +6 -0
  136. package/src/pages/api/v1/bookings/manage/cancel.ts +6 -0
  137. package/src/pages/api/v1/bookings/manage/ics.ts +6 -0
  138. package/src/pages/api/v1/bookings/manage/index.ts +6 -0
  139. package/src/pages/api/v1/bookings/manage/reschedule.ts +6 -0
  140. package/src/pages/api/v1/calendars.ts +1 -1
  141. package/src/pages/api/v1/forms/[id]/index.ts +7 -0
  142. package/src/pages/api/v1/forms/[id]/responses.ts +6 -0
  143. package/src/pages/api/v1/guest/bookings.ts +6 -0
  144. package/src/pages/api/v1/guest/code.ts +6 -0
  145. package/src/pages/api/v1/guest/revoke.ts +6 -0
  146. package/src/pages/api/v1/guest/token.ts +6 -0
  147. package/src/pages/api/v1/links.ts +7 -0
  148. package/src/pages/api/v1/openapi.json.ts +1 -1
  149. package/src/pages/api/v1/push/topics.ts +1 -1
  150. package/src/pages/api/v1/resources.ts +1 -1
  151. package/src/pages/api/v1/search.ts +7 -0
  152. package/src/pages/api/v1/theme.ts +1 -1
  153. package/src/pages/blog/[slug].astro +3 -2
  154. package/src/pages/book/[resource]/[link].astro +3 -3
  155. package/src/pages/book/manage/[token].astro +11 -7
  156. package/src/pages/events/[slug].astro +3 -2
  157. package/src/pages/preview/[type]/[id].astro +3 -3
  158. package/src/styles/admin.css +169 -16
  159. package/src/styles/email-settings.css +6 -0
  160. package/src/virtual.d.ts +1 -0
  161. package/src/worker.ts +7 -1
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool }), apiVersion: z.literal(1),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
12
12
  });
13
13
  export const Theme = z.object({
14
14
  id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
@@ -35,6 +35,57 @@ export const Event = CalEvent.pick({
35
35
  export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
36
36
  occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
37
37
  });
38
+ const Snippet = z.object({ text: str, highlights: z.array(z.object({ start: z.number().int(), end: z.number().int() })) });
39
+ const ContentHit = ContentSummary.extend({ snippet: Snippet });
40
+ export const SearchResults = z.object({ locale, query: str,
41
+ posts: z.array(ContentHit), pages: z.array(ContentHit),
42
+ events: z.array(z.object({ snippet: Snippet, occurrence: z.object({ start: date, end: date, allDay: bool }).nullable(), event: Event })),
43
+ more: z.object({ posts: bool, pages: bool, events: bool }),
44
+ });
45
+ const Icon = z.object({ type: z.enum(['svg', 'image', 'emoji']), value: str }).nullable();
46
+ const ButtonStyle = { color: str.nullable(), textColor: str.nullable() };
47
+ export const Links = z.object({
48
+ name: str, bio: str, avatar: str, url: str,
49
+ socials: z.array(z.object({ label: str, url: str, iconPath: str.nullable() })),
50
+ items: z.array(z.discriminatedUnion('kind', [
51
+ z.object({ id: str, kind: z.literal('link'), label: str, url: str, destination: str, icon: Icon, ...ButtonStyle }),
52
+ z.object({ id: str, kind: z.literal('email'), label: str, url: str, icon: Icon, ...ButtonStyle }),
53
+ z.object({ id: str, kind: z.literal('phone'), label: str, url: str, icon: Icon, ...ButtonStyle }),
54
+ z.object({ id: str, kind: z.literal('contact'), label: str, icon: Icon, ...ButtonStyle, contact: z.object({ firstName: str, lastName: str, organization: str, phone: str, email: str, url: str }) }),
55
+ z.object({ id: str, kind: z.literal('heading'), label: str }),
56
+ z.object({ id: str, kind: z.literal('text'), text: str }),
57
+ z.object({ id: str, kind: z.literal('spacer'), size: z.enum(['s', 'm', 'l']) }),
58
+ z.object({ id: str, kind: z.literal('live') }),
59
+ z.object({ id: str, kind: z.literal('embed'), url: str }),
60
+ ])),
61
+ });
62
+ export const Form = z.object({
63
+ id: str, version: str, title: str, intro: str, submitLabel: str, confirmation: str,
64
+ fields: z.array(z.object({
65
+ id: str, type: z.enum(['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date', 'radio', 'checkboxes', 'url', 'consent', 'heading']),
66
+ label: str, required: bool, help: str, options: strings, placeholder: str, width: z.enum(['full', 'half']), allowOther: bool,
67
+ })),
68
+ });
69
+ export const FormReceipt = z.object({ received: z.literal(true), confirmation: str });
70
+ const GuestChanges = z.object({ cancel: bool, reschedule: bool, cutoffHours: z.number().int(), closed: bool });
71
+ export const ManagedBooking = z.object({
72
+ booking: z.object({
73
+ id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']),
74
+ start: date, end: date, allDay: bool, timezone: str, seats: z.number().int(), name: str, email: str, phone: str, answers: z.record(str), createdAt: date,
75
+ }),
76
+ title: str, location: str,
77
+ resource: z.object({ slug: str, name: str }).nullable(),
78
+ link: z.object({ slug: str, name: str, mode: z.enum(['slots', 'range', 'day']), lengths: z.array(z.number()) }).nullable(),
79
+ event: z.object({ slug: str, title: str, url: str }).nullable(),
80
+ manageUrl: str, can: GuestChanges,
81
+ });
82
+ export const GuestCodeSent = z.object({ sent: z.literal(true), expiresInMinutes: z.number().int() });
83
+ export const GuestToken = z.object({ accessToken: str, tokenType: z.literal('Bearer'), email: str, idleDays: z.number().int() });
84
+ export const GuestBookings = z.object({ email: str, pagination: Pagination, items: z.array(z.object({
85
+ id: str, reference: str, kind: z.enum(['booking', 'rsvp']), status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']),
86
+ start: date, end: date, seats: z.number().int(), title: str, manageToken: str, manageUrl: str, can: GuestChanges,
87
+ })) });
88
+ export const GuestSignedOut = z.object({ signedOut: z.literal(true) });
38
89
  export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
39
90
  export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
40
91
  id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { CONTENT } from '../content';
4
+ import { normalizePageLocale } from '../i18n';
5
+ import { siteOrigin } from '../seo';
6
+ import { SEARCH } from '../search-config';
7
+ import { CALENDAR } from '../calendar/config';
8
+ import { eventIsListed, featuredOccurrence } from '../calendar/events';
9
+ import { localizeDoc } from '../translations';
10
+ import { ApiError } from '../api';
11
+ import { contentDto } from './content';
12
+ import { publicEventDto } from './calendar';
13
+ import { queryParams } from './http';
14
+ import { localeQuery, mobileLocale } from './locale';
15
+
16
+ // One query across posts, pages and events for an app's search screen: the
17
+ // same FTS index and ranking as the website's search box. Each group holds
18
+ // the top `limit` matches; `more` says a group has further matches, which
19
+ // apps page through with /content?q= (or /events?q=, date-ordered in a window,
20
+ // matching the same index plus title/excerpt/location).
21
+
22
+ const TYPES = ['post', 'page', 'event'] as const;
23
+ export const searchQuery = z.object({
24
+ q: z.string().trim().min(1).max(100),
25
+ types: z.string().max(40).optional()
26
+ .transform((v, ctx) => {
27
+ const list = v ? v.split(',').map((t) => t.trim()).filter(Boolean) : [...TYPES];
28
+ const bad = list.find((t) => !(TYPES as readonly string[]).includes(t));
29
+ if (bad) { ctx.addIssue({ code: 'custom', message: 'Use a comma-separated list of post, page and event.' }); return z.NEVER; }
30
+ return list as (typeof TYPES)[number][];
31
+ }),
32
+ collection: z.string().min(1).max(80).optional(),
33
+ limit: z.coerce.number().int().min(1).max(20).default(5),
34
+ locale: localeQuery,
35
+ }).strict();
36
+
37
+ const MARK = /<mark>(.*?)<\/mark>/g;
38
+ const decode = (s: string) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
39
+ /** The repo's safe-HTML snippet as plain text plus the matched ranges. */
40
+ export function plainSnippet(html: string) {
41
+ let text = '';
42
+ const highlights: { start: number; end: number }[] = [];
43
+ let last = 0;
44
+ for (const m of html.matchAll(MARK)) {
45
+ text += decode(html.slice(last, m.index));
46
+ const start = text.length;
47
+ text += decode(m[1]);
48
+ highlights.push({ start, end: text.length });
49
+ last = m.index! + m[0].length;
50
+ }
51
+ return { text: text + decode(html.slice(last)), highlights };
52
+ }
53
+
54
+ export async function search(repo: ContentRepository, url: URL) {
55
+ if (!SEARCH.enabled) throw new ApiError(404, 'This feature is disabled.');
56
+ const q = searchQuery.parse(queryParams(url));
57
+ if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
58
+ const locale = mobileLocale(q.locale);
59
+ const [found, settings, calendars] = await Promise.all([
60
+ repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: q.limit + 1, collections: q.collection ? [q.collection] : Object.keys(CONTENT.collections) }),
61
+ repo.getSettings(), repo.listCalendars(),
62
+ ]);
63
+ const origin = siteOrigin(settings, url);
64
+ const want = new Set(q.types);
65
+ const posts = want.has('post') ? found.posts : [];
66
+ // a collection narrows the search to that collection's posts
67
+ const pages = want.has('page') && !q.collection ? found.pages : [];
68
+ const all = new Map(calendars.map((c) => [c.id, c]));
69
+ const listed = new Map(calendars.filter((c) => c.visibility === 'public').map((c) => [c.id, c]));
70
+ let events = want.has('event') && CALENDAR.enabled && !q.collection ? found.events.filter(({ doc }) => eventIsListed(doc, all)) : [];
71
+ // the index ranks before visibility is known: if unlisted events filled the
72
+ // first batch, look deeper once (the repo's 200-row cap) for listed ones
73
+ if (want.has('event') && CALENDAR.enabled && !q.collection && events.length <= q.limit && found.events.length >= q.limit + 1) {
74
+ const deeper = await repo.searchPublished(q.q, { locale: normalizePageLocale(locale ?? ''), limit: 200, collections: [] });
75
+ events = deeper.events.filter(({ doc }) => eventIsListed(doc, all));
76
+ }
77
+ const now = Date.now();
78
+ const iso = (n: number) => new Date(n).toISOString();
79
+ return {
80
+ locale, query: q.q,
81
+ posts: posts.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'post', origin, false, locale), snippet: plainSnippet(snippet) })),
82
+ pages: pages.slice(0, q.limit).map(({ doc, snippet }) => ({ ...contentDto(doc, 'page', origin, false, locale), snippet: plainSnippet(snippet) })),
83
+ events: events.slice(0, q.limit).map(({ doc, snippet }) => {
84
+ const e = localizeDoc(doc, locale ?? undefined);
85
+ const o = featuredOccurrence(e, settings, now);
86
+ return {
87
+ snippet: plainSnippet(snippet),
88
+ // the next occurrence, or the latest one when the event is over
89
+ occurrence: o ? { start: iso(o.start), end: iso(o.end), allDay: o.allDay } : null,
90
+ event: publicEventDto(e, origin, listed, settings.timezone, locale),
91
+ };
92
+ }),
93
+ more: { posts: posts.length > q.limit, pages: pages.length > q.limit, events: events.length > q.limit },
94
+ };
95
+ }
@@ -9,8 +9,9 @@ import { resolveDesign } from '../theme';
9
9
  import { darkPalette } from '../themeTokens';
10
10
  import { safeUrl } from './content';
11
11
  import { queryParams } from './http';
12
+ import { guestSignInReady } from './guest';
12
13
 
13
- export async function getSite(repo: ContentRepository, url: URL) {
14
+ export async function getSite(repo: ContentRepository, url: URL, _id = '', locals?: unknown) {
14
15
  z.object({}).strict().parse(queryParams(url));
15
16
  const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
16
17
  const origin = siteOrigin(settings, url);
@@ -26,7 +27,12 @@ export async function getSite(repo: ContentRepository, url: URL) {
26
27
  logo: safeUrl(settings.siteLogo, origin), logoDark: safeUrl(settings.siteLogoDark, origin), icon: safeUrl(settings.siteFavicon, origin),
27
28
  menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
28
29
  languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
29
- features: { blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled },
30
+ features: {
31
+ blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
32
+ // Admin → Mobile app → App access
33
+ bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
34
+ forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
35
+ },
30
36
  apiVersion: 1,
31
37
  };
32
38
  }
@@ -0,0 +1,171 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import type { Monitoring } from './schema';
3
+ import { resolveConfig, runtimeValue } from './email/config';
4
+ import { z } from 'zod';
5
+ import { ApiError } from './api';
6
+ import { fetchPublicUrl } from './net';
7
+
8
+ // Uptime monitoring with Uptime Kuma: a public health check Kuma polls
9
+ // (GET /api/health), a heartbeat the scheduler pushes to a Kuma push monitor
10
+ // after every run, and a reader for a Kuma status page (Settings → Monitoring).
11
+
12
+ /** the scheduler runs every minute; three missed runs means it stopped */
13
+ export const SCHEDULER_STALE_MS = 3 * 60_000;
14
+ /** queued mail this far past its send time means the queue is stuck */
15
+ const EMAIL_STUCK_MS = 15 * 60_000;
16
+ const CACHE_MS = 10_000;
17
+
18
+ export type CheckState = 'ok' | 'down' | 'stalled' | 'stuck' | 'off';
19
+ export interface HealthReport {
20
+ /** down: the site can't do its job (503); degraded: email is stuck (200) */
21
+ status: 'ok' | 'degraded' | 'down';
22
+ checks: { database: CheckState; scheduler: CheckState; email: CheckState };
23
+ checkedAt: string;
24
+ }
25
+
26
+ let cached: { at: number; report: HealthReport } | null = null;
27
+ /** test hook */
28
+ export const resetHealthCache = () => { cached = null; };
29
+
30
+ export async function healthReport(repo: ContentRepository, locals?: unknown, now = Date.now()): Promise<HealthReport> {
31
+ if (cached && now - cached.at < CACHE_MS) return cached.report;
32
+ const checks: HealthReport['checks'] = { database: 'down', scheduler: 'stalled', email: 'off' };
33
+ try {
34
+ await repo.getSettings();
35
+ checks.database = 'ok';
36
+ const run = await repo.email.schedulerStatus();
37
+ checks.scheduler = run && now - run.finishedAt < SCHEDULER_STALE_MS ? 'ok' : 'stalled';
38
+ const email = resolveConfig(await repo.email.config(), locals);
39
+ if (!email.error && email.provider !== 'none' && email.config.enabled) checks.email = await repo.email.stuckJobs(now - EMAIL_STUCK_MS) > 0 ? 'stuck' : 'ok';
40
+ } catch { /* whatever didn't answer stays failed */ }
41
+ const status = checks.database !== 'ok' || checks.scheduler !== 'ok' ? 'down' : checks.email === 'stuck' ? 'degraded' : 'ok';
42
+ const report: HealthReport = { status, checks, checkedAt: new Date(now).toISOString() };
43
+ cached = { at: now, report };
44
+ return report;
45
+ }
46
+
47
+ // ---- scheduler heartbeat ----------------------------------------------------
48
+
49
+ const PUSH = /^https?:\/\/[^/?#\s]+(?:\/[^?#\s]*)?\/api\/push\/[A-Za-z0-9_-]{4,100}$/;
50
+ /** a Kuma push URL without its query (Kuma shows "?status=up&msg=OK&ping=" after it) */
51
+ export function normalizePushUrl(raw: string): string | null {
52
+ const url = raw.trim().split('?')[0].replace(/\/+$/, '');
53
+ return url === '' ? '' : PUSH.test(url) ? url : null;
54
+ }
55
+ /** UPTIME_PUSH_URL wins over the admin setting */
56
+ export function pushUrlFor(monitoring: Partial<Monitoring> | undefined, locals?: unknown) {
57
+ return normalizePushUrl(runtimeValue('UPTIME_PUSH_URL', locals)) || normalizePushUrl(monitoring?.pushUrl ?? '') || '';
58
+ }
59
+
60
+ /**
61
+ * Tells the Kuma push monitor the scheduler ran (status=down names the
62
+ * failed jobs). Never throws and gives up after 5 s: a slow or missing Kuma
63
+ * must not affect the site.
64
+ */
65
+ export async function pushHeartbeat(url: string, report: { ok: boolean; startedAt: number; finishedAt: number; jobs: Record<string, { ok: boolean }> }, message?: string) {
66
+ if (!url) return false;
67
+ const failed = Object.entries(report.jobs).filter(([, j]) => !j.ok).map(([name]) => name);
68
+ const q = new URLSearchParams({
69
+ status: report.ok ? 'up' : 'down',
70
+ msg: message ?? (report.ok ? `${Object.keys(report.jobs).length} jobs ok` : `failed: ${failed.join(', ')}`),
71
+ ping: String(Math.max(0, report.finishedAt - report.startedAt)),
72
+ });
73
+ try {
74
+ const res = await fetch(`${url}?${q}`, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'acms-scheduler' } });
75
+ const body = await res.json().catch(() => null) as { ok?: boolean } | null;
76
+ return res.ok && body?.ok !== false;
77
+ } catch { return false; }
78
+ }
79
+
80
+ // ---- status page (admin card) -----------------------------------------------
81
+
82
+ /** https://kuma.example/status/my-site → { origin, slug } */
83
+ export function parseStatusPage(raw: string): { origin: string; slug: string } | null {
84
+ try {
85
+ const u = new URL(raw.trim());
86
+ const m = /^\/status\/([A-Za-z0-9_-]{1,100})\/?$/.exec(u.pathname);
87
+ return /^https?:$/.test(u.protocol) && m ? { origin: u.origin, slug: m[1] } : null;
88
+ } catch { return null; }
89
+ }
90
+
91
+ export interface MonitorStatus { id: number; name: string; status: 'up' | 'down' | 'pending' | 'maintenance' | 'unknown'; since: string | null; uptime24h: number | null; message: string; recent: ('up' | 'down' | 'pending' | 'maintenance')[] }
92
+ const STATES = ['down', 'up', 'pending', 'maintenance'] as const;
93
+ // Kuma writes "2026-10-03 12:00:00" in UTC without a zone
94
+ const iso = (t: string) => { const n = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t.replace(' ', 'T')}Z`); return Number.isFinite(n) ? new Date(n).toISOString() : null; };
95
+ let pageCache: { key: string; at: number; data: MonitorStatus[] } | null = null;
96
+
97
+ /** the monitors on a public Kuma status page with their latest beats (cached 60 s) */
98
+ export async function readStatusPage(raw: string, now = Date.now()): Promise<MonitorStatus[] | null> {
99
+ const page = parseStatusPage(raw);
100
+ if (!page) return null;
101
+ if (pageCache && pageCache.key === raw && now - pageCache.at < 60_000) return pageCache.data;
102
+ const get = async (path: string) => {
103
+ const r = await fetchPublicUrl(`${page.origin}${path}`, { accept: 'application/json', userAgent: 'acms-monitoring', timeoutMs: 6000 });
104
+ return r && r.res.ok ? await r.res.json().catch(() => null) : null;
105
+ };
106
+ const [info, beats] = await Promise.all([get(`/api/status-page/${page.slug}`), get(`/api/status-page/heartbeat/${page.slug}`)]);
107
+ if (!info || !beats) return null;
108
+ const monitors = ((info.publicGroupList ?? []) as { monitorList?: { id: number; name: string }[] }[]).flatMap((g) => g.monitorList ?? []);
109
+ const data = monitors.map((m): MonitorStatus => {
110
+ const list = ((beats.heartbeatList?.[m.id] ?? []) as { status: number; time: string; msg?: string }[]).slice(-30);
111
+ const last = list[list.length - 1];
112
+ const state = (n: number) => STATES[n] ?? 'pending';
113
+ // when the current state began, if the change is among the recent beats
114
+ // (all beats alike: it began before them, so the duration is unknown)
115
+ let since: string | null = null;
116
+ if (last) { let i = list.length - 1; while (i > 0 && list[i - 1].status === last.status) i--; if (i > 0) since = iso(list[i].time); }
117
+ const uptime = beats.uptimeList?.[`${m.id}_24`];
118
+ return {
119
+ id: m.id, name: m.name, status: last ? state(last.status) : 'unknown', since,
120
+ uptime24h: typeof uptime === 'number' ? uptime : null, message: last?.msg ?? '', recent: list.map((b) => state(b.status)),
121
+ };
122
+ });
123
+ pageCache = { key: raw, at: now, data };
124
+ return data;
125
+ }
126
+
127
+ // ---- Settings → Monitoring (owner) --------------------------------------------
128
+
129
+ /** "https://kuma.example/api/push/Xk29…": enough to recognise, not to use */
130
+ const masked = (url: string) => (url ? url.replace(/(\/api\/push\/)(.{4}).*$/, '$1$2…') : '');
131
+ export async function monitoringState(repo: ContentRepository, locals?: unknown) {
132
+ const settings = await repo.getSettings();
133
+ const m = settings.monitoring;
134
+ const fromEnv = !!normalizePushUrl(runtimeValue('UPTIME_PUSH_URL', locals));
135
+ const run = await repo.email.schedulerStatus();
136
+ const monitors = m.statusPage ? await readStatusPage(m.statusPage) : null;
137
+ return {
138
+ pushUrl: masked(pushUrlFor(m, locals)), pushFromEnv: fromEnv, statusPage: m.statusPage,
139
+ // null with a statusPage set: Kuma didn't answer or the page doesn't exist
140
+ monitors, lastRun: run ? new Date(run.finishedAt).toISOString() : null,
141
+ health: await healthReport(repo, locals),
142
+ };
143
+ }
144
+ export type MonitoringState = Awaited<ReturnType<typeof monitoringState>>;
145
+
146
+ export const MonitoringInput = z.discriminatedUnion('action', [
147
+ z.object({ action: z.literal('save'), pushUrl: z.string().max(500).optional(), statusPage: z.string().max(500).optional() }).strict(),
148
+ z.object({ action: z.literal('test') }).strict(),
149
+ ]);
150
+ export async function monitoringAction(repo: ContentRepository, input: z.infer<typeof MonitoringInput>, locals?: unknown) {
151
+ const settings = await repo.getSettings();
152
+ if (input.action === 'test') {
153
+ const url = pushUrlFor(settings.monitoring, locals);
154
+ if (!url) throw new ApiError(400, 'Add the push URL first.');
155
+ const now = Date.now();
156
+ if (!await pushHeartbeat(url, { ok: true, startedAt: now, finishedAt: now, jobs: {} }, 'Test from ACMS admin')) throw new ApiError(502, 'Kuma didn’t accept the test. Check the push URL and that the monitor is active.');
157
+ return;
158
+ }
159
+ const next = { ...settings.monitoring };
160
+ if (input.pushUrl !== undefined) {
161
+ const url = normalizePushUrl(input.pushUrl);
162
+ if (url === null) throw new ApiError(400, 'Paste the push URL from the Kuma push monitor (…/api/push/…).');
163
+ next.pushUrl = url;
164
+ }
165
+ if (input.statusPage !== undefined) {
166
+ const page = input.statusPage.trim();
167
+ if (page && !parseStatusPage(page)) throw new ApiError(400, 'Paste the status page address (…/status/your-page).');
168
+ next.statusPage = page;
169
+ }
170
+ await repo.updateSettings({ monitoring: next });
171
+ }
package/src/lib/net.ts CHANGED
@@ -1,20 +1,88 @@
1
1
  // SSRF guard shared by every server-side fetch of a user-supplied URL (the
2
2
  // bookmark card's metadata scrape, the calendar card's ICS feed): block
3
- // loopback, RFC1918, CGNAT, link-local and IPv6 ULA hosts so a pasted URL —
4
- // or a redirect hop — can never reach the Worker's own network.
5
- export function isPrivateHost(hostname: string): boolean {
6
- const h = hostname.toLowerCase();
7
- if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true;
8
- const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
3
+ // loopback, RFC1918, CGNAT, link-local, multicast/reserved and IPv6 ULA hosts
4
+ // so a pasted URL — or a redirect hop — can never reach the server's own network.
5
+
6
+ function v4Private(a: number, b: number): boolean {
7
+ return (
8
+ a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) ||
9
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
10
+ (a === 198 && (b === 18 || b === 19))
11
+ );
12
+ }
13
+
14
+ // eight 16-bit groups, or null when not an IPv6 address (zone ids dropped)
15
+ function v6Groups(ip: string): number[] | null {
16
+ let s = ip.split('%')[0];
17
+ const v4 = s.match(/:(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
18
+ if (v4) {
19
+ const o = v4.slice(1).map(Number);
20
+ if (o.some((n) => n > 255)) return null;
21
+ s = `${s.slice(0, v4.index! + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
22
+ }
23
+ const parts = s.split('::');
24
+ if (parts.length > 2) return null;
25
+ const head = parts[0] ? parts[0].split(':') : [];
26
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
27
+ const fill = 8 - head.length - tail.length;
28
+ if (parts.length === 1 ? fill !== 0 : fill < 1) return null;
29
+ const groups = [...head, ...Array(parts.length === 2 ? fill : 0).fill('0'), ...tail];
30
+ if (!groups.every((g) => /^[0-9a-f]{1,4}$/i.test(g))) return null;
31
+ return groups.map((g) => parseInt(g, 16));
32
+ }
33
+
34
+ /** true for any IP literal that isn't a public unicast address; non-IPs count as private */
35
+ export function isPrivateAddress(ip: string): boolean {
36
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
9
37
  if (v4) {
10
- const [a, b] = [Number(v4[1]), Number(v4[2])];
11
- return (
12
- a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
13
- (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
14
- );
38
+ const o = v4.slice(1).map(Number);
39
+ return o.some((n) => n > 255) || v4Private(o[0], o[1]);
15
40
  }
16
- if (h.includes(':')) return h === '::1' || h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('[');
17
- return false;
41
+ const g = v6Groups(ip.replace(/^\[|\]$/g, ''));
42
+ if (!g) return true;
43
+ // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d) and NAT64 (64:ff9b::/96) carry an IPv4 address
44
+ const embedded = (g.slice(0, 5).every((n) => n === 0) && (g[5] === 0xffff || g[5] === 0))
45
+ || (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((n) => n === 0));
46
+ if (embedded && (g[6] || g[7] || g[5] === 0xffff)) return v4Private(g[6] >> 8, g[6] & 0xff);
47
+ return (
48
+ g.every((n) => n === 0) || (g.slice(0, 7).every((n) => n === 0) && g[7] === 1) ||
49
+ (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00
50
+ );
51
+ }
52
+
53
+ const isIp = (h: string) => /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.includes(':');
54
+ const bare = (hostname: string) => hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
55
+
56
+ /** literal check only: names that are private by convention, and private IP literals */
57
+ export function isPrivateHost(hostname: string): boolean {
58
+ const h = bare(hostname);
59
+ if (!h || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true;
60
+ return isIp(h) && isPrivateAddress(h);
61
+ }
62
+
63
+ type Lookup = (host: string, opts: { all: true; verbatim: true }) => Promise<{ address: string }[]>;
64
+ let dnsLookup: Promise<Lookup | null> | undefined;
65
+ // Workers have no usable node:dns, and their fetch can't reach private
66
+ // networks anyway; there (or wherever the import fails) only the literal check runs
67
+ const loadLookup = () => (dnsLookup ??= typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers'
68
+ ? Promise.resolve(null)
69
+ : import(/* @vite-ignore */ 'node:dns/promises').then((m) => m.lookup as unknown as Lookup, () => null));
70
+
71
+ /**
72
+ * isPrivateHost, plus (on Node) true when the name resolves to ANY private
73
+ * address — a public name pointing at 127.0.0.1 or 169.254.169.254. fetch
74
+ * resolves again and can't be pinned to the checked IP, so a rebinding DNS
75
+ * server can still race this: it narrows the window, it doesn't close it. A
76
+ * failed lookup passes (fetch's own lookup will fail the same way).
77
+ */
78
+ export async function resolvesToPrivate(hostname: string): Promise<boolean> {
79
+ if (isPrivateHost(hostname)) return true;
80
+ const h = bare(hostname);
81
+ if (isIp(h)) return false;
82
+ const lookup = await loadLookup();
83
+ if (!lookup) return false;
84
+ const addrs = await lookup(h, { all: true, verbatim: true }).catch((): { address: string }[] => []);
85
+ return addrs.some((a) => isPrivateAddress(a.address));
18
86
  }
19
87
 
20
88
  // Fetch that follows redirects manually so EVERY hop is re-checked against the
@@ -32,7 +100,7 @@ export async function fetchPublicUrl(
32
100
  } catch {
33
101
  return null;
34
102
  }
35
- if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) return null;
103
+ if (!/^https?:$/.test(u.protocol) || await resolvesToPrivate(u.hostname)) return null;
36
104
  const res = await fetch(u.href, {
37
105
  redirect: 'manual',
38
106
  signal: AbortSignal.timeout(init.timeoutMs ?? 8000),
@@ -0,0 +1,96 @@
1
+ import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
2
+
3
+ // Applies pending schema migrations under a database lock. Shared by the app
4
+ // (first request after a deploy — the fallback) and `acms migrate` (Docker
5
+ // entrypoint, before `wrangler deploy`), which runs a bundled copy of this
6
+ // file (cli/migrations.generated.mjs), so the two can never disagree.
7
+
8
+ export interface MigrationDriver {
9
+ all(sql: string, args?: unknown[]): Promise<Record<string, unknown>[]>;
10
+ run(sql: string, args?: unknown[]): Promise<void>;
11
+ batch(stmts: Array<{ sql: string; args?: unknown[] }>): Promise<void>;
12
+ }
13
+
14
+ export const MIGRATION_LOCK_TABLE = `CREATE TABLE IF NOT EXISTS migration_lock (
15
+ id INTEGER PRIMARY KEY CHECK (id = 1), owner TEXT NOT NULL, expires_at INTEGER NOT NULL
16
+ )`;
17
+ // a crashed migrator's lock expires; long enough for a big table rebuild
18
+ const LOCK_TTL_MS = 10 * 60_000;
19
+
20
+ export interface MigrationResult {
21
+ from: number; to: number; applied: number[];
22
+ /** set when a refused version is pending: nothing was applied, the site will migrate itself on start */
23
+ deferred?: number;
24
+ }
25
+ export interface MigrateOptions {
26
+ /** how long to wait for another migrator before giving up (ms) */
27
+ waitMs?: number;
28
+ /** versions this runner can't finish on an existing database (the CLI can't rebuild search for v21) */
29
+ refuse?: number[];
30
+ now?: () => number;
31
+ sleep?: (ms: number) => Promise<void>;
32
+ log?: (message: string) => void;
33
+ }
34
+
35
+ export class MigrationLockTimeout extends Error {}
36
+
37
+ async function appliedVersion(db: MigrationDriver): Promise<number> {
38
+ const r = (await db.all('SELECT MAX(version) AS v FROM migrations'))[0];
39
+ return Number(r?.v ?? 0);
40
+ }
41
+
42
+ export async function pendingMigrations(db: MigrationDriver) {
43
+ await db.run(MIGRATIONS_TABLE);
44
+ let applied = await appliedVersion(db);
45
+ // Pre-migrations DB: the baseline tables already exist, count v1 as applied
46
+ if (applied === 0 && (await db.all("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'")).length) applied = 1;
47
+ return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
48
+ }
49
+
50
+ export async function runMigrations(db: MigrationDriver, opts: MigrateOptions = {}): Promise<MigrationResult> {
51
+ const now = opts.now ?? Date.now;
52
+ const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
53
+ // the common case (nothing to do) takes no lock and writes nothing
54
+ const first = await pendingMigrations(db);
55
+ if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
56
+ // a brand-new database has nothing to rebuild, so only old ones are refused
57
+ const refused = first.applied > 0 && first.pending.find((m) => opts.refuse?.includes(m.version));
58
+ if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
59
+
60
+ await db.run(MIGRATION_LOCK_TABLE);
61
+ const owner = crypto.randomUUID();
62
+ const deadline = now() + (opts.waitMs ?? 60_000);
63
+ let waited = false;
64
+ for (;;) {
65
+ const t = now();
66
+ await db.run(
67
+ `INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
68
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
69
+ [owner, t + LOCK_TTL_MS, t],
70
+ );
71
+ const holder = (await db.all('SELECT owner FROM migration_lock WHERE id = 1'))[0];
72
+ if (holder?.owner === owner) break;
73
+ if (t >= deadline) throw new MigrationLockTimeout('Another process is migrating this database. Try again when it finishes.');
74
+ if (!waited) { opts.log?.('Waiting for another migration to finish…'); waited = true; }
75
+ await sleep(500);
76
+ }
77
+ try {
78
+ // someone else may have finished while we waited
79
+ const { applied, pending } = await pendingMigrations(db);
80
+ if (applied === 1 && !(await appliedVersion(db))) {
81
+ await db.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [now()]);
82
+ }
83
+ for (const m of pending) {
84
+ opts.log?.(`Applying migration ${m.version}`);
85
+ await db.batch([
86
+ ...m.statements.map((sql) => ({ sql })),
87
+ { sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, now()] },
88
+ ]);
89
+ }
90
+ return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
91
+ } finally {
92
+ await db.run('DELETE FROM migration_lock WHERE id = 1 AND owner = ?', [owner]).catch(() => {});
93
+ }
94
+ }
95
+
96
+ export { MIGRATIONS, LATEST_VERSION } from './migrations';