@acms-subir/core 0.3.10 → 0.3.12
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 +24 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +2 -1
- package/cli/main.mjs +11 -3
- package/cli/migrate.mjs +102 -0
- package/cli/migrations.generated.mjs +1044 -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/theme-authoring.md +31 -0
- package/integration.mjs +39 -0
- package/migrate.mjs +5 -0
- package/package.json +9 -11
- package/src/actions/index.ts +5 -5
- package/src/components/DesignManager.tsx +29 -10
- package/src/components/EmailSettings.tsx +53 -11
- 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 +40 -9
- package/src/layouts/Site.astro +6 -1
- package/src/lib/api.ts +6 -3
- 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/offsite.ts +173 -0
- package/src/lib/backup/store.ts +28 -0
- 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/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/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 +1 -0
- package/src/lib/email/templates.ts +2 -1
- package/src/lib/email/types.ts +1 -0
- package/src/lib/mobile/http.ts +4 -3
- package/src/lib/mobile/route.ts +41 -4
- package/src/lib/net.ts +82 -14
- package/src/lib/repo/migrate-runner.ts +96 -0
- package/src/lib/repo/migrations.ts +76 -0
- package/src/lib/repo/sql.ts +56 -24
- package/src/lib/repo/sqlite.ts +14 -1
- package/src/lib/repo/types.ts +12 -1
- package/src/lib/scheduler.ts +7 -0
- 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/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/settings.astro +31 -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/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/push/admin.ts +3 -2
- package/src/pages/api/restore.ts +6 -3
- package/src/pages/api/v1/calendars.ts +1 -1
- package/src/pages/api/v1/links.ts +1 -1
- 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/theme.ts +1 -1
- package/src/pages/blog/[slug].astro +3 -2
- package/src/pages/events/[slug].astro +3 -2
- package/src/pages/preview/[type]/[id].astro +3 -3
- package/src/styles/admin.css +103 -16
- package/src/styles/email-settings.css +6 -0
- package/src/theme-sdk/calendar.ts +48 -0
- package/src/theme-sdk/index.ts +2 -0
- package/src/virtual.d.ts +1 -0
- package/src/worker.ts +7 -1
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { AUTH } from '../auth/config';
|
|
2
|
+
import { AuthStore } from '../auth/store';
|
|
1
3
|
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
2
4
|
import { GuestStore } from '../mobile/guest-store';
|
|
3
5
|
import { LiveStore } from '../live/store';
|
|
4
6
|
import { PushStore } from '../push/store';
|
|
5
7
|
import { EditorStore } from '../editor/store';
|
|
8
|
+
import { BackupStore } from '../backup/store';
|
|
6
9
|
import { LiveConfig } from '../live/types';
|
|
7
10
|
import { ApiError } from '../api';
|
|
8
11
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
@@ -11,7 +14,7 @@ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings,
|
|
|
11
14
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
12
15
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
13
16
|
import { cleanRrule } from '../calendar/rrule';
|
|
14
|
-
import {
|
|
17
|
+
import { runMigrations } from './migrate-runner';
|
|
15
18
|
import { htmlToPlaintext } from '../plaintext';
|
|
16
19
|
import { SEED } from '../brand';
|
|
17
20
|
import { CONTENT, postsIndexUrl } from '../content';
|
|
@@ -258,12 +261,14 @@ function safeSnippet(raw: unknown): string {
|
|
|
258
261
|
export class SqlRepository implements ContentRepository {
|
|
259
262
|
private driver!: SqlDriver;
|
|
260
263
|
private ready: Promise<void>;
|
|
264
|
+
readonly auth: AuthStore;
|
|
261
265
|
readonly email: EmailStore;
|
|
262
266
|
readonly forms: FormStore;
|
|
263
267
|
readonly guests: GuestStore;
|
|
264
268
|
readonly live: LiveStore;
|
|
265
269
|
readonly push: PushStore;
|
|
266
270
|
readonly editor: EditorStore;
|
|
271
|
+
readonly backups: BackupStore;
|
|
267
272
|
|
|
268
273
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
269
274
|
this.ready = this.init(driver);
|
|
@@ -272,6 +277,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
272
277
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
273
278
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
274
279
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
280
|
+
this.backups = new BackupStore(async () => { await this.ready; return this.driver; });
|
|
281
|
+
this.auth = new AuthStore(async () => { await this.ready; return this.driver; });
|
|
275
282
|
this.email = new EmailStore(async () => { await this.ready; return this.driver; });
|
|
276
283
|
}
|
|
277
284
|
|
|
@@ -287,27 +294,11 @@ export class SqlRepository implements ContentRepository {
|
|
|
287
294
|
}
|
|
288
295
|
|
|
289
296
|
private async migrate() {
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
let applied = Number(r?.v ?? 0);
|
|
293
|
-
if (applied === 0) {
|
|
294
|
-
// Pre-migrations DB: the baseline tables already exist, mark v1 applied.
|
|
295
|
-
const baseline = await this.first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'");
|
|
296
|
-
if (baseline) {
|
|
297
|
-
await this.driver.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [Date.now()]);
|
|
298
|
-
applied = 1;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
const pending = MIGRATIONS.filter((m) => m.version > applied);
|
|
302
|
-
for (const m of pending) {
|
|
303
|
-
await this.driver.batch([
|
|
304
|
-
...m.statements.map((sql) => ({ sql })),
|
|
305
|
-
{ sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
|
|
306
|
-
]);
|
|
307
|
-
}
|
|
297
|
+
// under the shared migration lock (also used by `acms migrate`)
|
|
298
|
+
const { applied } = await runMigrations(this.driver);
|
|
308
299
|
// v21 introduced the search index; plaintext can't be derived in SQL, so
|
|
309
300
|
// the backfill runs here once, right after that version lands.
|
|
310
|
-
if (
|
|
301
|
+
if (applied.includes(21)) await this.reindexSearch();
|
|
311
302
|
}
|
|
312
303
|
|
|
313
304
|
private async seed() {
|
|
@@ -320,7 +311,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
320
311
|
{ id: uuid(), type: 'text', config: { markdown: 'This home page was built in the **widget editor** — open `/admin` to change it.' } },
|
|
321
312
|
];
|
|
322
313
|
await this.driver.batch([
|
|
323
|
-
|
|
314
|
+
// random start: see migration 49
|
|
315
|
+
{ sql: 'INSERT INTO settings (id, site_title, tagline, content_version) VALUES (1, ?, ?, abs(random() % 1000000000))', args: [SEED.siteTitle, SEED.tagline] },
|
|
324
316
|
{
|
|
325
317
|
sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
|
326
318
|
args: [uuid(), 'home', 'Home', 'published', JSON.stringify(homeBlocks), now, now],
|
|
@@ -1559,6 +1551,23 @@ export class SqlRepository implements ContentRepository {
|
|
|
1559
1551
|
await this.driver.run('DELETE FROM snippets WHERE id = ?', [id]);
|
|
1560
1552
|
}
|
|
1561
1553
|
|
|
1554
|
+
private ownerGuard(id: string): SqlStatement[] {
|
|
1555
|
+
if (AUTH.provider !== 'clerk') return [];
|
|
1556
|
+
// Execute in the same write transaction as demotion/deletion: concurrent
|
|
1557
|
+
// owners cannot each remove the other and leave the site without one.
|
|
1558
|
+
return [{ sql: `INSERT INTO auth_owner_checks(allowed) SELECT 0 FROM users target
|
|
1559
|
+
WHERE target.id=? AND target.role='owner' AND EXISTS (
|
|
1560
|
+
SELECT 1 FROM auth_identities a WHERE a.user_id=target.id AND a.disabled=0 AND NOT EXISTS (
|
|
1561
|
+
SELECT 1 FROM users other JOIN auth_identities b ON b.user_id=other.id
|
|
1562
|
+
WHERE other.id<>target.id AND other.role='owner' AND b.disabled=0 AND b.issuer=a.issuer))`, args: [id] }];
|
|
1563
|
+
}
|
|
1564
|
+
private async guardedUserWrite(statements: SqlStatement[]) {
|
|
1565
|
+
try { await this.driver.batch(statements); }
|
|
1566
|
+
catch (error) {
|
|
1567
|
+
if (String(error).includes('keep_clerk_owner')) throw new Error('Keep at least one linked owner. Add another owner before removing or demoting this account.');
|
|
1568
|
+
throw error;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1562
1571
|
// ---- users ----
|
|
1563
1572
|
async listUsers() {
|
|
1564
1573
|
await this.ready;
|
|
@@ -1592,16 +1601,28 @@ export class SqlRepository implements ContentRepository {
|
|
|
1592
1601
|
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
1593
1602
|
if (!sets.length) return;
|
|
1594
1603
|
args.push(id);
|
|
1595
|
-
await this.
|
|
1604
|
+
await this.guardedUserWrite([
|
|
1605
|
+
...(data.role !== undefined && data.role !== 'owner' ? this.ownerGuard(id) : []),
|
|
1596
1606
|
{sql:`UPDATE users SET ${sets.join(', ')} WHERE id = ?`,args},
|
|
1597
1607
|
...(data.passHash === undefined ? [] : [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
|
|
1598
1608
|
SELECT ?,?,'password-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=?`,
|
|
1599
1609
|
args:[crypto.randomUUID(),crypto.randomUUID(),Date.now(),Date.now(),Date.now(),id]}]),
|
|
1600
1610
|
]);
|
|
1601
1611
|
}
|
|
1612
|
+
async changeUserEmail(id: string, email: string, { signOut, notify }: { signOut: boolean; notify: boolean }) {
|
|
1613
|
+
await this.ready;
|
|
1614
|
+
const now = Date.now();
|
|
1615
|
+
// the notice is queued before the update so it reads the previous address
|
|
1616
|
+
await this.driver.batch([
|
|
1617
|
+
...(notify ? [{sql:`INSERT INTO email_jobs(id,dedupe,kind,recipient,payload,config_version,next_at,created_at,updated_at)
|
|
1618
|
+
SELECT ?,?,'email-changed',email,'{}',COALESCE((SELECT MAX(version) FROM email_configs),0),?,?,? FROM users WHERE id=? AND email<>?`,
|
|
1619
|
+
args:[crypto.randomUUID(),crypto.randomUUID(),now,now,now,id,email.toLowerCase()]}] : []),
|
|
1620
|
+
{sql:`UPDATE users SET email = ?${signOut ? ', session_version = session_version + 1' : ''} WHERE id = ?`,args:[email.toLowerCase(),id]},
|
|
1621
|
+
]);
|
|
1622
|
+
}
|
|
1602
1623
|
async deleteUser(id: string) {
|
|
1603
1624
|
await this.ready;
|
|
1604
|
-
await this.
|
|
1625
|
+
await this.guardedUserWrite([...this.ownerGuard(id), { sql: 'DELETE FROM users WHERE id = ?', args: [id] }]);
|
|
1605
1626
|
}
|
|
1606
1627
|
async getUserAuth(email: string) {
|
|
1607
1628
|
await this.ready;
|
|
@@ -1735,12 +1756,13 @@ export class SqlRepository implements ContentRepository {
|
|
|
1735
1756
|
}
|
|
1736
1757
|
|
|
1737
1758
|
// ---- restore ----
|
|
1738
|
-
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
|
|
1759
|
+
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0], authRecovery?: { issuer: string; subject: string; userId: string }) {
|
|
1739
1760
|
await this.ready;
|
|
1740
1761
|
const stmts: SqlStatement[] = [
|
|
1741
1762
|
// Suppression and re-enabling share the restore transaction. A failed
|
|
1742
1763
|
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1743
1764
|
{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
|
|
1765
|
+
{ sql: "UPDATE auth_invitations SET state='revoked' WHERE state<>'accepted'" },
|
|
1744
1766
|
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
|
|
1745
1767
|
sql: `DELETE FROM ${t}`,
|
|
1746
1768
|
})),
|
|
@@ -1816,6 +1838,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1816
1838
|
args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
|
|
1817
1839
|
})),
|
|
1818
1840
|
];
|
|
1841
|
+
if (authRecovery) stmts.push({ sql: `UPDATE auth_identities SET disabled=0 WHERE issuer=? AND subject=? AND user_id=? AND EXISTS(SELECT 1 FROM users WHERE id=? AND role='owner')`, args: [authRecovery.issuer, authRecovery.subject, authRecovery.userId, authRecovery.userId] });
|
|
1819
1842
|
const settingsWrite = this.settingsStatement({ ...content.settings, live: { ...LiveConfig.parse(content.settings.live ?? {}), mode: 'off', manual: { watchUrl: '', title: 'We’re live', until: 0 } } });
|
|
1820
1843
|
stmts.push({ sql: "UPDATE live_connections SET enabled=0,tested_at=0,revision=revision+1,lease='',lease_until=0,snapshot=NULL,upcoming=NULL,error='Restored content. Test this connection before enabling automatic checks.'" }, { sql: 'DELETE FROM live_oauth_states' });
|
|
1821
1844
|
if (settingsWrite) stmts.push(settingsWrite);
|
|
@@ -1995,6 +2018,15 @@ export class SqlRepository implements ContentRepository {
|
|
|
1995
2018
|
await this.ready;
|
|
1996
2019
|
await this.driver.run('UPDATE settings SET mobile_access = json_patch(mobile_access, ?) WHERE id = 1', [JSON.stringify(patch)]);
|
|
1997
2020
|
}
|
|
2021
|
+
async getCacheState() {
|
|
2022
|
+
await this.ready;
|
|
2023
|
+
const r = await this.first('SELECT content_version, cache_purged_version FROM settings WHERE id = 1');
|
|
2024
|
+
return { version: Number(r?.content_version ?? 0), purged: Number(r?.cache_purged_version ?? 0) };
|
|
2025
|
+
}
|
|
2026
|
+
async markCachePurged(version: number) {
|
|
2027
|
+
await this.ready;
|
|
2028
|
+
await this.driver.run('UPDATE settings SET cache_purged_version = MAX(cache_purged_version, ?) WHERE id = 1', [version]);
|
|
2029
|
+
}
|
|
1998
2030
|
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
1999
2031
|
const columns: Record<string, string> = {
|
|
2000
2032
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
package/src/lib/repo/sqlite.ts
CHANGED
|
@@ -39,7 +39,20 @@ export class SqliteRepository extends SqlRepository {
|
|
|
39
39
|
}
|
|
40
40
|
// remote libSQL (Turso, sqld) needs a token; local files don't
|
|
41
41
|
const authToken = env('SQLITE_AUTH_TOKEN', '');
|
|
42
|
-
|
|
42
|
+
const client = createClient(authToken ? { url, authToken } : { url });
|
|
43
|
+
if (url.startsWith('file:')) await tuneLocalFile(client);
|
|
44
|
+
return new LibsqlDriver(client);
|
|
43
45
|
});
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
// Local files only (remote libSQL/Turso manage their own journal): WAL lets
|
|
50
|
+
// page reads continue while a write commits, busy_timeout waits out a
|
|
51
|
+
// concurrent writer (the scheduler, a second process) instead of failing
|
|
52
|
+
// with SQLITE_BUSY, and synchronous=NORMAL is the safe pairing for WAL.
|
|
53
|
+
// File-copy backups must take acms.db, acms.db-wal and acms.db-shm together.
|
|
54
|
+
export async function tuneLocalFile(client: Pick<Client, 'execute'>) {
|
|
55
|
+
await client.execute('PRAGMA journal_mode=WAL');
|
|
56
|
+
await client.execute('PRAGMA busy_timeout=5000');
|
|
57
|
+
await client.execute('PRAGMA synchronous=NORMAL');
|
|
58
|
+
}
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { AuthStore } from '../auth/store';
|
|
1
2
|
import type { FormStore } from '../forms/store';
|
|
3
|
+
import type { BackupStore } from '../backup/store';
|
|
2
4
|
import type { GuestStore } from '../mobile/guest-store';
|
|
3
5
|
import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
4
6
|
import type { LiveStore } from '../live/store';
|
|
@@ -50,8 +52,10 @@ export type EventUpdate = Partial<
|
|
|
50
52
|
// The single contract both backends implement. The app only ever imports
|
|
51
53
|
// this — never a concrete adapter.
|
|
52
54
|
export interface ContentRepository {
|
|
55
|
+
readonly auth: AuthStore;
|
|
53
56
|
readonly email: EmailStore;
|
|
54
57
|
readonly forms: FormStore;
|
|
58
|
+
readonly backups: BackupStore;
|
|
55
59
|
readonly guests: GuestStore;
|
|
56
60
|
readonly live: LiveStore;
|
|
57
61
|
readonly push: PushStore;
|
|
@@ -258,6 +262,9 @@ export interface ContentRepository {
|
|
|
258
262
|
getUser(id: string): Promise<User | null>;
|
|
259
263
|
createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
|
|
260
264
|
updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
|
|
265
|
+
/** Replace a user's email. `signOut` ends their sessions (the email is their
|
|
266
|
+
* login); `notify` queues an `email-changed` notice to the previous address. */
|
|
267
|
+
changeUserEmail(id: string, email: string, opts: { signOut: boolean; notify: boolean }): Promise<void>;
|
|
261
268
|
deleteUser(id: string): Promise<void>;
|
|
262
269
|
getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
|
|
263
270
|
|
|
@@ -300,7 +307,7 @@ export interface ContentRepository {
|
|
|
300
307
|
places?: Place[];
|
|
301
308
|
forms?: SavedForm[];
|
|
302
309
|
formSubmissions?: FormSubmission[];
|
|
303
|
-
}): Promise<void>;
|
|
310
|
+
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
304
311
|
|
|
305
312
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
306
313
|
// id-preserving bulk write for imported posts/pages: original timestamps
|
|
@@ -327,6 +334,10 @@ export interface ContentRepository {
|
|
|
327
334
|
updateSettings(data: Partial<Settings>): Promise<void>;
|
|
328
335
|
/** merges just these App access fields in one statement, so concurrent saves don't undo each other */
|
|
329
336
|
patchMobileAccess(patch: Partial<Settings['mobileAccess']>): Promise<void>;
|
|
337
|
+
/** public cache: version = bumped by triggers on every visible change; purged = last version cleared from caches */
|
|
338
|
+
getCacheState(): Promise<{ version: number; purged: number }>;
|
|
339
|
+
/** record that caches were cleared up to `version` (never moves backwards) */
|
|
340
|
+
markCachePurged(version: number): Promise<void>;
|
|
330
341
|
// Design-screen staging: save stages into settings.draftDesign, apply
|
|
331
342
|
// copies staged → live design fields and clears, discard just clears
|
|
332
343
|
saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { decideOverdue, sendDueReminders } from './booking/service';
|
|
|
6
6
|
import { runPushJobs } from './push/service';
|
|
7
7
|
import { GUEST_IDLE_MS } from './mobile/guest-store';
|
|
8
8
|
import { pushHeartbeat, pushUrlFor } from './monitoring';
|
|
9
|
+
import { syncCachePurge } from './cache/purge';
|
|
10
|
+
import { runOffsiteBackup } from './backup/offsite';
|
|
9
11
|
|
|
10
12
|
// All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
|
|
11
13
|
// this heartbeat: monitoring must prove the independent scheduler ran.
|
|
@@ -24,6 +26,11 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
|
|
|
24
26
|
push: () => runPushJobs(repo, { locals, now: startedAt }),
|
|
25
27
|
// expired guest sign-in codes and app sessions unused for 90 days
|
|
26
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),
|
|
27
34
|
};
|
|
28
35
|
const results: Record<string, { ok: boolean; count?: number }> = {};
|
|
29
36
|
for (const [name, run] of Object.entries(jobs)) {
|
|
@@ -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);
|