@bygghammar/cms-platform 0.38.1 → 0.40.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.0
4
+
5
+ - New `backoffice/shell`: shared `BackofficeShell` (auth gate, no-access screen,
6
+ sidebar, sign-in form), a feature-flag driven `backofficeNav(siteConfig)` model
7
+ and `backofficeRedirectHost` for the admin-domain guard. A site's backoffice
8
+ layout route is now a thin adapter that supplies its auth client and admin check.
9
+
10
+ ## 0.39.0
11
+
12
+ - 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.
13
+
3
14
  ## 0.38.1
4
15
 
5
16
  - 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.38.1",
3
+ "version": "0.40.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,9 @@
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",
158
+ "./backoffice/shell": "./src/backoffice/shell/index.ts"
157
159
  },
158
160
  "dependencies": {
159
161
  "fflate": "^0.8.3"
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Shared backoffice shell: auth gate, no-access screen, sidebar and content slot.
3
+ *
4
+ * A host route supplies its auth client, an admin check and the nav model; the
5
+ * layout, states and sign-in form are identical for every site.
6
+ */
7
+ import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
8
+ import { ExternalLink, LogOut } from "lucide-react";
9
+ import { useEffect, useState, type FormEvent, type ReactNode } from "react";
10
+ import { toast } from "sonner";
11
+
12
+ import { isBackofficeNavigationActive } from "../../navigation";
13
+ import type { BackofficeNavItem } from "./nav-model";
14
+
15
+ export type ShellAuthUser = { email?: string | null } | null;
16
+
17
+ export type ShellAuthAdapter = {
18
+ /** Current session user, or null when signed out. */
19
+ getUser: () => Promise<ShellAuthUser>;
20
+ /** Subscribe to session changes; returns an unsubscribe function. */
21
+ onUserChange: (handler: (user: ShellAuthUser) => void) => () => void;
22
+ signIn: (email: string, password: string) => Promise<{ error?: string | null }>;
23
+ signOut: () => Promise<unknown>;
24
+ /** True only for a genuine hosted misconfiguration (not "signed out"). */
25
+ isConfigBroken?: () => boolean;
26
+ };
27
+
28
+ export type BackofficeShellProps = {
29
+ /** Site name shown in the sidebar header. */
30
+ siteName: string;
31
+ /** Backoffice base path, e.g. `/kontoret`. */
32
+ backofficePath: string;
33
+ nav: BackofficeNavItem[];
34
+ auth: ShellAuthAdapter;
35
+ /** Resolves whether the signed-in user may use the backoffice. */
36
+ checkIsAdmin: () => Promise<boolean>;
37
+ /** localStorage key for the "remember me" email. */
38
+ rememberKey?: string;
39
+ /** Route content — normally `<Outlet />`. */
40
+ children: ReactNode;
41
+ };
42
+
43
+ type State =
44
+ | { kind: "loading" }
45
+ | { kind: "signed-out" }
46
+ | { kind: "config-error" }
47
+ | { kind: "not-admin"; email: string }
48
+ | { kind: "admin"; email: string };
49
+
50
+ export function BackofficeShell(props: BackofficeShellProps) {
51
+ const { auth, checkIsAdmin } = props;
52
+ const [state, setState] = useState<State>({ kind: "loading" });
53
+
54
+ useEffect(() => {
55
+ let alive = true;
56
+
57
+ const resolve = async (user: ShellAuthUser) => {
58
+ if (!alive) return;
59
+ if (!user) {
60
+ setState(auth.isConfigBroken?.() ? { kind: "config-error" } : { kind: "signed-out" });
61
+ return;
62
+ }
63
+ try {
64
+ const ok = await checkIsAdmin();
65
+ if (!alive) return;
66
+ setState(
67
+ ok
68
+ ? { kind: "admin", email: user.email ?? "" }
69
+ : { kind: "not-admin", email: user.email ?? "" },
70
+ );
71
+ } catch {
72
+ if (alive) setState({ kind: "signed-out" });
73
+ }
74
+ };
75
+
76
+ void auth.getUser().then(resolve);
77
+ const unsubscribe = auth.onUserChange(resolve);
78
+
79
+ return () => {
80
+ alive = false;
81
+ unsubscribe();
82
+ };
83
+ }, [auth, checkIsAdmin]);
84
+
85
+ if (state.kind === "loading") {
86
+ return (
87
+ <main className="min-h-screen bg-ink text-paper grid place-items-center">
88
+ <div className="text-sm uppercase tracking-[0.22em] text-paper/60">Loading…</div>
89
+ </main>
90
+ );
91
+ }
92
+
93
+ if (state.kind === "config-error") {
94
+ return (
95
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6">
96
+ <div className="max-w-md text-center">
97
+ <h1 className="font-serif text-3xl">Authentication unavailable</h1>
98
+ <p className="mt-4 text-paper/70">
99
+ The backend configuration for this environment is missing or invalid, so the backoffice
100
+ cannot verify your account. The public website is unaffected. Check the deployment
101
+ configuration and reload this page.
102
+ </p>
103
+ </div>
104
+ </main>
105
+ );
106
+ }
107
+
108
+ if (state.kind === "signed-out") {
109
+ return <AuthGate auth={auth} rememberKey={props.rememberKey ?? "cms.backoffice.remember"} />;
110
+ }
111
+
112
+ if (state.kind === "not-admin") {
113
+ return (
114
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6">
115
+ <div className="max-w-md text-center">
116
+ <h1 className="font-serif text-3xl">No access</h1>
117
+ <p className="mt-4 text-paper/70">
118
+ The account <span className="text-paper">{state.email}</span> does not have
119
+ administrator access to the backoffice.
120
+ </p>
121
+ <button
122
+ className="mt-8 inline-flex items-center border border-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-paper hover:text-ink transition-colors"
123
+ onClick={() => void auth.signOut()}
124
+ >
125
+ Sign out
126
+ </button>
127
+ </div>
128
+ </main>
129
+ );
130
+ }
131
+
132
+ return <Shell {...props} email={state.email} />;
133
+ }
134
+
135
+ function Shell({
136
+ siteName,
137
+ nav,
138
+ auth,
139
+ children,
140
+ }: BackofficeShellProps & { email: string }) {
141
+ const pathname = useRouterState({ select: (r) => r.location.pathname });
142
+
143
+ return (
144
+ <div className="min-h-screen bg-muted text-ink grid grid-cols-1 md:grid-cols-[240px_1fr]">
145
+ <aside className="bg-ink text-paper md:min-h-screen flex md:flex-col">
146
+ <div className="p-6 border-b border-paper/10">
147
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-paper/60">Backoffice</div>
148
+ <div className="mt-1 font-serif text-lg">{siteName} Backoffice</div>
149
+ </div>
150
+ <nav className="flex md:flex-col overflow-x-auto md:overflow-visible p-2 md:p-4 gap-1 flex-1">
151
+ {nav.map((n) => (
152
+ <Link
153
+ key={n.to}
154
+ to={n.to}
155
+ className={[
156
+ "flex items-center gap-3 px-4 py-2.5 text-sm transition-colors whitespace-nowrap",
157
+ isBackofficeNavigationActive(pathname, { to: n.to, exact: n.exact })
158
+ ? "bg-paper text-ink"
159
+ : "text-paper/75 hover:text-paper hover:bg-paper/10",
160
+ ].join(" ")}
161
+ >
162
+ <n.icon className="size-4" />
163
+ {n.label}
164
+ </Link>
165
+ ))}
166
+ <a
167
+ href="/"
168
+ target="_blank"
169
+ rel="noreferrer"
170
+ className="mt-2 md:mt-3 md:border-t md:border-paper/10 md:pt-3 flex items-center gap-3 px-4 py-2.5 text-sm whitespace-nowrap text-paper/75 transition-colors hover:text-paper hover:bg-paper/10"
171
+ >
172
+ <ExternalLink className="size-4" />
173
+ View website
174
+ </a>
175
+ </nav>
176
+ <SignedInPanel auth={auth} />
177
+ </aside>
178
+ <div className="flex-1 min-w-0">{children}</div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function SignedInPanel({ auth }: { auth: ShellAuthAdapter }) {
184
+ const [email, setEmail] = useState("");
185
+
186
+ useEffect(() => {
187
+ let alive = true;
188
+ void auth.getUser().then((user) => {
189
+ if (alive) setEmail(user?.email ?? "");
190
+ });
191
+ return () => {
192
+ alive = false;
193
+ };
194
+ }, [auth]);
195
+
196
+ return (
197
+ <div className="p-4 border-t border-paper/10 hidden md:block">
198
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/50">Signed in</div>
199
+ <div className="mt-1 text-sm text-paper truncate" title={email}>
200
+ {email}
201
+ </div>
202
+ <button
203
+ className="mt-4 inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-paper/70 hover:text-paper"
204
+ onClick={async () => {
205
+ await auth.signOut();
206
+ toast.success("Signed out");
207
+ }}
208
+ >
209
+ <LogOut className="size-3.5" />
210
+ Sign out
211
+ </button>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function AuthGate({ auth, rememberKey }: { auth: ShellAuthAdapter; rememberKey: string }) {
217
+ const [email, setEmail] = useState("");
218
+ const [password, setPassword] = useState("");
219
+ const [remember, setRemember] = useState(false);
220
+ const [busy, setBusy] = useState(false);
221
+ const navigate = useNavigate();
222
+
223
+ useEffect(() => {
224
+ const saved = window.localStorage.getItem(rememberKey);
225
+ if (saved) {
226
+ setEmail(saved);
227
+ setRemember(true);
228
+ }
229
+ }, [rememberKey]);
230
+
231
+ const submit = async (e: FormEvent) => {
232
+ e.preventDefault();
233
+ setBusy(true);
234
+ try {
235
+ const { error } = await auth.signIn(email, password);
236
+ if (error) throw new Error(error);
237
+ if (remember) window.localStorage.setItem(rememberKey, email);
238
+ else window.localStorage.removeItem(rememberKey);
239
+ toast.success("Signed in");
240
+ } catch (err) {
241
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
242
+ } finally {
243
+ setBusy(false);
244
+ }
245
+ };
246
+
247
+ return (
248
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6 py-16">
249
+ <div className="w-full max-w-sm">
250
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-paper/60">Backoffice</div>
251
+ <h1 className="mt-3 font-serif text-3xl">Sign in</h1>
252
+ <p className="mt-3 text-sm text-paper/60">
253
+ Only authorized administrators can access the backoffice.
254
+ </p>
255
+
256
+ <form onSubmit={submit} className="mt-8 space-y-4">
257
+ <label className="block">
258
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/60">Email</span>
259
+ <input
260
+ type="email"
261
+ required
262
+ value={email}
263
+ onChange={(e) => setEmail(e.target.value)}
264
+ className="mt-2 w-full bg-transparent border border-paper/25 px-4 py-3 text-paper placeholder:text-paper/40 focus:border-sea focus:outline-none"
265
+ autoComplete="email"
266
+ />
267
+ </label>
268
+ <label className="block">
269
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/60">
270
+ Password
271
+ </span>
272
+ <input
273
+ type="password"
274
+ required
275
+ minLength={8}
276
+ value={password}
277
+ onChange={(e) => setPassword(e.target.value)}
278
+ className="mt-2 w-full bg-transparent border border-paper/25 px-4 py-3 text-paper placeholder:text-paper/40 focus:border-sea focus:outline-none"
279
+ autoComplete="current-password"
280
+ />
281
+ </label>
282
+ <label className="flex items-center gap-3 text-xs text-paper/70 select-none cursor-pointer">
283
+ <input
284
+ type="checkbox"
285
+ checked={remember}
286
+ onChange={(e) => setRemember(e.target.checked)}
287
+ className="size-4 accent-sea bg-transparent border border-paper/25"
288
+ />
289
+ Remember me
290
+ </label>
291
+ <button
292
+ type="submit"
293
+ disabled={busy}
294
+ className="mt-2 w-full inline-flex items-center justify-center border border-paper bg-paper text-ink px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-transparent hover:text-paper transition-colors disabled:opacity-60"
295
+ >
296
+ {busy ? "Working…" : "Sign in"}
297
+ </button>
298
+ </form>
299
+
300
+ <div className="mt-10 text-center">
301
+ <button
302
+ onClick={() => navigate({ to: "/" })}
303
+ className="text-xs uppercase tracking-[0.22em] text-paper/50 hover:text-paper"
304
+ >
305
+ ← Back to website
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </main>
310
+ );
311
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Admin-domain guard for the backoffice route.
3
+ *
4
+ * Returns the host the backoffice should be served from, or null when the
5
+ * current host is fine (local dev, Lovable preview, or already the admin host).
6
+ */
7
+ const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
8
+
9
+ function normalizeHost(value: string): string {
10
+ return value
11
+ .replace(/^https?:\/\//, "")
12
+ .replace(/\/+$/, "")
13
+ .replace(/^www\./, "")
14
+ .toLowerCase();
15
+ }
16
+
17
+ /** True for hosts where the admin-domain guard must not apply. */
18
+ export function isDevelopmentHost(host: string): boolean {
19
+ const h = normalizeHost(host);
20
+ return (
21
+ DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
22
+ h === "localhost" ||
23
+ h.startsWith("localhost:") ||
24
+ h.startsWith("127.0.0.1")
25
+ );
26
+ }
27
+
28
+ /** Resolve the admin host to redirect to, or null to stay put. */
29
+ export function backofficeRedirectHost(
30
+ currentHost: string,
31
+ adminDomain: string | null | undefined,
32
+ ): string | null {
33
+ if (!currentHost || isDevelopmentHost(currentHost)) return null;
34
+ const current = normalizeHost(currentHost);
35
+ const admin = normalizeHost(adminDomain ?? "");
36
+ if (!admin || current === admin) return null;
37
+ return admin;
38
+ }
@@ -0,0 +1,9 @@
1
+ export { BackofficeShell } from "./BackofficeShell";
2
+ export type {
3
+ BackofficeShellProps,
4
+ ShellAuthAdapter,
5
+ ShellAuthUser,
6
+ } from "./BackofficeShell";
7
+ export { backofficeNav } from "./nav-model";
8
+ export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "./nav-model";
9
+ export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Backoffice navigation model.
3
+ *
4
+ * The nav is derived from `SiteConfig` (backoffice base path + feature flags)
5
+ * so every site gets the same sidebar without copying a list of links.
6
+ * A host may override labels or drop/add items through `options`.
7
+ */
8
+ import type { LucideProps } from "lucide-react";
9
+ import {
10
+ Blocks,
11
+ Calendar,
12
+ ClipboardList,
13
+ FileText,
14
+ Globe,
15
+ GraduationCap,
16
+ ImageIcon,
17
+ Layers,
18
+ LayoutDashboard,
19
+ LayoutList,
20
+ MessageSquareQuote,
21
+ ShieldCheck,
22
+ } from "lucide-react";
23
+
24
+ import type { SiteConfig } from "../../config/site-config";
25
+ import type { BackofficeNavigationItem } from "../../navigation";
26
+
27
+ export type BackofficeNavKey =
28
+ | "dashboard"
29
+ | "pages"
30
+ | "global-sections"
31
+ | "blocks"
32
+ | "sections"
33
+ | "media"
34
+ | "seo"
35
+ | "domains"
36
+ | "trips"
37
+ | "courses"
38
+ | "forms"
39
+ | "reviews"
40
+ | "admin";
41
+
42
+ export type BackofficeNavItem = BackofficeNavigationItem<LucideProps>;
43
+
44
+ type Definition = {
45
+ key: BackofficeNavKey;
46
+ path: string;
47
+ label: string;
48
+ icon: BackofficeNavItem["icon"];
49
+ exact?: boolean;
50
+ /** Feature flag that has to be enabled for the item to appear. */
51
+ feature?: keyof SiteConfig["features"];
52
+ };
53
+
54
+ const DEFINITIONS: readonly Definition[] = [
55
+ { key: "dashboard", path: "", label: "Dashboard", icon: LayoutDashboard, exact: true },
56
+ { key: "pages", path: "/pages-builder", label: "Page builder", icon: Blocks },
57
+ { key: "global-sections", path: "/global-sections", label: "Global Sections", icon: Layers },
58
+ { key: "blocks", path: "/blocks", label: "Block library", icon: Blocks },
59
+ { key: "sections", path: "/sections", label: "Shared content", icon: LayoutList },
60
+ { key: "media", path: "/media", label: "Media", icon: ImageIcon },
61
+ { key: "seo", path: "/seo", label: "SEO", icon: FileText },
62
+ { key: "domains", path: "/domains", label: "Domains", icon: Globe, feature: "domains" },
63
+ { key: "trips", path: "/trips", label: "Trips", icon: Calendar, feature: "trips" },
64
+ { key: "courses", path: "/kurser", label: "Courses", icon: GraduationCap, feature: "courses" },
65
+ { key: "forms", path: "/forms", label: "Forms", icon: ClipboardList, feature: "forms" },
66
+ {
67
+ key: "reviews",
68
+ path: "/recensioner",
69
+ label: "Reviews",
70
+ icon: MessageSquareQuote,
71
+ feature: "reviews",
72
+ },
73
+ { key: "admin", path: "/admin", label: "Admin", icon: ShieldCheck },
74
+ ];
75
+
76
+ export type BackofficeNavOptions = {
77
+ /** Per-key label overrides, e.g. `{ trips: "Cruises" }`. */
78
+ labels?: Partial<Record<BackofficeNavKey, string>>;
79
+ /** Keys to hide regardless of feature flags. */
80
+ hide?: readonly BackofficeNavKey[];
81
+ };
82
+
83
+ /** Build the sidebar model for a site. */
84
+ export function backofficeNav(
85
+ config: SiteConfig,
86
+ options: BackofficeNavOptions = {},
87
+ ): BackofficeNavItem[] {
88
+ const base = config.backofficePath.replace(/\/+$/, "");
89
+ const hidden = new Set(options.hide ?? []);
90
+
91
+ return DEFINITIONS.filter((d) => {
92
+ if (hidden.has(d.key)) return false;
93
+ if (d.feature && config.features[d.feature] !== true) return false;
94
+ return true;
95
+ }).map((d) => ({
96
+ key: d.key,
97
+ to: `${base}${d.path}` || "/",
98
+ label: options.labels?.[d.key] ?? d.label,
99
+ icon: d.icon,
100
+ exact: d.exact === true,
101
+ }));
102
+ }
@@ -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
+ }