@acms-subir/core 0.3.16 → 0.3.18
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 +16 -0
- package/cli/common.mjs +2 -2
- package/cli/main.mjs +10 -3
- package/cli/migrations.generated.mjs +71 -1
- package/cli/remote.mjs +1 -1
- package/cli/sync.mjs +876 -0
- package/docs/environment-sync.md +302 -0
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/section-library/upcoming-events/UpcomingEvents.astro +26 -17
- package/section-library/upcoming-events/section.json +41 -0
- package/src/actions/index.ts +5 -1
- package/src/components/CalendarFallbackImage.tsx +18 -0
- package/src/components/DesignAccess.tsx +3 -0
- package/src/components/EventPage.astro +4 -2
- package/src/components/LinksManager.tsx +86 -55
- package/src/components/ThemeSectionsEditor.tsx +13 -1
- package/src/layouts/Site.astro +1 -0
- package/src/lib/calendar/cards.ts +4 -2
- package/src/lib/calendar/images.ts +17 -0
- package/src/lib/editor/schemas.ts +2 -2
- package/src/lib/editor/site-data.ts +5 -3
- package/src/lib/links-avatar.ts +10 -0
- package/src/lib/mobile/links.ts +4 -0
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +26 -0
- package/src/lib/repo/sql.ts +8 -3
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +14 -0
- package/src/lib/sync/auth.ts +24 -0
- package/src/lib/sync/catalog.ts +250 -0
- package/src/lib/sync/migration.ts +51 -0
- package/src/lib/sync/protocol.mjs +125 -0
- package/src/lib/sync/service.ts +994 -0
- package/src/lib/sync/store.ts +349 -0
- package/src/pages/admin/calendar/settings.astro +21 -2
- package/src/pages/admin/integrations.astro +2 -0
- package/src/pages/admin/links.astro +1 -1
- package/src/pages/api/agent/docs.ts +2 -0
- package/src/pages/api/design/keys.ts +7 -3
- package/src/pages/api/sync/index.ts +114 -0
- package/src/pages/api/sync/media.ts +90 -0
- package/src/pages/links.astro +13 -2
- package/src/styles/admin.css +27 -36
- package/src/styles/page-transitions.css +27 -0
- package/src/styles/site.css +3 -1
- package/src/styles/theme-editor.css +47 -12
- package/src/theme-sdk/index.ts +1 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { SqlDriver, SqlStatement } from "../repo/sql";
|
|
2
|
+
import type { MediaStore } from "../media/store";
|
|
3
|
+
import { ApiError } from "../api";
|
|
4
|
+
import {
|
|
5
|
+
canonical,
|
|
6
|
+
hash,
|
|
7
|
+
reference,
|
|
8
|
+
transform,
|
|
9
|
+
PROTOCOL,
|
|
10
|
+
} from "./protocol.mjs";
|
|
11
|
+
import {
|
|
12
|
+
rowData,
|
|
13
|
+
effective,
|
|
14
|
+
displayRef,
|
|
15
|
+
matchKey,
|
|
16
|
+
tables,
|
|
17
|
+
parseData,
|
|
18
|
+
type Data,
|
|
19
|
+
type Kind,
|
|
20
|
+
} from "./catalog";
|
|
21
|
+
|
|
22
|
+
export interface Entity {
|
|
23
|
+
key: string;
|
|
24
|
+
aliases: string[];
|
|
25
|
+
id: string;
|
|
26
|
+
kind: Kind;
|
|
27
|
+
ref: string;
|
|
28
|
+
match: string;
|
|
29
|
+
data: Data;
|
|
30
|
+
hash: string;
|
|
31
|
+
stateHash: string;
|
|
32
|
+
dependencies: string[];
|
|
33
|
+
live: Data;
|
|
34
|
+
draft: Data | null;
|
|
35
|
+
files?: { sourceKey: string; key: string; digest: string; size: number }[];
|
|
36
|
+
}
|
|
37
|
+
export interface Snapshot {
|
|
38
|
+
protocol: number;
|
|
39
|
+
siteId: string;
|
|
40
|
+
revision: number;
|
|
41
|
+
entities: Entity[];
|
|
42
|
+
}
|
|
43
|
+
export const MAX_OBJECT = 16 * 1024 * 1024;
|
|
44
|
+
export async function objectBytes(
|
|
45
|
+
store: MediaStore,
|
|
46
|
+
key: string,
|
|
47
|
+
): Promise<Uint8Array> {
|
|
48
|
+
const object = await store.get(key);
|
|
49
|
+
if (!object) throw new ApiError(409, `Missing media object: ${key}`);
|
|
50
|
+
if (object.size !== undefined && object.size > MAX_OBJECT)
|
|
51
|
+
throw new ApiError(413, "Sync media objects are limited to 16 MiB each.");
|
|
52
|
+
const reader = new Response(object.body).body!.getReader();
|
|
53
|
+
const chunks: Uint8Array[] = [];
|
|
54
|
+
let size = 0;
|
|
55
|
+
try {
|
|
56
|
+
for (;;) {
|
|
57
|
+
const r = await reader.read();
|
|
58
|
+
if (r.done) break;
|
|
59
|
+
size += r.value.byteLength;
|
|
60
|
+
if (size > MAX_OBJECT) {
|
|
61
|
+
await reader.cancel();
|
|
62
|
+
throw new ApiError(
|
|
63
|
+
413,
|
|
64
|
+
"Sync media objects are limited to 16 MiB each.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
chunks.push(r.value);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
reader.releaseLock();
|
|
71
|
+
}
|
|
72
|
+
const out = new Uint8Array(size);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const c of chunks) {
|
|
75
|
+
out.set(c, offset);
|
|
76
|
+
offset += c.length;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
export class SyncStore {
|
|
81
|
+
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
82
|
+
async state() {
|
|
83
|
+
const row = (
|
|
84
|
+
await (await this.driver()).all("SELECT * FROM sync_state WHERE id=1")
|
|
85
|
+
)[0];
|
|
86
|
+
return { siteId: String(row.site_id), revision: Number(row.revision) };
|
|
87
|
+
}
|
|
88
|
+
async rawSettings() {
|
|
89
|
+
return (
|
|
90
|
+
await (await this.driver()).all("SELECT * FROM settings WHERE id=1")
|
|
91
|
+
)[0] as Data;
|
|
92
|
+
}
|
|
93
|
+
async snapshot(media: MediaStore): Promise<Snapshot> {
|
|
94
|
+
const db = await this.driver(),
|
|
95
|
+
state = await this.state();
|
|
96
|
+
const aliases = await db.all("SELECT * FROM sync_aliases ORDER BY alias");
|
|
97
|
+
const authorRows = await db.all("SELECT * FROM post_authors ORDER BY ord");
|
|
98
|
+
const settings = await this.rawSettings();
|
|
99
|
+
const designDraft = settings.draft_design
|
|
100
|
+
? JSON.parse(settings.draft_design)
|
|
101
|
+
: null;
|
|
102
|
+
const liveLayouts = JSON.parse(settings.section_pages || "{}"),
|
|
103
|
+
layouts = designDraft?.sectionPages ?? liveLayouts;
|
|
104
|
+
const entities: Entity[] = [];
|
|
105
|
+
const add = (kind: Kind, id: string, live: Data, draft: Data | null) => {
|
|
106
|
+
const own = `${state.siteId}:${kind}:${id}`;
|
|
107
|
+
const linked = aliases
|
|
108
|
+
.filter((a) => a.kind === kind && a.local_id === id)
|
|
109
|
+
.map((a) => String(a.alias));
|
|
110
|
+
const key = String(
|
|
111
|
+
aliases.find((a) => a.kind === kind && a.local_id === id && a.canonical)
|
|
112
|
+
?.alias ?? own,
|
|
113
|
+
);
|
|
114
|
+
entities.push({
|
|
115
|
+
key,
|
|
116
|
+
aliases: [...new Set([key, own, ...linked])],
|
|
117
|
+
id,
|
|
118
|
+
kind,
|
|
119
|
+
live,
|
|
120
|
+
draft,
|
|
121
|
+
data: effective(kind, live, draft),
|
|
122
|
+
ref: "",
|
|
123
|
+
match: "",
|
|
124
|
+
hash: "",
|
|
125
|
+
stateHash: "",
|
|
126
|
+
dependencies: [],
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
for (const [kind, table] of Object.entries(tables) as [Kind, string][]) {
|
|
130
|
+
// Never read account credentials into the sync inventory.
|
|
131
|
+
const select = kind === "author" ? "id,name,slug" : "*";
|
|
132
|
+
const rows = await db.all(`SELECT ${select} FROM ${table} LIMIT 2001`);
|
|
133
|
+
if (rows.length > 2000 || entities.length + rows.length > 2000)
|
|
134
|
+
throw new ApiError(
|
|
135
|
+
413,
|
|
136
|
+
"Sync inventory exceeds 2,000 items; split this site before syncing.",
|
|
137
|
+
);
|
|
138
|
+
for (const row of rows) {
|
|
139
|
+
const live = rowData(kind, row);
|
|
140
|
+
if ((kind === "event" || kind === "resource") && !live.timezone)
|
|
141
|
+
live.timezone = settings.timezone || "UTC";
|
|
142
|
+
const draft = row.draft ? JSON.parse(String(row.draft)) : null;
|
|
143
|
+
if (kind === "post")
|
|
144
|
+
live.authorIds = authorRows
|
|
145
|
+
.filter((a) => a.post_id === row.id)
|
|
146
|
+
.map((a) => a.author_id);
|
|
147
|
+
if (kind === "page") live.layout = liveLayouts[String(row.id)] ?? null;
|
|
148
|
+
add(kind, String(row.id), live, draft);
|
|
149
|
+
if (kind === "page")
|
|
150
|
+
entities.at(-1)!.data = {
|
|
151
|
+
...entities.at(-1)!.data,
|
|
152
|
+
layout: layouts[String(row.id)] ?? null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
add("design", "site", rowData("design", settings), designDraft);
|
|
157
|
+
add("settings", "site", rowData("settings", settings), null);
|
|
158
|
+
add(
|
|
159
|
+
"booking-settings",
|
|
160
|
+
"site",
|
|
161
|
+
JSON.parse(settings.calendar || "{}"),
|
|
162
|
+
null,
|
|
163
|
+
);
|
|
164
|
+
const substitutions = new Map<string, string>();
|
|
165
|
+
for (const entity of entities) {
|
|
166
|
+
if (entity.kind !== "media") {
|
|
167
|
+
if (entity.id !== "site")
|
|
168
|
+
substitutions.set(entity.id, reference(entity.key));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const data = entity.data,
|
|
172
|
+
files: NonNullable<Entity["files"]> = [];
|
|
173
|
+
for (const sourceKey of [
|
|
174
|
+
data.key,
|
|
175
|
+
...(data.variants ?? []).map((v: Data) => v.key),
|
|
176
|
+
]) {
|
|
177
|
+
const bytes = await objectBytes(media, sourceKey),
|
|
178
|
+
digest = await hash(bytes);
|
|
179
|
+
const ext =
|
|
180
|
+
/\.([a-zA-Z0-9]{1,8})$/.exec(sourceKey)?.[1].toLowerCase() ?? "bin";
|
|
181
|
+
const key = `sync/${digest}.${ext}`;
|
|
182
|
+
files.push({ sourceKey, key, digest, size: bytes.length });
|
|
183
|
+
substitutions.set(`/media/${sourceKey}`, `/media/${key}`);
|
|
184
|
+
}
|
|
185
|
+
entity.files = files;
|
|
186
|
+
const normalized = {
|
|
187
|
+
...data,
|
|
188
|
+
key: files[0].key,
|
|
189
|
+
variants: (data.variants ?? []).map((v: Data, i: number) => ({
|
|
190
|
+
...v,
|
|
191
|
+
key: files[i + 1].key,
|
|
192
|
+
})),
|
|
193
|
+
objects: files.map(({ key, digest, size }) => ({ key, digest, size })),
|
|
194
|
+
};
|
|
195
|
+
entity.live = normalized;
|
|
196
|
+
entity.data = normalized;
|
|
197
|
+
}
|
|
198
|
+
// Longest media path first; replace whole URL path tokens, not substrings.
|
|
199
|
+
const mediaPaths = [...substitutions]
|
|
200
|
+
.filter(([k]) => k.startsWith("/media/"))
|
|
201
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
202
|
+
const convert = (s: string) => {
|
|
203
|
+
if (substitutions.has(s)) return substitutions.get(s)!;
|
|
204
|
+
// JSON editor documents are strings. Decode before rewriting IDs/URLs.
|
|
205
|
+
if (/^\s*[\[{]/.test(s)) {
|
|
206
|
+
try {
|
|
207
|
+
return JSON.stringify(transform(JSON.parse(s), convert));
|
|
208
|
+
} catch {}
|
|
209
|
+
}
|
|
210
|
+
for (const [from, to] of mediaPaths) {
|
|
211
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
212
|
+
const boundary = "(?=$|[\\s\"'<>?#),\\]\\\\])";
|
|
213
|
+
if (settings.site_url) {
|
|
214
|
+
const origin = String(settings.site_url)
|
|
215
|
+
.replace(/\/$/, "")
|
|
216
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
217
|
+
s = s.replace(new RegExp(origin + escaped + boundary, "g"), to);
|
|
218
|
+
}
|
|
219
|
+
s = s.replace(new RegExp(escaped + boundary, "g"), to);
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
};
|
|
223
|
+
const mediaByPath = new Map<string, string>(
|
|
224
|
+
entities
|
|
225
|
+
.filter((e) => e.kind === "media")
|
|
226
|
+
.flatMap((e) =>
|
|
227
|
+
(e.data.objects ?? []).map(
|
|
228
|
+
(o: Data) => [`/media/${o.key}`, e.key] as [string, string],
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
for (const entity of entities) {
|
|
233
|
+
entity.data = transform(parseData(entity.kind, entity.data), convert);
|
|
234
|
+
entity.live = transform(parseData(entity.kind, entity.live), convert);
|
|
235
|
+
entity.draft = entity.draft ? transform(entity.draft, convert) : null;
|
|
236
|
+
entity.ref = displayRef(entity.kind, entity.data);
|
|
237
|
+
entity.match = matchKey(entity.kind, entity.data);
|
|
238
|
+
entity.hash = await hash(entity.data);
|
|
239
|
+
entity.stateHash = await hash({
|
|
240
|
+
live: entity.live,
|
|
241
|
+
data: entity.data,
|
|
242
|
+
draft: entity.draft,
|
|
243
|
+
});
|
|
244
|
+
const text = canonical(entity.data);
|
|
245
|
+
const deps = entities
|
|
246
|
+
.filter((e) => e.key !== entity.key && text.includes(reference(e.key)))
|
|
247
|
+
.map((e) => e.key);
|
|
248
|
+
for (const [path, key] of mediaByPath)
|
|
249
|
+
if (entity.kind !== "media" && text.includes(path)) deps.push(key);
|
|
250
|
+
if (entity.kind === "post" || entity.kind === "event")
|
|
251
|
+
for (const tag of entities.filter(
|
|
252
|
+
(e) =>
|
|
253
|
+
e.kind === "tag" && (entity.data.tags ?? []).includes(e.data.name),
|
|
254
|
+
))
|
|
255
|
+
deps.push(tag.key);
|
|
256
|
+
entity.dependencies = [...new Set(deps)];
|
|
257
|
+
}
|
|
258
|
+
if ((await this.state()).revision !== state.revision)
|
|
259
|
+
throw new ApiError(
|
|
260
|
+
409,
|
|
261
|
+
"Content changed while reading the inventory. Retry.",
|
|
262
|
+
);
|
|
263
|
+
const snapshot = { protocol: PROTOCOL, ...state, entities };
|
|
264
|
+
if (
|
|
265
|
+
new TextEncoder().encode(JSON.stringify(snapshot)).length >
|
|
266
|
+
8 * 1024 * 1024
|
|
267
|
+
)
|
|
268
|
+
throw new ApiError(413, "Sync inventory exceeds 8 MiB.");
|
|
269
|
+
return snapshot;
|
|
270
|
+
}
|
|
271
|
+
async plan(id: string) {
|
|
272
|
+
const r = (
|
|
273
|
+
await (
|
|
274
|
+
await this.driver()
|
|
275
|
+
).all("SELECT * FROM sync_plans WHERE id=?", [id])
|
|
276
|
+
)[0];
|
|
277
|
+
return r
|
|
278
|
+
? {
|
|
279
|
+
id: String(r.id),
|
|
280
|
+
digest: String(r.digest),
|
|
281
|
+
actor: String(r.actor),
|
|
282
|
+
body: JSON.parse(String(r.body)),
|
|
283
|
+
result: r.result ? JSON.parse(String(r.result)) : null,
|
|
284
|
+
createdAt: Number(r.created_at),
|
|
285
|
+
}
|
|
286
|
+
: null;
|
|
287
|
+
}
|
|
288
|
+
async savePlan(body: Data, actor: string) {
|
|
289
|
+
const id = crypto.randomUUID(),
|
|
290
|
+
digest = await hash(body);
|
|
291
|
+
await (
|
|
292
|
+
await this.driver()
|
|
293
|
+
).run(
|
|
294
|
+
"INSERT INTO sync_plans(id,digest,actor,body,created_at) VALUES(?,?,?,?,?)",
|
|
295
|
+
[id, digest, actor, JSON.stringify(body), Date.now()],
|
|
296
|
+
);
|
|
297
|
+
return { id, digest, ...body };
|
|
298
|
+
}
|
|
299
|
+
async history() {
|
|
300
|
+
return (
|
|
301
|
+
await (
|
|
302
|
+
await this.driver()
|
|
303
|
+
).all(
|
|
304
|
+
"SELECT id,created_at,result FROM sync_plans ORDER BY created_at DESC LIMIT 100",
|
|
305
|
+
)
|
|
306
|
+
).map((r) => ({
|
|
307
|
+
id: r.id,
|
|
308
|
+
createdAt: r.created_at,
|
|
309
|
+
result: r.result ? JSON.parse(String(r.result)) : null,
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
async commit(
|
|
313
|
+
planId: string,
|
|
314
|
+
expectedRevision: number,
|
|
315
|
+
statements: SqlStatement[],
|
|
316
|
+
result: Data,
|
|
317
|
+
versionKeys: string[],
|
|
318
|
+
) {
|
|
319
|
+
const db = await this.driver();
|
|
320
|
+
try {
|
|
321
|
+
await db.batch([
|
|
322
|
+
{
|
|
323
|
+
sql: "INSERT INTO revision_checks(expected,actual) SELECT ?,revision FROM sync_state WHERE id=1",
|
|
324
|
+
args: [expectedRevision],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
sql: "INSERT INTO revision_checks(expected,actual) SELECT 1,CASE WHEN result IS NULL THEN 1 ELSE 0 END FROM sync_plans WHERE id=?",
|
|
328
|
+
args: [planId],
|
|
329
|
+
},
|
|
330
|
+
...statements,
|
|
331
|
+
{
|
|
332
|
+
sql: "UPDATE sync_plans SET result=json_set(?,'$.versions',json((SELECT json_group_object(entity,revision) FROM sync_versions WHERE entity IN (SELECT value FROM json_each(?))))) WHERE id=?",
|
|
333
|
+
args: [JSON.stringify(result), JSON.stringify(versionKeys), planId],
|
|
334
|
+
},
|
|
335
|
+
{ sql: "DELETE FROM revision_checks" },
|
|
336
|
+
]);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (/CHECK constraint/i.test(String(e)))
|
|
339
|
+
throw new ApiError(
|
|
340
|
+
409,
|
|
341
|
+
"Destination changed after review. Generate a new plan.",
|
|
342
|
+
);
|
|
343
|
+
throw e;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async query(sql: string, args: unknown[] = []) {
|
|
347
|
+
return (await this.driver()).all(sql, args);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import CalendarFallbackImage from '../../../components/CalendarFallbackImage';
|
|
4
|
+
import { env } from '../../../lib/env';
|
|
5
|
+
import '../../../styles/editor.css';
|
|
3
6
|
import Modal from '../../../components/Modal.astro';
|
|
4
7
|
import { getRepo } from '../../../lib/repo';
|
|
5
|
-
import { ApprovalRules, slugify, type ApprovalMode, type CalendarVisibility } from '../../../lib/schema';
|
|
8
|
+
import { EventImageUrl, ApprovalRules, slugify, type ApprovalMode, type CalendarVisibility } from '../../../lib/schema';
|
|
6
9
|
import { CALENDAR, bookingsEnabled, calendarFeedUrl, calendarPageUrl } from '../../../lib/calendar/config';
|
|
7
10
|
import { safeColor } from '../../../lib/calendar/events';
|
|
8
11
|
import { normalizeCalendarSrc } from '../../../lib/calendar/src';
|
|
@@ -54,6 +57,12 @@ if (Astro.request.method === 'POST') {
|
|
|
54
57
|
const color = safeColor(String(form.get('color') || '#4f46e5'));
|
|
55
58
|
const description = String(form.get('description') || '').slice(0, 1000);
|
|
56
59
|
|
|
60
|
+
if (op === 'event-images') {
|
|
61
|
+
const image = EventImageUrl.safeParse(form.get('eventFallbackImage'));
|
|
62
|
+
if (!image.success) return back(image.error.issues[0].message, '#event-images');
|
|
63
|
+
await repo.updateSettings({ calendar: { ...settings.calendar, eventFallbackImage: image.data } });
|
|
64
|
+
return back('Event fallback image saved.', '#event-images');
|
|
65
|
+
}
|
|
57
66
|
if (op === 'defaults') {
|
|
58
67
|
const defaultCalendarId = String(form.get('defaultCalendarId') || '');
|
|
59
68
|
const ok = !defaultCalendarId || (await repo.getCalendar(defaultCalendarId))?.kind === 'native';
|
|
@@ -188,7 +197,7 @@ const I = {
|
|
|
188
197
|
pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
|
189
198
|
};
|
|
190
199
|
const sections = [
|
|
191
|
-
['calendars', 'Calendars'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
|
|
200
|
+
['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
|
|
192
201
|
...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
|
|
193
202
|
] as const;
|
|
194
203
|
---
|
|
@@ -242,6 +251,16 @@ const sections = [
|
|
|
242
251
|
))}
|
|
243
252
|
</section>
|
|
244
253
|
|
|
254
|
+
<section class="cs-sect" id="event-images">
|
|
255
|
+
<header><div><h2>Event images</h2><p>Choose a fallback for events without their own image. Used on event cards, event pages, and Upcoming events sections.</p></div></header>
|
|
256
|
+
<form method="post">
|
|
257
|
+
<input type="hidden" name="op" value="event-images" />
|
|
258
|
+
<CalendarFallbackImage client:load value={settings.calendar.eventFallbackImage} unsplashEnabled={Boolean(env('UNSPLASH_ACCESS_KEY'))} />
|
|
259
|
+
<p class="hint">An event’s own image takes priority. Upcoming events sections can choose a different fallback in Design → Pages. Removing the fallback restores date tiles on cards.</p>
|
|
260
|
+
<button class="btn primary" type="submit">Save event image</button>
|
|
261
|
+
</form>
|
|
262
|
+
</section>
|
|
263
|
+
|
|
245
264
|
<!-- Places -->
|
|
246
265
|
<section class="cs-sect" id="places">
|
|
247
266
|
<header>
|
|
@@ -6,6 +6,7 @@ import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
|
6
6
|
import { accessFor } from '../../lib/permissions/access';
|
|
7
7
|
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
8
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
|
+
import { sessionRole } from '../../lib/api';
|
|
9
10
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
10
11
|
import { resolveConfig } from '../../lib/email/config';
|
|
11
12
|
import { env } from '../../lib/env';
|
|
@@ -32,6 +33,7 @@ if (Astro.request.method === 'POST') {
|
|
|
32
33
|
if (op === 'key') {
|
|
33
34
|
const scopes = form.getAll('scopes').map(String).filter((s): s is ApiScope => (API_SCOPES as readonly string[]).includes(s));
|
|
34
35
|
if (!scopes.length) throw new Error('Pick at least one permission.');
|
|
36
|
+
if (scopes.some(s => s.startsWith('sync:')) && (await sessionRole(Astro.request, Astro.cookies, repo)).role !== 'owner') throw new Error('Only owners can issue environment sync keys.');
|
|
35
37
|
const { key } = await issueApiKey(repo, String(form.get('name') || 'API key').slice(0, 80), scopes);
|
|
36
38
|
newKey = key;
|
|
37
39
|
} else if (op === 'revoke') {
|
|
@@ -13,7 +13,7 @@ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
|
13
13
|
|
|
14
14
|
<Admin title="Links page" section="links">
|
|
15
15
|
<div class="page-head">
|
|
16
|
-
<div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links
|
|
16
|
+
<div><h1>Links page</h1><p class="sub">Your link-in-bio page at /links. Save & publish makes changes live immediately.</p></div>
|
|
17
17
|
<a class="btn sm" href="/links" target="_blank" rel="noopener">View links page ↗</a>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
@@ -27,6 +27,8 @@ Connect a Streamable HTTP MCP client to ${url.origin}/api/mcp with the same Auth
|
|
|
27
27
|
|
|
28
28
|
Themes define editable sections (hero, service times, upcoming events…) with typed fields. list_section_types returns the active theme's section types and field schemas; get_page_layout reads a page's layout; set_page_layout replaces it; update_section_data changes one section. The two write tools appear only when the owner turns on "Let the agent edit page sections". Field values: text/textarea/richtext/image/url/select/date (YYYY-MM-DD)/time (HH:MM) are strings, number is a number, toggle is a boolean, link is {"label","href"} (optionally "ref" like "page:<id>"), list is an array of row objects keyed by its sub-fields, source is {"calendar"|"collection"|"tag"|"resource", "limit"} and shows live content. All section writes stage into the design draft (review at the returned reviewUrl). update_section_data with publish:true publishes that one section when publishing is enabled.
|
|
29
29
|
|
|
30
|
+
The library upcoming-events section supports cards/list variants and editable heading, intro, events (calendar and limit), more, empty, fallbackImage, showImages, imageRatio (landscape/square/portrait), imageFit (cover/contain), showLocation, and showSummary. Discover the active theme's schema first: copied or custom sections may expose different fields. Event images take priority over the section fallback, then the fallback chosen by the owner in Calendar settings → Event images. For a section override, call update_section_data with fallbackImage and the desired display fields; it follows the normal draft and publishing permissions.
|
|
31
|
+
|
|
30
32
|
Updates replace only supplied fields; content replaces the body. Pages support title/content/seoTitle/seoDesc. Posts also support excerpt/tags. Create accepts slug and posts accept collection. Content length is limited to 100,000 characters; request size is limited to 300,000 bytes. List pagination defaults to 25 and allows up to 100.
|
|
31
33
|
|
|
32
34
|
## Tool schemas (publish_content requires permission)
|
|
@@ -3,21 +3,25 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
4
|
import { apiError, json, readJson, ApiError } from '../../../lib/api';
|
|
5
5
|
import { DESIGN_SCOPES } from '../../../lib/design-service';
|
|
6
|
+
import { SYNC_SCOPES } from '../../../lib/sync/auth';
|
|
7
|
+
import { sessionRole } from '../../../lib/api';
|
|
8
|
+
const TOOL_SCOPES=[...DESIGN_SCOPES,...SYNC_SCOPES] as const;
|
|
6
9
|
import { generateAgentKey } from '../../../lib/ai-config';
|
|
7
10
|
import { requirePermission } from '../../../lib/permissions/access';
|
|
8
11
|
export const ALL:APIRoute=async({request,cookies,locals})=>{
|
|
9
12
|
try {
|
|
10
13
|
const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'integrations.manage');
|
|
11
|
-
if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>
|
|
14
|
+
if(request.method==='GET')return json({keys:(await repo.listApiKeys()).filter(k=>k.scopes.some(s=>TOOL_SCOPES.includes(s as typeof TOOL_SCOPES[number])))});
|
|
12
15
|
if(request.method==='POST') {
|
|
13
|
-
const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(
|
|
16
|
+
const input=z.object({name:z.string().min(1).max(100),scopes:z.array(z.enum(TOOL_SCOPES)).min(1)}).strict().parse(await readJson(request,10000));
|
|
17
|
+
if(input.scopes.some(s=>s.startsWith('sync:'))&&(await sessionRole(request,cookies,repo)).role!=='owner')throw new ApiError(403,'Only owners can issue environment sync keys.');
|
|
14
18
|
const {token,keyHash,keyPrefix}=generateAgentKey();
|
|
15
19
|
const key=await repo.createApiKey({name:input.name,scopes:input.scopes,keyHash,prefix:keyPrefix});return json({key,token},201);
|
|
16
20
|
}
|
|
17
21
|
if(request.method==='DELETE') {
|
|
18
22
|
const {id}=z.object({id:z.string()}).strict().parse(await readJson(request,10000));
|
|
19
23
|
const key=(await repo.listApiKeys()).find(k=>k.id===id);
|
|
20
|
-
if(!key||!key.scopes.some(s=>
|
|
24
|
+
if(!key||!key.scopes.some(s=>TOOL_SCOPES.includes(s as typeof TOOL_SCOPES[number])))throw new ApiError(404,'Tool key not found.');
|
|
21
25
|
await repo.deleteApiKey(id);return json({ok:true});
|
|
22
26
|
}
|
|
23
27
|
throw new ApiError(405,'Method not allowed.');
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getRepo } from "../../../lib/repo";
|
|
4
|
+
import { getMediaStore } from "../../../lib/media/store";
|
|
5
|
+
import { apiError, ApiError, json, readJson } from "../../../lib/api";
|
|
6
|
+
import { syncIdentity } from "../../../lib/sync/auth";
|
|
7
|
+
import { applyPlan, createPlan, followupPlan } from "../../../lib/sync/service";
|
|
8
|
+
import { PROTOCOL, TYPES } from "../../../lib/sync/protocol.mjs";
|
|
9
|
+
|
|
10
|
+
export const GET: APIRoute = async ({ request, locals }) => {
|
|
11
|
+
try {
|
|
12
|
+
const identity = await syncIdentity(request, await getRepo(locals), [
|
|
13
|
+
"sync:read",
|
|
14
|
+
]);
|
|
15
|
+
return json({
|
|
16
|
+
protocol: PROTOCOL,
|
|
17
|
+
scopes: identity.scopes,
|
|
18
|
+
capabilities: {
|
|
19
|
+
sync: true,
|
|
20
|
+
types: Object.keys(TYPES),
|
|
21
|
+
maxItems: 200,
|
|
22
|
+
maxObjectBytes: 16 * 1024 * 1024,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return apiError(e);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const RequestBody = z.discriminatedUnion("op", [
|
|
30
|
+
z.object({ op: z.literal("snapshot") }).strict(),
|
|
31
|
+
z
|
|
32
|
+
.object({
|
|
33
|
+
op: z.literal("plan"),
|
|
34
|
+
input: z.unknown(),
|
|
35
|
+
validateOnly: z.boolean().default(false),
|
|
36
|
+
})
|
|
37
|
+
.strict(),
|
|
38
|
+
z
|
|
39
|
+
.object({
|
|
40
|
+
op: z.literal("apply"),
|
|
41
|
+
id: z.string().uuid(),
|
|
42
|
+
digest: z.string().regex(/^[a-f0-9]{64}$/),
|
|
43
|
+
})
|
|
44
|
+
.strict(),
|
|
45
|
+
z.object({ op: z.literal("show"), id: z.string().uuid() }).strict(),
|
|
46
|
+
z.object({ op: z.literal("history") }).strict(),
|
|
47
|
+
z
|
|
48
|
+
.object({
|
|
49
|
+
op: z.enum(["publish", "rollback"]),
|
|
50
|
+
id: z.string().uuid(),
|
|
51
|
+
validateOnly: z.boolean().default(false),
|
|
52
|
+
})
|
|
53
|
+
.strict(),
|
|
54
|
+
]);
|
|
55
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
56
|
+
try {
|
|
57
|
+
const repo = await getRepo(locals);
|
|
58
|
+
// Authenticate before parsing potentially large content.
|
|
59
|
+
const who = await syncIdentity(request, repo, ["sync:read"]);
|
|
60
|
+
const body = RequestBody.parse(await readJson(request, 8 * 1024 * 1024));
|
|
61
|
+
if (
|
|
62
|
+
["plan", "apply", "publish", "rollback"].includes(body.op) &&
|
|
63
|
+
!who.scopes.includes("sync:write")
|
|
64
|
+
)
|
|
65
|
+
throw new ApiError(403, "Required scope: sync:write");
|
|
66
|
+
const media = getMediaStore(locals);
|
|
67
|
+
switch (body.op) {
|
|
68
|
+
case "snapshot":
|
|
69
|
+
return json(await repo.sync.snapshot(media));
|
|
70
|
+
case "plan":
|
|
71
|
+
return json(
|
|
72
|
+
await createPlan(
|
|
73
|
+
repo,
|
|
74
|
+
media,
|
|
75
|
+
body.input,
|
|
76
|
+
who.actor,
|
|
77
|
+
!body.validateOnly,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
case "apply":
|
|
81
|
+
return json(
|
|
82
|
+
await applyPlan(
|
|
83
|
+
repo,
|
|
84
|
+
media,
|
|
85
|
+
body.id,
|
|
86
|
+
body.digest,
|
|
87
|
+
who.actor,
|
|
88
|
+
who.scopes.includes("sync:publish"),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
case "show": {
|
|
92
|
+
const p = await repo.sync.plan(body.id);
|
|
93
|
+
if (!p) throw new ApiError(404, "Plan not found.");
|
|
94
|
+
return json(p);
|
|
95
|
+
}
|
|
96
|
+
case "history":
|
|
97
|
+
return json({ runs: await repo.sync.history() });
|
|
98
|
+
case "publish":
|
|
99
|
+
case "rollback":
|
|
100
|
+
return json(
|
|
101
|
+
await followupPlan(
|
|
102
|
+
repo,
|
|
103
|
+
media,
|
|
104
|
+
body.id,
|
|
105
|
+
body.op,
|
|
106
|
+
who.actor,
|
|
107
|
+
!body.validateOnly,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return apiError(e);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { getRepo } from "../../../lib/repo";
|
|
3
|
+
import { getMediaStore } from "../../../lib/media/store";
|
|
4
|
+
import { apiError, ApiError, json } from "../../../lib/api";
|
|
5
|
+
import { syncIdentity } from "../../../lib/sync/auth";
|
|
6
|
+
import { objectBytes, MAX_OBJECT } from "../../../lib/sync/store";
|
|
7
|
+
import { safeMediaKey } from "../../../lib/sync/catalog";
|
|
8
|
+
import { hash } from "../../../lib/sync/protocol.mjs";
|
|
9
|
+
|
|
10
|
+
export const ALL: APIRoute = async ({ request, locals }) => {
|
|
11
|
+
try {
|
|
12
|
+
const repo = await getRepo(locals),
|
|
13
|
+
write = request.method === "PUT";
|
|
14
|
+
await syncIdentity(
|
|
15
|
+
request,
|
|
16
|
+
repo,
|
|
17
|
+
write ? ["sync:read", "sync:write"] : ["sync:read"],
|
|
18
|
+
);
|
|
19
|
+
if (!["GET", "HEAD", "PUT"].includes(request.method))
|
|
20
|
+
throw new ApiError(405, "Method not allowed.");
|
|
21
|
+
const key = safeMediaKey(
|
|
22
|
+
new URL(request.url).searchParams.get("key") ?? "",
|
|
23
|
+
);
|
|
24
|
+
const media = getMediaStore(locals);
|
|
25
|
+
const digest = /^sync\/([a-f0-9]{64})\.[a-z0-9]{1,8}$/.exec(key)?.[1];
|
|
26
|
+
if (write) {
|
|
27
|
+
if (!request.body) throw new ApiError(400, "An upload body is required.");
|
|
28
|
+
if (!digest)
|
|
29
|
+
throw new ApiError(
|
|
30
|
+
400,
|
|
31
|
+
"Sync uploads require a content-addressed key.",
|
|
32
|
+
);
|
|
33
|
+
const bytes = await objectBytes(
|
|
34
|
+
{
|
|
35
|
+
get: async () => ({
|
|
36
|
+
body: request.body!,
|
|
37
|
+
contentType:
|
|
38
|
+
request.headers.get("content-type") ?? "application/octet-stream",
|
|
39
|
+
size: Number(request.headers.get("content-length")) || undefined,
|
|
40
|
+
}),
|
|
41
|
+
put: media.put.bind(media),
|
|
42
|
+
delete: media.delete.bind(media),
|
|
43
|
+
},
|
|
44
|
+
key,
|
|
45
|
+
);
|
|
46
|
+
if (bytes.length > MAX_OBJECT || (await hash(bytes)) !== digest)
|
|
47
|
+
throw new ApiError(400, "Media checksum mismatch.");
|
|
48
|
+
const existing = await media.get(key);
|
|
49
|
+
if (existing) {
|
|
50
|
+
if ((await hash(await objectBytes(media, key))) !== digest)
|
|
51
|
+
throw new ApiError(
|
|
52
|
+
409,
|
|
53
|
+
"Existing content-addressed object is damaged.",
|
|
54
|
+
);
|
|
55
|
+
return json({ ok: true, skipped: true });
|
|
56
|
+
}
|
|
57
|
+
await media.put(
|
|
58
|
+
key,
|
|
59
|
+
bytes.buffer as ArrayBuffer,
|
|
60
|
+
request.headers.get("content-type") ?? "application/octet-stream",
|
|
61
|
+
);
|
|
62
|
+
return json({ ok: true, skipped: false });
|
|
63
|
+
}
|
|
64
|
+
const rows = await repo.listMedia();
|
|
65
|
+
if (
|
|
66
|
+
!digest &&
|
|
67
|
+
!rows.some((m) => m.key === key || m.variants.some((v) => v.key === key))
|
|
68
|
+
)
|
|
69
|
+
throw new ApiError(404, "Media not found.");
|
|
70
|
+
const obj = await media.get(key);
|
|
71
|
+
if (!obj) throw new ApiError(404, "Media not found.");
|
|
72
|
+
if (request.method === "HEAD") {
|
|
73
|
+
const actual = await hash(await objectBytes(media, key));
|
|
74
|
+
if (digest && actual !== digest)
|
|
75
|
+
throw new ApiError(409, "Media checksum mismatch.");
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
headers: { ETag: actual, "Cache-Control": "no-store" },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return new Response(obj.body, {
|
|
81
|
+
headers: {
|
|
82
|
+
"Content-Type": obj.contentType,
|
|
83
|
+
"Cache-Control": "no-store",
|
|
84
|
+
"Content-Disposition": "attachment",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return apiError(e);
|
|
89
|
+
}
|
|
90
|
+
};
|