@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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 helpers for the backup module.
|
|
3
|
+
*
|
|
4
|
+
* Provides S3 client access and credential resolution specifically
|
|
5
|
+
* for backup operations (GetObjectCommand for export, PutObjectCommand
|
|
6
|
+
* for restore). Reuses the same credential pattern as the R2 adapter
|
|
7
|
+
* but exposes the S3 client directly for streaming operations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
S3Client,
|
|
12
|
+
ListObjectsV2Command,
|
|
13
|
+
GetObjectCommand,
|
|
14
|
+
PutObjectCommand,
|
|
15
|
+
DeleteObjectsCommand,
|
|
16
|
+
} from "@aws-sdk/client-s3";
|
|
17
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
18
|
+
import { adminClient } from "../sanity/client";
|
|
19
|
+
import { decryptToken } from "../security";
|
|
20
|
+
import { logger } from "../logger";
|
|
21
|
+
import { getMimeType } from "../storage/types";
|
|
22
|
+
|
|
23
|
+
// ============================================
|
|
24
|
+
// Types
|
|
25
|
+
// ============================================
|
|
26
|
+
|
|
27
|
+
export interface R2BackupConfig {
|
|
28
|
+
bucketUrl: string;
|
|
29
|
+
endpoint: string;
|
|
30
|
+
bucketName: string;
|
|
31
|
+
accessKeyId: string;
|
|
32
|
+
secretAccessKey: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface R2FileEntry {
|
|
36
|
+
key: string;
|
|
37
|
+
size: number;
|
|
38
|
+
mimeType: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// Credential resolution
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetch and decrypt R2 credentials from Sanity.
|
|
47
|
+
* Returns null if R2 is not connected.
|
|
48
|
+
*/
|
|
49
|
+
export async function getR2Config(): Promise<R2BackupConfig | null> {
|
|
50
|
+
try {
|
|
51
|
+
const registry = await adminClient.fetch(
|
|
52
|
+
`*[_type == "assetRegistry"][0]{
|
|
53
|
+
r2_bucket_url,
|
|
54
|
+
r2_endpoint,
|
|
55
|
+
r2_bucket_name,
|
|
56
|
+
r2_access_key_id,
|
|
57
|
+
r2_secret_access_key
|
|
58
|
+
}`
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (!registry?.r2_endpoint || !registry?.r2_bucket_name) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!registry.r2_access_key_id || !registry.r2_secret_access_key) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const accessKeyId = await decryptToken(registry.r2_access_key_id);
|
|
70
|
+
const secretAccessKey = await decryptToken(registry.r2_secret_access_key);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
bucketUrl: registry.r2_bucket_url || "",
|
|
74
|
+
endpoint: registry.r2_endpoint,
|
|
75
|
+
bucketName: registry.r2_bucket_name,
|
|
76
|
+
accessKeyId,
|
|
77
|
+
secretAccessKey,
|
|
78
|
+
};
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error("[Backup:R2]", "Failed to get R2 config", err);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create an S3 client from the resolved config.
|
|
87
|
+
*/
|
|
88
|
+
export function createS3Client(config: R2BackupConfig): S3Client {
|
|
89
|
+
return new S3Client({
|
|
90
|
+
region: "auto",
|
|
91
|
+
endpoint: config.endpoint,
|
|
92
|
+
credentials: {
|
|
93
|
+
accessKeyId: config.accessKeyId,
|
|
94
|
+
secretAccessKey: config.secretAccessKey,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================
|
|
100
|
+
// File listing
|
|
101
|
+
// ============================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List ALL files in the R2 bucket for backup.
|
|
105
|
+
*
|
|
106
|
+
* No extension filter applied — every non-empty object is captured so the
|
|
107
|
+
* backup is a true mirror of the bucket. This keeps backup ↔ wipe symmetric:
|
|
108
|
+
* anything that the wipe would destroy is included in the export, including
|
|
109
|
+
* fonts (.woff2, .ttf, …), PDFs, and any other files the admin uploads.
|
|
110
|
+
*/
|
|
111
|
+
export async function listAllR2Files(
|
|
112
|
+
s3: S3Client,
|
|
113
|
+
bucketName: string
|
|
114
|
+
): Promise<R2FileEntry[]> {
|
|
115
|
+
const files: R2FileEntry[] = [];
|
|
116
|
+
let continuationToken: string | undefined;
|
|
117
|
+
|
|
118
|
+
do {
|
|
119
|
+
const response = await s3.send(
|
|
120
|
+
new ListObjectsV2Command({
|
|
121
|
+
Bucket: bucketName,
|
|
122
|
+
ContinuationToken: continuationToken,
|
|
123
|
+
MaxKeys: 1000,
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
for (const obj of response.Contents || []) {
|
|
128
|
+
const key = obj.Key;
|
|
129
|
+
if (!key || !obj.Size) continue;
|
|
130
|
+
|
|
131
|
+
const filename = key.split("/").pop() || key;
|
|
132
|
+
files.push({
|
|
133
|
+
key,
|
|
134
|
+
size: obj.Size,
|
|
135
|
+
mimeType: getMimeType(filename),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
continuationToken = response.NextContinuationToken;
|
|
140
|
+
} while (continuationToken);
|
|
141
|
+
|
|
142
|
+
files.sort((a, b) => a.key.localeCompare(b.key));
|
|
143
|
+
return files;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================
|
|
147
|
+
// Presigned upload URL batch generation (for V2 restore)
|
|
148
|
+
// ============================================
|
|
149
|
+
|
|
150
|
+
/** A presigned PUT URL paired with the Content-Type the client must send. */
|
|
151
|
+
export interface PresignedUpload {
|
|
152
|
+
key: string;
|
|
153
|
+
uploadUrl: string;
|
|
154
|
+
contentType: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate presigned PUT URLs for a batch of asset keys in one server call.
|
|
159
|
+
*
|
|
160
|
+
* This is the piece that keeps Vercel CPU flat during a restore: instead of
|
|
161
|
+
* the browser invoking `/api/admin/r2/upload-url` once per asset (which also
|
|
162
|
+
* hits a 60-req/min rate limit and breaks for sites with >60 assets), the
|
|
163
|
+
* restore endpoint signs every URL in a single invocation and the browser
|
|
164
|
+
* just PUTs directly to R2.
|
|
165
|
+
*
|
|
166
|
+
* Signing is pure HMAC on the local machine — no round-trip to R2 — so
|
|
167
|
+
* generating hundreds of URLs is a few hundred milliseconds of CPU at most.
|
|
168
|
+
*
|
|
169
|
+
* @param ttlSeconds - presigned URL lifetime (default 30 min, to cover slow
|
|
170
|
+
* uploads and retries during a long restore).
|
|
171
|
+
*/
|
|
172
|
+
export async function generatePresignedUploadUrls(
|
|
173
|
+
s3: S3Client,
|
|
174
|
+
bucketName: string,
|
|
175
|
+
keys: string[],
|
|
176
|
+
ttlSeconds = 1800
|
|
177
|
+
): Promise<PresignedUpload[]> {
|
|
178
|
+
if (keys.length === 0) return [];
|
|
179
|
+
|
|
180
|
+
// Deduplicate to avoid re-signing the same key twice if the client lists it.
|
|
181
|
+
const uniqueKeys = Array.from(new Set(keys));
|
|
182
|
+
|
|
183
|
+
return Promise.all(
|
|
184
|
+
uniqueKeys.map(async (key): Promise<PresignedUpload> => {
|
|
185
|
+
const filename = key.split("/").pop() || key;
|
|
186
|
+
const contentType = getMimeType(filename);
|
|
187
|
+
const command = new PutObjectCommand({
|
|
188
|
+
Bucket: bucketName,
|
|
189
|
+
Key: key,
|
|
190
|
+
ContentType: contentType,
|
|
191
|
+
});
|
|
192
|
+
const uploadUrl = await getSignedUrl(s3, command, {
|
|
193
|
+
expiresIn: ttlSeconds,
|
|
194
|
+
});
|
|
195
|
+
return { key, uploadUrl, contentType };
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Upload a single file to R2.
|
|
202
|
+
* Used during restore to push assets back into the bucket.
|
|
203
|
+
*/
|
|
204
|
+
export async function uploadR2File(
|
|
205
|
+
s3: S3Client,
|
|
206
|
+
bucketName: string,
|
|
207
|
+
key: string,
|
|
208
|
+
body: Buffer | Uint8Array,
|
|
209
|
+
contentType: string
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
await s3.send(
|
|
212
|
+
new PutObjectCommand({
|
|
213
|
+
Bucket: bucketName,
|
|
214
|
+
Key: key,
|
|
215
|
+
Body: body,
|
|
216
|
+
ContentType: contentType,
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Delete ALL files in the R2 bucket.
|
|
223
|
+
* Used during restore to wipe existing assets before uploading.
|
|
224
|
+
* Processes deletions in batches of 1000 (S3 API limit).
|
|
225
|
+
*/
|
|
226
|
+
export async function deleteAllR2Files(
|
|
227
|
+
s3: S3Client,
|
|
228
|
+
bucketName: string
|
|
229
|
+
): Promise<number> {
|
|
230
|
+
let totalDeleted = 0;
|
|
231
|
+
let continuationToken: string | undefined;
|
|
232
|
+
|
|
233
|
+
do {
|
|
234
|
+
const listResponse = await s3.send(
|
|
235
|
+
new ListObjectsV2Command({
|
|
236
|
+
Bucket: bucketName,
|
|
237
|
+
ContinuationToken: continuationToken,
|
|
238
|
+
MaxKeys: 1000,
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const objects = listResponse.Contents ?? [];
|
|
243
|
+
if (objects.length === 0) break;
|
|
244
|
+
|
|
245
|
+
const keysToDelete = objects
|
|
246
|
+
.map((obj) => obj.Key)
|
|
247
|
+
.filter((key): key is string => !!key);
|
|
248
|
+
|
|
249
|
+
if (keysToDelete.length > 0) {
|
|
250
|
+
await s3.send(
|
|
251
|
+
new DeleteObjectsCommand({
|
|
252
|
+
Bucket: bucketName,
|
|
253
|
+
Delete: {
|
|
254
|
+
Objects: keysToDelete.map((Key) => ({ Key })),
|
|
255
|
+
Quiet: true,
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
totalDeleted += keysToDelete.length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
continuationToken = listResponse.NextContinuationToken;
|
|
263
|
+
} while (continuationToken);
|
|
264
|
+
|
|
265
|
+
return totalDeleted;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Download a single file from R2 as a readable stream.
|
|
270
|
+
* Returns the response body stream, or null if the file doesn't exist.
|
|
271
|
+
*/
|
|
272
|
+
export async function downloadR2File(
|
|
273
|
+
s3: S3Client,
|
|
274
|
+
bucketName: string,
|
|
275
|
+
key: string
|
|
276
|
+
): Promise<ReadableStream | null> {
|
|
277
|
+
try {
|
|
278
|
+
const response = await s3.send(
|
|
279
|
+
new GetObjectCommand({
|
|
280
|
+
Bucket: bucketName,
|
|
281
|
+
Key: key,
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// The AWS SDK v3 returns a Web ReadableStream in Node.js 18+
|
|
286
|
+
if (response.Body) {
|
|
287
|
+
return response.Body.transformToWebStream();
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logger.error("[Backup:R2]", `Failed to download ${key}`, err);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup Restore — core logic.
|
|
3
|
+
*
|
|
4
|
+
* Receives a ZIP buffer (the backup archive), validates it, then
|
|
5
|
+
* performs a full wipe-and-replace of:
|
|
6
|
+
* 1. All Sanity documents (5 types)
|
|
7
|
+
* 2. All R2 assets (if R2 is connected)
|
|
8
|
+
*
|
|
9
|
+
* This is a destructive operation — it replaces the entire site.
|
|
10
|
+
* The API route handles auth, CSRF, and rate limiting.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as unzipper from "unzipper";
|
|
14
|
+
import { logger } from "../logger";
|
|
15
|
+
import { getMimeType } from "../storage/types";
|
|
16
|
+
import { validateManifest, type BackupManifest } from "./manifest";
|
|
17
|
+
import { wipeAllDocuments, restoreDocuments, type AndamiDocType } from "./sanity-ops";
|
|
18
|
+
import {
|
|
19
|
+
getR2Config,
|
|
20
|
+
createS3Client,
|
|
21
|
+
deleteAllR2Files,
|
|
22
|
+
uploadR2File,
|
|
23
|
+
} from "./r2-helpers";
|
|
24
|
+
|
|
25
|
+
// ============================================
|
|
26
|
+
// Types
|
|
27
|
+
// ============================================
|
|
28
|
+
|
|
29
|
+
export interface RestoreResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
manifest: BackupManifest;
|
|
32
|
+
sanity: {
|
|
33
|
+
wiped: Record<string, number>;
|
|
34
|
+
restored: Record<string, number>;
|
|
35
|
+
};
|
|
36
|
+
assets: {
|
|
37
|
+
expected: number;
|
|
38
|
+
uploaded: number;
|
|
39
|
+
failed: string[];
|
|
40
|
+
skipped_reason?: string;
|
|
41
|
+
};
|
|
42
|
+
duration_ms: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================
|
|
46
|
+
// ZIP parsing
|
|
47
|
+
// ============================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a ZIP buffer and extract all entries as a map of path → Buffer.
|
|
51
|
+
* Uses unzipper for streaming extraction.
|
|
52
|
+
*/
|
|
53
|
+
async function parseZipBuffer(
|
|
54
|
+
zipBuffer: Buffer
|
|
55
|
+
): Promise<Map<string, Buffer>> {
|
|
56
|
+
const entries = new Map<string, Buffer>();
|
|
57
|
+
|
|
58
|
+
const directory = await unzipper.Open.buffer(zipBuffer);
|
|
59
|
+
|
|
60
|
+
for (const file of directory.files) {
|
|
61
|
+
// Skip directories
|
|
62
|
+
if (file.type === "Directory") continue;
|
|
63
|
+
|
|
64
|
+
const content = await file.buffer();
|
|
65
|
+
entries.set(file.path, content);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Safely parse a JSON file from the ZIP entries.
|
|
73
|
+
* Throws if the file is missing or contains invalid JSON.
|
|
74
|
+
*/
|
|
75
|
+
function parseJsonEntry(entries: Map<string, Buffer>, path: string): unknown {
|
|
76
|
+
const buffer = entries.get(path);
|
|
77
|
+
if (!buffer) {
|
|
78
|
+
throw new Error(`Backup archive is missing required file: ${path}`);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(buffer.toString("utf-8"));
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error(`Invalid JSON in backup file: ${path}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Safely parse an optional JSON file from the ZIP entries.
|
|
89
|
+
* Returns null if the file is missing, throws on invalid JSON.
|
|
90
|
+
*/
|
|
91
|
+
function parseOptionalJsonEntry(
|
|
92
|
+
entries: Map<string, Buffer>,
|
|
93
|
+
path: string
|
|
94
|
+
): unknown | null {
|
|
95
|
+
const buffer = entries.get(path);
|
|
96
|
+
if (!buffer) return null;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(buffer.toString("utf-8"));
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`Invalid JSON in backup file: ${path}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================
|
|
105
|
+
// Core restore logic
|
|
106
|
+
// ============================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Restore a site from a backup ZIP buffer.
|
|
110
|
+
*
|
|
111
|
+
* Steps:
|
|
112
|
+
* 1. Parse ZIP and validate manifest
|
|
113
|
+
* 2. Wipe all existing Sanity documents
|
|
114
|
+
* 3. Restore documents from the backup
|
|
115
|
+
* 4. If R2 is connected, wipe and re-upload assets
|
|
116
|
+
*
|
|
117
|
+
* This is an all-or-nothing operation for Sanity (wipe then restore).
|
|
118
|
+
* Asset upload is best-effort — failures are logged but don't fail
|
|
119
|
+
* the entire restore.
|
|
120
|
+
*/
|
|
121
|
+
export async function restoreFromBackup(
|
|
122
|
+
zipBuffer: Buffer
|
|
123
|
+
): Promise<RestoreResult> {
|
|
124
|
+
const startTime = Date.now();
|
|
125
|
+
|
|
126
|
+
// ── Phase 1: Parse ZIP and validate manifest ──
|
|
127
|
+
logger.info("[Backup:Restore]", "Parsing backup archive...");
|
|
128
|
+
const entries = await parseZipBuffer(zipBuffer);
|
|
129
|
+
|
|
130
|
+
const manifestRaw = parseJsonEntry(entries, "manifest.json");
|
|
131
|
+
const manifest = validateManifest(manifestRaw);
|
|
132
|
+
|
|
133
|
+
logger.info(
|
|
134
|
+
"[Backup:Restore]",
|
|
135
|
+
`Valid backup from ${manifest.created_at} (v${manifest.framework_version}). ` +
|
|
136
|
+
`${manifest.documents.pages} pages, ${manifest.assets.total_files} assets.`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// ── Phase 2: Wipe all existing Sanity documents ──
|
|
140
|
+
logger.info("[Backup:Restore]", "Wiping existing Sanity documents...");
|
|
141
|
+
const wiped = await wipeAllDocuments();
|
|
142
|
+
|
|
143
|
+
// ── Phase 3: Restore documents from backup ──
|
|
144
|
+
logger.info("[Backup:Restore]", "Restoring Sanity documents...");
|
|
145
|
+
|
|
146
|
+
const restored: Record<string, number> = {};
|
|
147
|
+
|
|
148
|
+
// Map of ZIP paths to Sanity document types
|
|
149
|
+
const docMap: { path: string; type: AndamiDocType }[] = [
|
|
150
|
+
{ path: "sanity/pages.json", type: "page" },
|
|
151
|
+
{ path: "sanity/settings.json", type: "siteSettings" },
|
|
152
|
+
{ path: "sanity/styles.json", type: "siteStyles" },
|
|
153
|
+
{ path: "sanity/asset-registry.json", type: "assetRegistry" },
|
|
154
|
+
{ path: "sanity/custom-sections.json", type: "customSection" },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
for (const { path, type } of docMap) {
|
|
158
|
+
const data = parseOptionalJsonEntry(entries, path);
|
|
159
|
+
if (data !== null) {
|
|
160
|
+
restored[type] = await restoreDocuments(type, data);
|
|
161
|
+
} else {
|
|
162
|
+
restored[type] = 0;
|
|
163
|
+
logger.info("[Backup:Restore]", `No ${path} in backup, skipping`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Phase 4: Restore R2 assets ──
|
|
168
|
+
const assetResult = {
|
|
169
|
+
expected: 0,
|
|
170
|
+
uploaded: 0,
|
|
171
|
+
failed: [] as string[],
|
|
172
|
+
skipped_reason: undefined as string | undefined,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Collect asset entries from the ZIP
|
|
176
|
+
const assetEntries = new Map<string, Buffer>();
|
|
177
|
+
for (const [path, buffer] of entries) {
|
|
178
|
+
if (path.startsWith("assets/")) {
|
|
179
|
+
assetEntries.set(path, buffer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
assetResult.expected = assetEntries.size;
|
|
184
|
+
|
|
185
|
+
if (assetEntries.size > 0) {
|
|
186
|
+
const r2Config = await getR2Config();
|
|
187
|
+
|
|
188
|
+
if (!r2Config) {
|
|
189
|
+
logger.warn(
|
|
190
|
+
"[Backup:Restore]",
|
|
191
|
+
`R2 not connected — skipping ${assetEntries.size} asset(s). ` +
|
|
192
|
+
"Connect R2 and restore again to include assets."
|
|
193
|
+
);
|
|
194
|
+
assetResult.skipped_reason = "R2 not connected";
|
|
195
|
+
} else {
|
|
196
|
+
const s3 = createS3Client(r2Config);
|
|
197
|
+
|
|
198
|
+
// Wipe existing R2 files before uploading
|
|
199
|
+
logger.info("[Backup:Restore]", "Wiping existing R2 assets...");
|
|
200
|
+
try {
|
|
201
|
+
const deletedCount = await deleteAllR2Files(s3, r2Config.bucketName);
|
|
202
|
+
logger.info(
|
|
203
|
+
"[Backup:Restore]",
|
|
204
|
+
`Deleted ${deletedCount} existing R2 file(s)`
|
|
205
|
+
);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.error(
|
|
208
|
+
"[Backup:Restore]",
|
|
209
|
+
"Failed to wipe R2 bucket — continuing with upload",
|
|
210
|
+
err
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Upload assets from the backup
|
|
215
|
+
logger.info(
|
|
216
|
+
"[Backup:Restore]",
|
|
217
|
+
`Uploading ${assetEntries.size} asset(s) to R2...`
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
for (const [zipPath, buffer] of assetEntries) {
|
|
221
|
+
// Strip the "assets/" prefix to get the R2 key
|
|
222
|
+
const r2Key = zipPath.replace(/^assets\//, "");
|
|
223
|
+
const filename = r2Key.split("/").pop() || r2Key;
|
|
224
|
+
const contentType = getMimeType(filename);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await uploadR2File(s3, r2Config.bucketName, r2Key, buffer, contentType);
|
|
228
|
+
assetResult.uploaded++;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
logger.error(
|
|
231
|
+
"[Backup:Restore]",
|
|
232
|
+
`Failed to upload asset: ${r2Key}`,
|
|
233
|
+
err
|
|
234
|
+
);
|
|
235
|
+
assetResult.failed.push(r2Key);
|
|
236
|
+
// Continue with remaining assets — partial restore > no restore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
logger.info(
|
|
241
|
+
"[Backup:Restore]",
|
|
242
|
+
`Assets: ${assetResult.uploaded}/${assetResult.expected} uploaded` +
|
|
243
|
+
(assetResult.failed.length > 0
|
|
244
|
+
? `, ${assetResult.failed.length} failed`
|
|
245
|
+
: "")
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
logger.info("[Backup:Restore]", "No assets in backup archive");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Done ──
|
|
253
|
+
const duration = Date.now() - startTime;
|
|
254
|
+
logger.info(
|
|
255
|
+
"[Backup:Restore]",
|
|
256
|
+
`Restore completed in ${(duration / 1000).toFixed(1)}s`
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
success: true,
|
|
261
|
+
manifest,
|
|
262
|
+
sanity: { wiped, restored },
|
|
263
|
+
assets: assetResult,
|
|
264
|
+
duration_ms: duration,
|
|
265
|
+
};
|
|
266
|
+
}
|