@bygghammar/cms-platform 0.38.1 → 0.39.0
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 +4 -0
- package/package.json +3 -2
- package/src/server/backup-core.ts +731 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.39.0
|
|
4
|
+
|
|
5
|
+
- New `server/backup-core`: superadmin verification, dataset counting, archive build, analysis and restore (database + media) now live in the package. Hosts pass a service-role client factory.
|
|
6
|
+
|
|
3
7
|
## 0.38.1
|
|
4
8
|
|
|
5
9
|
- Type fix in `server/forms-submissions-core` (per-form rollup form names).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -153,7 +153,8 @@
|
|
|
153
153
|
"./server/smtp": "./src/server/smtp.ts",
|
|
154
154
|
"./server/email-send-core": "./src/server/email-send-core.ts",
|
|
155
155
|
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts",
|
|
156
|
-
"./server/forms-submissions-core": "./src/server/forms-submissions-core.ts"
|
|
156
|
+
"./server/forms-submissions-core": "./src/server/forms-submissions-core.ts",
|
|
157
|
+
"./server/backup-core": "./src/server/backup-core.ts"
|
|
157
158
|
},
|
|
158
159
|
"dependencies": {
|
|
159
160
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate";
|
|
2
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
3
|
+
|
|
4
|
+
import { getMediaStorage } from "../media/storage";
|
|
5
|
+
import {
|
|
6
|
+
ARCHIVE_FORMAT_VERSION,
|
|
7
|
+
IMPORT_ORDER,
|
|
8
|
+
LINEAGE_KEYS,
|
|
9
|
+
SCHEMA_VERSION,
|
|
10
|
+
groupById,
|
|
11
|
+
type ArchiveManifest,
|
|
12
|
+
type ImportMode,
|
|
13
|
+
type TableSpec,
|
|
14
|
+
} from "../backup/datasets";
|
|
15
|
+
import {
|
|
16
|
+
hasAllowedMediaExtension,
|
|
17
|
+
isSafeArchivePath,
|
|
18
|
+
neutralizeInjection,
|
|
19
|
+
rewriteMediaUrls,
|
|
20
|
+
sanitizeSecrets,
|
|
21
|
+
verifyMediaFile,
|
|
22
|
+
} from "../backup/safety";
|
|
23
|
+
|
|
24
|
+
/** Host plumbing the backup core needs: a service-role client factory. */
|
|
25
|
+
export type BackupDeps = {
|
|
26
|
+
adminClient: () => Promise<SupabaseClient>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PAGE_SIZE = 1000;
|
|
30
|
+
const WRITE_CHUNK = 400;
|
|
31
|
+
|
|
32
|
+
/** Apply a table's export sanitiser: `["*"]` scrubs the whole row. */
|
|
33
|
+
function sanitizeRow(spec: TableSpec, row: Record<string, unknown>): Record<string, unknown> {
|
|
34
|
+
const cols = spec.sanitize;
|
|
35
|
+
if (!cols?.length) return row;
|
|
36
|
+
if (cols.includes("*")) return sanitizeSecrets(row);
|
|
37
|
+
const out = { ...row };
|
|
38
|
+
for (const c of cols) if (c in out) out[c] = sanitizeSecrets(out[c]);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ------------------------------------------------------------------ auth */
|
|
43
|
+
|
|
44
|
+
export type Caller = { userId: string; email: string | null; supabase: SupabaseClient };
|
|
45
|
+
|
|
46
|
+
function newKeyFetch(key: string): typeof fetch {
|
|
47
|
+
return (input, init) => {
|
|
48
|
+
const h = new Headers(init?.headers);
|
|
49
|
+
if (key.startsWith("sb_") && h.get("Authorization") === `Bearer ${key}`)
|
|
50
|
+
h.delete("Authorization");
|
|
51
|
+
h.set("apikey", key);
|
|
52
|
+
return fetch(input, { ...init, headers: h });
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify the request's bearer token and that the caller is a superadmin.
|
|
58
|
+
* Throws a Response so route handlers can return the right status code.
|
|
59
|
+
*/
|
|
60
|
+
export async function requireSuperadminFromRequestCore(request: Request): Promise<Caller> {
|
|
61
|
+
const url = process.env["SUPABASE_URL"];
|
|
62
|
+
const key = process.env["SUPABASE_PUBLISHABLE_KEY"];
|
|
63
|
+
if (!url || !key) throw new Response("Server not configured", { status: 500 });
|
|
64
|
+
|
|
65
|
+
const header = request.headers.get("authorization") ?? "";
|
|
66
|
+
if (!header.startsWith("Bearer ")) throw new Response("Unauthorized", { status: 401 });
|
|
67
|
+
const token = header.slice(7);
|
|
68
|
+
if (token.split(".").length !== 3) throw new Response("Unauthorized", { status: 401 });
|
|
69
|
+
|
|
70
|
+
const supabase = createClient(url, key, {
|
|
71
|
+
global: { fetch: newKeyFetch(key), headers: { Authorization: `Bearer ${token}` } },
|
|
72
|
+
auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { data, error } = await supabase.auth.getClaims(token);
|
|
76
|
+
if (error || !data?.claims?.sub) throw new Response("Unauthorized", { status: 401 });
|
|
77
|
+
|
|
78
|
+
const userId = data.claims.sub as string;
|
|
79
|
+
const { data: isSuper, error: roleErr } = await supabase.rpc("has_role", {
|
|
80
|
+
_user_id: userId,
|
|
81
|
+
_role: "superadmin",
|
|
82
|
+
});
|
|
83
|
+
if (roleErr) throw new Response("Forbidden", { status: 403 });
|
|
84
|
+
if (!isSuper) throw new Response("Forbidden — superadmin only", { status: 403 });
|
|
85
|
+
|
|
86
|
+
return { userId, email: (data.claims.email as string) ?? null, supabase };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function audit(
|
|
90
|
+
deps: BackupDeps,
|
|
91
|
+
caller: Caller,
|
|
92
|
+
action: string,
|
|
93
|
+
meta: Record<string, unknown>,
|
|
94
|
+
) {
|
|
95
|
+
try {
|
|
96
|
+
const supabaseAdmin = await deps.adminClient();
|
|
97
|
+
await supabaseAdmin.from("audit_logs").insert({
|
|
98
|
+
actor_id: caller.userId,
|
|
99
|
+
actor_email: caller.email,
|
|
100
|
+
action,
|
|
101
|
+
entity: "backup",
|
|
102
|
+
entity_id: null,
|
|
103
|
+
meta: meta as never,
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.warn("[backup] audit failed", e);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ---------------------------------------------------------------- export */
|
|
111
|
+
|
|
112
|
+
async function readAll(deps: BackupDeps, table: string): Promise<Record<string, unknown>[]> {
|
|
113
|
+
const supabaseAdmin = await deps.adminClient();
|
|
114
|
+
const rows: Record<string, unknown>[] = [];
|
|
115
|
+
for (let from = 0; ; from += PAGE_SIZE) {
|
|
116
|
+
const { data, error } = await supabaseAdmin
|
|
117
|
+
.from(table as never)
|
|
118
|
+
.select("*")
|
|
119
|
+
.range(from, from + PAGE_SIZE - 1);
|
|
120
|
+
if (error) throw new Error(`${table}: ${error.message}`);
|
|
121
|
+
const batch = (data ?? []) as unknown as Record<string, unknown>[];
|
|
122
|
+
rows.push(...batch);
|
|
123
|
+
if (batch.length < PAGE_SIZE) break;
|
|
124
|
+
}
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function countRowsCore(
|
|
129
|
+
deps: BackupDeps,
|
|
130
|
+
groupIds: string[],
|
|
131
|
+
caller?: Caller,
|
|
132
|
+
): Promise<{
|
|
133
|
+
counts: Record<string, number>;
|
|
134
|
+
mediaFiles: number;
|
|
135
|
+
mediaBytes: number;
|
|
136
|
+
mediaError?: string;
|
|
137
|
+
}> {
|
|
138
|
+
const supabaseAdmin = await deps.adminClient();
|
|
139
|
+
const counts: Record<string, number> = {};
|
|
140
|
+
let mediaFiles = 0;
|
|
141
|
+
let mediaBytes = 0;
|
|
142
|
+
let mediaError: string | undefined;
|
|
143
|
+
|
|
144
|
+
for (const id of groupIds) {
|
|
145
|
+
const group = groupById(id);
|
|
146
|
+
if (!group) continue;
|
|
147
|
+
for (const spec of group.tables) {
|
|
148
|
+
const { count, error } = await supabaseAdmin
|
|
149
|
+
.from(spec.table as never)
|
|
150
|
+
.select("*", { count: "exact", head: true });
|
|
151
|
+
counts[spec.table] = error ? 0 : (count ?? 0);
|
|
152
|
+
}
|
|
153
|
+
if (group.special === "mediaFiles") {
|
|
154
|
+
try {
|
|
155
|
+
// Use the service-role client so storage listing works server-side.
|
|
156
|
+
// This is an estimate: names are filtered here, contents are verified
|
|
157
|
+
// when the archive is actually built.
|
|
158
|
+
const files = (
|
|
159
|
+
await getMediaStorage(
|
|
160
|
+
caller?.supabase ?? (supabaseAdmin as unknown as SupabaseClient),
|
|
161
|
+
).list()
|
|
162
|
+
).filter((f) => hasAllowedMediaExtension(f.name));
|
|
163
|
+
mediaFiles = files.length;
|
|
164
|
+
mediaBytes = files.reduce((s, f) => s + (f.size ?? 0), 0);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
mediaError = e instanceof Error ? e.message : "Could not list media files";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (group.special === "authUsers") {
|
|
170
|
+
const { data } = await supabaseAdmin.auth.admin.listUsers({ page: 1, perPage: 1000 });
|
|
171
|
+
counts["auth_users"] = data?.users.length ?? 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { counts, mediaFiles, mediaBytes, ...(mediaError ? { mediaError } : {}) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function buildArchiveCore(
|
|
178
|
+
deps: BackupDeps,
|
|
179
|
+
caller: Caller,
|
|
180
|
+
groupIds: string[],
|
|
181
|
+
site: string | null,
|
|
182
|
+
options: { deferMediaToBrowser?: boolean } = {},
|
|
183
|
+
): Promise<Uint8Array> {
|
|
184
|
+
const supabaseAdmin = await deps.adminClient();
|
|
185
|
+
const files: Record<string, Uint8Array> = {};
|
|
186
|
+
const counts: Record<string, number> = {};
|
|
187
|
+
let mediaFiles = 0;
|
|
188
|
+
let mediaBytes = 0;
|
|
189
|
+
const quarantined: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const id of groupIds) {
|
|
192
|
+
const group = groupById(id);
|
|
193
|
+
if (!group) continue;
|
|
194
|
+
|
|
195
|
+
for (const spec of group.tables) {
|
|
196
|
+
const rows = (await readAll(deps, spec.table)).map((r) => sanitizeRow(spec, r));
|
|
197
|
+
counts[spec.table] = rows.length;
|
|
198
|
+
files[`data/${spec.table}.json`] = strToU8(JSON.stringify(rows));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (group.special === "authUsers") {
|
|
202
|
+
const { data } = await supabaseAdmin.auth.admin.listUsers({ page: 1, perPage: 1000 });
|
|
203
|
+
const users = (data?.users ?? []).map((u) => ({
|
|
204
|
+
id: u.id,
|
|
205
|
+
email: u.email,
|
|
206
|
+
email_confirmed_at: u.email_confirmed_at,
|
|
207
|
+
created_at: u.created_at,
|
|
208
|
+
// Metadata is user-supplied and can hold tokens; scrub it.
|
|
209
|
+
user_metadata: sanitizeSecrets(u.user_metadata),
|
|
210
|
+
}));
|
|
211
|
+
counts["auth_users"] = users.length;
|
|
212
|
+
files["data/auth_users.json"] = strToU8(JSON.stringify(users));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (group.special === "mediaFiles") {
|
|
216
|
+
if (options.deferMediaToBrowser) continue;
|
|
217
|
+
const storage = getMediaStorage(supabaseAdmin as unknown as SupabaseClient);
|
|
218
|
+
const list = await storage.list();
|
|
219
|
+
for (const f of list) {
|
|
220
|
+
// Names that are not media, or unsafe archive paths, never enter the zip.
|
|
221
|
+
if (!hasAllowedMediaExtension(f.name) || !isSafeArchivePath(f.name)) {
|
|
222
|
+
quarantined.push(f.name);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const bytes = await storage.download(f.name);
|
|
227
|
+
// The file's own bytes decide: HTML renamed to .jpg is excluded.
|
|
228
|
+
const verdict = verifyMediaFile(f.name, bytes);
|
|
229
|
+
if (!verdict.ok) {
|
|
230
|
+
quarantined.push(`${f.name} (${verdict.reason})`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
files[`media/${f.name}`] = bytes;
|
|
234
|
+
mediaFiles += 1;
|
|
235
|
+
mediaBytes += bytes.byteLength;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.warn("[backup] could not read media file", f.name, e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const manifest: ArchiveManifest = {
|
|
244
|
+
format: ARCHIVE_FORMAT_VERSION,
|
|
245
|
+
schemaVersion: SCHEMA_VERSION,
|
|
246
|
+
createdAt: new Date().toISOString(),
|
|
247
|
+
createdBy: caller.email,
|
|
248
|
+
site,
|
|
249
|
+
groups: groupIds,
|
|
250
|
+
counts,
|
|
251
|
+
mediaFiles,
|
|
252
|
+
mediaBytes,
|
|
253
|
+
quarantinedMedia: quarantined,
|
|
254
|
+
};
|
|
255
|
+
files["manifest.json"] = strToU8(JSON.stringify(manifest, null, 2));
|
|
256
|
+
|
|
257
|
+
await audit(deps, caller, "backup.export", {
|
|
258
|
+
groups: groupIds,
|
|
259
|
+
counts,
|
|
260
|
+
mediaFiles,
|
|
261
|
+
mediaBytes,
|
|
262
|
+
quarantinedMedia: quarantined.length,
|
|
263
|
+
mediaDeferredToBrowser: options.deferMediaToBrowser === true,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return zipSync(files, { level: 6 });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ---------------------------------------------------------------- import */
|
|
270
|
+
|
|
271
|
+
export type ArchiveAnalysis = {
|
|
272
|
+
manifest: ArchiveManifest;
|
|
273
|
+
compatible: boolean;
|
|
274
|
+
problems: string[];
|
|
275
|
+
/** Media actually present in the archive (verified, not manifest-reported). */
|
|
276
|
+
mediaFiles: number;
|
|
277
|
+
mediaBytes: number;
|
|
278
|
+
/** Archive entries that are not real media and will not be restored. */
|
|
279
|
+
quarantinedMedia: string[];
|
|
280
|
+
datasets: {
|
|
281
|
+
group: string;
|
|
282
|
+
label: string;
|
|
283
|
+
insertOnly: boolean;
|
|
284
|
+
tables: { table: string; incoming: number; existing: number }[];
|
|
285
|
+
mediaFiles?: number;
|
|
286
|
+
mediaBytes?: number;
|
|
287
|
+
}[];
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Count and measure the media payload actually contained in an archive.
|
|
292
|
+
* Entries that are not verified media, or whose path is unsafe, are
|
|
293
|
+
* quarantined: never counted, never written back to the library.
|
|
294
|
+
*/
|
|
295
|
+
function measureMedia(entries: Record<string, Uint8Array>): {
|
|
296
|
+
files: number;
|
|
297
|
+
bytes: number;
|
|
298
|
+
quarantined: string[];
|
|
299
|
+
} {
|
|
300
|
+
let files = 0;
|
|
301
|
+
let bytes = 0;
|
|
302
|
+
const quarantined: string[] = [];
|
|
303
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
304
|
+
if (!key.startsWith("media/")) continue;
|
|
305
|
+
const name = key.slice("media/".length);
|
|
306
|
+
if (!name) continue;
|
|
307
|
+
if (!isSafeArchivePath(name)) {
|
|
308
|
+
quarantined.push(`${name} (unsafe path)`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const verdict = verifyMediaFile(name, value);
|
|
312
|
+
if (!verdict.ok) {
|
|
313
|
+
quarantined.push(`${name} (${verdict.reason})`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
files += 1;
|
|
317
|
+
bytes += value.byteLength;
|
|
318
|
+
}
|
|
319
|
+
return { files, bytes, quarantined };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseArchive(bytes: Uint8Array) {
|
|
323
|
+
const entries = unzipSync(bytes);
|
|
324
|
+
const manifestRaw = entries["manifest.json"];
|
|
325
|
+
if (!manifestRaw) throw new Error("Not a valid backup archive — manifest.json is missing.");
|
|
326
|
+
for (const key of Object.keys(entries)) {
|
|
327
|
+
if (!isSafeArchivePath(key)) {
|
|
328
|
+
throw new Error(`Unsafe entry in archive: ${key}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const manifest = JSON.parse(strFromU8(manifestRaw)) as ArchiveManifest;
|
|
332
|
+
return { entries, manifest };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function tableRows(entries: Record<string, Uint8Array>, table: string): Record<string, unknown>[] {
|
|
336
|
+
const raw = entries[`data/${table}.json`];
|
|
337
|
+
if (!raw) return [];
|
|
338
|
+
return JSON.parse(strFromU8(raw)) as Record<string, unknown>[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function analyzeArchiveCore(deps: BackupDeps, bytes: Uint8Array): Promise<ArchiveAnalysis> {
|
|
342
|
+
const supabaseAdmin = await deps.adminClient();
|
|
343
|
+
const { entries, manifest } = parseArchive(bytes);
|
|
344
|
+
const problems: string[] = [];
|
|
345
|
+
|
|
346
|
+
if (manifest.format !== ARCHIVE_FORMAT_VERSION) {
|
|
347
|
+
problems.push(
|
|
348
|
+
`Archive format ${manifest.format} does not match this site (${ARCHIVE_FORMAT_VERSION}).`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (manifest.schemaVersion !== SCHEMA_VERSION) {
|
|
352
|
+
problems.push(
|
|
353
|
+
`Archive was made for database version ${manifest.schemaVersion}, this site runs version ${SCHEMA_VERSION}.`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const media = measureMedia(entries);
|
|
358
|
+
|
|
359
|
+
const datasets: ArchiveAnalysis["datasets"] = [];
|
|
360
|
+
for (const id of manifest.groups ?? []) {
|
|
361
|
+
const group = groupById(id);
|
|
362
|
+
if (!group) {
|
|
363
|
+
problems.push(`Unknown dataset "${id}" in the archive — it will be skipped.`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const tables: { table: string; incoming: number; existing: number }[] = [];
|
|
367
|
+
for (const spec of group.tables) {
|
|
368
|
+
const incoming = tableRows(entries, spec.table).length;
|
|
369
|
+
const { count } = await supabaseAdmin
|
|
370
|
+
.from(spec.table as never)
|
|
371
|
+
.select("*", { count: "exact", head: true });
|
|
372
|
+
tables.push({ table: spec.table, incoming, existing: count ?? 0 });
|
|
373
|
+
}
|
|
374
|
+
datasets.push({
|
|
375
|
+
group: id,
|
|
376
|
+
label: group.label,
|
|
377
|
+
insertOnly: Boolean(group.insertOnly),
|
|
378
|
+
tables,
|
|
379
|
+
...(group.special === "mediaFiles"
|
|
380
|
+
? { mediaFiles: media.files, mediaBytes: media.bytes }
|
|
381
|
+
: {}),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Verify the archive's real media payload against what the export recorded,
|
|
386
|
+
// so both sides of the round trip report the same usage for marked items.
|
|
387
|
+
if ((manifest.groups ?? []).includes("mediaFiles")) {
|
|
388
|
+
if (typeof manifest.mediaFiles === "number" && manifest.mediaFiles !== media.files) {
|
|
389
|
+
problems.push(
|
|
390
|
+
`Media file count mismatch — the archive lists ${manifest.mediaFiles.toLocaleString()} files but contains ${media.files.toLocaleString()}.`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
if (typeof manifest.mediaBytes === "number" && manifest.mediaBytes !== media.bytes) {
|
|
394
|
+
problems.push(
|
|
395
|
+
`Media size mismatch — the archive lists ${manifest.mediaBytes.toLocaleString()} bytes but contains ${media.bytes.toLocaleString()}.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
manifest,
|
|
402
|
+
compatible: problems.length === 0,
|
|
403
|
+
problems,
|
|
404
|
+
mediaFiles: media.files,
|
|
405
|
+
mediaBytes: media.bytes,
|
|
406
|
+
quarantinedMedia: media.quarantined,
|
|
407
|
+
datasets,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export type ImportResult = {
|
|
412
|
+
/** False when any row, table or media file could not be restored. */
|
|
413
|
+
ok: boolean;
|
|
414
|
+
applied: {
|
|
415
|
+
table: string;
|
|
416
|
+
inserted: number;
|
|
417
|
+
skipped: number;
|
|
418
|
+
mode: ImportMode;
|
|
419
|
+
/** Primary keys of rows that could not be written. */
|
|
420
|
+
failedKeys: string[];
|
|
421
|
+
}[];
|
|
422
|
+
/** Tables that were not attempted because a table they depend on failed. */
|
|
423
|
+
aborted: string[];
|
|
424
|
+
mediaRestored: number;
|
|
425
|
+
/** Bytes actually written back to storage. */
|
|
426
|
+
mediaBytesRestored: number;
|
|
427
|
+
/** Media payload present in the archive for the selected groups. */
|
|
428
|
+
mediaFilesInArchive: number;
|
|
429
|
+
mediaBytesInArchive: number;
|
|
430
|
+
/** Archive entries that were not media and were deliberately not restored. */
|
|
431
|
+
quarantinedMedia: string[];
|
|
432
|
+
warnings: string[];
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
export async function importArchiveCore(
|
|
436
|
+
deps: BackupDeps,
|
|
437
|
+
caller: Caller,
|
|
438
|
+
bytes: Uint8Array,
|
|
439
|
+
opts: {
|
|
440
|
+
groups: string[];
|
|
441
|
+
mode: ImportMode;
|
|
442
|
+
includeMedia: boolean;
|
|
443
|
+
/** When set, hosted media URLs in content are rewritten to this base. */
|
|
444
|
+
rewriteMediaBase?: string | null;
|
|
445
|
+
/** Keep restored tracking/code injections switched on. Off by default. */
|
|
446
|
+
keepInjectionsEnabled?: boolean;
|
|
447
|
+
},
|
|
448
|
+
): Promise<ImportResult> {
|
|
449
|
+
const supabaseAdmin = await deps.adminClient();
|
|
450
|
+
const { entries, manifest } = parseArchive(bytes);
|
|
451
|
+
|
|
452
|
+
if (manifest.format !== ARCHIVE_FORMAT_VERSION) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`Incompatible archive — this site reads format ${ARCHIVE_FORMAT_VERSION} archives, this one is format ${manifest.format}. Re-export it from the site that produced it.`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (manifest.schemaVersion !== SCHEMA_VERSION) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`Incompatible archive — it was made for database version ${manifest.schemaVersion}, this site runs version ${SCHEMA_VERSION}.`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const selected = opts.groups.filter((g) => (manifest.groups ?? []).includes(g));
|
|
464
|
+
const specs = new Map<string, { spec: TableSpec; insertOnly: boolean }>();
|
|
465
|
+
for (const id of selected) {
|
|
466
|
+
const group = groupById(id);
|
|
467
|
+
if (!group) continue;
|
|
468
|
+
for (const spec of group.tables)
|
|
469
|
+
specs.set(spec.table, { spec, insertOnly: Boolean(group.insertOnly) });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Lineage guard: in Merge mode an archive from another database would try to
|
|
473
|
+
// change the primary key of rows that child tables already reference.
|
|
474
|
+
if (opts.mode === "merge") {
|
|
475
|
+
const clashes: string[] = [];
|
|
476
|
+
for (const [table, keyCol] of Object.entries(LINEAGE_KEYS)) {
|
|
477
|
+
if (!specs.has(table)) continue;
|
|
478
|
+
const rows = tableRows(entries, table);
|
|
479
|
+
if (!rows.length) continue;
|
|
480
|
+
const spec = specs.get(table)!.spec;
|
|
481
|
+
if (spec.pk === keyCol) continue; // key *is* the primary key — safe
|
|
482
|
+
const incoming = new Map(rows.map((r) => [String(r[keyCol]), String(r[spec.pk])]));
|
|
483
|
+
const { data } = await supabaseAdmin
|
|
484
|
+
.from(table as never)
|
|
485
|
+
.select(`${spec.pk},${keyCol}` as never);
|
|
486
|
+
for (const existing of (data ?? []) as unknown as Record<string, unknown>[]) {
|
|
487
|
+
const key = String(existing[keyCol]);
|
|
488
|
+
const incomingId = incoming.get(key);
|
|
489
|
+
if (incomingId && incomingId !== String(existing[spec.pk])) {
|
|
490
|
+
clashes.push(`${table}.${keyCol}="${key}"`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (clashes.length) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Different database lineage — use Replace on an empty/test database. ` +
|
|
497
|
+
`${clashes.length} rows already exist here under a different id: ${clashes.slice(0, 5).join(", ")}` +
|
|
498
|
+
`${clashes.length > 5 ? " …" : ""}`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const applied: ImportResult["applied"] = [];
|
|
504
|
+
const aborted: string[] = [];
|
|
505
|
+
const warnings: string[] = [];
|
|
506
|
+
const failedTables = new Set<string>();
|
|
507
|
+
const deferredWork: {
|
|
508
|
+
table: string;
|
|
509
|
+
pk: string;
|
|
510
|
+
rows: Record<string, unknown>[];
|
|
511
|
+
cols: string[];
|
|
512
|
+
}[] = [];
|
|
513
|
+
|
|
514
|
+
const base = opts.rewriteMediaBase?.trim();
|
|
515
|
+
const prepare = (table: string, row: Record<string, unknown>) => {
|
|
516
|
+
let out = base ? rewriteMediaUrls(row, base) : row;
|
|
517
|
+
if (table === "site_injections") {
|
|
518
|
+
out = neutralizeInjection(out, Boolean(opts.keepInjectionsEnabled));
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
for (const table of IMPORT_ORDER) {
|
|
524
|
+
const entry = specs.get(table);
|
|
525
|
+
if (!entry) continue;
|
|
526
|
+
|
|
527
|
+
// Never write into a table whose parent failed — it can only cascade.
|
|
528
|
+
const blockedBy = (entry.spec.requires ?? []).filter((t) => failedTables.has(t));
|
|
529
|
+
if (blockedBy.length) {
|
|
530
|
+
aborted.push(table);
|
|
531
|
+
failedTables.add(table);
|
|
532
|
+
warnings.push(`${table}: skipped because ${blockedBy.join(", ")} did not restore cleanly.`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let rows = tableRows(entries, table);
|
|
537
|
+
|
|
538
|
+
// Older/companion payload: rebuild account rows from the auth user list so
|
|
539
|
+
// role assignments have a parent row to reference.
|
|
540
|
+
if (table === "app_users" && !rows.length) {
|
|
541
|
+
const auth = tableRows(entries, "auth_users");
|
|
542
|
+
rows = auth.map((u) => ({
|
|
543
|
+
id: u["id"],
|
|
544
|
+
email: u["email"] ?? null,
|
|
545
|
+
display_name:
|
|
546
|
+
(u["user_metadata"] as Record<string, unknown> | null)?.["full_name"] ??
|
|
547
|
+
(u["user_metadata"] as Record<string, unknown> | null)?.["name"] ??
|
|
548
|
+
null,
|
|
549
|
+
created_at: u["created_at"] ?? new Date().toISOString(),
|
|
550
|
+
}));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!rows.length) continue;
|
|
554
|
+
|
|
555
|
+
const insertOnly = entry.insertOnly;
|
|
556
|
+
const mode: ImportMode = insertOnly ? "merge" : opts.mode;
|
|
557
|
+
const conflict = entry.spec.conflict ?? entry.spec.pk;
|
|
558
|
+
|
|
559
|
+
if (mode === "replace" && !insertOnly) {
|
|
560
|
+
const { error } = await supabaseAdmin
|
|
561
|
+
.from(table as never)
|
|
562
|
+
.delete()
|
|
563
|
+
.not(entry.spec.pk, "is", null);
|
|
564
|
+
if (error) {
|
|
565
|
+
warnings.push(`Could not clear ${table}: ${error.message}`);
|
|
566
|
+
failedTables.add(table);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const deferred = entry.spec.deferred ?? [];
|
|
571
|
+
const payload = rows.map((r) => {
|
|
572
|
+
const copy = prepare(table, { ...r });
|
|
573
|
+
for (const c of deferred) copy[c] = null;
|
|
574
|
+
return copy;
|
|
575
|
+
});
|
|
576
|
+
if (deferred.length) {
|
|
577
|
+
deferredWork.push({ table, pk: entry.spec.pk, rows, cols: deferred });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const write = async (chunk: Record<string, unknown>[]) => {
|
|
581
|
+
const q = insertOnly
|
|
582
|
+
? supabaseAdmin
|
|
583
|
+
.from(table as never)
|
|
584
|
+
.upsert(chunk as never, { onConflict: conflict, ignoreDuplicates: true })
|
|
585
|
+
: supabaseAdmin.from(table as never).upsert(chunk as never, { onConflict: conflict });
|
|
586
|
+
return (await q).error;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
let inserted = 0;
|
|
590
|
+
let skipped = 0;
|
|
591
|
+
const failedKeys: string[] = [];
|
|
592
|
+
for (let i = 0; i < payload.length; i += WRITE_CHUNK) {
|
|
593
|
+
const chunk = payload.slice(i, i + WRITE_CHUNK);
|
|
594
|
+
const error = await write(chunk);
|
|
595
|
+
if (!error) {
|
|
596
|
+
inserted += chunk.length;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// One bad row must not discard the rest of the batch — retry row by row.
|
|
600
|
+
warnings.push(`${table}: batch failed (${error.message}) — retrying row by row.`);
|
|
601
|
+
for (const row of chunk) {
|
|
602
|
+
const rowError = await write([row]);
|
|
603
|
+
if (rowError) {
|
|
604
|
+
skipped += 1;
|
|
605
|
+
failedKeys.push(String(row[entry.spec.pk] ?? "(unknown)"));
|
|
606
|
+
warnings.push(`${table} ${String(row[entry.spec.pk] ?? "?")}: ${rowError.message}`);
|
|
607
|
+
} else {
|
|
608
|
+
inserted += 1;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (skipped) failedTables.add(table);
|
|
613
|
+
applied.push({ table, inserted, skipped, mode, failedKeys });
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Second pass for self-referencing columns (e.g. pages.current_version_id).
|
|
617
|
+
for (const job of deferredWork) {
|
|
618
|
+
for (const row of job.rows) {
|
|
619
|
+
const patch: Record<string, unknown> = {};
|
|
620
|
+
let has = false;
|
|
621
|
+
for (const c of job.cols) {
|
|
622
|
+
if (row[c] != null) {
|
|
623
|
+
patch[c] = row[c];
|
|
624
|
+
has = true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (!has) continue;
|
|
628
|
+
const { error } = await supabaseAdmin
|
|
629
|
+
.from(job.table as never)
|
|
630
|
+
.update(patch as never)
|
|
631
|
+
.eq(job.pk, row[job.pk] as never);
|
|
632
|
+
if (error) {
|
|
633
|
+
warnings.push(`${job.table} link: ${error.message}`);
|
|
634
|
+
failedTables.add(job.table);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const archiveMedia = selected.includes("mediaFiles")
|
|
640
|
+
? measureMedia(entries)
|
|
641
|
+
: { files: 0, bytes: 0, quarantined: [] as string[] };
|
|
642
|
+
|
|
643
|
+
let mediaRestored = 0;
|
|
644
|
+
let mediaBytesRestored = 0;
|
|
645
|
+
if (opts.includeMedia && selected.includes("mediaFiles")) {
|
|
646
|
+
const storage = getMediaStorage(caller.supabase);
|
|
647
|
+
for (const key of Object.keys(entries)) {
|
|
648
|
+
if (!key.startsWith("media/")) continue;
|
|
649
|
+
const name = key.slice("media/".length);
|
|
650
|
+
if (!name) continue;
|
|
651
|
+
// Entries whose own bytes are not verified media (e.g. saved HTML pages)
|
|
652
|
+
// are never written back: serving them from the media origin is unsafe.
|
|
653
|
+
const bytes = entries[key]!;
|
|
654
|
+
if (!isSafeArchivePath(name) || !verifyMediaFile(name, bytes).ok) continue;
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
await storage.upload(name, bytes, contentTypeFor(name), { upsert: true });
|
|
658
|
+
mediaRestored += 1;
|
|
659
|
+
mediaBytesRestored += bytes.byteLength;
|
|
660
|
+
} catch (e) {
|
|
661
|
+
warnings.push(`media ${name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (mediaRestored !== archiveMedia.files || mediaBytesRestored !== archiveMedia.bytes) {
|
|
665
|
+
warnings.push(
|
|
666
|
+
`Media restore incomplete — ${mediaRestored.toLocaleString()}/${archiveMedia.files.toLocaleString()} files (${mediaBytesRestored.toLocaleString()}/${archiveMedia.bytes.toLocaleString()} bytes) written.`,
|
|
667
|
+
);
|
|
668
|
+
failedTables.add("media");
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (archiveMedia.quarantined.length) {
|
|
672
|
+
warnings.push(
|
|
673
|
+
`${archiveMedia.quarantined.length} archive entries were not media and were not restored: ${archiveMedia.quarantined
|
|
674
|
+
.slice(0, 5)
|
|
675
|
+
.join(", ")}${archiveMedia.quarantined.length > 5 ? " …" : ""}`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (selected.includes("users")) {
|
|
680
|
+
warnings.push(
|
|
681
|
+
"Sign-in credentials cannot be restored — accounts and roles are back, but every user must set a new password.",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const ok = failedTables.size === 0 && aborted.length === 0;
|
|
686
|
+
|
|
687
|
+
await audit(deps, caller, "backup.import", {
|
|
688
|
+
groups: selected,
|
|
689
|
+
mode: opts.mode,
|
|
690
|
+
ok,
|
|
691
|
+
applied,
|
|
692
|
+
aborted,
|
|
693
|
+
rewriteMediaBase: base ?? null,
|
|
694
|
+
mediaRestored,
|
|
695
|
+
mediaBytesRestored,
|
|
696
|
+
mediaFilesInArchive: archiveMedia.files,
|
|
697
|
+
mediaBytesInArchive: archiveMedia.bytes,
|
|
698
|
+
quarantinedMedia: archiveMedia.quarantined.length,
|
|
699
|
+
warnings: warnings.slice(0, 20),
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
ok,
|
|
704
|
+
applied,
|
|
705
|
+
aborted,
|
|
706
|
+
mediaRestored,
|
|
707
|
+
mediaBytesRestored,
|
|
708
|
+
mediaFilesInArchive: archiveMedia.files,
|
|
709
|
+
mediaBytesInArchive: archiveMedia.bytes,
|
|
710
|
+
quarantinedMedia: archiveMedia.quarantined,
|
|
711
|
+
warnings,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function contentTypeFor(name: string): string {
|
|
716
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
717
|
+
const map: Record<string, string> = {
|
|
718
|
+
".webp": "image/webp",
|
|
719
|
+
".jpg": "image/jpeg",
|
|
720
|
+
".jpeg": "image/jpeg",
|
|
721
|
+
".png": "image/png",
|
|
722
|
+
".gif": "image/gif",
|
|
723
|
+
".svg": "image/svg+xml",
|
|
724
|
+
".avif": "image/avif",
|
|
725
|
+
".mp4": "video/mp4",
|
|
726
|
+
".webm": "video/webm",
|
|
727
|
+
".mov": "video/quicktime",
|
|
728
|
+
".pdf": "application/pdf",
|
|
729
|
+
};
|
|
730
|
+
return map[ext] ?? "application/octet-stream";
|
|
731
|
+
}
|