@morphika/andami 0.2.8 → 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.
- package/README.md +8 -3
- package/admin/backups.ts +7 -0
- package/app/(site)/layout.tsx +0 -2
- package/app/admin/backups/page.tsx +1 -0
- package/app/admin/layout.tsx +327 -274
- package/app/api/admin/backups/export/route.ts +76 -0
- package/app/api/admin/backups/prepare-export/route.ts +56 -0
- package/app/api/admin/backups/restore/route.ts +131 -0
- package/app/api/admin/backups/restore-data/route.ts +424 -0
- package/app/api/admin/backups/status/route.ts +35 -0
- package/app/api/custom-sections/[id]/route.ts +9 -5
- package/components/admin/backups/BackupsPage.tsx +1581 -0
- package/components/builder/CustomSectionInstanceCard.tsx +7 -3
- package/components/builder/ReadOnlyFrame.tsx +4 -2
- package/components/builder/settings-panel/CustomSectionSettings.tsx +5 -3
- package/lib/backup/export.ts +377 -0
- package/lib/backup/manifest.ts +121 -0
- package/lib/backup/r2-helpers.ts +294 -0
- package/lib/backup/restore.ts +266 -0
- package/lib/backup/sanity-ops.ts +194 -0
- package/lib/builder/serializer/normalizers.ts +1 -0
- package/lib/builder/store-canvas.ts +4 -0
- package/lib/builder/store.ts +1 -0
- package/lib/builder/types.ts +4 -0
- package/lib/security.ts +30 -0
- package/lib/security.ts.new +27 -0
- package/lib/storage/types.ts +33 -1
- package/lib/version.ts +7 -4
- package/package.json +17 -1
- package/components/ui/PortfolioTracker.tsx +0 -87
|
@@ -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
|
+
}
|