@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.
|
|
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
|
+
}
|