feat: add live cloud backup scanner and one-click cloud restore modal for Google Drive and S3

This commit is contained in:
Chris 2026-10-31 00:52:45 -04:00
parent 12dbf04b8e
commit 6e8e948ce1
3 changed files with 689 additions and 27 deletions

View file

@ -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, '&quot;')}">⚠ 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, '&quot;')}">${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();
}
}
}

View file

@ -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
View file

@ -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) {