mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: add live cloud backup scanner and one-click cloud restore modal for Google Drive and S3
This commit is contained in:
parent
12dbf04b8e
commit
6e8e948ce1
3 changed files with 689 additions and 27 deletions
316
public/app.js
316
public/app.js
|
|
@ -93,6 +93,13 @@ const elements = {
|
|||
gdriveFolderHint: document.getElementById('gdrive-folder-hint'),
|
||||
gdriveInlineStatus: document.getElementById('gdrive-inline-status'),
|
||||
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
|
||||
btnBackupRestoreCloudModal: document.getElementById('btn-backup-restore-cloud-modal'),
|
||||
btnSyncCloudBackups: document.getElementById('btn-sync-cloud-backups'),
|
||||
restoreCloudModal: document.getElementById('restore-cloud-modal'),
|
||||
cloudRestoreProviderFilter: document.getElementById('cloud-restore-provider-filter'),
|
||||
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
|
||||
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
|
||||
cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'),
|
||||
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
|
||||
|
||||
// Admin Create User Modal
|
||||
|
|
@ -327,6 +334,18 @@ function registerEvents() {
|
|||
if (elements.btnGdriveRefreshFolders) {
|
||||
elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true));
|
||||
}
|
||||
if (elements.btnBackupRestoreCloudModal) {
|
||||
elements.btnBackupRestoreCloudModal.addEventListener('click', openCloudRestoreModal);
|
||||
}
|
||||
if (elements.btnSyncCloudBackups) {
|
||||
elements.btnSyncCloudBackups.addEventListener('click', handleSyncCloudBackups);
|
||||
}
|
||||
if (elements.btnRefreshCloudModal) {
|
||||
elements.btnRefreshCloudModal.addEventListener('click', () => loadLiveCloudBackupsForModal(true));
|
||||
}
|
||||
if (elements.cloudRestoreProviderFilter) {
|
||||
elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList());
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
|
|
@ -3302,6 +3321,10 @@ async function fetchAdminBackupConfig() {
|
|||
}
|
||||
|
||||
renderAdminBackupHistory(data.history || []);
|
||||
// Automatically scan cloud storage if connected and local history is empty
|
||||
if ((!data.history || data.history.length === 0) && (cfg.gdriveConnected || cfg.s3Enabled)) {
|
||||
handleSyncCloudBackups(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching backup config:', err);
|
||||
showBackupStatus(err.message, 'error');
|
||||
|
|
@ -3323,7 +3346,7 @@ function renderAdminBackupHistory(history) {
|
|||
elements.adminBackupsHistoryList.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
|
||||
No cloud backups have been recorded yet. Configure Wasabi S3 or Google Drive above and click "Backup to Cloud Now", or use "Download .DB" / "Download .JSON" for immediate local computer downloads.
|
||||
No cloud backups found yet. Click "Backup to Cloud Now" to create one, or "Sync from Cloud" / "Restore from Cloud" to scan Google Drive & Wasabi S3.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
|
@ -3335,38 +3358,51 @@ function renderAdminBackupHistory(history) {
|
|||
const dateStr = new Date(item.createdAt).toLocaleString();
|
||||
const triggerBadge = item.triggerType === 'scheduled'
|
||||
? `<span class="admin-role-badge" style="background:rgba(99,102,241,0.12);color:#6366f1;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Scheduled</span>`
|
||||
: item.triggerType === 'cloud'
|
||||
? `<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Cloud Sync</span>`
|
||||
: `<span class="admin-role-badge" style="background:rgba(249,115,22,0.12);color:var(--accent);padding:3px 8px;border-radius:6px;font-size:0.78rem;">Manual</span>`;
|
||||
|
||||
const destBadges = [];
|
||||
if (item.s3KeyDb) {
|
||||
if (item.s3KeyDb || item.s3KeyJson) {
|
||||
destBadges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.76rem;margin-right:4px;">Wasabi S3</span>`);
|
||||
}
|
||||
if (item.gdriveFileIdDb) {
|
||||
if (item.gdriveFileIdDb || item.gdriveFileIdJson) {
|
||||
destBadges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.76rem;">Google Drive</span>`);
|
||||
}
|
||||
|
||||
const statusBadge = item.status === 'success'
|
||||
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Success</span>`
|
||||
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Available</span>`
|
||||
: `<span style="color:#ef4444;font-weight:600;font-size:0.84rem;" title="${(item.errorMessage || '').replace(/"/g, '"')}">⚠ Error</span>`;
|
||||
|
||||
const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
|
||||
const hasDbFile = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
|
||||
const hasJsonFile = Boolean(item.s3KeyJson || item.gdriveFileIdJson);
|
||||
const hasCloudFiles = hasDbFile || hasJsonFile;
|
||||
const countsText = (item.userCount || item.recipeCount || item.shoppingListCount)
|
||||
? `${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists`
|
||||
: `<span style="font-style:italic;">${item.label || 'Cloud Snapshot'}</span>`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="font-size:0.88rem;color:var(--text-main);white-space:nowrap;">${dateStr}</td>
|
||||
<td>${triggerBadge}</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);">${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">DB: ${formatBytesHuman(item.dbSizeBytes)}<br>JSON: ${formatBytesHuman(item.jsonSizeBytes)}</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);">${countsText}</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">
|
||||
${hasDbFile ? `DB: ${formatBytesHuman(item.dbSizeBytes)}` : ''}${hasDbFile && hasJsonFile ? '<br>' : ''}${hasJsonFile ? `JSON: ${formatBytesHuman(item.jsonSizeBytes)}` : ''}
|
||||
</td>
|
||||
<td>${destBadges.join('') || '<span style="color:var(--text-muted);font-size:0.8rem;">None</span>'}</td>
|
||||
<td>${statusBadge}${item.errorMessage ? `<div style="font-size:0.75rem;color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${item.errorMessage.replace(/"/g, '"')}">${item.errorMessage}</div>` : ''}</td>
|
||||
<td style="text-align:right;white-space:nowrap;">
|
||||
${hasCloudFiles ? `
|
||||
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .db from Cloud to Computer">
|
||||
${hasDbFile ? `
|
||||
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Download .db Snapshot from Cloud to Computer">
|
||||
<i data-lucide="download" style="width:12px;height:12px;"></i> .DB
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .json from Cloud to Computer">
|
||||
` : ''}
|
||||
${hasJsonFile ? `
|
||||
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Download .json Archive from Cloud to Computer">
|
||||
<i data-lucide="file-json" style="width:12px;height:12px;"></i> .JSON
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(249,115,22,0.4);color:var(--accent);" title="Restore Database from this Cloud Snapshot">
|
||||
` : ''}
|
||||
${hasCloudFiles ? `
|
||||
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 10px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(16,185,129,0.45);color:#10b981;font-weight:600;" title="Restore Database from this Cloud Backup">
|
||||
<i data-lucide="rotate-ccw" style="width:12px;height:12px;"></i> Restore
|
||||
</button>
|
||||
` : ''}
|
||||
|
|
@ -3710,5 +3746,263 @@ async function handleRestoreLocalFile(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LIVE CLOUD BACKUP BROWSER & RESTORE MODAL
|
||||
// ==========================================
|
||||
|
||||
let cachedCloudBackups = [];
|
||||
|
||||
function showCloudRestoreModalStatus(message, type = 'info') {
|
||||
const box = elements.cloudRestoreModalStatus;
|
||||
if (!box) return;
|
||||
box.classList.remove('hidden');
|
||||
if (type === 'success') {
|
||||
box.style.background = 'rgba(16, 185, 129, 0.12)';
|
||||
box.style.border = '1px solid rgba(16, 185, 129, 0.35)';
|
||||
box.style.color = '#10b981';
|
||||
} else if (type === 'error') {
|
||||
box.style.background = 'rgba(239, 68, 68, 0.12)';
|
||||
box.style.border = '1px solid rgba(239, 68, 68, 0.35)';
|
||||
box.style.color = '#ef4444';
|
||||
} else {
|
||||
box.style.background = 'rgba(59, 130, 246, 0.12)';
|
||||
box.style.border = '1px solid rgba(59, 130, 246, 0.35)';
|
||||
box.style.color = '#3b82f6';
|
||||
}
|
||||
box.textContent = message;
|
||||
}
|
||||
|
||||
async function handleSyncCloudBackups(silent = false) {
|
||||
const btn = elements.btnSyncCloudBackups;
|
||||
const origHtml = btn ? btn.innerHTML : '';
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> <span>Scanning Cloud...</span>`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups/cloud-files', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud backups.');
|
||||
|
||||
cachedCloudBackups = data.backups || [];
|
||||
renderAdminBackupHistory(cachedCloudBackups);
|
||||
if (!silent) {
|
||||
if (data.providerErrors && data.providerErrors.length > 0) {
|
||||
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) (${data.providerErrors.join('; ')}).`, 'info');
|
||||
} else {
|
||||
showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) from connected storage.`, 'success');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) showBackupStatus(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCloudRestoreModal() {
|
||||
if (elements.cloudRestoreModalStatus) {
|
||||
elements.cloudRestoreModalStatus.classList.add('hidden');
|
||||
}
|
||||
openModal('restore-cloud-modal');
|
||||
loadLiveCloudBackupsForModal(false);
|
||||
}
|
||||
|
||||
async function loadLiveCloudBackupsForModal(showToast = false) {
|
||||
if (!elements.cloudRestoreFilesContainer) return;
|
||||
elements.cloudRestoreFilesContainer.innerHTML = `
|
||||
<div style="text-align: center; padding: 32px; color: var(--text-muted);">
|
||||
<p>Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups/cloud-files', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to scan cloud storage.');
|
||||
|
||||
cachedCloudBackups = data.backups || [];
|
||||
renderAdminBackupHistory(cachedCloudBackups);
|
||||
renderCloudRestoreModalList();
|
||||
|
||||
if (data.providerErrors && data.providerErrors.length > 0) {
|
||||
showCloudRestoreModalStatus(`Note: ${data.providerErrors.join(' | ')}`, 'error');
|
||||
} else if (showToast) {
|
||||
showCloudRestoreModalStatus(`Found ${cachedCloudBackups.length} backup snapshot(s) in cloud storage.`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
showCloudRestoreModalStatus(err.message, 'error');
|
||||
elements.cloudRestoreFilesContainer.innerHTML = `
|
||||
<div style="text-align: center; padding: 28px; color: var(--text-muted);">
|
||||
Could not load cloud backups. Make sure Google Drive or Wasabi S3 is connected.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCloudRestoreModalList() {
|
||||
if (!elements.cloudRestoreFilesContainer) return;
|
||||
const filter = elements.cloudRestoreProviderFilter ? elements.cloudRestoreProviderFilter.value : 'all';
|
||||
|
||||
const filtered = cachedCloudBackups.filter(snap => {
|
||||
const hasGdrive = Boolean(snap.gdriveFileIdDb || snap.gdriveFileIdJson);
|
||||
const hasS3 = Boolean(snap.s3KeyDb || snap.s3KeyJson);
|
||||
if (filter === 'gdrive') return hasGdrive;
|
||||
if (filter === 's3') return hasS3;
|
||||
return hasGdrive || hasS3;
|
||||
});
|
||||
|
||||
elements.cloudRestoreFilesContainer.innerHTML = '';
|
||||
|
||||
if (filtered.length === 0) {
|
||||
elements.cloudRestoreFilesContainer.innerHTML = `
|
||||
<div style="text-align: center; padding: 32px; color: var(--text-muted); border: 1px dashed var(--border); border-radius: 10px;">
|
||||
No cloud backups found for this source. Click "Backup to Cloud Now" to create your first cloud snapshot, or verify your Google Drive destination folder.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(snap => {
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = 'background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;';
|
||||
|
||||
const dateStr = new Date(snap.createdAt).toLocaleString();
|
||||
const badges = [];
|
||||
if (snap.gdriveFileIdDb || snap.gdriveFileIdJson) {
|
||||
badges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.75rem;">Google Drive</span>`);
|
||||
}
|
||||
if (snap.s3KeyDb || snap.s3KeyJson) {
|
||||
badges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.75rem;">Wasabi S3</span>`);
|
||||
}
|
||||
|
||||
const statsLine = (snap.userCount || snap.recipeCount || snap.shoppingListCount)
|
||||
? `${snap.userCount} users · ${snap.recipeCount} recipes · ${snap.shoppingListCount} shopping lists`
|
||||
: snap.label;
|
||||
|
||||
const hasDb = Boolean(snap.gdriveFileIdDb || snap.s3KeyDb);
|
||||
const hasJson = Boolean(snap.gdriveFileIdJson || snap.s3KeyJson);
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<strong style="color: var(--text-main); font-size: 0.95rem;">${dateStr}</strong>
|
||||
${badges.join('')}
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--text-muted);">
|
||||
${statsLine}
|
||||
${hasDb ? ` · DB: ${formatBytesHuman(snap.dbSizeBytes)}` : ''}
|
||||
${hasJson ? ` · JSON: ${formatBytesHuman(snap.jsonSizeBytes)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
${hasDb ? `
|
||||
<button type="button" class="btn btn-primary btn-sm btn-modal-restore-db" title="Restore full SQLite database snapshot from cloud">
|
||||
<i data-lucide="rotate-ccw" style="width:13px;height:13px;"></i> <span>Restore (.DB)</span>
|
||||
</button>
|
||||
` : ''}
|
||||
${hasJson ? `
|
||||
<button type="button" class="btn ${hasDb ? 'btn-outline' : 'btn-primary'} btn-sm btn-modal-restore-json" title="Restore from JSON archive with embedded images">
|
||||
<i data-lucide="file-json" style="width:13px;height:13px;"></i> <span>Restore (.JSON)</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btnRestoreDb = card.querySelector('.btn-modal-restore-db');
|
||||
if (btnRestoreDb) {
|
||||
btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr, btnRestoreDb));
|
||||
}
|
||||
|
||||
const btnRestoreJson = card.querySelector('.btn-modal-restore-json');
|
||||
if (btnRestoreJson) {
|
||||
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr, btnRestoreJson));
|
||||
}
|
||||
|
||||
elements.cloudRestoreFilesContainer.appendChild(card);
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) {
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to restore your ForkFolio database from the cloud ${format.toUpperCase()} backup created on ${dateLabel}?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this cloud backup.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const origHtml = btnEl ? btnEl.innerHTML : '';
|
||||
if (btnEl) {
|
||||
btnEl.disabled = true;
|
||||
btnEl.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> <span>Restoring...</span>`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
try {
|
||||
showCloudRestoreModalStatus(`Downloading ${format.toUpperCase()} backup from cloud and restoring database...`, 'info');
|
||||
|
||||
// Determine provider & identifier
|
||||
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
|
||||
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
|
||||
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
|
||||
|
||||
let res;
|
||||
if (gdriveId) {
|
||||
res = await fetch('/api/admin/backups/restore-cloud-file', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename })
|
||||
});
|
||||
} else if (s3Key) {
|
||||
res = await fetch('/api/admin/backups/restore-cloud-file', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ provider: 's3', objectKey: s3Key, filename })
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ format })
|
||||
});
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
|
||||
|
||||
const msg = `✓ Database restored from cloud (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`;
|
||||
showCloudRestoreModalStatus(msg, 'success');
|
||||
showBackupStatus(msg, 'success');
|
||||
|
||||
await fetchRecipes();
|
||||
await fetchShoppingLists();
|
||||
await fetchAdminBackupConfig();
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal('restore-cloud-modal');
|
||||
}, 1400);
|
||||
} catch (err) {
|
||||
showCloudRestoreModalStatus(`Restore failed: ${err.message}`, 'error');
|
||||
showBackupStatus(`Restore failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (btnEl) {
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -290,6 +290,9 @@
|
|||
<button type="button" class="btn btn-primary" id="btn-backup-run-cloud" title="Run immediate cloud backup to enabled providers">
|
||||
<i data-lucide="cloud-upload"></i> <span>Backup to Cloud Now</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline" id="btn-backup-restore-cloud-modal" style="border-color: rgba(16, 185, 129, 0.45); color: #10b981;" title="Browse and restore backups directly from Google Drive or Wasabi S3">
|
||||
<i data-lucide="cloud-download"></i> <span>Restore from Cloud</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline" id="btn-backup-download-db" title="Download SQLite .db snapshot to your computer">
|
||||
<i data-lucide="download"></i> <span>Download .DB</span>
|
||||
</button>
|
||||
|
|
@ -501,7 +504,15 @@
|
|||
|
||||
<!-- Cloud Backup History Table -->
|
||||
<div style="margin-top: 32px;">
|
||||
<h4 style="font-size: 1.15rem; color: var(--text-main); margin-bottom: 12px;">Cloud Backup History</h4>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<h4 style="font-size: 1.15rem; color: var(--text-main); margin-bottom: 2px;">Cloud Backup History & Recovery</h4>
|
||||
<p style="font-size: 0.8rem; color: var(--text-muted); margin: 0;">Restore your database directly from any Google Drive or Wasabi S3 backup below, or stream a copy to your computer.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="btn-sync-cloud-backups" title="Scan Google Drive and Wasabi S3 for existing backup files">
|
||||
<i data-lucide="refresh-cw"></i> <span>Sync from Cloud</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="admin-users-table">
|
||||
<thead>
|
||||
|
|
@ -526,6 +537,43 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<!-- RESTORE FROM CLOUD MODAL -->
|
||||
<div class="modal-overlay" id="restore-cloud-modal">
|
||||
<div class="modal-box max-width-lg">
|
||||
<div class="modal-header">
|
||||
<h3 style="display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="cloud-download" style="color: #10b981;"></i>
|
||||
<span>Restore Database from Cloud Storage</span>
|
||||
</h3>
|
||||
<button class="close-btn" onclick="closeModal('restore-cloud-modal')"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 16px;">
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<label for="cloud-restore-provider-filter" style="font-size: 0.85rem; color: var(--text-muted); font-weight: 600;">Cloud Source:</label>
|
||||
<select id="cloud-restore-provider-filter" class="shopping-select" style="min-width: 190px;">
|
||||
<option value="all">All Connected Clouds (Drive & S3)</option>
|
||||
<option value="gdrive">Google Drive Only</option>
|
||||
<option value="s3">Wasabi S3 Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="btn-refresh-cloud-modal">
|
||||
<i data-lucide="refresh-cw"></i> <span>Scan Cloud Now</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="cloud-restore-modal-status" class="hidden" style="font-size: 0.85rem; padding: 10px 14px; border-radius: 8px; margin-bottom: 14px;"></div>
|
||||
|
||||
<div id="cloud-restore-files-container" style="max-height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px;">
|
||||
<!-- Populated dynamically with discovered Google Drive / Wasabi S3 backups -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('restore-cloud-modal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCRAPE PREVIEW & EDIT DIALOG -->
|
||||
<div class="modal-overlay" id="save-modal">
|
||||
<div class="modal-box max-width-lg">
|
||||
|
|
|
|||
348
server.js
348
server.js
|
|
@ -2453,6 +2453,47 @@ async function s3DeleteObject(cfg, objectKey) {
|
|||
}
|
||||
}
|
||||
|
||||
async function s3ListBackupObjects(cfg) {
|
||||
if (!cfg.s3Bucket || !cfg.s3AccessKeyId || !cfg.s3SecretAccessKey) return [];
|
||||
const prefix = (cfg.s3Prefix || '').trim().replace(/^\/+/, '');
|
||||
const queryParams = { 'list-type': '2', 'max-keys': '200' };
|
||||
if (prefix) queryParams.prefix = prefix;
|
||||
|
||||
const signed = buildS3SignedRequest({
|
||||
method: 'GET',
|
||||
endpoint: cfg.s3Endpoint,
|
||||
region: cfg.s3Region,
|
||||
bucket: cfg.s3Bucket,
|
||||
queryParams,
|
||||
accessKeyId: cfg.s3AccessKeyId,
|
||||
secretAccessKey: cfg.s3SecretAccessKey
|
||||
});
|
||||
const res = await fetch(signed.url, { method: 'GET', headers: signed.headers });
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(`S3 list failed (${res.status}): ${errText.slice(0, 200)}`);
|
||||
}
|
||||
const xmlText = await res.text();
|
||||
const $ = cheerio.load(xmlText, { xmlMode: true });
|
||||
const results = [];
|
||||
$('Contents').each((_, el) => {
|
||||
const key = $(el).find('Key').text().trim();
|
||||
const size = parseInt($(el).find('Size').text().trim(), 10) || 0;
|
||||
const lastModified = $(el).find('LastModified').text().trim();
|
||||
const name = key.split('/').pop() || key;
|
||||
if (/\.(db|sqlite|json)$/i.test(name)) {
|
||||
results.push({
|
||||
key,
|
||||
name,
|
||||
size,
|
||||
createdAt: lastModified || new Date().toISOString(),
|
||||
format: /\.json$/i.test(name) ? 'json' : 'db'
|
||||
});
|
||||
}
|
||||
});
|
||||
return results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// GOOGLE DRIVE REST API V3 CONNECTION CLIENT
|
||||
// -------------------------------------------------------------
|
||||
|
|
@ -2579,6 +2620,34 @@ async function gdriveCreateFolder(accessToken, folderName) {
|
|||
return { id: created.id, name: created.name || cleanName };
|
||||
}
|
||||
|
||||
async function gdriveListBackupFiles(accessToken, folderId) {
|
||||
const clauses = ["mimeType != 'application/vnd.google-apps.folder'", 'trashed = false'];
|
||||
if (folderId && folderId.trim()) {
|
||||
clauses.push(`('${folderId.trim()}' in parents or name contains 'forkfolio-backup')`);
|
||||
} else {
|
||||
clauses.push("name contains 'forkfolio-backup'");
|
||||
}
|
||||
const q = encodeURIComponent(clauses.join(' and '));
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/drive/v3/files?q=${q}&pageSize=150&orderBy=createdTime desc&fields=files(id,name,size,createdTime,modifiedTime,mimeType,parents)`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error?.message || 'Failed to list backup files from Google Drive.');
|
||||
}
|
||||
const files = Array.isArray(data.files) ? data.files : [];
|
||||
return files
|
||||
.filter(f => /\.(db|sqlite|json)$/i.test(f.name || ''))
|
||||
.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: parseInt(f.size, 10) || 0,
|
||||
createdAt: f.createdTime || f.modifiedTime || new Date().toISOString(),
|
||||
format: /\.json$/i.test(f.name) ? 'json' : 'db'
|
||||
}));
|
||||
}
|
||||
|
||||
async function gdriveUploadFile(accessToken, folderId, filename, buffer, mimeType) {
|
||||
const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex');
|
||||
const metadata = {
|
||||
|
|
@ -2980,6 +3049,22 @@ function applyParsedRestoreData(data) {
|
|||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
// Sync active in-memory sessions with restored user accounts so the admin stays logged in
|
||||
for (const [token, sess] of activeSessions.entries()) {
|
||||
const matched =
|
||||
db.prepare('SELECT * FROM users WHERE id = ?').get(sess.id) ||
|
||||
db.prepare('SELECT * FROM users WHERE username = ?').get(sess.username);
|
||||
if (matched) {
|
||||
activeSessions.set(token, {
|
||||
id: matched.id,
|
||||
username: matched.username,
|
||||
isAdmin: matched.isAdmin === 1,
|
||||
isRecipeManager: matched.isRecipeManager === 1,
|
||||
defaultConsolidateLists: matched.defaultConsolidateLists !== 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
|
@ -3468,6 +3553,241 @@ app.get('/api/admin/backups/download-local', requireAuth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Discover all remote backup files directly from Google Drive and Wasabi S3 and sync with backup_history
|
||||
async function discoverAndSyncCloudBackups() {
|
||||
const cfg = getBackupConfig();
|
||||
const snapshotMap = new Map();
|
||||
|
||||
// Seed from existing local backup_history first
|
||||
const localRows = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 100').all();
|
||||
for (const row of localRows) {
|
||||
const s3Objects = JSON.parse(row.s3Objects || '[]');
|
||||
const gdriveFiles = JSON.parse(row.gdriveFiles || '[]');
|
||||
snapshotMap.set(row.label, {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
triggerType: row.triggerType || 'cloud',
|
||||
createdAt: row.createdAt,
|
||||
sizeBytes: row.sizeBytes || 0,
|
||||
userCount: row.userCount || 0,
|
||||
recipeCount: row.recipeCount || 0,
|
||||
imageCount: row.imageCount || 0,
|
||||
listCount: row.listCount || 0,
|
||||
s3Objects,
|
||||
gdriveFiles,
|
||||
status: row.status || 'success',
|
||||
errorDetails: row.errorDetails || null,
|
||||
inLocalDb: true
|
||||
});
|
||||
}
|
||||
|
||||
const providerErrors = [];
|
||||
|
||||
// 1. Live scan Google Drive backup folder if connected
|
||||
if (cfg.gdriveConnected) {
|
||||
try {
|
||||
const accessToken = await getGoogleDriveAccessToken(cfg);
|
||||
const folderId = await ensureGoogleDriveFolder(accessToken, cfg);
|
||||
const driveFiles = await gdriveListBackupFiles(accessToken, folderId);
|
||||
|
||||
for (const df of driveFiles) {
|
||||
const baseLabel = df.name.replace(/\.(db|sqlite|json)$/i, '');
|
||||
let entry = snapshotMap.get(baseLabel);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: 'cld_gd_' + df.id.slice(0, 12),
|
||||
label: baseLabel,
|
||||
triggerType: 'cloud',
|
||||
createdAt: df.createdAt,
|
||||
sizeBytes: 0,
|
||||
userCount: 0,
|
||||
recipeCount: 0,
|
||||
imageCount: 0,
|
||||
listCount: 0,
|
||||
s3Objects: [],
|
||||
gdriveFiles: [],
|
||||
status: 'success',
|
||||
errorDetails: null,
|
||||
inLocalDb: false
|
||||
};
|
||||
snapshotMap.set(baseLabel, entry);
|
||||
}
|
||||
const alreadyLinked = entry.gdriveFiles.some(g => g.id === df.id || g.format === df.format);
|
||||
if (!alreadyLinked) {
|
||||
entry.gdriveFiles.push({
|
||||
id: df.id,
|
||||
name: df.name,
|
||||
format: df.format,
|
||||
size: df.size
|
||||
});
|
||||
if (!entry.inLocalDb) {
|
||||
entry.sizeBytes += df.size || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (gdErr) {
|
||||
providerErrors.push(`Google Drive: ${gdErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Live scan Wasabi S3 bucket if configured
|
||||
if (cfg.s3Enabled && cfg.s3Bucket && cfg.s3AccessKeyId && cfg.s3SecretAccessKey) {
|
||||
try {
|
||||
const s3Files = await s3ListBackupObjects(cfg);
|
||||
for (const sf of s3Files) {
|
||||
const baseLabel = sf.name.replace(/\.(db|sqlite|json)$/i, '');
|
||||
let entry = snapshotMap.get(baseLabel);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
id: 'cld_s3_' + crypto.createHash('md5').update(sf.key).digest('hex').slice(0, 10),
|
||||
label: baseLabel,
|
||||
triggerType: 'cloud',
|
||||
createdAt: sf.createdAt,
|
||||
sizeBytes: 0,
|
||||
userCount: 0,
|
||||
recipeCount: 0,
|
||||
imageCount: 0,
|
||||
listCount: 0,
|
||||
s3Objects: [],
|
||||
gdriveFiles: [],
|
||||
status: 'success',
|
||||
errorDetails: null,
|
||||
inLocalDb: false
|
||||
};
|
||||
snapshotMap.set(baseLabel, entry);
|
||||
}
|
||||
const alreadyLinked = entry.s3Objects.some(o => o.key === sf.key || o.format === sf.format);
|
||||
if (!alreadyLinked) {
|
||||
entry.s3Objects.push({
|
||||
key: sf.key,
|
||||
name: sf.name,
|
||||
format: sf.format,
|
||||
size: sf.size
|
||||
});
|
||||
if (!entry.inLocalDb) {
|
||||
entry.sizeBytes += sf.size || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (s3Err) {
|
||||
providerErrors.push(`Wasabi S3: ${s3Err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist any newly discovered remote cloud snapshots into backup_history so they can be managed seamlessly
|
||||
const insHistory = db.prepare(`
|
||||
INSERT OR IGNORE INTO backup_history (
|
||||
id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount,
|
||||
s3Objects, gdriveFiles, status, errorDetails, createdAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?)
|
||||
`);
|
||||
const updHistory = db.prepare(`
|
||||
UPDATE backup_history
|
||||
SET s3Objects = ?, gdriveFiles = ?, sizeBytes = CASE WHEN sizeBytes = 0 THEN ? ELSE sizeBytes END
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const allSnapshots = Array.from(snapshotMap.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
for (const snap of allSnapshots) {
|
||||
if (!snap.inLocalDb && (snap.gdriveFiles.length > 0 || snap.s3Objects.length > 0)) {
|
||||
insHistory.run(
|
||||
snap.id,
|
||||
snap.label,
|
||||
snap.triggerType,
|
||||
snap.sizeBytes,
|
||||
snap.userCount,
|
||||
snap.recipeCount,
|
||||
snap.imageCount,
|
||||
snap.listCount,
|
||||
JSON.stringify(snap.s3Objects),
|
||||
JSON.stringify(snap.gdriveFiles),
|
||||
snap.createdAt
|
||||
);
|
||||
} else if (snap.inLocalDb) {
|
||||
updHistory.run(
|
||||
JSON.stringify(snap.s3Objects),
|
||||
JSON.stringify(snap.gdriveFiles),
|
||||
snap.sizeBytes,
|
||||
snap.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = allSnapshots.map(snap => {
|
||||
const s3Db = snap.s3Objects.find(o => o.format === 'db');
|
||||
const s3Json = snap.s3Objects.find(o => o.format === 'json');
|
||||
const gdDb = snap.gdriveFiles.find(f => f.format === 'db');
|
||||
const gdJson = snap.gdriveFiles.find(f => f.format === 'json');
|
||||
const dbSize = (gdDb?.size || s3Db?.size) || Math.round((snap.sizeBytes || 0) * 0.55);
|
||||
const jsonSize = (gdJson?.size || s3Json?.size) || Math.round((snap.sizeBytes || 0) * 0.45);
|
||||
return {
|
||||
...snap,
|
||||
shoppingListCount: snap.listCount || 0,
|
||||
dbSizeBytes: dbSize,
|
||||
jsonSizeBytes: jsonSize,
|
||||
s3KeyDb: s3Db ? s3Db.key : null,
|
||||
s3KeyJson: s3Json ? s3Json.key : null,
|
||||
gdriveFileIdDb: gdDb ? gdDb.id : null,
|
||||
gdriveFileIdJson: gdJson ? gdJson.id : null,
|
||||
filenameDb: (gdDb?.name || s3Db?.name) || `${snap.label}.db`,
|
||||
filenameJson: (gdJson?.name || s3Json?.name) || `${snap.label}.json`,
|
||||
errorMessage: snap.errorDetails || ''
|
||||
};
|
||||
});
|
||||
|
||||
return { snapshots: enriched, providerErrors };
|
||||
}
|
||||
|
||||
// Live scan & list cloud backups from Google Drive and Wasabi S3
|
||||
app.get('/api/admin/backups/cloud-files', requireAuth, async (req, res) => {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
||||
}
|
||||
try {
|
||||
const result = await discoverAndSyncCloudBackups();
|
||||
res.json({
|
||||
backups: result.snapshots.filter(s => s.gdriveFiles.length > 0 || s.s3Objects.length > 0),
|
||||
providerErrors: result.providerErrors,
|
||||
config: getBackupConfig()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing cloud backup files:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Restore directly from a specified cloud file (Google Drive fileId or Wasabi S3 objectKey)
|
||||
app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res) => {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
||||
}
|
||||
const { provider, fileId, objectKey, filename } = req.body || {};
|
||||
try {
|
||||
const cfg = getBackupConfig();
|
||||
let fileBuf = null;
|
||||
let resolvedName = filename || 'cloud-backup.db';
|
||||
|
||||
if (provider === 'gdrive' && fileId) {
|
||||
const token = await getGoogleDriveAccessToken(cfg);
|
||||
fileBuf = await gdriveDownloadFile(token, fileId);
|
||||
} else if (provider === 's3' && objectKey) {
|
||||
fileBuf = await s3GetObject(cfg, objectKey);
|
||||
resolvedName = filename || objectKey;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Valid cloud provider and file identifier are required.' });
|
||||
}
|
||||
|
||||
const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName);
|
||||
res.json({ success: true, restored: counts });
|
||||
} catch (error) {
|
||||
console.error('Error restoring from direct cloud file:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Download a cloud backup file from Wasabi S3 or Google Drive to Admin's computer
|
||||
app.get('/api/admin/backups/:id/download-cloud', requireAuth, async (req, res) => {
|
||||
if (!req.user.isAdmin) {
|
||||
|
|
@ -3488,15 +3808,15 @@ app.get('/api/admin/backups/:id/download-cloud', requireAuth, async (req, res) =
|
|||
let fileBuf = null;
|
||||
let downloadName = `${row.label}.${format}`;
|
||||
|
||||
if (s3Target && cfg.s3Enabled) {
|
||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
||||
downloadName = s3Target.name || downloadName;
|
||||
} else if (gdTarget && cfg.gdriveEnabled) {
|
||||
if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
||||
const token = await getGoogleDriveAccessToken(cfg);
|
||||
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
||||
downloadName = gdTarget.name || downloadName;
|
||||
} else if (s3Target && cfg.s3Enabled) {
|
||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
||||
downloadName = s3Target.name || downloadName;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Cloud provider for this backup is not currently enabled.' });
|
||||
return res.status(400).json({ error: 'Cloud provider for this backup is not currently connected.' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', downloadName.endsWith('.json') ? 'application/json' : 'application/x-sqlite3');
|
||||
|
|
@ -3514,6 +3834,7 @@ app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) =
|
|||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
||||
}
|
||||
const preferredFormat = req.body?.format === 'json' ? 'json' : 'db';
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Backup record not found.' });
|
||||
|
|
@ -3522,22 +3843,21 @@ app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) =
|
|||
const s3List = JSON.parse(row.s3Objects || '[]');
|
||||
const gdList = JSON.parse(row.gdriveFiles || '[]');
|
||||
|
||||
// Prefer .db snapshot if present, otherwise .json
|
||||
const s3Target = s3List.find(o => o.format === 'db') || s3List[0];
|
||||
const gdTarget = gdList.find(f => f.format === 'db') || gdList[0];
|
||||
const s3Target = s3List.find(o => o.format === preferredFormat) || s3List.find(o => o.format === 'db') || s3List[0];
|
||||
const gdTarget = gdList.find(f => f.format === preferredFormat) || gdList.find(f => f.format === 'db') || gdList[0];
|
||||
|
||||
let fileBuf = null;
|
||||
let filename = '';
|
||||
|
||||
if (s3Target && cfg.s3Enabled) {
|
||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
||||
filename = s3Target.name || s3Target.key;
|
||||
} else if (gdTarget && cfg.gdriveEnabled) {
|
||||
if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
||||
const token = await getGoogleDriveAccessToken(cfg);
|
||||
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
||||
filename = gdTarget.name || '';
|
||||
} else if (s3Target && cfg.s3Enabled) {
|
||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
||||
filename = s3Target.name || s3Target.key;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'No enabled cloud provider found to retrieve this backup.' });
|
||||
return res.status(400).json({ error: 'No connected cloud provider found to retrieve this backup.' });
|
||||
}
|
||||
|
||||
const counts = restoreDatabaseFromBuffer(fileBuf, filename);
|
||||
|
|
@ -3586,7 +3906,7 @@ app.delete('/api/admin/backups/:id', requireAuth, async (req, res) => {
|
|||
await s3DeleteObject(cfg, obj.key).catch(e => console.warn('S3 delete warning:', e.message));
|
||||
}
|
||||
}
|
||||
if (cfg.gdriveEnabled && gdList.length > 0) {
|
||||
if ((cfg.gdriveEnabled || cfg.gdriveConnected) && gdList.length > 0) {
|
||||
const token = await getGoogleDriveAccessToken(cfg).catch(() => null);
|
||||
if (token) {
|
||||
for (const f of gdList) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue