diff --git a/public/app.js b/public/app.js
index 4613b52..6813560 100644
--- a/public/app.js
+++ b/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 = `
- 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.
`;
@@ -3335,38 +3358,51 @@ function renderAdminBackupHistory(history) {
const dateStr = new Date(item.createdAt).toLocaleString();
const triggerBadge = item.triggerType === 'scheduled'
? `Scheduled `
- : `Manual `;
+ : item.triggerType === 'cloud'
+ ? `Cloud Sync `
+ : `Manual `;
const destBadges = [];
- if (item.s3KeyDb) {
+ if (item.s3KeyDb || item.s3KeyJson) {
destBadges.push(`Wasabi S3 `);
}
- if (item.gdriveFileIdDb) {
+ if (item.gdriveFileIdDb || item.gdriveFileIdJson) {
destBadges.push(`Google Drive `);
}
const statusBadge = item.status === 'success'
- ? `✓ Success `
+ ? `✓ Available `
: `⚠ Error `;
- 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`
+ : `${item.label || 'Cloud Snapshot'} `;
tr.innerHTML = `
${dateStr}
${triggerBadge}
- ${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists
- DB: ${formatBytesHuman(item.dbSizeBytes)} JSON: ${formatBytesHuman(item.jsonSizeBytes)}
+ ${countsText}
+
+ ${hasDbFile ? `DB: ${formatBytesHuman(item.dbSizeBytes)}` : ''}${hasDbFile && hasJsonFile ? ' ' : ''}${hasJsonFile ? `JSON: ${formatBytesHuman(item.jsonSizeBytes)}` : ''}
+
${destBadges.join('') || 'None '}
${statusBadge}${item.errorMessage ? `${item.errorMessage}
` : ''}
- ${hasCloudFiles ? `
-
+ ${hasDbFile ? `
+
.DB
-
+ ` : ''}
+ ${hasJsonFile ? `
+
.JSON
-
+ ` : ''}
+ ${hasCloudFiles ? `
+
Restore
` : ''}
@@ -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 = ` Scanning Cloud... `;
+ 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 = `
+
+
Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...
+
+ `;
+
+ 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 = `
+
+ Could not load cloud backups. Make sure Google Drive or Wasabi S3 is connected.
+
+ `;
+ }
+}
+
+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 = `
+
+ 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.
+
+ `;
+ 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(`Google Drive `);
+ }
+ if (snap.s3KeyDb || snap.s3KeyJson) {
+ badges.push(`Wasabi S3 `);
+ }
+
+ 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 = `
+
+
+ ${dateStr}
+ ${badges.join('')}
+
+
+ ${statsLine}
+ ${hasDb ? ` · DB: ${formatBytesHuman(snap.dbSizeBytes)}` : ''}
+ ${hasJson ? ` · JSON: ${formatBytesHuman(snap.jsonSizeBytes)}` : ''}
+
+
+
+ ${hasDb ? `
+
+ Restore (.DB)
+
+ ` : ''}
+ ${hasJson ? `
+
+ Restore (.JSON)
+
+ ` : ''}
+
+ `;
+
+ 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 = ` Restoring... `;
+ 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();
+ }
+ }
+}
+
+
diff --git a/public/index.html b/public/index.html
index 0343d00..67bd892 100644
--- a/public/index.html
+++ b/public/index.html
@@ -290,6 +290,9 @@
Backup to Cloud Now
+
+ Restore from Cloud
+
Download .DB
@@ -501,7 +504,15 @@
-
Cloud Backup History
+
+
+
Cloud Backup History & Recovery
+
Restore your database directly from any Google Drive or Wasabi S3 backup below, or stream a copy to your computer.
+
+
+ Sync from Cloud
+
+
@@ -526,6 +537,43 @@
+
+
+
+
+
+
+
+ Cloud Source:
+
+ All Connected Clouds (Drive & S3)
+ Google Drive Only
+ Wasabi S3 Only
+
+
+
+ Scan Cloud Now
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server.js b/server.js
index 1da66f9..0c2e675 100644
--- a/server.js
+++ b/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) {