@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
package/src/lib/scheduler.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { processDueDeliveries } from './webhooks';
|
|
|
4
4
|
import { dispatchEmail } from './email/service';
|
|
5
5
|
import { decideOverdue, sendDueReminders } from './booking/service';
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
|
+
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
8
|
+
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
9
|
+
import { syncCachePurge } from './cache/purge';
|
|
10
|
+
import { runOffsiteBackup } from './backup/offsite';
|
|
7
11
|
|
|
8
12
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
9
13
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -20,6 +24,13 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
20
24
|
live: () => syncDueLive(repo, locals),
|
|
21
25
|
// queued first-publish notices, starting-soon reminders, receipts, retention
|
|
22
26
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
27
|
+
// expired guest sign-in codes and app sessions unused for 90 days
|
|
28
|
+
guests: () => repo.guests.purge(GUEST_IDLE_MS, startedAt).then(() => 0),
|
|
29
|
+
// clear shared caches after scheduled publishes, and retry a purge that
|
|
30
|
+
// failed after an admin save (Cloudflare API down or rate limited)
|
|
31
|
+
cache: () => syncCachePurge(repo, locals, { force: true }),
|
|
32
|
+
// off-site backup ZIP when one is due (BACKUP_* settings / BACKUPS binding)
|
|
33
|
+
backup: () => runOffsiteBackup(repo, locals),
|
|
23
34
|
};
|
|
24
35
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
25
36
|
for (const [name, run] of Object.entries(jobs)) {
|
|
@@ -28,5 +39,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
28
39
|
}
|
|
29
40
|
const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
|
|
30
41
|
await repo.email.recordScheduler(report);
|
|
42
|
+
// Uptime Kuma push monitor (Settings → Monitoring): a missing beat means the scheduler stopped
|
|
43
|
+
// (never allowed to fail the run)
|
|
44
|
+
await Promise.resolve().then(() => pushHeartbeat(pushUrlFor(settings.monitoring, locals), report)).catch(() => false);
|
|
31
45
|
return report;
|
|
32
46
|
}
|
package/src/lib/schema.ts
CHANGED
|
@@ -425,7 +425,8 @@ export type WeeklyHours = z.infer<typeof WeeklyHours>;
|
|
|
425
425
|
// a date (or inclusive date span) with replacement hours; [] = closed
|
|
426
426
|
export const DateOverride = z.object({
|
|
427
427
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
428
|
-
|
|
428
|
+
// '' = a single day
|
|
429
|
+
endDate: z.union([z.literal(''), z.string().regex(/^\d{4}-\d{2}-\d{2}$/)]).default(''),
|
|
429
430
|
ranges: z.array(TimeRange).default([]),
|
|
430
431
|
label: z.string().default(''),
|
|
431
432
|
});
|
|
@@ -526,6 +527,35 @@ export const CalendarSettings = z.object({
|
|
|
526
527
|
});
|
|
527
528
|
export type CalendarSettings = z.infer<typeof CalendarSettings>;
|
|
528
529
|
|
|
530
|
+
// Admin → Mobile app → App access: owner switches and abuse limits for the
|
|
531
|
+
// public app routes. Turning a switch off answers 404 at once, no redeploy.
|
|
532
|
+
export const MobileAccess = z.object({
|
|
533
|
+
// visitors view, cancel and move a booking with its private token
|
|
534
|
+
bookingChanges: z.boolean().default(true),
|
|
535
|
+
// "My bookings" through an emailed sign-in code (also needs email sending)
|
|
536
|
+
guestSignIn: z.boolean().default(true),
|
|
537
|
+
// published forms accept responses from apps
|
|
538
|
+
forms: z.boolean().default(true),
|
|
539
|
+
links: z.boolean().default(true),
|
|
540
|
+
// new bookings + RSVPs per email address per day, every channel; 0 = no cap
|
|
541
|
+
bookingsPerEmailDay: z.number().int().min(0).max(1000).default(10),
|
|
542
|
+
// responses per form per day from apps; 0 = no cap
|
|
543
|
+
responsesPerFormDay: z.number().int().min(0).max(100000).default(500),
|
|
544
|
+
// who changes rooms, booking types, hours and calendars from staff apps
|
|
545
|
+
scheduleRoles: z.enum(['admins', 'staff']).default('admins'),
|
|
546
|
+
});
|
|
547
|
+
export type MobileAccess = z.infer<typeof MobileAccess>;
|
|
548
|
+
|
|
549
|
+
// Settings → Monitoring (owner): Uptime Kuma. pushUrl is a Kuma push monitor
|
|
550
|
+
// the scheduler reports to after every run (a small secret: left out of
|
|
551
|
+
// exports; UPTIME_PUSH_URL overrides it). statusPage is a public Kuma status
|
|
552
|
+
// page the admin card reads.
|
|
553
|
+
export const Monitoring = z.object({
|
|
554
|
+
pushUrl: z.string().max(500).default(''),
|
|
555
|
+
statusPage: z.string().max(500).default(''),
|
|
556
|
+
});
|
|
557
|
+
export type Monitoring = z.infer<typeof Monitoring>;
|
|
558
|
+
|
|
529
559
|
export const BookingLink = z.object({
|
|
530
560
|
id: z.string(),
|
|
531
561
|
resourceId: z.string(),
|
|
@@ -543,6 +573,8 @@ export const BookingLink = z.object({
|
|
|
543
573
|
bufferAfter: z.number().int().default(0),
|
|
544
574
|
minNotice: z.number().int().default(120), // minutes
|
|
545
575
|
maxAdvance: z.number().int().default(90), // days
|
|
576
|
+
// guests can cancel or move until this many hours before the start; 0 = any time
|
|
577
|
+
changeCutoff: z.number().int().min(0).max(8760).default(0),
|
|
546
578
|
perDayCap: z.number().int().default(0), // 0 = unlimited
|
|
547
579
|
// narrows the resource's hours; null = use the resource's hours as-is
|
|
548
580
|
hours: WeeklyHours.nullable().default(null),
|
|
@@ -671,6 +703,8 @@ export function normalizeRedirectPath(input: string): string {
|
|
|
671
703
|
export const Settings = z.object({
|
|
672
704
|
live: LiveConfig.default({}),
|
|
673
705
|
calendar: CalendarSettings.default({}),
|
|
706
|
+
mobileAccess: MobileAccess.default({}),
|
|
707
|
+
monitoring: Monitoring.default({}),
|
|
674
708
|
// push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
|
|
675
709
|
push: PushSettings.default({}),
|
|
676
710
|
siteTitle: z.string().default('My Site'),
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { SectionLayout, type SectionPages } from '../theme-sdk/contract.mjs';
|
|
3
|
+
import type { NavItem, Page, Settings } from './schema';
|
|
4
|
+
|
|
5
|
+
// Theme transfer (acms-theme v1): a theme as you see it on the Design screen,
|
|
6
|
+
// moved between sites or machines as one ZIP:
|
|
7
|
+
// manifest.json format, theme identity, core version, what's included
|
|
8
|
+
// design.json the database half: overrides, brand images, section
|
|
9
|
+
// layouts (keyed by page slug, not ID), optional navigation
|
|
10
|
+
// media/<key> brand and section images, ids and keys verbatim
|
|
11
|
+
// Theme code (src/themes/<slug>/) is compiled into the build, so it never
|
|
12
|
+
// installs at runtime; an import that names a theme this site lacks stages
|
|
13
|
+
// the rest and keeps the current theme.
|
|
14
|
+
|
|
15
|
+
export const THEME_TRANSFER_FORMAT = 'acms-theme';
|
|
16
|
+
|
|
17
|
+
export const ThemeTransferManifest = z.object({
|
|
18
|
+
format: z.literal(THEME_TRANSFER_FORMAT),
|
|
19
|
+
version: z.literal(1),
|
|
20
|
+
exportedAt: z.number().int().nonnegative(),
|
|
21
|
+
coreVersion: z.string().max(50).default(''),
|
|
22
|
+
source: z.object({ title: z.string().max(300).default(''), siteUrl: z.string().max(2000).default('') }).default({}),
|
|
23
|
+
theme: z.object({ slug: z.string().max(50), name: z.string().max(200).default(''), version: z.string().max(50).default(''), project: z.boolean().default(false) }),
|
|
24
|
+
includes: z.array(z.enum(['design', 'media', 'nav'])).max(10),
|
|
25
|
+
}).passthrough();
|
|
26
|
+
export type ThemeTransferManifest = z.infer<typeof ThemeTransferManifest>;
|
|
27
|
+
|
|
28
|
+
const PageKey = z.object({ slug: z.string().max(300), locale: z.string().max(20).default(''), isHome: z.boolean().default(false), title: z.string().max(500).default('') });
|
|
29
|
+
export const ThemeDesignFile = z.object({
|
|
30
|
+
activeTheme: z.string().max(50),
|
|
31
|
+
designOverrides: z.record(z.union([z.string().max(2000), z.number().finite()])).default({}),
|
|
32
|
+
siteLogo: z.string().max(2000).default(''),
|
|
33
|
+
siteLogoDark: z.string().max(2000).default(''),
|
|
34
|
+
siteFavicon: z.string().max(2000).default(''),
|
|
35
|
+
siteCover: z.string().max(2000).default(''),
|
|
36
|
+
layouts: z.array(z.object({ page: PageKey, layout: SectionLayout })).max(500).default([]),
|
|
37
|
+
// link refs (page:/post:/event:/booking:<id>) → the public URL they pointed
|
|
38
|
+
// at, so the importing site can find its own item at the same address
|
|
39
|
+
refs: z.record(z.string().max(200), z.string().max(2000)).default({}),
|
|
40
|
+
navItems: z.array(z.object({ label: z.string().max(200), url: z.string().max(2000), area: z.enum(['header', 'footer', 'social']) })).max(200).optional(),
|
|
41
|
+
});
|
|
42
|
+
export type ThemeDesignFile = z.infer<typeof ThemeDesignFile>;
|
|
43
|
+
|
|
44
|
+
type Pages = z.infer<typeof SectionPages>;
|
|
45
|
+
type PageRow = Pick<Page, 'id' | 'slug' | 'locale' | 'isHome' | 'title'>;
|
|
46
|
+
export interface LinkRef { ref: string; url: string }
|
|
47
|
+
|
|
48
|
+
const walk = (v: unknown, fn: (s: string) => string): unknown => {
|
|
49
|
+
if (typeof v === 'string') return fn(v);
|
|
50
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, fn));
|
|
51
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, walk(x, fn)]));
|
|
52
|
+
return v;
|
|
53
|
+
};
|
|
54
|
+
const collectRefs = (v: unknown, out: Set<string>) => {
|
|
55
|
+
if (Array.isArray(v)) v.forEach((x) => collectRefs(x, out));
|
|
56
|
+
else if (v && typeof v === 'object') {
|
|
57
|
+
for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
|
|
58
|
+
if (k === 'ref' && typeof x === 'string') out.add(x); else collectRefs(x, out);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** every uploaded-media key a value points at (/media/<id>/<file>) */
|
|
64
|
+
export function mediaKeysIn(value: unknown): string[] {
|
|
65
|
+
const keys = new Set<string>();
|
|
66
|
+
walk(value, (s) => {
|
|
67
|
+
for (const m of s.matchAll(/\/media\/([\w-]+\/[\w.-]+)/g)) keys.add(m[1]);
|
|
68
|
+
return s;
|
|
69
|
+
});
|
|
70
|
+
return [...keys];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The database half of an export: the design as the Design screen shows it
|
|
75
|
+
* (draft merged over live). Same-origin absolute media URLs become
|
|
76
|
+
* site-relative so they resolve on the importing site.
|
|
77
|
+
*/
|
|
78
|
+
export function buildDesignExport(input: {
|
|
79
|
+
settings: Settings; navItems: Pick<NavItem, 'label' | 'url' | 'area'>[]; pages: PageRow[]; links: LinkRef[]; origin: string; includeNav: boolean;
|
|
80
|
+
}): ThemeDesignFile {
|
|
81
|
+
const { settings: s, origin } = input;
|
|
82
|
+
const local = (v: string) => (origin && v.startsWith(`${origin}/media/`) ? v.slice(origin.length) : v);
|
|
83
|
+
const byId = new Map(input.pages.map((p) => [p.id, p]));
|
|
84
|
+
const layouts = Object.entries(s.sectionPages as Pages).flatMap(([id, layout]) => {
|
|
85
|
+
const p = byId.get(id);
|
|
86
|
+
return p ? [{ page: { slug: p.slug, locale: p.locale ?? '', isHome: Boolean(p.isHome), title: p.title ?? '' }, layout: walk(layout, local) as Pages[string] }] : [];
|
|
87
|
+
});
|
|
88
|
+
const used = new Set<string>();
|
|
89
|
+
collectRefs(layouts, used);
|
|
90
|
+
const urls = new Map(input.links.map((l) => [l.ref, l.url]));
|
|
91
|
+
const refs = Object.fromEntries([...used].flatMap((r) => (urls.has(r) ? [[r, urls.get(r)!]] : [])));
|
|
92
|
+
return {
|
|
93
|
+
activeTheme: s.activeTheme,
|
|
94
|
+
designOverrides: (s.designOverrides ?? {}) as Record<string, string | number>,
|
|
95
|
+
siteLogo: local(s.siteLogo ?? ''), siteLogoDark: local(s.siteLogoDark ?? ''), siteFavicon: local(s.siteFavicon ?? ''), siteCover: local(s.siteCover ?? ''),
|
|
96
|
+
layouts, refs,
|
|
97
|
+
...(input.includeNav ? { navItems: input.navItems.map(({ label, url, area }) => ({ label, url: local(url), area })) } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface DesignImportReport {
|
|
102
|
+
theme: { slug: string; installed: boolean };
|
|
103
|
+
layouts: { matched: string[]; skipped: string[] };
|
|
104
|
+
links: { kept: number; dropped: number };
|
|
105
|
+
nav: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The stage patch for an imported design file, against this site's pages,
|
|
110
|
+
* link targets and installed themes. Layouts land on the page with the same
|
|
111
|
+
* slug (and locale, else the home page for the exported home), replacing any
|
|
112
|
+
* staged layout there; pages without a match are reported, never created.
|
|
113
|
+
* Link refs follow the item at the same URL, else fall back to their href.
|
|
114
|
+
*/
|
|
115
|
+
export function planDesignImport(input: {
|
|
116
|
+
file: ThemeDesignFile; pages: PageRow[]; links: LinkRef[]; installedThemes: string[];
|
|
117
|
+
stagedPages: Pages; includeNav: boolean;
|
|
118
|
+
}): { patch: Record<string, unknown>; report: DesignImportReport } {
|
|
119
|
+
const { file } = input;
|
|
120
|
+
const installed = input.installedThemes.includes(file.activeTheme);
|
|
121
|
+
const refByUrl = new Map(input.links.map((l) => [l.url, l.ref]));
|
|
122
|
+
let kept = 0, dropped = 0;
|
|
123
|
+
const remap = (v: unknown): unknown => {
|
|
124
|
+
if (Array.isArray(v)) return v.map(remap);
|
|
125
|
+
if (!v || typeof v !== 'object') return v;
|
|
126
|
+
const o = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, remap(x)]));
|
|
127
|
+
if (typeof o.ref === 'string') {
|
|
128
|
+
const url = file.refs[o.ref];
|
|
129
|
+
const to = url !== undefined ? refByUrl.get(url) : undefined;
|
|
130
|
+
if (to) { o.ref = to; kept++; } else { delete o.ref; dropped++; }
|
|
131
|
+
}
|
|
132
|
+
return o;
|
|
133
|
+
};
|
|
134
|
+
const find = (k: ThemeDesignFile['layouts'][number]['page']) =>
|
|
135
|
+
input.pages.find((p) => p.slug === k.slug && (p.locale ?? '') === k.locale)
|
|
136
|
+
?? input.pages.find((p) => p.slug === k.slug)
|
|
137
|
+
?? (k.isHome ? input.pages.find((p) => p.isHome && (p.locale ?? '') === k.locale) ?? input.pages.find((p) => p.isHome) : undefined);
|
|
138
|
+
const sectionPages: Pages = { ...input.stagedPages };
|
|
139
|
+
const matched: string[] = [], skipped: string[] = [];
|
|
140
|
+
for (const { page, layout } of file.layouts) {
|
|
141
|
+
const label = page.isHome ? 'Home' : `/${page.slug}`;
|
|
142
|
+
const target = find(page);
|
|
143
|
+
if (!target) { skipped.push(label); continue; }
|
|
144
|
+
sectionPages[target.id] = { ...layout, sections: layout.sections.map((s) => ({ ...s, data: remap(s.data) as typeof s.data })) };
|
|
145
|
+
matched.push(label);
|
|
146
|
+
}
|
|
147
|
+
const patch: Record<string, unknown> = {
|
|
148
|
+
designOverrides: file.designOverrides,
|
|
149
|
+
siteLogo: file.siteLogo, siteLogoDark: file.siteLogoDark, siteFavicon: file.siteFavicon, siteCover: file.siteCover,
|
|
150
|
+
};
|
|
151
|
+
if (installed) patch.activeTheme = file.activeTheme;
|
|
152
|
+
if (matched.length) patch.sectionPages = sectionPages;
|
|
153
|
+
// an empty list never wipes the receiving site's menus
|
|
154
|
+
const nav = input.includeNav && file.navItems?.length ? file.navItems : null;
|
|
155
|
+
if (nav) patch.navItems = nav.map((n) => ({ ...n, id: `tmp-${crypto.randomUUID()}` }));
|
|
156
|
+
return {
|
|
157
|
+
patch,
|
|
158
|
+
report: { theme: { slug: file.activeTheme, installed }, layouts: { matched, skipped }, links: { kept, dropped }, nav: nav ? nav.length : null },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const themeFileName = (slug: string, now: number) =>
|
|
163
|
+
`${slug.replace(/[^a-z0-9-]/gi, '') || 'theme'}-${new Date(now).toISOString().slice(0, 10)}.acms-theme.zip`;
|
package/src/lib/theme.ts
CHANGED
|
@@ -194,6 +194,11 @@ export function listThemes(): Theme[] {
|
|
|
194
194
|
return themes;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/** true for a theme from the project's src/themes/ (its code lives in the site repo) */
|
|
198
|
+
export function isProjectTheme(slug: string): boolean {
|
|
199
|
+
return projectSlugs.has(slug);
|
|
200
|
+
}
|
|
201
|
+
|
|
197
202
|
export function getTheme(slug: string): Theme {
|
|
198
203
|
return themes.find((t) => t.slug === slug) ?? themes.find((t) => t.slug === DEFAULT_THEME) ?? themes[0];
|
|
199
204
|
}
|
package/src/lib/webhooks.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// rejecting stale timestamps.
|
|
12
12
|
import type { ContentRepository } from './repo/types';
|
|
13
13
|
import type { Webhook, WebhookDelivery, WebhookEventType } from './schema';
|
|
14
|
-
import { isPrivateHost } from './net';
|
|
14
|
+
import { isPrivateHost, resolvesToPrivate } from './net';
|
|
15
15
|
|
|
16
16
|
export const MAX_ATTEMPTS = 4;
|
|
17
17
|
// minutes until attempt n+1 (after the first immediate try)
|
|
@@ -82,7 +82,12 @@ export async function attemptDelivery(repo: ContentRepository, d: WebhookDeliver
|
|
|
82
82
|
const claimed = await repo.claimDelivery(d.id, now);
|
|
83
83
|
if (!claimed) return false;
|
|
84
84
|
d = claimed;
|
|
85
|
-
|
|
85
|
+
// the URL passed validateWebhookUrl when saved; re-check what its name
|
|
86
|
+
// resolves to now (dev allows local receivers, as validation does)
|
|
87
|
+
const fetcher: Fetcher = opts.fetcher ?? (async (url, init) => {
|
|
88
|
+
if (!import.meta.env?.DEV && await resolvesToPrivate(new URL(url).hostname)) throw new Error('private host');
|
|
89
|
+
return fetch(url, init);
|
|
90
|
+
});
|
|
86
91
|
const ts = Math.floor(now / 1000);
|
|
87
92
|
let code: number | null = null;
|
|
88
93
|
try {
|
package/src/middleware.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
|
+
import type { APIContext, MiddlewareNext } from 'astro';
|
|
2
3
|
import { I18N } from './lib/i18n';
|
|
3
|
-
import {
|
|
4
|
+
import { bindAuthEnvironment, getRequestAuth, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
|
|
5
|
+
import { AUTH } from './lib/auth/config';
|
|
4
6
|
import { getRepo } from './lib/repo';
|
|
5
7
|
import { CONTENT } from './lib/content';
|
|
6
8
|
import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
|
|
@@ -9,6 +11,8 @@ import { decideOverdue, sendDueReminders } from './lib/booking/service';
|
|
|
9
11
|
import { PUSH } from './lib/push/config';
|
|
10
12
|
import { processPushQueue, runPushJobs } from './lib/push/service';
|
|
11
13
|
import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
|
|
14
|
+
import { applyCachePolicy } from './lib/cache/policy';
|
|
15
|
+
import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
|
|
12
16
|
|
|
13
17
|
// Legacy content-URL redirects, checked only on would-be 404s (below) so they
|
|
14
18
|
// can never shadow real content. Tags moved from `${base}/tag/` to a
|
|
@@ -48,8 +52,16 @@ export function legacyContentRedirect(pathname: string): string | null {
|
|
|
48
52
|
// Node target and local dev.
|
|
49
53
|
let lastPublishCheck = 0;
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
// Every response, early redirects included, leaves through the cache policy
|
|
56
|
+
// (lib/cache/policy.ts): public GET pages and the public app API get a
|
|
57
|
+
// shared-cache header; everything personal or staff-only is private.
|
|
58
|
+
export const onRequest = defineMiddleware(async (context, next) =>
|
|
59
|
+
applyCachePolicy(context.request, await handle(context, next), context.url.pathname));
|
|
60
|
+
|
|
61
|
+
async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
|
|
52
62
|
const { pathname } = context.url;
|
|
63
|
+
bindAuthEnvironment(context.request, context.locals);
|
|
64
|
+
bindExecutionContext(context.request, context.locals);
|
|
53
65
|
|
|
54
66
|
// one canonical URL per page: trailing-slash variants 301 to the bare path.
|
|
55
67
|
// GET pages only — the astro:actions client POSTs to /_actions/<name>/ with
|
|
@@ -77,21 +89,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
const auth = AUTH.provider === 'clerk' && shouldResolveClerk(context.request) ? await getRequestAuth(context.request, context.cookies, context.locals) : null;
|
|
93
|
+
if (auth?.headers.get('location') && context.request.method === 'GET' && !pathname.startsWith('/api/')) {
|
|
94
|
+
const headers = new Headers(auth.headers); headers.set('Cache-Control', 'no-store');
|
|
95
|
+
return new Response(null, { status: 307, headers });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (pathname.startsWith('/api/') && (auth?.error === 'unavailable' || auth?.error === 'configuration')) {
|
|
99
|
+
return withClerkHeaders(Response.json({ error: 'Sign-in is temporarily unavailable. Please retry.' }, { status: 503 }), auth, context.request);
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
// design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
|
|
81
103
|
// ?preview=off) become a cookie, then the clean URL — so every link clicked
|
|
82
104
|
// afterwards stays in preview mode
|
|
83
105
|
const previewTo = await previewParamRedirect(context.url, context.request.method, context.cookies, async () =>
|
|
84
|
-
Boolean(await
|
|
85
|
-
if (previewTo) return context.redirect(previewTo, 302);
|
|
106
|
+
Boolean(await validateRequestSession(context.request, context.cookies, context.locals)));
|
|
107
|
+
if (previewTo) return withClerkHeaders(context.redirect(previewTo, 302), auth, context.request);
|
|
86
108
|
|
|
87
|
-
if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
|
|
88
|
-
|
|
89
|
-
if (!(await
|
|
109
|
+
if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
|
|
110
|
+
if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
|
|
111
|
+
if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
|
|
90
112
|
// editor-app sign-in comes back here after login (see login.astro's next)
|
|
91
113
|
if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
|
|
92
|
-
return context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`);
|
|
114
|
+
return withClerkHeaders(context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`), auth, context.request);
|
|
93
115
|
}
|
|
94
|
-
return context.redirect('/admin/login');
|
|
116
|
+
return withClerkHeaders(context.redirect('/admin/login'), auth, context.request);
|
|
95
117
|
}
|
|
96
118
|
}
|
|
97
119
|
|
|
@@ -121,12 +143,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
121
143
|
const push = runPushJobs(repo, { locals: context.locals, fallbackOrigin: context.url.origin, now })
|
|
122
144
|
.catch((e) => console.error('acms push: scheduled jobs failed', e));
|
|
123
145
|
if (wait) wait(push);
|
|
146
|
+
// a purge that failed or was throttled after an earlier write
|
|
147
|
+
// (Node: detached — the process outlives the request)
|
|
148
|
+
const purge = syncCachePurge(repo, context.locals).catch((e) => console.error('acms cache: purge failed', e));
|
|
149
|
+
if (wait) wait(purge);
|
|
124
150
|
} catch {
|
|
125
151
|
// never let the publish sweep break a request
|
|
126
152
|
}
|
|
127
153
|
}
|
|
128
154
|
|
|
129
|
-
const response = await next();
|
|
155
|
+
const response = withClerkHeaders(await next(), auth, context.request);
|
|
130
156
|
|
|
131
157
|
// Database writes enqueue publication/media notifications atomically.
|
|
132
158
|
// Flush after mutations (including Astro actions), once their transaction
|
|
@@ -142,6 +168,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
142
168
|
.catch((e) => console.error('acms push: queue flush failed', e));
|
|
143
169
|
if (wait) wait(push);
|
|
144
170
|
}
|
|
171
|
+
// content changed → clear shared caches (the scheduler retries failures)
|
|
172
|
+
const purge = getRepo(context.locals).then(repo => syncCachePurge(repo, context.locals))
|
|
173
|
+
.catch((e) => console.error('acms cache: purge failed', e));
|
|
174
|
+
if (wait) wait(purge);
|
|
145
175
|
}
|
|
146
176
|
|
|
147
177
|
// a preview render must never be cached where a visitor could get it
|
|
@@ -169,7 +199,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
169
199
|
}
|
|
170
200
|
|
|
171
201
|
return response;
|
|
172
|
-
}
|
|
202
|
+
}
|
|
173
203
|
|
|
174
204
|
let redirectCache: { at: number; map: Map<string, { toPath: string; code: 301 | 302 }> } | null = null;
|
|
175
205
|
|
|
@@ -3,7 +3,7 @@ import ThemeSections from '../components/ThemeSections.astro';
|
|
|
3
3
|
import Site from '../layouts/Site.astro';
|
|
4
4
|
import WidgetRenderer from '../components/WidgetRenderer.astro';
|
|
5
5
|
import { getRepo } from '../lib/repo';
|
|
6
|
-
import {
|
|
6
|
+
import { validateRequestSession } from '../lib/auth/request';
|
|
7
7
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
8
8
|
import { highlightCodeHtml } from '../lib/highlightCode';
|
|
9
9
|
import { resolveDesign } from '../lib/theme';
|
|
@@ -18,7 +18,7 @@ const rawPage = slug ? await repo.getPageBySlug(slug) : null;
|
|
|
18
18
|
const page = rawPage ? localizeDoc(rawPage, locale) : null;
|
|
19
19
|
|
|
20
20
|
// Drafts are visible only to a logged-in admin (doubles as draft preview).
|
|
21
|
-
const isAdmin = (await
|
|
21
|
+
const isAdmin = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
22
22
|
if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
23
23
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
24
24
|
}
|
|
@@ -37,6 +37,7 @@ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !=
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
39
|
<Site
|
|
40
|
+
staffPreview={!!isAdmin && page.status !== 'published'}
|
|
40
41
|
title={page.seoTitle || page.title}
|
|
41
42
|
exactTitle={Boolean(page.seoTitle)}
|
|
42
43
|
description={page.seoDesc || undefined}
|
|
@@ -5,10 +5,16 @@ import { sha256 } from '../../lib/email/config';
|
|
|
5
5
|
import { dispatchEmail, kickEmail } from '../../lib/email/service';
|
|
6
6
|
import { waitUntilFrom } from '../../lib/webhooks';
|
|
7
7
|
import { SESSION_COOKIE } from '../../lib/auth';
|
|
8
|
+
import { AUTH } from '../../lib/auth/config';
|
|
8
9
|
import '../../styles/admin.css';
|
|
9
10
|
const action=Astro.params.action||'';
|
|
10
11
|
if(!['invite','reset','forgot'].includes(action))return new Response('Not found',{status:404});
|
|
11
12
|
Astro.response.headers.set('Cache-Control','no-store');
|
|
13
|
+
// Clerk handles invitations and recovery; local links are never redeemed.
|
|
14
|
+
if(AUTH.provider==='clerk'){
|
|
15
|
+
if(Astro.request.method!=='GET'&&Astro.request.method!=='HEAD')return new Response('Account access is managed by the sign-in provider.',{status:403,headers:{'Cache-Control':'no-store'}});
|
|
16
|
+
return new Response(null,{status:303,headers:{Location:'/admin/login','Cache-Control':'no-store','Referrer-Policy':'no-referrer'}});
|
|
17
|
+
}
|
|
12
18
|
Astro.response.headers.set('Referrer-Policy','no-referrer');
|
|
13
19
|
Astro.response.headers.set('X-Robots-Tag','noindex');
|
|
14
20
|
const repo=await getRepo(Astro.locals);
|
|
@@ -7,7 +7,10 @@ import { roleLabels } from '../../lib/roles';
|
|
|
7
7
|
// return address and the PKCE challenge, then returns to the app.
|
|
8
8
|
import { getRepo } from '../../lib/repo';
|
|
9
9
|
import { BRAND } from '../../lib/brand';
|
|
10
|
-
import {
|
|
10
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
11
|
+
import { AUTH } from '../../lib/auth/config';
|
|
12
|
+
import { getRequestAuth, requestSessionBinding } from '../../lib/auth/request';
|
|
13
|
+
import ClerkSession from '../../components/auth/ClerkSession.astro';
|
|
11
14
|
import { EDITOR } from '../../lib/editor/config';
|
|
12
15
|
import { checkAuthorize, checkConsentTicket, consentTicket, issueCode, redirectWith } from '../../lib/editor/auth';
|
|
13
16
|
import { bookingsEnabled } from '../../lib/calendar/config';
|
|
@@ -26,8 +29,10 @@ if (!EDITOR.enabled) return new Response(null, { status: 404 });
|
|
|
26
29
|
|
|
27
30
|
const leave = (to: string) => new Response(null, { status: 303, headers: { Location: to, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } });
|
|
28
31
|
const repo = await getRepo(Astro.locals);
|
|
29
|
-
const
|
|
30
|
-
const userId =
|
|
32
|
+
const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
|
|
33
|
+
const userId = auth.userId;
|
|
34
|
+
// consent tickets are bound to this browser session (built-in cookie or Clerk session)
|
|
35
|
+
const session = await requestSessionBinding(Astro.request, Astro.cookies, Astro.locals);
|
|
31
36
|
if (!userId) return Astro.redirect(`/admin/login?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
|
|
32
37
|
|
|
33
38
|
const check = checkAuthorize(Astro.url.searchParams);
|
|
@@ -41,7 +46,10 @@ const envOwner = userId === OWNER_ENV;
|
|
|
41
46
|
const user = envOwner ? null : await repo.getUser(userId);
|
|
42
47
|
const settings = await repo.getSettings();
|
|
43
48
|
const appName = EDITOR.name || params?.clientId || 'An app';
|
|
44
|
-
const
|
|
49
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
50
|
+
const switchUrl = `/admin/logout?next=${encodeURIComponent(here)}`;
|
|
51
|
+
// Clerk sign-out only accepts a same-origin POST; built-in sign-out is a link
|
|
52
|
+
const postLogout = AUTH.provider === 'clerk';
|
|
45
53
|
|
|
46
54
|
let error = '';
|
|
47
55
|
if (Astro.request.method === 'POST' && params && user) {
|
|
@@ -56,7 +64,7 @@ if (Astro.request.method === 'POST' && params && user) {
|
|
|
56
64
|
const version = await repo.email.sessionVersion(user.id);
|
|
57
65
|
if (version === null) error = 'Your account changed. Sign in again.';
|
|
58
66
|
else {
|
|
59
|
-
const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''));
|
|
67
|
+
const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''), Date.now(), auth.clerk ?? undefined);
|
|
60
68
|
return leave(redirectWith(params.redirectUri, { code, state: params.state, iss }));
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -85,8 +93,10 @@ const can = [
|
|
|
85
93
|
<title>Sign in to {appName} · {settings.siteTitle}</title>
|
|
86
94
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
87
95
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
96
|
+
<ClerkSession keepAlive signedOutUrl={`/admin/login?next=${encodeURIComponent(here)}`} />
|
|
88
97
|
</head>
|
|
89
98
|
<body>
|
|
99
|
+
{postLogout && <form id="switch-account" method="post" action={switchUrl} hidden></form>}
|
|
90
100
|
<main class="login-wrap consent-wrap">
|
|
91
101
|
<div class="card consent-card">
|
|
92
102
|
<div class="consent-site">
|
|
@@ -110,7 +120,9 @@ const can = [
|
|
|
110
120
|
<div class="consent-body">
|
|
111
121
|
<h1>Use your own account</h1>
|
|
112
122
|
<p class="consent-lead"><b>{appName}</b> signs in as a person, so each phone can be signed out on its own. You’re signed in with the owner password, which has no account to connect.</p>
|
|
113
|
-
|
|
123
|
+
{postLogout
|
|
124
|
+
? <button class="btn primary block" type="submit" form="switch-account">Sign in with an account</button>
|
|
125
|
+
: <a class="btn primary block" href={switchUrl}>Sign in with an account</a>}
|
|
114
126
|
<p class="hint consent-foot">Owners create accounts in Users.</p>
|
|
115
127
|
</div>
|
|
116
128
|
)}
|
|
@@ -150,7 +162,9 @@ const can = [
|
|
|
150
162
|
<div><dt>Returns to</dt><dd><code>{returnTo}</code></dd></div>
|
|
151
163
|
<div><dt>App ID</dt><dd><code>{params.clientId}</code></dd></div>
|
|
152
164
|
</dl>
|
|
153
|
-
<p class="hint consent-foot">Not you?
|
|
165
|
+
<p class="hint consent-foot">Not you? {postLogout
|
|
166
|
+
? <button class="linklike" type="submit" form="switch-account">Switch account</button>
|
|
167
|
+
: <a href={switchUrl}>Switch account</a>}</p>
|
|
154
168
|
</form>
|
|
155
169
|
)}
|
|
156
170
|
</div>
|
|
@@ -80,6 +80,7 @@ if (Astro.request.method === 'POST') {
|
|
|
80
80
|
minNotice: int(form.get('minNoticeH'), 0, 24 * 60, 2) * 60,
|
|
81
81
|
maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
|
|
82
82
|
perDayCap: int(form.get('perDayCap'), 0, 100, 0),
|
|
83
|
+
changeCutoff: int(form.get('changeCutoff'), 0, 8760, 0),
|
|
83
84
|
hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
|
|
84
85
|
approval, approvalRules,
|
|
85
86
|
// legacy flag for older API readers; approval is what decides
|
|
@@ -183,6 +184,7 @@ const effective = linkApproval(view, settings).mode;
|
|
|
183
184
|
<label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={view.perDayCap} /></label>
|
|
184
185
|
<label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(view.minNotice / 60)} /></label>
|
|
185
186
|
<label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={view.maxAdvance} /></label>
|
|
187
|
+
<label class="field"><span>Guests can change until (hours before) <em class="hint">0 = any time</em></span><input type="number" name="changeCutoff" min="0" max="8760" value={view.changeCutoff} /></label>
|
|
186
188
|
</div>
|
|
187
189
|
</section>
|
|
188
190
|
|
|
@@ -3,7 +3,7 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { env } from '../../lib/env';
|
|
5
5
|
import DesignManager from '../../components/DesignManager';
|
|
6
|
-
import { listThemes, FONT_PAIRINGS, getTheme } from '../../lib/theme';
|
|
6
|
+
import { listThemes, FONT_PAIRINGS, getTheme, isProjectTheme } from '../../lib/theme';
|
|
7
7
|
import { diffDesign } from '../../lib/designDiff';
|
|
8
8
|
import { mergeDesignDraft } from '../../lib/drafts';
|
|
9
9
|
import { postUrl } from '../../lib/content';
|
|
@@ -31,6 +31,7 @@ const themes = listThemes().map((t) => ({
|
|
|
31
31
|
version: t.version,
|
|
32
32
|
templateCount: Number(Boolean(t.HomeTemplate)) + Number(Boolean(t.EventTemplate)) + Object.keys(t.postTemplates).length + Object.keys(t.pageTemplates).length,
|
|
33
33
|
sectionCount: Object.keys(t.sections).length,
|
|
34
|
+
project: isProjectTheme(t.slug),
|
|
34
35
|
}));
|
|
35
36
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
36
37
|
---
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../layouts/Admin.astro';
|
|
3
3
|
import ImportExport from '../../components/import/ImportExport';
|
|
4
|
+
import { AUTH } from '../../lib/auth/config';
|
|
4
5
|
import { getRepo } from '../../lib/repo';
|
|
5
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
6
8
|
|
|
7
9
|
// owner-only: an import can create accounts and change site settings
|
|
8
10
|
const repo = await getRepo(Astro.locals);
|
|
9
|
-
const sessionUserId = await
|
|
11
|
+
const sessionUserId = await validateRequestSession(Astro.request, Astro.cookies, Astro.locals);
|
|
10
12
|
const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
11
13
|
if (sessionUserId !== OWNER_ENV && sessionUser?.role !== 'owner') return Astro.redirect('/admin/settings');
|
|
12
14
|
const tab = Astro.url.searchParams.get('tab');
|
|
13
15
|
---
|
|
14
16
|
<Admin title="Import & export" section="settings" wide>
|
|
15
|
-
<ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} />
|
|
17
|
+
<ImportExport client:load initialTab={tab === 'export' || tab === 'history' ? tab : 'import'} clerk={AUTH.provider === 'clerk'} />
|
|
16
18
|
</Admin>
|
|
@@ -3,13 +3,14 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { userConfig } from 'virtual:acms-config';
|
|
5
5
|
import { CONTENT } from '../../lib/content';
|
|
6
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
7
8
|
import { getTraffic, umamiReadEnabled } from '../../lib/umami';
|
|
8
9
|
import { collectionUrl } from '../../lib/content';
|
|
9
10
|
|
|
10
11
|
const coreVersion = userConfig.coreVersion;
|
|
11
12
|
const repo = await getRepo(Astro.locals);
|
|
12
|
-
const sessionUserId = (await
|
|
13
|
+
const sessionUserId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
13
14
|
const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
|
|
14
15
|
repo.getStats(),
|
|
15
16
|
repo.listPages(),
|
|
@@ -3,7 +3,8 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import Modal from '../../components/Modal.astro';
|
|
4
4
|
import { getRepo } from '../../lib/repo';
|
|
5
5
|
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
6
|
-
import {
|
|
6
|
+
import { OWNER_ENV } from '../../lib/auth';
|
|
7
|
+
import { validateRequestSession } from '../../lib/auth/request';
|
|
7
8
|
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
9
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
10
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
@@ -15,7 +16,7 @@ import { PROVIDERS } from '../../lib/maps/providers';
|
|
|
15
16
|
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
16
17
|
// calendar-specific feeds and API help appear only when enabled.
|
|
17
18
|
const repo = await getRepo(Astro.locals);
|
|
18
|
-
const sessionId = (await
|
|
19
|
+
const sessionId = (await validateRequestSession(Astro.request, Astro.cookies, Astro.locals));
|
|
19
20
|
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
20
21
|
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
21
22
|
const settings = await repo.getSettings();
|