@acms-subir/core 0.3.10 → 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 (95) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +6 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/virtual.d.ts +1 -0
  95. package/src/worker.ts +7 -1
@@ -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));
@@ -32,6 +32,7 @@ async function render(repo: ContentRepository, job: EmailJob, locals?: unknown,
32
32
  return accountMail(payload.purpose, job.recipient, settings.siteTitle, `${origin}/account/${payload.purpose}?token=${encodeURIComponent(raw)}`);
33
33
  }
34
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);
35
36
  return null;
36
37
  }
37
38
  export async function dispatchEmail(repo: ContentRepository, locals?: unknown, limit = 10): Promise<number> {
@@ -1,11 +1,12 @@
1
1
  import type { Mail } from './types';
2
2
  export const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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'),
@@ -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;
@@ -4,7 +4,7 @@ import { BookingError } from '../booking/service';
4
4
  import { sha256Hex } from '../api-keys';
5
5
  import type { ContentRepository } from '../repo/types';
6
6
 
7
- const headers = {
7
+ export const headers = {
8
8
  'Access-Control-Allow-Origin': '*',
9
9
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
10
10
  // Authorization carries editor-app bearer tokens; no route reads cookies
@@ -25,9 +25,10 @@ export async function publicResponse(request: Request, data: unknown, raw = fals
25
25
  res.headers.set('ETag', etag);
26
26
  // Revalidate even fresh-looking content: unpublishing must take effect.
27
27
  res.headers.set('Cache-Control', 'public, max-age=0, must-revalidate');
28
- const match = request.headers.get('if-none-match')?.split(',').some((s) => s.trim().replace(/^W\//, '') === etag || s.trim() === '*');
29
- return match ? new Response(null, { status: 304, headers: res.headers }) : res;
28
+ return etagMatches(request, etag) ? new Response(null, { status: 304, headers: res.headers }) : res;
30
29
  }
30
+ export const etagMatches = (request: Request, etag: string) =>
31
+ request.headers.get('if-none-match')?.split(',').some((s) => s.trim().replace(/^W\//, '') === etag || s.trim() === '*') ?? false;
31
32
  export function failure(error: unknown) {
32
33
  const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
33
34
  const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
@@ -1,16 +1,28 @@
1
1
  import type { APIRoute } from 'astro';
2
2
  import type { ContentRepository } from '../repo/types';
3
3
  import { getRepo } from '../repo';
4
- import { limitRequest, failure, publicResponse, response } from './http';
5
- export function readRoute(load: (repo: ContentRepository, url: URL, id: string, locals: unknown) => Promise<unknown>, cache = true, raw = false): APIRoute {
4
+ import { userConfig } from 'virtual:acms-config';
5
+ import { limitRequest, failure, response, headers as baseHeaders, etagMatches } from './http';
6
+ import { API_REVALIDATE } from '../cache/policy';
7
+ import { CACHE } from '../cache/config';
8
+ import { memoryGet, memorySet } from '../cache/memory';
9
+
10
+ type Load = (repo: ContentRepository, url: URL, id: string, locals: unknown) => Promise<unknown>;
11
+ /**
12
+ * A public, cacheable read. `timeless` routes depend only on stored content;
13
+ * the rest (upcoming events, dynamic page blocks, search) also change with
14
+ * the clock, so their ETag rolls over every cache.maxAge seconds too.
15
+ */
16
+ export function readRoute(load: Load, cache = true, raw = false, opts: { timeless?: boolean } = {}): APIRoute {
6
17
  return async context => {
7
18
  try {
8
19
  const repo = await getRepo(context.locals);
9
20
  let address = 'unknown';
10
21
  try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
11
22
  await limitRequest(repo, address);
12
- const data = await load(repo, context.url, context.params.id ?? context.params.slug ?? '', context.locals);
13
- const res = cache ? await publicResponse(context.request, data, raw) : response(data);
23
+ const id = context.params.id ?? context.params.slug ?? '';
24
+ const res = cache ? await versionedResponse(repo, context, () => load(repo, context.url, id, context.locals), raw, opts.timeless)
25
+ : response(await load(repo, context.url, id, context.locals));
14
26
  return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
15
27
  } catch (error) {
16
28
  const res = failure(error);
@@ -19,6 +31,31 @@ export function readRoute(load: (repo: ContentRepository, url: URL, id: string,
19
31
  };
20
32
  }
21
33
 
34
+ const CORE = String((userConfig as { coreVersion?: string }).coreVersion ?? '0');
35
+ // The ETag comes from the content version (plus the framework version, and
36
+ // a time window for clock-dependent routes), so a matching If-None-Match is
37
+ // answered 304 before any content is loaded or serialized.
38
+ async function versionedResponse(repo: ContentRepository, context: { request: Request; url: URL; locals: unknown }, build: () => Promise<unknown>, raw: boolean, timeless = false) {
39
+ const { version } = await repo.getCacheState();
40
+ const window = timeless ? '' : `.${Math.floor(Date.now() / (Math.max(CACHE.maxAge, 1) * 1000))}`;
41
+ const etag = `"c${version}${window}-${CORE}"`;
42
+ const h = new Headers(baseHeaders);
43
+ h.set('ETag', etag);
44
+ h.set('Cache-Control', API_REVALIDATE);
45
+ h.set('Content-Type', 'application/json');
46
+ if (etagMatches(context.request, etag)) return new Response(null, { status: 304, headers: h });
47
+ // Node only: Workers isolates are short-lived and many
48
+ const memory = CACHE.enabled && CACHE.memory && !(context.locals as { runtime?: unknown } | undefined)?.runtime;
49
+ const key = `${etag} ${context.url.href}`;
50
+ let body = memory ? memoryGet(key) : undefined;
51
+ if (body === undefined) {
52
+ const data = await build();
53
+ body = JSON.stringify(raw ? data : { version: 1, data });
54
+ if (memory) memorySet(key, body);
55
+ }
56
+ return new Response(body, { status: 200, headers: h });
57
+ }
58
+
22
59
  export interface PublicContext { repo: ContentRepository; request: Request; url: URL; address: string; params: Record<string, string | undefined>; locals: unknown }
23
60
  /**
24
61
  * A private or write route on the public API: rate limited per client
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';
@@ -19,6 +19,51 @@ export interface Migration {
19
19
  statements: string[];
20
20
  }
21
21
 
22
+ // Columns whose change a visitor can see (v49 triggers bump content_version).
23
+ // Left out on purpose: drafts and autosave bookkeeping (draft, updated_at,
24
+ // revision counters), click counts, credentials, and staff-only settings.
25
+ // Frozen with v49 — a later column joins by a new migration that recreates
26
+ // that table's triggers (the cache test fails until every column is sorted).
27
+ export const CONTENT_VERSION_COLUMNS: Record<string, string[]> = {
28
+ settings: ['site_title', 'tagline', 'links_name', 'links_bio', 'links_avatar', 'footer_text', 'active_theme', 'design_overrides', 'site_logo',
29
+ 'site_url', 'site_favicon', 'site_cover', 'announcement', 'head_code', 'footer_code', 'timezone', 'locale', 'analytics', 'section_pages',
30
+ 'live', 'push', 'calendar', 'site_logo_dark', 'mobile_access'],
31
+ pages: ['id', 'slug', 'title', 'status', 'is_home', 'blocks', 'seo_title', 'seo_desc', 'body_json', 'body_html', 'template', 'locale', 'translations'],
32
+ posts: ['id', 'slug', 'title', 'body', 'tags', 'cover', 'status', 'published_at', 'body_json', 'body_html', 'excerpt', 'cover_alt', 'seo_title',
33
+ 'seo_desc', 'scheduled_at', 'author_id', 'featured', 'template', 'collection', 'translations'],
34
+ post_authors: ['post_id', 'author_id', 'ord'],
35
+ events: ['id', 'slug', 'title', 'body_json', 'body_html', 'excerpt', 'cover', 'cover_alt', 'seo_title', 'seo_desc', 'status', 'featured', 'tags',
36
+ 'template', 'starts', 'ends', 'all_day', 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendar_ids', 'fields', 'source_uid',
37
+ 'start_at', 'end_at', 'published_at', 'scheduled_at', 'rsvp', 'translations', 'room_hold'],
38
+ calendars: ['id', 'slug', 'name', 'color', 'description', 'kind', 'feed_url', 'visibility', 'feed_token', 'ord'],
39
+ places: ['id', 'name', 'address', 'url', 'lat', 'lng', 'notes', 'is_default', 'map_image', 'map_image_dark', 'ord'],
40
+ resources: ['id', 'slug', 'name', 'kind', 'description', 'photo', 'capacity', 'amenities', 'timezone', 'hours', 'overrides', 'busy_feeds',
41
+ 'visibility', 'show_on_calendar', 'feed_token', 'ord', 'place_id'],
42
+ booking_links: ['id', 'resource_id', 'slug', 'name', 'description', 'mode', 'duration', 'lengths', 'min_days', 'max_days', 'step', 'buffer_before',
43
+ 'buffer_after', 'min_notice', 'max_advance', 'per_day_cap', 'hours', 'auto_confirm', 'visibility', 'ask_phone', 'questions', 'confirm_message',
44
+ 'redirect_url', 'active', 'ord', 'approval', 'approval_rules', 'change_cutoff'],
45
+ links: ['id', 'label', 'url', 'icon', 'ord', 'kind', 'html', 'meta'],
46
+ nav_items: ['id', 'label', 'url', 'area', 'ord'],
47
+ tags: ['id', 'name', 'slug', 'description'],
48
+ media: ['id', 'key', 'filename', 'mime', 'width', 'height', 'size', 'alt', 'variants'],
49
+ redirects: ['id', 'from_path', 'to_path', 'code'],
50
+ users: ['id', 'name', 'email', 'role', 'bio', 'avatar', 'slug'],
51
+ forms: ['id', 'name', 'kind', 'purpose', 'status', 'definition'],
52
+ };
53
+
54
+ const BUMP_CONTENT_VERSION = 'UPDATE settings SET content_version = content_version + 1 WHERE id = 1;';
55
+ function contentVersionTriggers(table: string, columns: string[]): string[] {
56
+ const changed = columns.map((c) => `OLD.${c} IS NOT NEW.${c}`).join(' OR ');
57
+ const update = `CREATE TRIGGER content_version_${table}_update AFTER UPDATE OF ${columns.join(', ')} ON ${table} WHEN ${changed} BEGIN ${BUMP_CONTENT_VERSION} END`;
58
+ // the settings row itself is created once and never deleted
59
+ if (table === 'settings') return [update];
60
+ return [
61
+ `CREATE TRIGGER content_version_${table}_insert AFTER INSERT ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
62
+ `CREATE TRIGGER content_version_${table}_delete AFTER DELETE ON ${table} BEGIN ${BUMP_CONTENT_VERSION} END`,
63
+ update,
64
+ ];
65
+ }
66
+
22
67
  export const MIGRATIONS_TABLE = `CREATE TABLE IF NOT EXISTS migrations (
23
68
  version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL
24
69
  )`;
@@ -554,6 +599,37 @@ export const MIGRATIONS: Migration[] = [
554
599
  { version: 47, statements: [
555
600
  `ALTER TABLE settings ADD COLUMN monitoring TEXT NOT NULL DEFAULT '{}'`,
556
601
  ] },
602
+ { version: 48, statements: [
603
+ `CREATE TABLE auth_owner_checks (allowed INTEGER CONSTRAINT keep_clerk_owner CHECK(allowed=1))`,
604
+ `ALTER TABLE editor_codes ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
605
+ `ALTER TABLE editor_devices ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'builtin'`,
606
+ `CREATE TABLE auth_identities (issuer TEXT NOT NULL, subject TEXT NOT NULL, user_id TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(issuer,subject), UNIQUE(issuer,user_id))`,
607
+ `CREATE TABLE auth_bootstrap (issuer TEXT PRIMARY KEY)`,
608
+ `CREATE TABLE auth_invitations (id TEXT PRIMARY KEY, issuer TEXT NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, state TEXT NOT NULL, remote_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
609
+ `CREATE UNIQUE INDEX auth_invitation_pending ON auth_invitations(issuer,email) WHERE state IN ('sending','pending')`,
610
+ `CREATE TABLE auth_mobile (kind TEXT NOT NULL, id TEXT NOT NULL, issuer TEXT NOT NULL, subject TEXT NOT NULL, session_id TEXT NOT NULL, PRIMARY KEY(kind,id))`,
611
+ `CREATE TRIGGER auth_user_deleted AFTER DELETE ON users BEGIN UPDATE auth_identities SET disabled=1 WHERE user_id=OLD.id; END`,
612
+ `CREATE TRIGGER auth_code_deleted AFTER DELETE ON editor_codes BEGIN DELETE FROM auth_mobile WHERE kind='code' AND id=OLD.hash; END`,
613
+ `CREATE TRIGGER auth_device_deleted AFTER DELETE ON editor_devices BEGIN DELETE FROM auth_mobile WHERE kind='device' AND id=OLD.id; END`,
614
+ ] },
615
+ // Public cache: content_version goes up on every change a visitor could
616
+ // see, from any write path (admin, editor API, scheduler, design publish,
617
+ // restore/import, raw SQL), because triggers do the bumping. Cached pages
618
+ // and app ETags are keyed by it; the purge loop records how far it got.
619
+ { version: 49, statements: [
620
+ `ALTER TABLE settings ADD COLUMN content_version INTEGER NOT NULL DEFAULT 0`,
621
+ `ALTER TABLE settings ADD COLUMN cache_purged_version INTEGER NOT NULL DEFAULT 0`,
622
+ // start at a random point so two databases (a reinstall, a test run)
623
+ // never hand out the same version, and an old ETag can't match by chance
624
+ `UPDATE settings SET content_version = abs(random() % 1000000000), cache_purged_version = content_version WHERE id = 1`,
625
+ ...Object.entries(CONTENT_VERSION_COLUMNS).flatMap(([table, columns]) => contentVersionTriggers(table, columns)),
626
+ ] },
627
+ // scheduled off-site backups (lib/backup/offsite.ts): last run + lease
628
+ { version: 50, statements: [
629
+ `CREATE TABLE backup_state (id INTEGER PRIMARY KEY CHECK (id = 1), last_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0,
630
+ last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', failed_at INTEGER NOT NULL DEFAULT 0)`,
631
+ `INSERT INTO backup_state (id) VALUES (1)`,
632
+ ] },
557
633
  ];
558
634
 
559
635
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;