smoonb 1.0.6 → 1.0.8

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.
@@ -1,584 +1,203 @@
1
- const chalk = require('chalk');
2
- const path = require('path');
3
- const fs = require('fs').promises;
4
- const AdmZip = require('adm-zip');
5
- const { createClient } = require('@supabase/supabase-js');
6
- const { t } = require('../../../i18n');
7
-
8
- /**
9
- * Etapa 6: Restaurar Storage Buckets e arquivos
10
- * Processo baseado no script oficial do Supabase Storage Migration
11
- * Segue exatamente o processo descrito no notebook oficial do Supabase
12
- */
13
- module.exports = async ({ backupPath, targetProject }) => {
14
-
15
- try {
16
- const getT = global.smoonbI18n?.t || t;
17
- // 1. Verificar se existe arquivo .storage.zip na pasta do backup
18
- const storageZipFiles = await fs.readdir(backupPath).then(files =>
19
- files.filter(f => f.endsWith('.storage.zip'))
20
- );
21
-
22
- // 2. Carregar metadados dos buckets do backup (se existirem)
23
- const storageDir = path.join(backupPath, 'storage');
24
- const manifestPath = path.join(backupPath, 'backup-manifest.json');
25
- const bucketMetadata = {};
26
- let manifest = null;
27
- let sourceProjectId = null;
28
-
29
- try {
30
- const manifestContent = await fs.readFile(manifestPath, 'utf8');
31
- manifest = JSON.parse(manifestContent);
32
-
33
- // Obter project ID do projeto origem do manifest
34
- sourceProjectId = manifest?.project_id || null;
35
-
36
- // Carregar metadados dos buckets do manifest
37
- const buckets = manifest?.components?.storage?.buckets || [];
38
- for (const bucket of buckets) {
39
- bucketMetadata[bucket.name] = {
40
- public: bucket.public || false,
41
- file_size_limit: bucket.file_size_limit || null,
42
- allowed_mime_types: bucket.allowed_mime_types || null
43
- };
44
- }
45
-
46
- // Também tentar carregar dos arquivos JSON individuais (backup mais detalhado)
47
- try {
48
- const storageFiles = await fs.readdir(storageDir);
49
- for (const file of storageFiles) {
50
- if (file.endsWith('.json') && file !== 'README.md') {
51
- const bucketName = file.replace('.json', '');
52
- try {
53
- const bucketInfoPath = path.join(storageDir, file);
54
- const bucketInfoContent = await fs.readFile(bucketInfoPath, 'utf8');
55
- const bucketInfo = JSON.parse(bucketInfoContent);
56
-
57
- // Usar metadados dos arquivos JSON se disponíveis (mais completo)
58
- // Incluir objetos para preservar metadados dos arquivos (contentType, cacheControl)
59
- bucketMetadata[bucketName] = {
60
- public: bucketInfo.public !== undefined ? bucketInfo.public : false,
61
- file_size_limit: bucketInfo.file_size_limit || null,
62
- allowed_mime_types: bucketInfo.allowed_mime_types || null,
63
- objects: bucketInfo.objects || [] // Metadados dos objetos para preservar contentType e cacheControl
64
- };
65
- } catch {
66
- // Ignorar arquivos JSON inválidos
67
- }
68
- }
69
- }
70
- } catch {
71
- // Pasta storage pode não existir, continuar
72
- }
73
- } catch {
74
- // Manifest pode não existir, continuar sem metadados
75
- }
76
-
77
- if (storageZipFiles.length === 0) {
78
- // Verificar se existe pasta storage com metadados (backup antigo)
79
- try {
80
- await fs.access(storageDir);
81
- const buckets = manifest?.components?.storage?.buckets || [];
82
-
83
- if (buckets.length === 0) {
84
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.noBuckets')}`));
85
- return { success: false, buckets_count: 0 };
86
- }
87
-
88
- console.log(chalk.yellow(`\n ⚠️ ${getT('restore.steps.storage.zipNotFound')}`));
89
- console.log(chalk.white(` ℹ️ ${getT('restore.steps.storage.metadataOnly')}`));
90
- console.log(chalk.white(` ℹ️ ${getT('restore.steps.storage.zipRequired')}`));
91
- console.log(chalk.green(`\n ✅ ${getT('restore.steps.storage.bucketsFoundMetadata', { count: buckets.length })}`));
92
- buckets.forEach(bucket => {
93
- const visibility = bucket.public ? getT('restore.steps.storage.public') : getT('restore.steps.storage.private');
94
- console.log(chalk.white(` - ${getT('restore.steps.storage.bucketInfo', { name: bucket.name, visibility })}`));
95
- });
96
-
97
- return { success: true, buckets_count: buckets.length, files_restored: false };
98
- } catch {
99
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.noBucketsInBackup')}`));
100
- return { success: false, buckets_count: 0 };
101
- }
102
- }
103
-
104
- // 3. Selecionar o primeiro arquivo .storage.zip encontrado
105
- const storageZipFile = path.join(backupPath, storageZipFiles[0]);
106
- const storageZipBaseName = path.basename(storageZipFiles[0], '.storage.zip');
107
- console.log(chalk.white(` - ${getT('restore.steps.storage.fileFound', { fileName: storageZipFiles[0] })}`));
108
-
109
- // 4. Validar credenciais do projeto destino
110
- if (!targetProject.targetProjectId || !targetProject.targetAccessToken) {
111
- const getT = global.smoonbI18n?.t || t;
112
- throw new Error(getT('error.storageCredentialsNotConfigured'));
113
- }
114
-
115
- if (!targetProject.targetUrl || !targetProject.targetServiceKey) {
116
- throw new Error(getT('error.supabaseCredentialsNotConfigured'));
117
- }
118
-
119
- // 4.1 Obter project ID do projeto origem e validar substituição
120
- if (sourceProjectId && sourceProjectId !== targetProject.targetProjectId) {
121
- console.log(chalk.cyan(` 🔄 ${getT('restore.steps.storage.replacingProjectId', { source: sourceProjectId, target: targetProject.targetProjectId })}`));
122
- } else if (!sourceProjectId) {
123
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.projectIdNotFound')}`));
124
- }
125
-
126
- // 5. Extrair arquivo ZIP
127
- console.log(chalk.white(` - ${getT('restore.steps.storage.extracting')}`));
128
- const extractDir = path.join(backupPath, 'storage_extracted');
129
-
130
- try {
131
- await fs.mkdir(extractDir, { recursive: true });
132
- } catch {
133
- // Diretório pode já existir
134
- }
135
-
136
- const zip = new AdmZip(storageZipFile);
137
- zip.extractAllTo(extractDir, true);
138
- console.log(chalk.green(` ✅ ${getT('restore.steps.storage.extracted')}`));
139
-
140
- // 5.1 Substituir Project ID antigo pelo novo nos diretórios e arquivos extraídos
141
- // O Supabase pode incluir o project ID nos caminhos dentro do ZIP
142
- if (sourceProjectId && sourceProjectId !== targetProject.targetProjectId) {
143
- console.log(chalk.white(` - ${getT('restore.steps.storage.replacing')}`));
144
- await replaceProjectIdInExtractedFiles(extractDir, sourceProjectId, targetProject.targetProjectId);
145
- console.log(chalk.green(` ✅ ${getT('restore.steps.storage.replaced')}`));
146
- }
147
-
148
- // 6. Ler estrutura de diretórios extraídos
149
- // O formato do .storage.zip do Supabase pode ter duas estruturas:
150
- // Estrutura 1 (direta): bucket-name/file1.jpg
151
- // Estrutura 2 (com Project ID): project-id/bucket-name/file1.jpg
152
- // Após a substituição do Project ID, a estrutura 2 fica: project-id-novo/bucket-name/file1.jpg
153
- const extractedContents = await fs.readdir(extractDir);
154
- const bucketDirs = [];
155
-
156
- // Verificar se a pasta raiz é o Project ID (antigo ou novo)
157
- // Se for, as subpastas são os buckets reais - NUNCA tratar a pasta raiz como bucket
158
- let rootDir = null;
159
- if (extractedContents.length === 1) {
160
- const firstItem = extractedContents[0];
161
- const firstItemPath = path.join(extractDir, firstItem);
162
- const firstItemStats = await fs.stat(firstItemPath);
163
-
164
- if (firstItemStats.isDirectory()) {
165
- // Verificar se o nome da pasta raiz corresponde ao Project ID antigo OU novo
166
- const matchesSourceProjectId = sourceProjectId && firstItem === sourceProjectId;
167
- const matchesTargetProjectId = firstItem === targetProject.targetProjectId;
168
- const matchesZipFileName = firstItem === storageZipBaseName;
169
- const matchesProjectIdPattern = isLikelyProjectId(firstItem);
170
-
171
- const isProjectId =
172
- matchesSourceProjectId ||
173
- matchesTargetProjectId ||
174
- matchesZipFileName ||
175
- matchesProjectIdPattern;
176
-
177
- if (isProjectId) {
178
- // A pasta raiz é um wrapper do Project ID - SEMPRE buscar buckets nas subpastas
179
- rootDir = firstItem;
180
- const reasons = [];
181
- if (matchesSourceProjectId) reasons.push('manifest');
182
- if (matchesTargetProjectId) reasons.push('projeto destino');
183
- if (matchesZipFileName) reasons.push('nome do arquivo ZIP');
184
- if (matchesProjectIdPattern) reasons.push('formato de Project ID');
185
- const reasonText = reasons.length ? ` (${reasons.join(', ')})` : '';
186
- console.log(chalk.white(` - ${getT('restore.steps.storage.detectedProjectId', { projectId: firstItem, reason: reasonText })}`));
187
- console.log(chalk.white(` - ${getT('restore.steps.storage.searchingBuckets')}`));
188
- }
189
- }
190
- }
191
-
192
- if (rootDir) {
193
- // Estrutura com Project ID: project-id/bucket-name/...
194
- // Listar subpastas dentro da pasta do Project ID - essas são os buckets reais
195
- const projectIdPath = path.join(extractDir, rootDir);
196
- const subContents = await fs.readdir(projectIdPath);
197
-
198
- for (const item of subContents) {
199
- const itemPath = path.join(projectIdPath, item);
200
- const stats = await fs.stat(itemPath);
201
- if (stats.isDirectory()) {
202
- bucketDirs.push({
203
- name: item,
204
- path: itemPath
205
- });
206
- }
207
- }
208
-
209
- // Se não encontrou buckets nas subpastas, avisar e retornar erro
210
- if (bucketDirs.length === 0) {
211
- console.log(chalk.red(` ❌ ${getT('restore.steps.storage.noBucketsInSubfolders', { rootDir })}`));
212
- console.log(chalk.red(` ❌ ${getT('restore.steps.storage.rootIsProjectId')}`));
213
- return { success: false, buckets_count: 0 };
214
- }
215
- } else {
216
- // Estrutura direta: bucket-name/...
217
- // As pastas raiz são os buckets
218
- for (const item of extractedContents) {
219
- const itemPath = path.join(extractDir, item);
220
- const stats = await fs.stat(itemPath);
221
- if (stats.isDirectory()) {
222
- bucketDirs.push({
223
- name: item,
224
- path: itemPath
225
- });
226
- }
227
- }
228
- }
229
-
230
- if (bucketDirs.length === 0) {
231
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.noBucketsInZip')}`));
232
- return { success: false, buckets_count: 0 };
233
- }
234
-
235
- console.log(chalk.white(` - ${getT('restore.steps.storage.bucketsFoundInZip', { count: bucketDirs.length })}`));
236
-
237
- // 7. Criar cliente Supabase para o projeto destino
238
- const supabase = createClient(targetProject.targetUrl, targetProject.targetServiceKey);
239
-
240
- // 8. Processar cada bucket
241
- let successCount = 0;
242
- let totalFilesUploaded = 0;
243
-
244
- for (const bucketInfo of bucketDirs) {
245
- const bucketName = bucketInfo.name;
246
- const bucketPath = bucketInfo.path;
247
-
248
- try {
249
- console.log(chalk.white(`\n - ${getT('restore.steps.storage.processingBucket', { bucketName })}`));
250
-
251
- // 8.1 Obter metadados do bucket do backup (se disponíveis)
252
- const bucketMeta = bucketMetadata[bucketName] || {
253
- public: false,
254
- file_size_limit: null,
255
- allowed_mime_types: null
256
- };
257
-
258
- // 8.2 Verificar se bucket já existe, se não, criar com configurações corretas
259
- const { data: existingBuckets, error: listError } = await supabase.storage.listBuckets();
260
-
261
- if (listError) {
262
- throw new Error(getT('restore.steps.edgeFunctions.listBucketsError', { message: listError.message }));
263
- }
264
-
265
- const existingBucket = existingBuckets?.find(b => b.name === bucketName);
266
- const bucketExists = !!existingBucket;
267
-
268
- if (!bucketExists) {
269
- // Criar bucket via Management API com configurações do backup
270
- console.log(chalk.white(` - ${getT('restore.steps.storage.creatingBucket', { bucketName })}`));
271
- const visibility = bucketMeta.public ? getT('restore.steps.storage.public') : getT('restore.steps.storage.private');
272
- const limit = bucketMeta.file_size_limit || getT('restore.steps.storage.noLimit');
273
- const types = bucketMeta.allowed_mime_types?.join(', ') || getT('restore.steps.storage.allTypes');
274
- console.log(chalk.white(` ${getT('restore.steps.storage.bucketConfig', { visibility, limit, types })}`));
275
-
276
- const createResponse = await fetch(
277
- `https://api.supabase.com/v1/projects/${targetProject.targetProjectId}/storage/buckets`,
278
- {
279
- method: 'POST',
280
- headers: {
281
- 'Authorization': `Bearer ${targetProject.targetAccessToken}`,
282
- 'Content-Type': 'application/json'
283
- },
284
- body: JSON.stringify({
285
- name: bucketName,
286
- public: bucketMeta.public,
287
- file_size_limit: bucketMeta.file_size_limit,
288
- allowed_mime_types: bucketMeta.allowed_mime_types
289
- })
290
- }
291
- );
292
-
293
- if (!createResponse.ok) {
294
- const errorText = await createResponse.text();
295
- throw new Error(getT('restore.steps.edgeFunctions.createBucketError', { status: createResponse.status, errorText }));
296
- }
297
-
298
- console.log(chalk.green(` ✅ ${getT('restore.steps.storage.bucketCreated', { bucketName })}`));
299
- } else {
300
- // Bucket já existe - verificar e atualizar configurações se necessário
301
- console.log(chalk.white(` - ${getT('restore.steps.storage.bucketExists', { bucketName })}`));
302
-
303
- const needsUpdate =
304
- existingBucket.public !== bucketMeta.public ||
305
- existingBucket.file_size_limit !== bucketMeta.file_size_limit ||
306
- JSON.stringify(existingBucket.allowed_mime_types || []) !== JSON.stringify(bucketMeta.allowed_mime_types || []);
307
-
308
- if (needsUpdate) {
309
- console.log(chalk.white(` - ${getT('restore.steps.storage.updating')}`));
310
-
311
- const updateResponse = await fetch(
312
- `https://api.supabase.com/v1/projects/${targetProject.targetProjectId}/storage/buckets/${bucketName}`,
313
- {
314
- method: 'PATCH',
315
- headers: {
316
- 'Authorization': `Bearer ${targetProject.targetAccessToken}`,
317
- 'Content-Type': 'application/json'
318
- },
319
- body: JSON.stringify({
320
- public: bucketMeta.public,
321
- file_size_limit: bucketMeta.file_size_limit,
322
- allowed_mime_types: bucketMeta.allowed_mime_types
323
- })
324
- }
325
- );
326
-
327
- if (!updateResponse.ok) {
328
- const errorText = await updateResponse.text();
329
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.updateError', { errorText })}`));
330
- } else {
331
- console.log(chalk.green(` ✅ ${getT('restore.steps.storage.bucketUpdated')}`));
332
- }
333
- } else {
334
- console.log(chalk.white(` - ${getT('restore.steps.storage.bucketConfigCorrect')}`));
335
- }
336
- }
337
-
338
- // 8.3 Listar todos os arquivos do bucket recursivamente
339
- async function getAllFiles(dir, basePath = '') {
340
- const files = [];
341
- const entries = await fs.readdir(dir, { withFileTypes: true });
342
-
343
- for (const entry of entries) {
344
- const fullPath = path.join(dir, entry.name);
345
- // Substituir project ID antigo pelo novo no caminho relativo
346
- let relativePath = path.join(basePath, entry.name).replace(/\\/g, '/');
347
- if (sourceProjectId && sourceProjectId !== targetProject.targetProjectId) {
348
- relativePath = relativePath.replace(new RegExp(sourceProjectId, 'g'), targetProject.targetProjectId);
349
- }
350
-
351
- if (entry.isDirectory()) {
352
- const subFiles = await getAllFiles(fullPath, relativePath);
353
- files.push(...subFiles);
354
- } else {
355
- files.push({
356
- localPath: fullPath,
357
- storagePath: relativePath
358
- });
359
- }
360
- }
361
-
362
- return files;
363
- }
364
-
365
- const filesToUpload = await getAllFiles(bucketPath);
366
- console.log(chalk.white(` - ${getT('restore.steps.storage.filesFoundForUpload', { count: filesToUpload.length })}`));
367
-
368
- // 8.4 Fazer upload de cada arquivo
369
- // Criar mapa de metadados dos objetos do backup (se disponível)
370
- // A Management API pode retornar objetos com diferentes estruturas
371
- const objectsMetadata = {};
372
- if (bucketMeta.objects && Array.isArray(bucketMeta.objects)) {
373
- for (const obj of bucketMeta.objects) {
374
- // Normalizar o caminho para comparação (remover barras iniciais e normalizar)
375
- // A API pode retornar 'name' ou 'path' ou 'id'
376
- const objPath = obj.name || obj.path || obj.id || '';
377
- const normalizedPath = objPath.replace(/^\/+/, '').replace(/\\/g, '/');
378
- if (normalizedPath) {
379
- objectsMetadata[normalizedPath] = obj;
380
- }
381
- }
382
- }
383
-
384
- let filesUploaded = 0;
385
- for (const file of filesToUpload) {
386
- try {
387
- const fileContent = await fs.readFile(file.localPath);
388
- const fileName = path.basename(file.localPath);
389
-
390
- // Usar o caminho relativo como path no storage
391
- const storagePath = file.storagePath;
392
-
393
- // Buscar metadados do objeto no backup (se disponível)
394
- // Normalizar caminho para comparação (remover barras iniciais e normalizar separadores)
395
- const normalizedStoragePath = storagePath.replace(/^\/+/, '').replace(/\\/g, '/');
396
- const objectMeta = objectsMetadata[normalizedStoragePath];
397
-
398
- // Extrair metadados do objeto (a estrutura pode variar)
399
- // A Management API pode retornar metadata.mimetype, metadata.contentType, ou metadata diretamente
400
- let contentType = getContentType(fileName);
401
- let cacheControl = undefined;
402
-
403
- if (objectMeta) {
404
- // Tentar diferentes estruturas possíveis de metadados
405
- const metadata = objectMeta.metadata || objectMeta;
406
- contentType = metadata?.mimetype ||
407
- metadata?.contentType ||
408
- metadata?.mime_type ||
409
- objectMeta.mimetype ||
410
- objectMeta.contentType ||
411
- getContentType(fileName);
412
-
413
- cacheControl = metadata?.cacheControl ||
414
- metadata?.cache_control ||
415
- objectMeta.cacheControl ||
416
- objectMeta.cache_control ||
417
- undefined;
418
- }
419
-
420
- // Preparar opções de upload seguindo o script oficial do Supabase
421
- // Preservar contentType e cacheControl quando disponíveis
422
- const uploadOptions = {
423
- upsert: true, // Sobrescrever se já existir
424
- contentType: contentType
425
- };
426
-
427
- // Adicionar cacheControl apenas se estiver definido
428
- if (cacheControl) {
429
- uploadOptions.cacheControl = cacheControl;
430
- }
431
-
432
- const { error: uploadError } = await supabase.storage
433
- .from(bucketName)
434
- .upload(storagePath, fileContent, uploadOptions);
435
-
436
- if (uploadError) {
437
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.uploadError', { path: storagePath, message: uploadError.message })}`));
438
- } else {
439
- filesUploaded++;
440
- if (filesToUpload.length <= 10 || filesUploaded % Math.ceil(filesToUpload.length / 10) === 0) {
441
- const metaInfo = objectMeta ? ` ${getT('restore.steps.storage.metadataPreserved')}` : '';
442
- console.log(chalk.white(` - ${getT('restore.steps.storage.uploadProgress', { path: storagePath, metaInfo })}`));
443
- }
444
- }
445
- } catch (fileError) {
446
- console.log(chalk.yellow(` ⚠️ ${getT('restore.steps.storage.fileError', { path: file.storagePath, message: fileError.message })}`));
447
- }
448
- }
449
-
450
- console.log(chalk.green(` ✅ ${getT('restore.steps.storage.bucketDone', { bucketName, uploaded: filesUploaded, total: filesToUpload.length })}`));
451
- successCount++;
452
- totalFilesUploaded += filesUploaded;
453
-
454
- } catch (bucketError) {
455
- console.log(chalk.red(` ❌ ${getT('restore.steps.storage.bucketError', { bucketName, message: bucketError.message })}`));
456
- }
457
- }
458
-
459
- // 9. Limpar diretório de extração
460
- try {
461
- await fs.rm(extractDir, { recursive: true, force: true });
462
- } catch {
463
- // Ignorar erro de limpeza
464
- }
465
-
466
- console.log(chalk.green(`\n ✅ ${getT('restore.steps.storage.restoreComplete', { success: successCount, total: bucketDirs.length, files: totalFilesUploaded })}`));
467
-
468
- return {
469
- success: true,
470
- buckets_count: successCount,
471
- files_restored: true,
472
- total_files: totalFilesUploaded
473
- };
474
-
475
- } catch (error) {
476
- const getT = global.smoonbI18n?.t || t;
477
- console.error(chalk.red(` ❌ ${getT('restore.steps.storage.error', { message: error.message })}`));
478
- throw error;
479
- }
480
- };
481
-
482
- /**
483
- * Substitui o Project ID antigo pelo novo em arquivos extraídos
484
- * Processa recursivamente todos os arquivos e diretórios
485
- * IMPORTANTE: Processa primeiro os filhos, depois renomeia o diretório atual
486
- * para evitar problemas com caminhos que mudam durante o processamento
487
- */
488
- async function replaceProjectIdInExtractedFiles(dir, oldProjectId, newProjectId) {
489
- const entries = await fs.readdir(dir, { withFileTypes: true });
490
-
491
- // Primeiro, processar recursivamente todos os filhos (arquivos e subdiretórios)
492
- for (const entry of entries) {
493
- const entryPath = path.join(dir, entry.name);
494
-
495
- if (entry.isDirectory()) {
496
- // Processar recursivamente o subdiretório ANTES de renomeá-lo
497
- await replaceProjectIdInExtractedFiles(entryPath, oldProjectId, newProjectId);
498
- } else {
499
- // Processar arquivos: substituir project ID no conteúdo (se for texto) e no nome
500
- // Renomear arquivo se contiver o project ID antigo
501
- if (entry.name.includes(oldProjectId)) {
502
- const newName = entry.name.replace(new RegExp(oldProjectId, 'g'), newProjectId);
503
- if (newName !== entry.name) {
504
- const newPath = path.join(dir, newName);
505
- try {
506
- await fs.rename(entryPath, newPath);
507
- } catch {
508
- // Ignorar erros de renomeação (pode já ter sido renomeado)
509
- }
510
- }
511
- }
512
-
513
- // Substituir project ID no conteúdo de arquivos de texto
514
- // Verificar extensões comuns de arquivos de texto
515
- const textExtensions = ['.json', '.txt', '.md', '.html', '.css', '.js', '.xml', '.yaml', '.yml'];
516
- const ext = path.extname(entry.name).toLowerCase();
517
-
518
- if (textExtensions.includes(ext)) {
519
- try {
520
- const content = await fs.readFile(entryPath, 'utf8');
521
- if (content.includes(oldProjectId)) {
522
- const newContent = content.replace(new RegExp(oldProjectId, 'g'), newProjectId);
523
- await fs.writeFile(entryPath, newContent, 'utf8');
524
- }
525
- } catch {
526
- // Ignorar erros ao processar arquivos (pode ser binário ou sem permissão)
527
- }
528
- }
529
- }
530
- }
531
-
532
- // Depois de processar todos os filhos, renomear o diretório atual se necessário
533
- // Ler novamente os diretórios para pegar os nomes atualizados
534
- const updatedEntries = await fs.readdir(dir, { withFileTypes: true });
535
- for (const entry of updatedEntries) {
536
- if (entry.isDirectory() && entry.name.includes(oldProjectId)) {
537
- const entryPath = path.join(dir, entry.name);
538
- const newName = entry.name.replace(new RegExp(oldProjectId, 'g'), newProjectId);
539
- if (newName !== entry.name) {
540
- const newPath = path.join(dir, newName);
541
- try {
542
- await fs.rename(entryPath, newPath);
543
- } catch {
544
- // Ignorar erros de renomeação (pode já ter sido renomeado)
545
- }
546
- }
547
- }
548
- }
549
- }
550
-
551
- /**
552
- * Determina o content-type baseado na extensão do arquivo
553
- */
554
- function getContentType(fileName) {
555
- const ext = path.extname(fileName).toLowerCase();
556
- const contentTypes = {
557
- '.jpg': 'image/jpeg',
558
- '.jpeg': 'image/jpeg',
559
- '.png': 'image/png',
560
- '.gif': 'image/gif',
561
- '.webp': 'image/webp',
562
- '.svg': 'image/svg+xml',
563
- '.pdf': 'application/pdf',
564
- '.json': 'application/json',
565
- '.txt': 'text/plain',
566
- '.html': 'text/html',
567
- '.css': 'text/css',
568
- '.js': 'application/javascript',
569
- '.zip': 'application/zip',
570
- '.mp4': 'video/mp4',
571
- '.mp3': 'audio/mpeg'
572
- };
573
-
574
- return contentTypes[ext] || 'application/octet-stream';
575
- }
576
-
577
- function isLikelyProjectId(name) {
578
- if (!name || typeof name !== 'string') {
579
- return false;
580
- }
581
-
582
- // Project IDs do Supabase são normalmente strings de 20 caracteres alfanuméricos minúsculos
583
- return /^[a-z0-9]{20}$/.test(name);
584
- }
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const AdmZip = require('adm-zip');
4
+ const { createHash } = require('crypto');
5
+ const { createClient } = require('@supabase/supabase-js');
6
+ const { readDump, records, textArray, remapServiceUrls } = require('../dump');
7
+ const { runSql, literal } = require('../pg');
8
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ const encodePath = (value) =>
10
+ value.split('/').map(encodeURIComponent).join('/');
11
+
12
+ function storagePlan(backupPath, backupFilePath) {
13
+ const dump = readDump(backupFilePath);
14
+ const buckets = records(
15
+ dump.tables.find((t) => t.schema === 'storage' && t.name === 'buckets'),
16
+ );
17
+ const objects = records(
18
+ dump.tables.find((t) => t.schema === 'storage' && t.name === 'objects'),
19
+ );
20
+ const manifest = JSON.parse(
21
+ fs.readFileSync(path.join(backupPath, 'backup-manifest.json'), 'utf8'),
22
+ );
23
+ const files = fs
24
+ .readdirSync(backupPath)
25
+ .filter((f) => f.endsWith('.storage.zip'));
26
+ if (files.length > 1 || (objects.length && files.length !== 1))
27
+ throw new Error('Storage archive missing or ambiguous');
28
+ const entries = new Map();
29
+ if (files.length)
30
+ for (const e of new AdmZip(path.join(backupPath, files[0])).getEntries()) {
31
+ if (e.isDirectory) continue;
32
+ let name = e.entryName;
33
+ if (name.startsWith(manifest.project_id + '/'))
34
+ name = name.slice(manifest.project_id.length + 1);
35
+ if (
36
+ name.includes('\\') ||
37
+ name.split('/').some((p) => ['.', '..', ''].includes(p)) ||
38
+ entries.has(name)
39
+ )
40
+ throw new Error('Unsafe or duplicate Storage archive path');
41
+ entries.set(name, e);
42
+ }
43
+ if (
44
+ entries.size !== objects.length ||
45
+ objects.some((o) => !entries.has(`${o.bucket_id}/${o.name}`))
46
+ )
47
+ throw new Error('Storage archive does not match database inventory');
48
+ return { buckets, objects, entries, sourceProjectId: manifest.project_id };
49
+ }
50
+
51
+ module.exports = async ({
52
+ backupPath,
53
+ backupFilePath,
54
+ targetProject,
55
+ remapUrls = true,
56
+ postgresMajor = 17,
57
+ }) => {
58
+ const plan = storagePlan(backupPath, backupFilePath);
59
+ const client = createClient(
60
+ targetProject.targetUrl,
61
+ targetProject.targetServiceKey,
62
+ { auth: { persistSession: false, autoRefreshToken: false } },
63
+ );
64
+ const { data: existing, error } = await client.storage.listBuckets();
65
+ if (error) throw new Error('Cannot inspect destination Storage buckets');
66
+ for (const b of plan.buckets) {
67
+ const options = {
68
+ public: b.public === 't',
69
+ fileSizeLimit:
70
+ b.file_size_limit === null ? null : Number(b.file_size_limit),
71
+ allowedMimeTypes: textArray(b.allowed_mime_types),
72
+ };
73
+ const operation = (existing || []).some((x) => x.id === b.id)
74
+ ? client.storage.updateBucket(b.id, options)
75
+ : client.storage.createBucket(b.id, options);
76
+ const result = await operation;
77
+ if (result.error)
78
+ throw new Error(`Storage bucket configuration failed: ${b.id}`);
79
+ }
80
+ const headers = {
81
+ Authorization: `Bearer ${targetProject.targetServiceKey}`,
82
+ apikey: targetProject.targetServiceKey,
83
+ };
84
+ let transformed = 0,
85
+ completed = 0;
86
+ const metadataSql = [];
87
+ for (const o of plan.objects) {
88
+ const metadata = JSON.parse(o.metadata || '{}');
89
+ let bytes = plan.entries.get(`${o.bucket_id}/${o.name}`).getData();
90
+ const textual =
91
+ /^(text\/|application\/(json|javascript|xml))/.test(
92
+ metadata.mimetype || '',
93
+ ) || /\.(html?|css|js|json|txt|md|svg|xml)$/i.test(o.name);
94
+ if (remapUrls && textual) {
95
+ const changed = Buffer.from(
96
+ remapServiceUrls(
97
+ bytes.toString('utf8'),
98
+ plan.sourceProjectId,
99
+ targetProject.targetProjectId,
100
+ ),
101
+ );
102
+ if (!bytes.equals(changed)) {
103
+ bytes = changed;
104
+ transformed++;
105
+ }
106
+ }
107
+ const objectPath = encodePath(`${o.bucket_id}/${o.name}`);
108
+ const response = await fetch(
109
+ `${targetProject.targetUrl}/storage/v1/object/${objectPath}`,
110
+ {
111
+ method: 'POST',
112
+ signal: AbortSignal.timeout(180000),
113
+ headers: {
114
+ ...headers,
115
+ 'x-upsert': 'true',
116
+ 'content-type': metadata.mimetype || 'application/octet-stream',
117
+ 'cache-control': metadata.cacheControl || 'max-age=3600',
118
+ },
119
+ body: bytes,
120
+ },
121
+ );
122
+ if (!response.ok)
123
+ throw new Error(
124
+ `Storage upload failed (HTTP ${response.status}); completed ${completed}/${plan.objects.length}`,
125
+ );
126
+ const downloaded = await fetch(
127
+ `${targetProject.targetUrl}/storage/v1/object/authenticated/${objectPath}`,
128
+ { headers, signal: AbortSignal.timeout(180000) },
129
+ );
130
+ if (
131
+ !downloaded.ok ||
132
+ hash(Buffer.from(await downloaded.arrayBuffer())) !== hash(bytes)
133
+ )
134
+ throw new Error(
135
+ `Storage content verification failed; completed ${completed}/${plan.objects.length}`,
136
+ );
137
+ const assignments = [];
138
+ for (const field of [
139
+ 'id',
140
+ 'owner',
141
+ 'owner_id',
142
+ 'created_at',
143
+ 'user_metadata',
144
+ ])
145
+ if (Object.prototype.hasOwnProperty.call(o, field))
146
+ assignments.push(
147
+ `${field}=${o[field] === null ? 'NULL' : literal(o[field])}`,
148
+ );
149
+ const expectedMetadata = {
150
+ mimetype: metadata.mimetype || 'application/octet-stream',
151
+ cacheControl: metadata.cacheControl || 'max-age=3600',
152
+ size: bytes.length,
153
+ };
154
+ assignments.push(
155
+ `metadata=COALESCE(metadata,'{}'::jsonb)||${literal(JSON.stringify(expectedMetadata))}::jsonb`,
156
+ );
157
+ const where = `bucket_id=${literal(o.bucket_id)} AND name=${literal(o.name)}`;
158
+ metadataSql.push(
159
+ `UPDATE storage.objects SET ${assignments.join(',')} WHERE ${where};`,
160
+ );
161
+ metadataSql.push(
162
+ `DO $verify$ BEGIN IF NOT EXISTS(SELECT 1 FROM storage.objects WHERE ${where} AND owner_id IS NOT DISTINCT FROM ${o.owner_id === null ? 'NULL' : literal(o.owner_id)} AND metadata @> ${literal(JSON.stringify(expectedMetadata))}::jsonb) THEN RAISE EXCEPTION 'Storage metadata verification failed'; END IF; END $verify$;`,
163
+ );
164
+ completed++;
165
+ if (completed % 50 === 0 || completed === plan.objects.length)
166
+ console.log(
167
+ `Storage: ${completed}/${plan.objects.length} files uploaded and verified.`,
168
+ );
169
+ }
170
+ if (metadataSql.length)
171
+ await runSql(
172
+ targetProject.targetDatabaseUrl,
173
+ `BEGIN; SET LOCAL session_replication_role=replica;\n${metadataSql.join('\n')}\nCOMMIT;`,
174
+ { postgresMajor },
175
+ );
176
+ const { data: actual, error: verifyError } =
177
+ await client.storage.listBuckets();
178
+ if (
179
+ verifyError ||
180
+ plan.buckets.some(
181
+ (b) =>
182
+ !actual.some(
183
+ (a) =>
184
+ a.id === b.id &&
185
+ a.public === (b.public === 't') &&
186
+ Number(a.file_size_limit || 0) === Number(b.file_size_limit || 0) &&
187
+ JSON.stringify(a.allowed_mime_types || null) ===
188
+ JSON.stringify(textArray(b.allowed_mime_types)),
189
+ ),
190
+ )
191
+ )
192
+ throw new Error('Storage bucket verification failed');
193
+ return {
194
+ success: true,
195
+ verified: true,
196
+ buckets_count: plan.buckets.length,
197
+ files_restored: true,
198
+ total_files: completed,
199
+ remapped_files: transformed,
200
+ verification: 'SHA-256 of every downloaded object and restored metadata',
201
+ };
202
+ };
203
+ module.exports.storagePlan = storagePlan;