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.
- package/CHANGELOG.md +44 -0
- package/README.md +679 -644
- package/README.pt-BR.md +679 -644
- package/package.json +68 -66
- package/src/commands/backup/index.js +8 -2
- package/src/commands/backup/steps/03-database-separated.js +161 -114
- package/src/commands/backup/steps/06-realtime-settings.js +19 -33
- package/src/commands/backup/steps/07-storage.js +7 -12
- package/src/commands/backup/steps/09-edge-functions.js +76 -202
- package/src/commands/restore/auth-fields.json +237 -0
- package/src/commands/restore/dump.js +284 -0
- package/src/commands/restore/index.js +4 -126
- package/src/commands/restore/management.js +74 -0
- package/src/commands/restore/pg.js +83 -0
- package/src/commands/restore/runner.js +117 -0
- package/src/commands/restore/steps/03-database.js +18 -84
- package/src/commands/restore/steps/04-edge-functions.js +95 -122
- package/src/commands/restore/steps/05-auth-settings.js +26 -54
- package/src/commands/restore/steps/06-storage.js +203 -584
- package/src/commands/restore/steps/07-database-settings.js +87 -69
- package/src/commands/restore/steps/08-realtime-settings.js +61 -53
- package/src/i18n/locales/en.json +800 -796
- package/src/i18n/locales/pt-BR.json +799 -795
- package/src/utils/pg-dump-diagnostics.js +64 -0
- package/src/utils/supabaseCli.js +32 -0
|
@@ -1,584 +1,203 @@
|
|
|
1
|
-
const
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const { createClient } = require('@supabase/supabase-js');
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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;
|