@morphika/andami 0.2.9 → 0.2.10

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.
@@ -0,0 +1,76 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { Readable } from "node:stream";
3
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
4
+ import { checkRateLimit, jsonError } from "../../../../../lib/security";
5
+ import { createBackupStream } from "../../../../../lib/backup/export";
6
+ import { logger } from "../../../../../lib/logger";
7
+
8
+ /**
9
+ * GET /api/admin/backups/export — Stream a full site backup as a ZIP file.
10
+ *
11
+ * @deprecated V1 endpoint — routes all binary data through Vercel.
12
+ * Use `GET /api/admin/backups/prepare-export` (V2) instead, which returns
13
+ * lightweight JSON and lets the browser download assets directly from R2.
14
+ * This endpoint is kept for backwards compatibility with small sites and
15
+ * local development. Will be removed in a future version.
16
+ *
17
+ * The response is a streaming ZIP containing:
18
+ * - manifest.json (backup metadata)
19
+ * - sanity/*.json (all CMS documents)
20
+ * - assets/** (all R2 files, preserving folder structure)
21
+ *
22
+ * Auth required. Rate limited to 1 per minute (backups are expensive).
23
+ * No CSRF check needed — GET request, no mutation.
24
+ */
25
+ export async function GET(request: NextRequest) {
26
+ // L-9: instance-level kill switch for the V1 flow.
27
+ // Setting `BACKUPS_V1_DISABLED=true` in the instance env disables this
28
+ // endpoint cleanly with a 410 Gone. Hosts on Vercel Hobby that only
29
+ // want the V2 client-side flow can flip this without redeploying the
30
+ // framework. We accept both the `NEXT_PUBLIC_*` and server-only env
31
+ // names — either is fine since this endpoint runs server-side.
32
+ const v1Disabled =
33
+ process.env.BACKUPS_V1_DISABLED === "true" ||
34
+ process.env.NEXT_PUBLIC_BACKUPS_V1_DISABLED === "true";
35
+ if (v1Disabled) {
36
+ return jsonError(
37
+ "V1 backup export is disabled on this instance. Use the V2 client-side flow (GET /api/admin/backups/prepare-export).",
38
+ 410
39
+ );
40
+ }
41
+
42
+ if (!(await isAdminAuthenticated())) {
43
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
44
+ }
45
+
46
+ // Rate limit: 1 backup per minute per IP
47
+ const ip = request.headers.get("x-forwarded-for") || "unknown";
48
+ if (!checkRateLimit(`backup-export:${ip}`, 1, 60_000)) {
49
+ return jsonError("Backup already in progress. Please wait.", 429);
50
+ }
51
+
52
+ try {
53
+ logger.info("[Admin:Backup]", "Starting backup export...");
54
+
55
+ const { stream, filename } = await createBackupStream();
56
+
57
+ // Convert Node.js Readable to Web ReadableStream for the Response
58
+ const webStream = Readable.toWeb(stream) as ReadableStream;
59
+
60
+ return new Response(webStream, {
61
+ status: 200,
62
+ headers: {
63
+ "Content-Type": "application/zip",
64
+ "Content-Disposition": `attachment; filename="${filename}"`,
65
+ // No caching — each backup is unique
66
+ "Cache-Control": "no-store",
67
+ },
68
+ });
69
+ } catch (err) {
70
+ logger.error("[Admin:Backup]", "Backup export failed", err);
71
+ return jsonError(
72
+ err instanceof Error ? err.message : "Backup export failed",
73
+ 500
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,56 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { checkRateLimit, jsonError } from "../../../../../lib/security";
4
+ import { prepareExportData } from "../../../../../lib/backup/export";
5
+ import { logger } from "../../../../../lib/logger";
6
+
7
+ /**
8
+ * GET /api/admin/backups/prepare-export — V2 client-side export data.
9
+ *
10
+ * Returns all data needed for the browser to build the backup ZIP locally:
11
+ * - manifest: backup metadata
12
+ * - documents: all Sanity documents (R2 credentials stripped)
13
+ * - assets: R2 public URL + flat file list for direct browser download
14
+ *
15
+ * The browser then:
16
+ * 1. Downloads each asset directly from R2 CDN
17
+ * 2. Builds the ZIP in memory with JSZip
18
+ * 3. Triggers a browser download
19
+ *
20
+ * This keeps all binary asset traffic off Vercel — the response is
21
+ * lightweight JSON (typically 50–500 KB). Completes in <2 seconds.
22
+ *
23
+ * Auth required. Rate limited to 1 per minute.
24
+ */
25
+ export async function GET(request: NextRequest) {
26
+ if (!(await isAdminAuthenticated())) {
27
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
28
+ }
29
+
30
+ // Rate limit: 1 per minute per IP
31
+ const ip = request.headers.get("x-forwarded-for") || "unknown";
32
+ if (!checkRateLimit(`backup-prepare-export:${ip}`, 1, 60_000)) {
33
+ return jsonError("Export already in progress. Please wait.", 429);
34
+ }
35
+
36
+ try {
37
+ logger.info("[Admin:Backup]", "Preparing V2 export data...");
38
+
39
+ const data = await prepareExportData();
40
+
41
+ logger.info(
42
+ "[Admin:Backup]",
43
+ `V2 export prepared: ${data.documents.pages.length} pages, ${data.assets.files.length} assets`
44
+ );
45
+
46
+ return NextResponse.json(data, {
47
+ headers: { "Cache-Control": "no-store" },
48
+ });
49
+ } catch (err) {
50
+ logger.error("[Admin:Backup]", "Prepare export failed", err);
51
+ return jsonError(
52
+ err instanceof Error ? err.message : "Prepare export failed",
53
+ 500
54
+ );
55
+ }
56
+ }
@@ -0,0 +1,131 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import { checkRateLimit, jsonError } from "../../../../../lib/security";
5
+ import { restoreFromBackup } from "../../../../../lib/backup/restore";
6
+ import { logger } from "../../../../../lib/logger";
7
+
8
+ /** Max upload size: 500 MB */
9
+ const MAX_RESTORE_SIZE = 500 * 1024 * 1024;
10
+
11
+ /**
12
+ * POST /api/admin/backups/restore — Restore site from a backup ZIP.
13
+ *
14
+ * @deprecated V1 endpoint — receives the entire ZIP (including binary assets)
15
+ * through Vercel, hitting the 4.5 MB body limit and 10s function timeout.
16
+ * Use `POST /api/admin/backups/restore-data` (V2) instead, which accepts
17
+ * only JSON documents. The browser uploads assets directly to R2 via
18
+ * presigned PUT URLs. This endpoint is kept for backwards compatibility.
19
+ * Will be removed in a future version.
20
+ *
21
+ * Accepts multipart/form-data with a single "file" field containing
22
+ * the .zip backup archive. Performs a full wipe-and-replace:
23
+ * 1. Validates the backup manifest
24
+ * 2. Wipes all Sanity documents
25
+ * 3. Restores documents from the backup
26
+ * 4. Wipes and re-uploads R2 assets (if R2 is connected)
27
+ *
28
+ * Auth + CSRF required. Rate limited to 1 per 5 minutes.
29
+ */
30
+ export async function POST(request: NextRequest) {
31
+ // L-9: instance-level kill switch for the V1 flow — same env var used
32
+ // by `GET /api/admin/backups/export`. Returns 410 Gone so the admin UI
33
+ // can surface a clear "V1 disabled" message rather than a confusing
34
+ // 401/405 when a user tries to hit the legacy route.
35
+ const v1Disabled =
36
+ process.env.BACKUPS_V1_DISABLED === "true" ||
37
+ process.env.NEXT_PUBLIC_BACKUPS_V1_DISABLED === "true";
38
+ if (v1Disabled) {
39
+ return jsonError(
40
+ "V1 backup restore is disabled on this instance. Use the V2 client-side flow (POST /api/admin/backups/restore-data).",
41
+ 410
42
+ );
43
+ }
44
+
45
+ if (!(await isAdminAuthenticated())) {
46
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
47
+ }
48
+ if (!validateCsrf(request)) {
49
+ return csrfErrorResponse();
50
+ }
51
+
52
+ // Rate limit: 1 restore per 5 minutes per IP
53
+ const ip = request.headers.get("x-forwarded-for") || "unknown";
54
+ if (!checkRateLimit(`backup-restore:${ip}`, 1, 300_000)) {
55
+ return jsonError(
56
+ "A restore is already in progress or was recently completed. Please wait 5 minutes.",
57
+ 429
58
+ );
59
+ }
60
+
61
+ try {
62
+ // Parse multipart form data
63
+ const contentType = request.headers.get("content-type") || "";
64
+ if (!contentType.includes("multipart/form-data")) {
65
+ return jsonError(
66
+ "Expected multipart/form-data with a ZIP file",
67
+ 400
68
+ );
69
+ }
70
+
71
+ // Check Content-Length before reading the body
72
+ const contentLength = request.headers.get("content-length");
73
+ if (contentLength) {
74
+ const size = parseInt(contentLength, 10);
75
+ if (!isNaN(size) && size > MAX_RESTORE_SIZE) {
76
+ return jsonError(
77
+ `Backup file too large. Maximum size is ${MAX_RESTORE_SIZE / 1024 / 1024} MB.`,
78
+ 413
79
+ );
80
+ }
81
+ }
82
+
83
+ const formData = await request.formData();
84
+ const file = formData.get("file");
85
+
86
+ if (!file || !(file instanceof File)) {
87
+ return jsonError(
88
+ 'Missing "file" field in form data. Upload the backup .zip file.',
89
+ 400
90
+ );
91
+ }
92
+
93
+ // Validate file size
94
+ if (file.size > MAX_RESTORE_SIZE) {
95
+ return jsonError(
96
+ `Backup file too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is ${MAX_RESTORE_SIZE / 1024 / 1024} MB.`,
97
+ 413
98
+ );
99
+ }
100
+
101
+ // Validate file type (basic check)
102
+ if (!file.name.endsWith(".zip") && file.type !== "application/zip") {
103
+ return jsonError("Only .zip backup files are accepted", 400);
104
+ }
105
+
106
+ logger.info(
107
+ "[Admin:Backup]",
108
+ `Starting restore from "${file.name}" (${(file.size / 1024 / 1024).toFixed(1)} MB)`
109
+ );
110
+
111
+ // Read the file into a Buffer
112
+ const arrayBuffer = await file.arrayBuffer();
113
+ const zipBuffer = Buffer.from(arrayBuffer);
114
+
115
+ // Run the restore
116
+ const result = await restoreFromBackup(zipBuffer);
117
+
118
+ logger.info(
119
+ "[Admin:Backup]",
120
+ `Restore completed in ${(result.duration_ms / 1000).toFixed(1)}s`
121
+ );
122
+
123
+ return NextResponse.json(result);
124
+ } catch (err) {
125
+ logger.error("[Admin:Backup]", "Restore failed", err);
126
+ return jsonError(
127
+ err instanceof Error ? err.message : "Restore failed",
128
+ 500
129
+ );
130
+ }
131
+ }
@@ -0,0 +1,424 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import {
5
+ checkRateLimit,
6
+ jsonError,
7
+ isValidAssetPath,
8
+ isBodyTooLarge,
9
+ MAX_PAGE_BODY_SIZE,
10
+ } from "../../../../../lib/security";
11
+ import { validateManifest } from "../../../../../lib/backup/manifest";
12
+ import { wipeAllDocuments, restoreDocuments, type AndamiDocType } from "../../../../../lib/backup/sanity-ops";
13
+ import {
14
+ getR2Config,
15
+ createS3Client,
16
+ deleteAllR2Files,
17
+ generatePresignedUploadUrls,
18
+ type PresignedUpload,
19
+ } from "../../../../../lib/backup/r2-helpers";
20
+ import { isBackupAssetFile } from "../../../../../lib/storage/types";
21
+ import { logger } from "../../../../../lib/logger";
22
+
23
+ /**
24
+ * Bounds for the presigned PUT URL TTL requested by the client (M-8).
25
+ *
26
+ * The server always clamps the requested TTL to a safe range:
27
+ * - `MIN_UPLOAD_TTL_SECONDS` (5 min): the minimum useful upload window.
28
+ * Anything shorter and even medium assets can time out mid-flight.
29
+ * - `MAX_UPLOAD_TTL_SECONDS` (1 hour): matches S3/R2's maximum presigned
30
+ * URL TTL for SigV4 credentials. Beyond 1h the signature is rejected.
31
+ * - `DEFAULT_UPLOAD_TTL_SECONDS` (30 min): safe default for typical
32
+ * portfolio sites with 100–200 assets at ~1 MB each over home fiber.
33
+ */
34
+ const MIN_UPLOAD_TTL_SECONDS = 300;
35
+ const MAX_UPLOAD_TTL_SECONDS = 3600;
36
+ const DEFAULT_UPLOAD_TTL_SECONDS = 1800;
37
+
38
+ /** Max number of asset keys the client can request presigning for in one call. */
39
+ const MAX_ASSET_KEYS = 5000;
40
+
41
+ /**
42
+ * Map of document-section keys in the request body to Sanity `_type` values.
43
+ * Defined at module scope so both the early validator and the restore loop
44
+ * share one source of truth.
45
+ */
46
+ const DOC_MAP: { key: string; type: AndamiDocType }[] = [
47
+ { key: "pages", type: "page" },
48
+ { key: "siteSettings", type: "siteSettings" },
49
+ { key: "siteStyles", type: "siteStyles" },
50
+ { key: "assetRegistry", type: "assetRegistry" },
51
+ { key: "customSections", type: "customSection" },
52
+ ];
53
+
54
+ /**
55
+ * POST /api/admin/backups/restore-data — V2 Sanity-only restore.
56
+ *
57
+ * Receives only Sanity documents (JSON), performs wipe + restore, and
58
+ * optionally pre-signs all asset upload URLs server-side so the browser
59
+ * can push binaries straight to R2 without invoking Vercel once per file.
60
+ *
61
+ * Request body (JSON, typically 50 KB–5 MB — hard cap = MAX_PAGE_BODY_SIZE):
62
+ * {
63
+ * manifest: BackupManifest,
64
+ * documents: { pages, siteSettings, siteStyles, assetRegistry, customSections },
65
+ * wipe_r2: boolean, // whether to wipe existing R2 assets
66
+ * assets?: Array<{ key: string, size: number }>, // keys to pre-sign
67
+ * upload_ttl_seconds?: number // clamped to [MIN..MAX]
68
+ * }
69
+ *
70
+ * Response:
71
+ * {
72
+ * success: boolean,
73
+ * sanity: { wiped: Record<string, number>, restored: Record<string, number> },
74
+ * r2_wiped: number | null,
75
+ * upload_urls?: Array<{ key: string, uploadUrl: string, contentType: string }>,
76
+ * upload_ttl_seconds?: number,
77
+ * duration_ms: number
78
+ * }
79
+ *
80
+ * This endpoint is lightweight — body is JSON only (well within Vercel's
81
+ * 4.5 MB limit, and we enforce a stricter 5 MB application-level cap via
82
+ * `MAX_PAGE_BODY_SIZE` to prevent huge bodies from burning CPU on a parse
83
+ * that will fail anyway). Generating hundreds of presigned URLs is pure
84
+ * local HMAC (~a few hundred ms of CPU).
85
+ *
86
+ * Auth + CSRF required. Rate limited to 1 per 5 minutes.
87
+ */
88
+ export async function POST(request: NextRequest) {
89
+ if (!(await isAdminAuthenticated())) {
90
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
91
+ }
92
+ if (!validateCsrf(request)) {
93
+ return csrfErrorResponse();
94
+ }
95
+
96
+ // M-2: reject oversize bodies up front so we don't spend CPU on
97
+ // `request.json()` for a payload we'll refuse anyway. Vercel's platform
98
+ // cap is ~4.5 MB; our app cap is MAX_PAGE_BODY_SIZE (5 MB — matches page
99
+ // builder saves, which is the largest JSON body in the framework).
100
+ if (isBodyTooLarge(request, MAX_PAGE_BODY_SIZE)) {
101
+ return jsonError(
102
+ `Request body too large. Max ${MAX_PAGE_BODY_SIZE} bytes.`,
103
+ 413
104
+ );
105
+ }
106
+
107
+ // Rate limit: 1 restore per 5 minutes per IP
108
+ const ip = request.headers.get("x-forwarded-for") || "unknown";
109
+ if (!checkRateLimit(`backup-restore-data:${ip}`, 1, 300_000)) {
110
+ return jsonError(
111
+ "A restore is already in progress or was recently completed. Please wait 5 minutes.",
112
+ 429
113
+ );
114
+ }
115
+
116
+ const startTime = Date.now();
117
+
118
+ try {
119
+ // ── Parse JSON body ──
120
+ const contentType = request.headers.get("content-type") || "";
121
+ if (!contentType.includes("application/json")) {
122
+ return jsonError("Expected application/json", 400);
123
+ }
124
+
125
+ const body = await request.json();
126
+
127
+ // ── Validate manifest ──
128
+ if (!body.manifest) {
129
+ return jsonError("Missing manifest in request body", 400);
130
+ }
131
+ const manifest = validateManifest(body.manifest);
132
+
133
+ // ── Validate documents ──
134
+ if (!body.documents || typeof body.documents !== "object") {
135
+ return jsonError("Missing documents in request body", 400);
136
+ }
137
+
138
+ const { documents } = body;
139
+ const wipeR2 = body.wipe_r2 === true;
140
+
141
+ // ── L-5: shape validation per section ──
142
+ // Before we touch Sanity we verify that each section is the expected
143
+ // shape: a list for plural sections and either a single object or null
144
+ // for singleton sections. Without this guard a client could send a
145
+ // string / number / boolean and `restoreDocuments` would happily no-op
146
+ // it — meaning a mistyped field silently drops documents during restore.
147
+ // Erroring out with a 400 here is much friendlier than completing the
148
+ // wipe and discovering the docs were never restored.
149
+ const PLURAL_SECTIONS = ["pages", "customSections"] as const;
150
+ const SINGLETON_SECTIONS = [
151
+ "siteSettings",
152
+ "siteStyles",
153
+ "assetRegistry",
154
+ ] as const;
155
+
156
+ for (const key of PLURAL_SECTIONS) {
157
+ const data = documents[key];
158
+ if (data === null || data === undefined) continue;
159
+ if (!Array.isArray(data)) {
160
+ return jsonError(
161
+ `Invalid documents payload: '${key}' must be an array or null, received '${typeof data}'.`,
162
+ 400
163
+ );
164
+ }
165
+ }
166
+ for (const key of SINGLETON_SECTIONS) {
167
+ const data = documents[key];
168
+ if (data === null || data === undefined) continue;
169
+ if (typeof data !== "object" || Array.isArray(data)) {
170
+ return jsonError(
171
+ `Invalid documents payload: '${key}' must be an object or null, received '${
172
+ Array.isArray(data) ? "array" : typeof data
173
+ }'.`,
174
+ 400
175
+ );
176
+ }
177
+ }
178
+
179
+ // ── M-4: early `_type` validation per section ──
180
+ // `restoreDocuments` already carries a safety net that skips docs
181
+ // whose `_type` doesn't match the batch type, but by the time we get
182
+ // there we've already wiped Sanity. Catching the mismatch _before_
183
+ // wiping saves CPU and gives the admin a clean error instead of a
184
+ // half-wiped dataset.
185
+ //
186
+ // We only fail the request if the discriminator is present AND wrong.
187
+ // Missing `_type` is acceptable — `restoreDocuments` will backfill it.
188
+ for (const { key, type } of DOC_MAP) {
189
+ const data = documents[key];
190
+ if (data === null || data === undefined) continue;
191
+ const docsInSection = Array.isArray(data) ? data : [data];
192
+ for (const rawDoc of docsInSection) {
193
+ if (!rawDoc || typeof rawDoc !== "object") continue;
194
+ const docType = (rawDoc as { _type?: unknown })._type;
195
+ if (typeof docType === "string" && docType !== type) {
196
+ const docId =
197
+ typeof (rawDoc as { _id?: unknown })._id === "string"
198
+ ? (rawDoc as { _id: string })._id
199
+ : "(no-id)";
200
+ logger.warn(
201
+ "[Admin:Backup]",
202
+ `Rejecting body: documents.${key} contains a '${docType}' doc (${docId}); expected '${type}'`
203
+ );
204
+ return jsonError(
205
+ `Invalid documents payload: '${key}' section contains a document with _type='${docType}' but expected '${type}'.`,
206
+ 400
207
+ );
208
+ }
209
+ }
210
+ }
211
+
212
+ // ── L-7: non-blocking manifest count verification ──
213
+ // Compare what the manifest claims against what's actually in the body.
214
+ // We do NOT abort on mismatch — a legitimate editing workflow (e.g. the
215
+ // admin pruned content between export and restore) can produce fewer
216
+ // docs than the manifest recorded. But we warn to server logs and
217
+ // accumulate a `manifest_mismatches` array that ships in the response
218
+ // so the UI can flag it. Manifest field names use snake_case; body
219
+ // sections use camelCase — so we translate here.
220
+ const manifestMismatches: Array<{ section: string; manifest: number; actual: number }> = [];
221
+ const manifestCounts = manifest.documents;
222
+ const sectionPairs: Array<[keyof typeof manifestCounts, string]> = [
223
+ ["pages", "pages"],
224
+ ["custom_sections", "customSections"],
225
+ ["site_settings", "siteSettings"],
226
+ ["site_styles", "siteStyles"],
227
+ ["asset_registry", "assetRegistry"],
228
+ ];
229
+ for (const [manifestKey, bodyKey] of sectionPairs) {
230
+ const manifestCount = manifestCounts[manifestKey];
231
+ const bodyValue = documents[bodyKey];
232
+ let actualCount = 0;
233
+ if (Array.isArray(bodyValue)) {
234
+ actualCount = bodyValue.length;
235
+ } else if (bodyValue && typeof bodyValue === "object") {
236
+ actualCount = 1;
237
+ }
238
+ if (manifestCount !== actualCount) {
239
+ manifestMismatches.push({
240
+ section: bodyKey,
241
+ manifest: manifestCount,
242
+ actual: actualCount,
243
+ });
244
+ }
245
+ }
246
+ if (manifestMismatches.length > 0) {
247
+ logger.warn(
248
+ "[Admin:Backup]",
249
+ `Manifest/body count mismatch — continuing restore: ${JSON.stringify(
250
+ manifestMismatches
251
+ )}`
252
+ );
253
+ }
254
+
255
+ // ── Validate & sanitize the optional asset key list ──
256
+ // The browser passes this so we can pre-sign upload URLs in one go.
257
+ // Reject clearly-invalid keys defensively so a compromised or buggy
258
+ // client can't get presigned URLs for things like "../../etc/passwd".
259
+ const rawAssets = Array.isArray(body.assets) ? body.assets : [];
260
+ if (rawAssets.length > MAX_ASSET_KEYS) {
261
+ return jsonError(
262
+ `Too many assets in request (${rawAssets.length}). Max ${MAX_ASSET_KEYS}.`,
263
+ 400
264
+ );
265
+ }
266
+
267
+ // ── M-8: resolve + clamp presigned-URL TTL ──
268
+ // Accept a client-requested `upload_ttl_seconds` but clamp to a safe
269
+ // range. Slow connections benefit from a longer TTL; very long TTLs
270
+ // weaken the security posture of leaked URLs, so the server makes
271
+ // the final call.
272
+ let uploadTtlSeconds = DEFAULT_UPLOAD_TTL_SECONDS;
273
+ const requestedTtlRaw = body.upload_ttl_seconds;
274
+ if (typeof requestedTtlRaw === "number" && Number.isFinite(requestedTtlRaw)) {
275
+ uploadTtlSeconds = Math.round(
276
+ Math.min(
277
+ MAX_UPLOAD_TTL_SECONDS,
278
+ Math.max(MIN_UPLOAD_TTL_SECONDS, requestedTtlRaw)
279
+ )
280
+ );
281
+ }
282
+ const assetKeys: string[] = [];
283
+ const rejectedKeys: string[] = [];
284
+ for (const entry of rawAssets) {
285
+ const key = entry && typeof entry === "object" && typeof (entry as { key?: unknown }).key === "string"
286
+ ? ((entry as { key: string }).key).trim()
287
+ : "";
288
+ if (!key) continue;
289
+ // Path safety + extension allow-list (media + fonts + PDF).
290
+ if (!isValidAssetPath(key) || !isBackupAssetFile(key)) {
291
+ rejectedKeys.push(key);
292
+ continue;
293
+ }
294
+ assetKeys.push(key);
295
+ }
296
+
297
+ logger.info(
298
+ "[Admin:Backup]",
299
+ `Starting V2 restore (wipe_r2=${wipeR2}, assets=${assetKeys.length}${
300
+ rejectedKeys.length > 0 ? `, rejected=${rejectedKeys.length}` : ""
301
+ })...`
302
+ );
303
+
304
+ // ── Phase 1: Wipe all existing Sanity documents ──
305
+ logger.info("[Admin:Backup]", "Wiping existing Sanity documents...");
306
+ const wiped = await wipeAllDocuments();
307
+
308
+ // ── Phase 2: Restore documents from backup ──
309
+ logger.info("[Admin:Backup]", "Restoring Sanity documents...");
310
+ const restored: Record<string, number> = {};
311
+
312
+ for (const { key, type } of DOC_MAP) {
313
+ const data = documents[key];
314
+ if (data !== null && data !== undefined) {
315
+ restored[type] = await restoreDocuments(type, data);
316
+ } else {
317
+ restored[type] = 0;
318
+ }
319
+ }
320
+
321
+ // ── Phase 3 (optional): Wipe R2 assets + pre-sign upload URLs ──
322
+ // Both operations share the same R2 config, so we fetch/build the S3
323
+ // client once and reuse it.
324
+ let r2Wiped: number | null = null;
325
+ let uploadUrls: PresignedUpload[] | undefined;
326
+
327
+ const needsR2 = wipeR2 || assetKeys.length > 0;
328
+ if (needsR2) {
329
+ const r2Config = await getR2Config();
330
+ if (r2Config) {
331
+ const s3 = createS3Client(r2Config);
332
+
333
+ if (wipeR2) {
334
+ try {
335
+ logger.info("[Admin:Backup]", "Wiping existing R2 assets...");
336
+ r2Wiped = await deleteAllR2Files(s3, r2Config.bucketName);
337
+ logger.info(
338
+ "[Admin:Backup]",
339
+ `Deleted ${r2Wiped} existing R2 file(s)`
340
+ );
341
+ } catch (err) {
342
+ logger.error(
343
+ "[Admin:Backup]",
344
+ "Failed to wipe R2 bucket",
345
+ err
346
+ );
347
+ // Don't fail the entire restore — Sanity docs are already restored.
348
+ // The client will upload over existing files.
349
+ r2Wiped = -1; // Signal that wipe was attempted but failed
350
+ }
351
+ }
352
+
353
+ if (assetKeys.length > 0) {
354
+ try {
355
+ logger.info(
356
+ "[Admin:Backup]",
357
+ `Generating ${assetKeys.length} presigned upload URL(s) (TTL=${uploadTtlSeconds}s)...`
358
+ );
359
+ uploadUrls = await generatePresignedUploadUrls(
360
+ s3,
361
+ r2Config.bucketName,
362
+ assetKeys,
363
+ uploadTtlSeconds
364
+ );
365
+ } catch (err) {
366
+ logger.error(
367
+ "[Admin:Backup]",
368
+ "Failed to generate presigned upload URLs",
369
+ err
370
+ );
371
+ // Leave uploadUrls undefined — the client will surface this.
372
+ }
373
+ }
374
+ } else {
375
+ logger.info(
376
+ "[Admin:Backup]",
377
+ "R2 not connected — skipping wipe and presign"
378
+ );
379
+ }
380
+ }
381
+
382
+ // ── Done ──
383
+ const durationMs = Date.now() - startTime;
384
+ logger.info(
385
+ "[Admin:Backup]",
386
+ `V2 restore completed in ${(durationMs / 1000).toFixed(1)}s`
387
+ );
388
+
389
+ return NextResponse.json({
390
+ success: true,
391
+ sanity: { wiped, restored },
392
+ r2_wiped: r2Wiped,
393
+ upload_urls: uploadUrls,
394
+ upload_ttl_seconds: uploadUrls ? uploadTtlSeconds : undefined,
395
+ // L-7: ship the non-blocking manifest/body count mismatch array so
396
+ // the UI can surface a friendly "heads up — the backup claimed N
397
+ // docs but only M were restored" note. Empty array means fully
398
+ // consistent.
399
+ manifest_mismatches: manifestMismatches,
400
+ duration_ms: durationMs,
401
+ });
402
+ } catch (err) {
403
+ // L-4: sanitize the error surface returned to the client.
404
+ //
405
+ // In development we keep the raw message because it's invaluable while
406
+ // iterating locally. In production we always return a generic message
407
+ // and emit a correlation id (`requestId`) that the server-side log line
408
+ // below carries — admins who hit this can paste the id to the dev team
409
+ // and we can look up the full stack without the client ever seeing it.
410
+ const requestId = crypto.randomUUID();
411
+ logger.error(
412
+ "[Admin:Backup]",
413
+ `V2 restore failed (requestId=${requestId})`,
414
+ err
415
+ );
416
+ const isDev = process.env.NODE_ENV !== "production";
417
+ const clientMessage = isDev
418
+ ? err instanceof Error
419
+ ? err.message
420
+ : "Restore failed"
421
+ : `Restore failed. If this persists, contact support with requestId=${requestId}.`;
422
+ return jsonError(clientMessage, 500);
423
+ }
424
+ }