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