diff --git a/public/app.js b/public/app.js index 75f2206..bc1388c 100644 --- a/public/app.js +++ b/public/app.js @@ -51,9 +51,43 @@ const elements = { btnAdminAddUser: document.getElementById('btn-admin-add-user'), btnAdminTabUsers: document.getElementById('btn-admin-tab-users'), btnAdminTabShares: document.getElementById('btn-admin-tab-shares'), + btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'), adminUsersSection: document.getElementById('admin-users-section'), adminSharesSection: document.getElementById('admin-shares-section'), + adminBackupsSection: document.getElementById('admin-backups-section'), adminSharesList: document.getElementById('admin-shares-list'), + + // Admin Backups Console Elements + btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'), + btnBackupDownloadDb: document.getElementById('btn-backup-download-db'), + btnBackupDownloadJson: document.getElementById('btn-backup-download-json'), + backupRestoreFileInput: document.getElementById('backup-restore-file-input'), + btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'), + backupStatusBanner: document.getElementById('backup-status-banner'), + adminBackupConfigForm: document.getElementById('admin-backup-config-form'), + backupLastRunBadge: document.getElementById('backup-last-run-badge'), + backupScheduleFrequency: document.getElementById('backup-schedule-frequency'), + backupScheduleTime: document.getElementById('backup-schedule-time'), + backupScheduleDay: document.getElementById('backup-schedule-day'), + backupRetentionCount: document.getElementById('backup-retention-count'), + backupFormat: document.getElementById('backup-format'), + backupEmbedImages: document.getElementById('backup-embed-images'), + backupS3Enabled: document.getElementById('backup-s3-enabled'), + backupS3Endpoint: document.getElementById('backup-s3-endpoint'), + backupS3Region: document.getElementById('backup-s3-region'), + backupS3Bucket: document.getElementById('backup-s3-bucket'), + backupS3Prefix: document.getElementById('backup-s3-prefix'), + backupS3AccessKey: document.getElementById('backup-s3-access-key'), + backupS3SecretKey: document.getElementById('backup-s3-secret-key'), + btnBackupTestS3: document.getElementById('btn-backup-test-s3'), + backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'), + backupGdriveClientId: document.getElementById('backup-gdrive-client-id'), + backupGdriveClientSecret: document.getElementById('backup-gdrive-client-secret'), + backupGdriveRefreshToken: document.getElementById('backup-gdrive-refresh-token'), + backupGdriveFolderId: document.getElementById('backup-gdrive-folder-id'), + btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'), + btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'), + adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), // Admin Create User Modal adminCreateUserModal: document.getElementById('admin-create-user-modal'), @@ -260,6 +294,29 @@ function registerEvents() { // Admin Sub-Tab Switchers elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users')); elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares')); + if (elements.btnAdminTabBackups) { + elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups')); + } + + // Admin Backups Console Actions + if (elements.adminBackupConfigForm) { + elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig); + elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow); + elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db')); + elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json')); + elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click()); + elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile); + elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection); + elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection); + elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive); + } + + window.addEventListener('message', (event) => { + if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') { + showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success'); + fetchAdminBackupConfig(); + } + }); // Servings Scaler Buttons elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); @@ -1530,24 +1587,31 @@ function renderAdminUsers(users) { } function switchAdminSubTab(subTabName) { + const tabs = [ + { name: 'users', btn: elements.btnAdminTabUsers, section: elements.adminUsersSection }, + { name: 'shares', btn: elements.btnAdminTabShares, section: elements.adminSharesSection }, + { name: 'backups', btn: elements.btnAdminTabBackups, section: elements.adminBackupsSection } + ]; + + tabs.forEach(t => { + if (!t.btn || !t.section) return; + if (t.name === subTabName) { + t.btn.classList.add('btn-primary'); + t.btn.classList.remove('btn-outline'); + t.section.style.display = 'block'; + } else { + t.btn.classList.add('btn-outline'); + t.btn.classList.remove('btn-primary'); + t.section.style.display = 'none'; + } + }); + if (subTabName === 'users') { - elements.btnAdminTabUsers.classList.add('btn-primary'); - elements.btnAdminTabUsers.classList.remove('btn-outline'); - elements.btnAdminTabShares.classList.add('btn-outline'); - elements.btnAdminTabShares.classList.remove('btn-primary'); - - elements.adminUsersSection.style.display = 'block'; - elements.adminSharesSection.style.display = 'none'; fetchAdminUsers(); } else if (subTabName === 'shares') { - elements.btnAdminTabShares.classList.add('btn-primary'); - elements.btnAdminTabShares.classList.remove('btn-outline'); - elements.btnAdminTabUsers.classList.add('btn-outline'); - elements.btnAdminTabUsers.classList.remove('btn-primary'); - - elements.adminUsersSection.style.display = 'none'; - elements.adminSharesSection.style.display = 'block'; fetchAdminSharedRecipes(); + } else if (subTabName === 'backups') { + fetchAdminBackupConfig(); } lucide.createIcons(); } @@ -2919,4 +2983,470 @@ function renderShoppingListsWorkspace() { lucide.createIcons(); } +// ========================================== +// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE +// ========================================== + +let backupStatusTimeout = null; + +function showBackupStatus(message, type = 'info') { + if (!elements.backupStatusAlert) return; + if (backupStatusTimeout) clearTimeout(backupStatusTimeout); + + elements.backupStatusAlert.className = `backup-status-alert status-${type}`; + const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info'; + elements.backupStatusAlert.innerHTML = `${message}`; + elements.backupStatusAlert.classList.remove('hidden'); + lucide.createIcons(); + + if (type !== 'error') { + backupStatusTimeout = setTimeout(() => { + elements.backupStatusAlert.classList.add('hidden'); + }, 8000); + } +} + +function getBackupFormPayload() { + return { + schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off', + scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00', + retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) || 14 : 14, + includeRemoteImages: elements.backupIncludeRemoteImages ? elements.backupIncludeRemoteImages.checked : true, + s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false, + s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com', + s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1', + s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '', + s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/', + s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '', + s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '', + gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false, + gdriveClientId: elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '', + gdriveClientSecret: elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '', + gdriveRefreshToken: elements.backupGdriveRefreshToken ? elements.backupGdriveRefreshToken.value.trim() : '', + gdriveFolderId: elements.backupGdriveFolderId ? elements.backupGdriveFolderId.value.trim() : '' + }; +} + +async function fetchAdminBackupConfig() { + try { + const res = await fetch('/api/admin/backups/config', { + headers: getAuthHeaders() + }); + if (res.status === 401) { + logout(); + return; + } + if (!res.ok) throw new Error('Failed to load backup configuration.'); + const data = await res.json(); + const cfg = data.config || {}; + + if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off'; + if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00'; + if (elements.backupRetentionCount) elements.backupRetentionCount.value = cfg.retentionCount || 14; + if (elements.backupIncludeRemoteImages) elements.backupIncludeRemoteImages.checked = Boolean(cfg.includeRemoteImages); + + if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled); + if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com'; + if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1'; + if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || ''; + if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/'; + if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || ''; + if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || ''; + + if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled); + if (elements.backupGdriveClientId) elements.backupGdriveClientId.value = cfg.gdriveClientId || ''; + if (elements.backupGdriveClientSecret) elements.backupGdriveClientSecret.value = cfg.gdriveClientSecret || ''; + if (elements.backupGdriveRefreshToken) elements.backupGdriveRefreshToken.value = cfg.gdriveRefreshToken || ''; + if (elements.backupGdriveFolderId) elements.backupGdriveFolderId.value = cfg.gdriveFolderId || ''; + + if (elements.backupLastRunBadge) { + if (cfg.lastRunAt) { + const dt = new Date(cfg.lastRunAt).toLocaleString(); + const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || ''; + elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`; + } else { + elements.backupLastRunBadge.textContent = 'Last Cloud Run: Never'; + } + } + + renderAdminBackupHistory(data.history || []); + } catch (err) { + console.error('Error fetching backup config:', err); + showBackupStatus(err.message, 'error'); + } +} + +function formatBytesHuman(bytes) { + const n = Number(bytes) || 0; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +function renderAdminBackupHistory(history) { + if (!elements.adminBackupsHistoryList) return; + elements.adminBackupsHistoryList.innerHTML = ''; + + if (!history || history.length === 0) { + 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. + + + `; + return; + } + + history.forEach(item => { + const tr = document.createElement('tr'); + const dateStr = new Date(item.createdAt).toLocaleString(); + const triggerBadge = item.triggerType === 'scheduled' + ? `Scheduled` + : `Manual`; + + const destBadges = []; + if (item.s3KeyDb) { + destBadges.push(`Wasabi S3`); + } + if (item.gdriveFileIdDb) { + destBadges.push(`Google Drive`); + } + + const statusBadge = item.status === 'success' + ? `✓ Success` + : `⚠ Error`; + + const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb); + + tr.innerHTML = ` + ${dateStr} + ${triggerBadge} + ${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists + DB: ${formatBytesHuman(item.dbSizeBytes)}
JSON: ${formatBytesHuman(item.jsonSizeBytes)} + ${destBadges.join('') || 'None'} + ${statusBadge}${item.errorMessage ? `
${item.errorMessage}
` : ''} + + ${hasCloudFiles ? ` + + + + ` : ''} + + + `; + + const btnDlDb = tr.querySelector('.btn-dl-db'); + if (btnDlDb) btnDlDb.addEventListener('click', () => downloadCloudBackupItem(item.id, 'db', item.filenameDb)); + + const btnDlJson = tr.querySelector('.btn-dl-json'); + if (btnDlJson) btnDlJson.addEventListener('click', () => downloadCloudBackupItem(item.id, 'json', item.filenameJson)); + + const btnRestore = tr.querySelector('.btn-restore-cloud'); + if (btnRestore) btnRestore.addEventListener('click', () => restoreCloudBackupItem(item.id, dateStr)); + + const btnDelete = tr.querySelector('.btn-delete-cloud'); + if (btnDelete) btnDelete.addEventListener('click', () => deleteCloudBackupItem(item.id, dateStr)); + + elements.adminBackupsHistoryList.appendChild(tr); + }); + + lucide.createIcons(); +} + +async function handleSaveBackupConfig(e) { + e.preventDefault(); + try { + const payload = getBackupFormPayload(); + const res = await fetch('/api/admin/backups/config', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save backup configuration.'); + showBackupStatus('Backup & schedule settings saved!', 'success'); + fetchAdminBackupConfig(); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + +async function handleTestS3Connection() { + const btn = elements.btnBackupTestS3; + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` Testing...`; + lucide.createIcons(); + + try { + const payload = getBackupFormPayload(); + const res = await fetch('/api/admin/backups/test-s3', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Wasabi S3 connection test failed.'); + showBackupStatus(data.message || 'Wasabi S3 connection verified!', 'success'); + } catch (err) { + showBackupStatus(`Wasabi S3 Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function handleTestGdriveConnection() { + const btn = elements.btnBackupTestGdrive; + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` Testing...`; + lucide.createIcons(); + + try { + const payload = getBackupFormPayload(); + const res = await fetch('/api/admin/backups/test-gdrive', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.'); + if (data.folderId && elements.backupGdriveFolderId && !elements.backupGdriveFolderId.value) { + elements.backupGdriveFolderId.value = data.folderId; + } + showBackupStatus(data.message || 'Google Drive API connection verified!', 'success'); + } catch (err) { + showBackupStatus(`Google Drive Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function handleAuthorizeGoogleDrive() { + try { + const clientId = elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : ''; + const clientSecret = elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : ''; + if (!clientId || !clientSecret) { + showBackupStatus('Please enter your Google OAuth2 Client ID and Client Secret first.', 'error'); + return; + } + const res = await fetch('/api/admin/backups/gdrive/auth-url', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + clientId, + clientSecret, + origin: window.location.origin + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.'); + window.open(data.authUrl, 'gdrive_oauth', 'width=560,height=680'); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + +async function handleRunCloudBackupNow() { + const btn = elements.btnBackupRunCloud; + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` Backing up to Cloud...`; + lucide.createIcons(); + + try { + // First save current form config so any newly toggled provider is persisted + const payload = getBackupFormPayload(); + await fetch('/api/admin/backups/config', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(payload) + }); + + const res = await fetch('/api/admin/backups/run', { + method: 'POST', + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Cloud backup failed.'); + showBackupStatus('Cloud backup completed! Temporary local snapshot was wiped after upload.', 'success'); + fetchAdminBackupConfig(); + } catch (err) { + showBackupStatus(err.message, 'error'); + fetchAdminBackupConfig(); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function handleDownloadLocalBackup(format) { + const btn = format === 'json' ? elements.btnBackupDownloadJson : elements.btnBackupDownloadDb; + const origHtml = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = ` Preparing...`; + lucide.createIcons(); + + try { + const res = await fetch(`/api/admin/backups/download-local?format=${encodeURIComponent(format)}`, { + headers: getAuthHeaders() + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || 'Failed to generate local backup download.'); + } + const blob = await res.blob(); + const disposition = res.headers.get('Content-Disposition') || ''; + const match = disposition.match(/filename="?([^"]+)"?/i); + const filename = match ? match[1] : `forkfolio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.${format === 'json' ? 'json' : 'db'}`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + + showBackupStatus(`Downloaded ${filename} to your computer (no copy kept on server).`, 'success'); + } catch (err) { + showBackupStatus(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function downloadCloudBackupItem(id, format, fallbackName) { + try { + showBackupStatus(`Streaming ${format.toUpperCase()} snapshot from cloud storage...`, 'info'); + const res = await fetch(`/api/admin/backups/${id}/download-cloud?format=${encodeURIComponent(format)}`, { + headers: getAuthHeaders() + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.error || 'Failed to download backup from cloud.'); + } + const blob = await res.blob(); + const filename = fallbackName || `forkfolio-cloud-backup-${id}.${format}`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + showBackupStatus(`Downloaded ${filename} from cloud storage.`, 'success'); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + +async function restoreCloudBackupItem(id, dateLabel) { + const confirmed = window.confirm( + `Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.` + ); + if (!confirmed) return; + + try { + showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info'); + const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, { + method: 'POST', + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Cloud restore failed.'); + showBackupStatus( + `Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, + 'success' + ); + await fetchRecipes(); + await fetchShoppingLists(); + fetchAdminBackupConfig(); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + +async function deleteCloudBackupItem(id, dateLabel) { + const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`); + if (!confirmed) return; + + try { + const res = await fetch(`/api/admin/backups/${id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to delete backup.'); + showBackupStatus('Cloud backup deleted.', 'success'); + fetchAdminBackupConfig(); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + +async function handleRestoreLocalFile(e) { + const file = e.target.files && e.target.files[0]; + e.target.value = ''; + if (!file) return; + + const confirmed = window.confirm( + `Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.` + ); + if (!confirmed) return; + + try { + showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info'); + const base64Data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ''); + const commaIdx = result.indexOf(','); + resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result); + }; + reader.onerror = () => reject(new Error('Failed to read selected file.')); + reader.readAsDataURL(file); + }); + + const res = await fetch('/api/admin/backups/restore-upload', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + filename: file.name, + dataBase64: base64Data + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.'); + showBackupStatus( + `Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`, + 'success' + ); + await fetchRecipes(); + await fetchShoppingLists(); + fetchAdminBackupConfig(); + } catch (err) { + showBackupStatus(err.message, 'error'); + } +} + + diff --git a/public/index.html b/public/index.html index 3f1df91..77f9354 100644 --- a/public/index.html +++ b/public/index.html @@ -210,13 +210,16 @@ + + + diff --git a/public/style.css b/public/style.css index 64763af..63f8427 100644 --- a/public/style.css +++ b/public/style.css @@ -2827,3 +2827,120 @@ h1, h2, h3, h4, h5, h6 { flex: 1; } } + +/* ============================================================= + ADMIN BACKUP & RECOVERY CONSOLE STYLES + ============================================================= */ + +.backup-top-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 20px; +} + +.backup-quick-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.backup-status-alert { + padding: 12px 16px; + border-radius: var(--border-radius-md); + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--border-color); +} + +.backup-status-alert.success { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.35); + color: #10b981; +} + +.backup-status-alert.error { + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.35); + color: var(--danger); +} + +.backup-status-alert.info { + background: rgba(99, 102, 241, 0.12); + border-color: rgba(99, 102, 241, 0.35); + color: #818cf8; +} + +.backup-config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 20px; + align-items: stretch; +} + +.backup-config-card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-md); + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: var(--shadow-sm); +} + +.backup-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 4px; +} + +.backup-card-title { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-main); +} + +.backup-card-title i, +.backup-card-title svg { + width: 18px; + height: 18px; + color: var(--primary); +} + +.backup-card-title h4 { + font-size: 1.05rem; + font-weight: 700; + margin: 0; +} + +.backup-save-bar { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + padding-top: 4px; +} + +@media (max-width: 768px) { + .backup-config-grid { + grid-template-columns: 1fr; + } + .backup-quick-actions { + width: 100%; + } + .backup-quick-actions button { + flex: 1; + } +} diff --git a/server.js b/server.js index 24ecd9c..fd2ab52 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ const axios = require('axios'); const cheerio = require('cheerio'); const cors = require('cors'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const crypto = require('crypto'); const puppeteer = require('puppeteer-extra'); @@ -130,6 +131,56 @@ db.prepare(` ) `).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS backup_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + scheduleFrequency TEXT NOT NULL DEFAULT 'off', + scheduleTime TEXT NOT NULL DEFAULT '02:00', + scheduleDay INTEGER NOT NULL DEFAULT 0, + retentionCount INTEGER NOT NULL DEFAULT 14, + backupFormat TEXT NOT NULL DEFAULT 'both', + embedRemoteImages INTEGER NOT NULL DEFAULT 1, + s3Enabled INTEGER NOT NULL DEFAULT 0, + s3Endpoint TEXT NOT NULL DEFAULT 'https://s3.us-east-1.wasabisys.com', + s3Region TEXT NOT NULL DEFAULT 'us-east-1', + s3Bucket TEXT NOT NULL DEFAULT '', + s3Prefix TEXT NOT NULL DEFAULT 'forkfolio-backups/', + s3AccessKeyId TEXT NOT NULL DEFAULT '', + s3SecretAccessKey TEXT NOT NULL DEFAULT '', + gdriveEnabled INTEGER NOT NULL DEFAULT 0, + gdriveClientId TEXT NOT NULL DEFAULT '', + gdriveClientSecret TEXT NOT NULL DEFAULT '', + gdriveRefreshToken TEXT NOT NULL DEFAULT '', + gdriveFolderId TEXT NOT NULL DEFAULT '', + lastBackupAt TEXT, + lastBackupStatus TEXT, + lastBackupMessage TEXT + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS backup_history ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + triggerType TEXT NOT NULL, + sizeBytes INTEGER NOT NULL DEFAULT 0, + userCount INTEGER NOT NULL DEFAULT 0, + recipeCount INTEGER NOT NULL DEFAULT 0, + imageCount INTEGER NOT NULL DEFAULT 0, + listCount INTEGER NOT NULL DEFAULT 0, + s3Objects TEXT DEFAULT '[]', + gdriveFiles TEXT DEFAULT '[]', + status TEXT NOT NULL, + errorDetails TEXT, + createdAt TEXT NOT NULL + ) +`).run(); + +const backupConfigCount = db.prepare('SELECT COUNT(*) as count FROM backup_config').get().count; +if (backupConfigCount === 0) { + db.prepare('INSERT INTO backup_config (id) VALUES (1)').run(); +} + // Initialize single settings record if empty const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; if (settingsCount === 0) { @@ -285,8 +336,8 @@ try { const activeSessions = new Map(); app.use(cors()); -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ limit: '10mb', extended: true })); +app.use(express.json({ limit: '100mb' })); +app.use(express.urlencoded({ limit: '100mb', extended: true })); app.use(express.static(path.join(__dirname, 'public'))); // Configure rate-limiting middleware for public shared routes @@ -2104,6 +2155,1169 @@ app.post('/api/scrape', requireAuth, async (req, res) => { } }); +// ------------------------------------------------------------- +// ADMIN CLOUD & MANUAL BACKUP ENGINE (ZERO LOCAL STORAGE) +// ------------------------------------------------------------- + +function getBackupConfig() { + const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get(); + if (!row) return null; + return { + ...row, + embedRemoteImages: row.embedRemoteImages === 1, + s3Enabled: row.s3Enabled === 1, + gdriveEnabled: row.gdriveEnabled === 1 + }; +} + +// Create an atomic SQLite .db snapshot in memory Buffer via temporary OS file, immediately unlinked +function createSqliteSnapshotBuffer() { + const tempPath = path.join( + os.tmpdir(), + `forkfolio_tmp_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db` + ); + try { + const escapedPath = tempPath.replace(/'/g, "''"); + db.exec(`VACUUM INTO '${escapedPath}'`); + const buf = fs.readFileSync(tempPath); + return buf; + } finally { + try { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } catch (cleanupErr) { + console.warn('[Backup] Temp file cleanup warning:', cleanupErr.message); + } + } +} + +// Create a full JSON archive Buffer (users, recipes, embedded images, shopping lists, settings, visit stats) +async function createJsonArchiveData(embedRemoteImages = true) { + const users = db.prepare('SELECT * FROM users').all(); + const recipes = db.prepare('SELECT * FROM recipes').all(); + const shoppingLists = db.prepare('SELECT * FROM shopping_lists').all(); + const shoppingListItems = db.prepare('SELECT * FROM shopping_list_items').all(); + const settings = db.prepare('SELECT * FROM settings').all(); + const recipeSharesVisits = db.prepare('SELECT * FROM recipe_shares_visits').all(); + const shoppingListSharesVisits = db.prepare('SELECT * FROM shopping_list_shares_visits').all(); + + let imageCount = 0; + const enrichedRecipes = []; + + for (const r of recipes) { + const copy = { ...r }; + if (copy.image && copy.image.startsWith('data:image/')) { + imageCount++; + } else if (copy.image && /^https?:\/\//i.test(copy.image)) { + imageCount++; + if (embedRemoteImages) { + try { + const resp = await axios.get(copy.image, { + responseType: 'arraybuffer', + timeout: 5000, + headers: { 'User-Agent': 'Mozilla/5.0' } + }); + const contentType = resp.headers['content-type'] || 'image/jpeg'; + const base64 = Buffer.from(resp.data).toString('base64'); + copy.embeddedCoverImage = `data:${contentType};base64,${base64}`; + } catch (imgErr) { + // Keep original URL if remote image cannot be fetched + } + } + } + enrichedRecipes.push(copy); + } + + const archiveObj = { + version: '1.0', + exportedAt: new Date().toISOString(), + counts: { + users: users.length, + recipes: enrichedRecipes.length, + images: imageCount, + shoppingLists: shoppingLists.length, + shoppingListItems: shoppingListItems.length + }, + data: { + users, + recipes: enrichedRecipes, + shoppingLists, + shoppingListItems, + settings, + recipeSharesVisits, + shoppingListSharesVisits + } + }; + + const buffer = Buffer.from(JSON.stringify(archiveObj, null, 2), 'utf8'); + return { + buffer, + userCount: users.length, + recipeCount: enrichedRecipes.length, + imageCount, + listCount: shoppingLists.length + }; +} + +// ------------------------------------------------------------- +// AWS SIGNATURE V4 HELPER FOR WASABI / S3-COMPATIBLE STORAGE +// ------------------------------------------------------------- + +function hmacSha256(key, data) { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest(); +} + +function sha256Hex(data) { + return crypto.createHash('sha256').update(data).digest('hex'); +} + +function encodeS3URI(uriPath, encodeSlash = false) { + return uriPath + .split('/') + .map(segment => + encodeURIComponent(segment).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()) + ) + .join(encodeSlash ? '%2F' : '/'); +} + +function buildS3SignedRequest({ method, endpoint, region, bucket, objectKey = '', queryParams = {}, headers = {}, body = Buffer.alloc(0), accessKeyId, secretAccessKey }) { + const baseEndpoint = (endpoint || 'https://s3.us-east-1.wasabisys.com').trim().replace(/\/+$/, ''); + const parsedEndpoint = new URL(baseEndpoint.startsWith('http') ? baseEndpoint : `https://${baseEndpoint}`); + + const cleanBucket = (bucket || '').trim(); + const cleanKey = (objectKey || '').replace(/^\/+/, ''); + const pathSegments = ['']; + if (cleanBucket) pathSegments.push(cleanBucket); + if (cleanKey) pathSegments.push(cleanKey); + const rawPath = pathSegments.join('/') || '/'; + const canonicalUri = encodeS3URI(rawPath, false); + + const sortedQueryKeys = Object.keys(queryParams).sort(); + const canonicalQueryString = sortedQueryKeys + .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(String(queryParams[k]))}`) + .join('&'); + + const now = new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); + const dateStamp = amzDate.slice(0, 8); + const payloadHash = sha256Hex(body); + + const signHeaders = { + host: parsedEndpoint.host, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + ...headers + }; + + const lowerHeaders = {}; + for (const [k, v] of Object.entries(signHeaders)) { + lowerHeaders[k.toLowerCase()] = String(v).trim(); + } + const signedHeaderKeys = Object.keys(lowerHeaders).sort(); + const canonicalHeaders = signedHeaderKeys.map(k => `${k}:${lowerHeaders[k]}\n`).join(''); + const signedHeadersStr = signedHeaderKeys.join(';'); + + const canonicalRequest = [ + method.toUpperCase(), + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeadersStr, + payloadHash + ].join('\n'); + + const s3Region = (region || 'us-east-1').trim(); + const credentialScope = `${dateStamp}/${s3Region}/s3/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + sha256Hex(canonicalRequest) + ].join('\n'); + + const kDate = hmacSha256(`AWS4${secretAccessKey}`, dateStamp); + const kRegion = hmacSha256(kDate, s3Region); + const kService = hmacSha256(kRegion, 's3'); + const kSigning = hmacSha256(kService, 'aws4_request'); + const signature = crypto.createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex'); + + const authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeadersStr}, Signature=${signature}`; + + const fullUrl = `${parsedEndpoint.origin}${canonicalUri}${canonicalQueryString ? '?' + canonicalQueryString : ''}`; + const requestHeaders = { + ...signHeaders, + Authorization: authorization + }; + delete requestHeaders.host; + + return { url: fullUrl, headers: requestHeaders }; +} + +async function s3TestConnection(cfg) { + if (!cfg.s3Bucket || !cfg.s3AccessKeyId || !cfg.s3SecretAccessKey) { + throw new Error('Bucket name, Access Key ID, and Secret Access Key are required.'); + } + const signed = buildS3SignedRequest({ + method: 'GET', + endpoint: cfg.s3Endpoint, + region: cfg.s3Region, + bucket: cfg.s3Bucket, + queryParams: { 'list-type': '2', 'max-keys': '1' }, + 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 returned HTTP ${res.status}: ${errText.slice(0, 220)}`); + } + return true; +} + +async function s3PutObject(cfg, objectKey, buffer, contentType) { + const signed = buildS3SignedRequest({ + method: 'PUT', + endpoint: cfg.s3Endpoint, + region: cfg.s3Region, + bucket: cfg.s3Bucket, + objectKey, + headers: { + 'Content-Type': contentType, + 'Content-Length': String(buffer.length) + }, + body: buffer, + accessKeyId: cfg.s3AccessKeyId, + secretAccessKey: cfg.s3SecretAccessKey + }); + const res = await fetch(signed.url, { method: 'PUT', headers: signed.headers, body: buffer }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(`S3 PUT failed (${res.status}): ${errText.slice(0, 220)}`); + } + return objectKey; +} + +async function s3GetObject(cfg, objectKey) { + const signed = buildS3SignedRequest({ + method: 'GET', + endpoint: cfg.s3Endpoint, + region: cfg.s3Region, + bucket: cfg.s3Bucket, + objectKey, + 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 GET failed (${res.status}): ${errText.slice(0, 200)}`); + } + const arrBuf = await res.arrayBuffer(); + return Buffer.from(arrBuf); +} + +async function s3DeleteObject(cfg, objectKey) { + const signed = buildS3SignedRequest({ + method: 'DELETE', + endpoint: cfg.s3Endpoint, + region: cfg.s3Region, + bucket: cfg.s3Bucket, + objectKey, + accessKeyId: cfg.s3AccessKeyId, + secretAccessKey: cfg.s3SecretAccessKey + }); + const res = await fetch(signed.url, { method: 'DELETE', headers: signed.headers }); + if (!res.ok && res.status !== 204 && res.status !== 404) { + const errText = await res.text(); + throw new Error(`S3 DELETE failed (${res.status}): ${errText.slice(0, 200)}`); + } +} + +// ------------------------------------------------------------- +// GOOGLE DRIVE REST API V3 CONNECTION CLIENT +// ------------------------------------------------------------- + +async function getGoogleDriveAccessToken(cfg) { + if (!cfg.gdriveClientId || !cfg.gdriveClientSecret || !cfg.gdriveRefreshToken) { + throw new Error('Google Drive Client ID, Client Secret, and Refresh Token are required.'); + } + const params = new URLSearchParams({ + client_id: cfg.gdriveClientId.trim(), + client_secret: cfg.gdriveClientSecret.trim(), + refresh_token: cfg.gdriveRefreshToken.trim(), + grant_type: 'refresh_token' + }); + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.access_token) { + throw new Error(data.error_description || data.error || `Google OAuth token exchange failed (${res.status}).`); + } + return data.access_token; +} + +async function ensureGoogleDriveFolder(accessToken, cfg) { + if (cfg.gdriveFolderId && cfg.gdriveFolderId.trim()) { + const folderId = cfg.gdriveFolderId.trim(); + const checkRes = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(folderId)}?fields=id,name,trashed`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (checkRes.ok) { + const f = await checkRes.json(); + if (!f.trashed) return f.id; + } + } + + // Search for existing "ForkFolio Backups" folder or create one + const q = encodeURIComponent("mimeType='application/vnd.google-apps.folder' and name='ForkFolio Backups' and trashed=false"); + const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name)`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (searchRes.ok) { + const searchData = await searchRes.json(); + if (Array.isArray(searchData.files) && searchData.files.length > 0) { + const folderId = searchData.files[0].id; + db.prepare('UPDATE backup_config SET gdriveFolderId = ? WHERE id = 1').run(folderId); + return folderId; + } + } + + // Create folder + const createRes = await fetch('https://www.googleapis.com/drive/v3/files?fields=id,name', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'ForkFolio Backups', + mimeType: 'application/vnd.google-apps.folder' + }) + }); + const created = await createRes.json().catch(() => ({})); + if (!createRes.ok || !created.id) { + throw new Error('Failed to verify or create Google Drive backup folder.'); + } + db.prepare('UPDATE backup_config SET gdriveFolderId = ? WHERE id = 1').run(created.id); + return created.id; +} + +async function gdriveUploadFile(accessToken, folderId, filename, buffer, mimeType) { + const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex'); + const metadata = { + name: filename, + parents: folderId ? [folderId] : undefined + }; + + const preamble = Buffer.from( + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n--${boundary}\r\nContent-Type: ${mimeType}\r\n\r\n`, + 'utf8' + ); + const epilogue = Buffer.from(`\r\n--${boundary}--`, 'utf8'); + const multipartBody = Buffer.concat([preamble, buffer, epilogue]); + + const res = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,size', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + 'Content-Length': String(multipartBody.length) + }, + body: multipartBody + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.id) { + throw new Error(data.error?.message || `Google Drive upload failed (${res.status}).`); + } + return { id: data.id, name: data.name || filename, mimeType }; +} + +async function gdriveDownloadFile(accessToken, fileId) { + const res = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?alt=media`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Google Drive download failed (${res.status}): ${errText.slice(0, 200)}`); + } + const arrBuf = await res.arrayBuffer(); + return Buffer.from(arrBuf); +} + +async function gdriveDeleteFile(accessToken, fileId) { + const res = await fetch(`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok && res.status !== 204 && res.status !== 404) { + const errText = await res.text(); + throw new Error(`Google Drive delete failed (${res.status}): ${errText.slice(0, 200)}`); + } +} + +// ------------------------------------------------------------- +// CLOUD BACKUP EXECUTION & RETENTION PRUNING +// ------------------------------------------------------------- + +let isBackupRunning = false; + +async function executeCloudBackup(triggerType = 'manual') { + if (isBackupRunning) { + throw new Error('A backup operation is already in progress.'); + } + isBackupRunning = true; + + const cfg = getBackupConfig(); + const backupId = 'bkp_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 4); + const now = new Date(); + const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); + const label = `forkfolio-backup-${stamp}`; + + const s3Objects = []; + const gdriveFiles = []; + + try { + if (!cfg.s3Enabled && !cfg.gdriveEnabled) { + throw new Error('Neither Wasabi S3 nor Google Drive is enabled. Enable at least one cloud provider or use "Download to Computer".'); + } + + const wantDb = cfg.backupFormat === 'both' || cfg.backupFormat === 'db'; + const wantJson = cfg.backupFormat === 'both' || cfg.backupFormat === 'json'; + + const jsonArchive = await createJsonArchiveData(cfg.embedRemoteImages); + const dbBuffer = wantDb ? createSqliteSnapshotBuffer() : null; + const jsonBuffer = wantJson ? jsonArchive.buffer : null; + + const totalSize = (dbBuffer ? dbBuffer.length : 0) + (jsonBuffer ? jsonBuffer.length : 0); + + // 1. Upload to Wasabi S3 if enabled + if (cfg.s3Enabled) { + const prefix = (cfg.s3Prefix || 'forkfolio-backups/').trim().replace(/^\/+/, ''); + const normalizedPrefix = prefix && !prefix.endsWith('/') ? `${prefix}/` : prefix; + + if (dbBuffer) { + const dbKey = `${normalizedPrefix}${label}.db`; + await s3PutObject(cfg, dbKey, dbBuffer, 'application/x-sqlite3'); + s3Objects.push({ key: dbKey, name: `${label}.db`, format: 'db' }); + } + if (jsonBuffer) { + const jsonKey = `${normalizedPrefix}${label}.json`; + await s3PutObject(cfg, jsonKey, jsonBuffer, 'application/json'); + s3Objects.push({ key: jsonKey, name: `${label}.json`, format: 'json' }); + } + } + + // 2. Upload to Google Drive if enabled + if (cfg.gdriveEnabled) { + const accessToken = await getGoogleDriveAccessToken(cfg); + const folderId = await ensureGoogleDriveFolder(accessToken, cfg); + + if (dbBuffer) { + const uploadedDb = await gdriveUploadFile(accessToken, folderId, `${label}.db`, dbBuffer, 'application/x-sqlite3'); + gdriveFiles.push({ ...uploadedDb, format: 'db' }); + } + if (jsonBuffer) { + const uploadedJson = await gdriveUploadFile(accessToken, folderId, `${label}.json`, jsonBuffer, 'application/json'); + gdriveFiles.push({ ...uploadedJson, format: 'json' }); + } + } + + const createdAt = now.toISOString(); + db.prepare(` + INSERT INTO backup_history ( + id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount, + s3Objects, gdriveFiles, status, errorDetails, createdAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?) + `).run( + backupId, + label, + triggerType, + totalSize, + jsonArchive.userCount, + jsonArchive.recipeCount, + jsonArchive.imageCount, + jsonArchive.listCount, + JSON.stringify(s3Objects), + JSON.stringify(gdriveFiles), + createdAt + ); + + db.prepare(` + UPDATE backup_config + SET lastBackupAt = ?, lastBackupStatus = 'success', lastBackupMessage = ? + WHERE id = 1 + `).run(createdAt, `Uploaded ${label} (${Math.round(totalSize / 1024)} KB)`); + + // Enforce retention limit on cloud backups + await pruneOldCloudBackups(cfg); + + return db.prepare('SELECT * FROM backup_history WHERE id = ?').get(backupId); + } catch (error) { + const failedAt = new Date().toISOString(); + db.prepare(` + INSERT INTO backup_history ( + id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount, + s3Objects, gdriveFiles, status, errorDetails, createdAt + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, '[]', '[]', 'failed', ?, ?) + `).run(backupId, label, triggerType, error.message, failedAt); + + db.prepare(` + UPDATE backup_config + SET lastBackupAt = ?, lastBackupStatus = 'failed', lastBackupMessage = ? + WHERE id = 1 + `).run(failedAt, error.message); + + throw error; + } finally { + isBackupRunning = false; + } +} + +async function pruneOldCloudBackups(cfg) { + const retention = Number(cfg.retentionCount || 0); + if (retention <= 0) return; + + const successfulRuns = db.prepare(` + SELECT * FROM backup_history + WHERE status = 'success' + ORDER BY createdAt DESC + `).all(); + + if (successfulRuns.length <= retention) return; + + const expiredRuns = successfulRuns.slice(retention); + let gdriveToken = null; + + for (const run of expiredRuns) { + try { + const s3List = JSON.parse(run.s3Objects || '[]'); + if (cfg.s3Enabled && s3List.length > 0) { + for (const obj of s3List) { + await s3DeleteObject(cfg, obj.key).catch(e => console.warn('[Retention] S3 delete warning:', e.message)); + } + } + + const gdList = JSON.parse(run.gdriveFiles || '[]'); + if (cfg.gdriveEnabled && gdList.length > 0) { + if (!gdriveToken) { + gdriveToken = await getGoogleDriveAccessToken(cfg).catch(() => null); + } + if (gdriveToken) { + for (const f of gdList) { + await gdriveDeleteFile(gdriveToken, f.id).catch(e => console.warn('[Retention] GDrive delete warning:', e.message)); + } + } + } + + db.prepare('DELETE FROM backup_history WHERE id = ?').run(run.id); + } catch (pruneErr) { + console.warn('[Retention] Failed pruning backup record:', pruneErr.message); + } + } +} + +// Restore database from a Buffer (.db SQLite file or .json archive) without leaving local files +function restoreDatabaseFromBuffer(fileBuffer, filename = '') { + const isSqliteHeader = + fileBuffer.length > 16 && + fileBuffer.subarray(0, 15).toString('utf8') === 'SQLite format 3'; + + if (isSqliteHeader || filename.toLowerCase().endsWith('.db')) { + const tempRestorePath = path.join( + os.tmpdir(), + `forkfolio_restore_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db` + ); + let sourceDb = null; + try { + fs.writeFileSync(tempRestorePath, fileBuffer); + sourceDb = new Database(tempRestorePath, { readonly: true }); + + const users = sourceDb.prepare('SELECT * FROM users').all(); + const recipes = sourceDb.prepare('SELECT * FROM recipes').all(); + let shoppingLists = []; + let shoppingListItems = []; + let settings = []; + let recipeVisits = []; + let listVisits = []; + + try { shoppingLists = sourceDb.prepare('SELECT * FROM shopping_lists').all(); } catch (e) {} + try { shoppingListItems = sourceDb.prepare('SELECT * FROM shopping_list_items').all(); } catch (e) {} + try { settings = sourceDb.prepare('SELECT * FROM settings').all(); } catch (e) {} + try { recipeVisits = sourceDb.prepare('SELECT * FROM recipe_shares_visits').all(); } catch (e) {} + try { listVisits = sourceDb.prepare('SELECT * FROM shopping_list_shares_visits').all(); } catch (e) {} + + applyParsedRestoreData({ + users, + recipes, + shoppingLists, + shoppingListItems, + settings, + recipeSharesVisits: recipeVisits, + shoppingListSharesVisits: listVisits + }); + + return { + users: users.length, + recipes: recipes.length, + shoppingLists: shoppingLists.length + }; + } finally { + if (sourceDb) { + try { sourceDb.close(); } catch (e) {} + } + try { + if (fs.existsSync(tempRestorePath)) fs.unlinkSync(tempRestorePath); + } catch (e) {} + } + } else { + // Parse as JSON archive + const parsed = JSON.parse(fileBuffer.toString('utf8')); + const data = parsed.data || parsed; + if (!Array.isArray(data.users) && !Array.isArray(data.recipes)) { + throw new Error('Invalid backup archive format: missing users and recipes tables.'); + } + applyParsedRestoreData(data); + return { + users: (data.users || []).length, + recipes: (data.recipes || []).length, + shoppingLists: (data.shoppingLists || []).length + }; + } +} + +function applyParsedRestoreData(data) { + db.pragma('foreign_keys = OFF'); + try { + const tx = db.transaction(() => { + if (Array.isArray(data.users) && data.users.length > 0) { + db.prepare('DELETE FROM users').run(); + const insUser = db.prepare(` + INSERT OR REPLACE INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, defaultConsolidateLists, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const u of data.users) { + insUser.run( + u.id, + u.username, + u.salt, + u.passwordHash, + u.isAdmin ? 1 : 0, + u.isRecipeManager ? 1 : 0, + u.defaultConsolidateLists !== 0 ? 1 : 0, + u.createdAt || new Date().toISOString() + ); + } + } + + if (Array.isArray(data.recipes)) { + db.prepare('DELETE FROM recipes').run(); + const insRecipe = db.prepare(` + INSERT OR REPLACE INTO recipes ( + id, userId, createdBy, isPublic, title, description, servings, + prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const r of data.recipes) { + const coverImg = r.image || r.embeddedCoverImage || ''; + insRecipe.run( + r.id, + r.userId || 'system_admin', + r.createdBy || 'gourmet_system', + r.isPublic !== 0 && r.isPublic !== false ? 1 : 0, + r.title || 'Untitled Recipe', + r.description || '', + r.servings || '', + r.prepTime || '', + r.cookTime || '', + r.totalTime || '', + coverImg, + typeof r.images === 'string' ? r.images : JSON.stringify(r.images || []), + typeof r.ingredients === 'string' ? r.ingredients : JSON.stringify(r.ingredients || []), + typeof r.instructions === 'string' ? r.instructions : JSON.stringify(r.instructions || []), + r.sourceUrl || '', + r.shareToken || null, + r.createdAt || new Date().toISOString(), + r.updatedAt || null + ); + } + } + + if (Array.isArray(data.shoppingLists)) { + db.prepare('DELETE FROM shopping_lists').run(); + const insList = db.prepare(` + INSERT OR REPLACE INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + for (const l of data.shoppingLists) { + insList.run(l.id, l.userId, l.createdBy, l.name, l.shareToken || null, l.createdAt || new Date().toISOString(), l.updatedAt || null); + } + } + + if (Array.isArray(data.shoppingListItems)) { + db.prepare('DELETE FROM shopping_list_items').run(); + const insItem = db.prepare(` + INSERT OR REPLACE INTO shopping_list_items ( + id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const item of data.shoppingListItems) { + insItem.run( + item.id, + item.listId, + item.rawText, + item.itemName || item.rawText, + item.quantity !== undefined ? item.quantity : null, + item.unit || '', + item.category || 'Other', + item.recipeId || null, + item.recipeTitle || null, + item.checked ? 1 : 0, + item.createdAt || new Date().toISOString() + ); + } + } + + if (Array.isArray(data.recipeSharesVisits)) { + db.prepare('DELETE FROM recipe_shares_visits').run(); + const insVisit = db.prepare('INSERT OR IGNORE INTO recipe_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)'); + for (const v of data.recipeSharesVisits) { + insVisit.run(v.shareToken, v.visitorIp, v.visitedAt); + } + } + + if (Array.isArray(data.shoppingListSharesVisits)) { + db.prepare('DELETE FROM shopping_list_shares_visits').run(); + const insListVisit = db.prepare('INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)'); + for (const v of data.shoppingListSharesVisits) { + insListVisit.run(v.shareToken, v.visitorIp, v.visitedAt); + } + } + }); + tx(); + } finally { + db.pragma('foreign_keys = ON'); + } +} + +// Background Schedule Checker (runs every 60 seconds) +setInterval(async () => { + try { + const cfg = getBackupConfig(); + if (!cfg || cfg.scheduleFrequency === 'off') return; + if (!cfg.s3Enabled && !cfg.gdriveEnabled) return; + if (isBackupRunning) return; + + const now = new Date(); + const lastRun = cfg.lastBackupAt ? new Date(cfg.lastBackupAt) : null; + const elapsedMs = lastRun ? now.getTime() - lastRun.getTime() : Infinity; + + let shouldRun = false; + if (cfg.scheduleFrequency === '6h') { + shouldRun = elapsedMs >= 6 * 60 * 60 * 1000; + } else if (cfg.scheduleFrequency === '12h') { + shouldRun = elapsedMs >= 12 * 60 * 60 * 1000; + } else if (cfg.scheduleFrequency === 'daily' || cfg.scheduleFrequency === 'weekly') { + const [targetHour, targetMin] = (cfg.scheduleTime || '02:00').split(':').map(n => parseInt(n, 10) || 0); + const matchesTime = now.getHours() === targetHour && now.getMinutes() === targetMin; + const minCooldown = 20 * 60 * 60 * 1000; // Prevent double-firing within same day + + if (matchesTime && elapsedMs >= minCooldown) { + if (cfg.scheduleFrequency === 'daily') { + shouldRun = true; + } else if (cfg.scheduleFrequency === 'weekly' && now.getDay() === Number(cfg.scheduleDay || 0)) { + shouldRun = true; + } + } + } + + if (shouldRun) { + console.log(`[Backup Scheduler] Running scheduled (${cfg.scheduleFrequency}) cloud backup...`); + await executeCloudBackup('scheduled'); + } + } catch (schedErr) { + console.error('[Backup Scheduler] Scheduled backup error:', schedErr.message); + } +}, 60 * 1000); + +// ------------------------------------------------------------- +// ADMIN BACKUP REST API ROUTES +// ------------------------------------------------------------- + +// Get backup configuration and cloud backup history +app.get('/api/admin/backups/config', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const config = getBackupConfig(); + const history = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 50').all().map(row => ({ + ...row, + s3Objects: JSON.parse(row.s3Objects || '[]'), + gdriveFiles: JSON.parse(row.gdriveFiles || '[]') + })); + res.json({ config, history }); + } catch (error) { + console.error('Error loading backup config:', error); + res.status(500).json({ error: 'Failed to load backup configuration.' }); + } +}); + +// Save backup configuration & schedule +app.put('/api/admin/backups/config', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + const b = req.body || {}; + try { + const existing = getBackupConfig(); + db.prepare(` + UPDATE backup_config + SET scheduleFrequency = ?, + scheduleTime = ?, + scheduleDay = ?, + retentionCount = ?, + backupFormat = ?, + embedRemoteImages = ?, + s3Enabled = ?, + s3Endpoint = ?, + s3Region = ?, + s3Bucket = ?, + s3Prefix = ?, + s3AccessKeyId = ?, + s3SecretAccessKey = ?, + gdriveEnabled = ?, + gdriveClientId = ?, + gdriveClientSecret = ?, + gdriveRefreshToken = ?, + gdriveFolderId = ? + WHERE id = 1 + `).run( + b.scheduleFrequency || existing.scheduleFrequency, + b.scheduleTime || existing.scheduleTime, + b.scheduleDay !== undefined ? Number(b.scheduleDay) : existing.scheduleDay, + b.retentionCount !== undefined ? Number(b.retentionCount) : existing.retentionCount, + b.backupFormat || existing.backupFormat, + b.embedRemoteImages !== undefined ? (b.embedRemoteImages ? 1 : 0) : (existing.embedRemoteImages ? 1 : 0), + b.s3Enabled !== undefined ? (b.s3Enabled ? 1 : 0) : (existing.s3Enabled ? 1 : 0), + b.s3Endpoint !== undefined ? b.s3Endpoint.trim() : existing.s3Endpoint, + b.s3Region !== undefined ? b.s3Region.trim() : existing.s3Region, + b.s3Bucket !== undefined ? b.s3Bucket.trim() : existing.s3Bucket, + b.s3Prefix !== undefined ? b.s3Prefix.trim() : existing.s3Prefix, + b.s3AccessKeyId !== undefined ? b.s3AccessKeyId.trim() : existing.s3AccessKeyId, + b.s3SecretAccessKey !== undefined ? b.s3SecretAccessKey.trim() : existing.s3SecretAccessKey, + b.gdriveEnabled !== undefined ? (b.gdriveEnabled ? 1 : 0) : (existing.gdriveEnabled ? 1 : 0), + b.gdriveClientId !== undefined ? b.gdriveClientId.trim() : existing.gdriveClientId, + b.gdriveClientSecret !== undefined ? b.gdriveClientSecret.trim() : existing.gdriveClientSecret, + b.gdriveRefreshToken !== undefined ? b.gdriveRefreshToken.trim() : existing.gdriveRefreshToken, + b.gdriveFolderId !== undefined ? b.gdriveFolderId.trim() : existing.gdriveFolderId + ); + res.json({ success: true, config: getBackupConfig() }); + } catch (error) { + console.error('Error saving backup config:', error); + res.status(500).json({ error: 'Failed to save backup configuration.' }); + } +}); + +// Test Wasabi S3 connection +app.post('/api/admin/backups/test-s3', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const testCfg = { ...getBackupConfig(), ...(req.body || {}) }; + await s3TestConnection(testCfg); + res.json({ success: true, message: `Successfully connected to S3 bucket "${testCfg.s3Bucket}".` }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Test Google Drive API connection +app.post('/api/admin/backups/test-gdrive', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const testCfg = { ...getBackupConfig(), ...(req.body || {}) }; + const accessToken = await getGoogleDriveAccessToken(testCfg); + const folderId = await ensureGoogleDriveFolder(accessToken, testCfg); + res.json({ + success: true, + folderId, + message: `Successfully connected to Google Drive API (Folder ID: ${folderId}).` + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Generate Google Drive OAuth2 authorization URL for one-click admin connection +app.post('/api/admin/backups/gdrive/auth-url', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + const { clientId, clientSecret, origin } = req.body || {}; + if (!clientId || !clientSecret) { + return res.status(400).json({ error: 'Please enter your Google OAuth Client ID and Client Secret first.' }); + } + + db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run( + clientId.trim(), + clientSecret.trim() + ); + + const redirectUri = `${origin || `http://localhost:${PORT}`}/api/admin/backups/gdrive/callback`; + const authParams = new URLSearchParams({ + client_id: clientId.trim(), + redirect_uri: redirectUri, + response_type: 'code', + scope: 'https://www.googleapis.com/auth/drive.file', + access_type: 'offline', + prompt: 'consent' + }); + + res.json({ + authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`, + redirectUri + }); +}); + +// Google OAuth2 callback to automatically capture and save Refresh Token +app.get('/api/admin/backups/gdrive/callback', async (req, res) => { + const { code, error } = req.query; + if (error || !code) { + return res.status(400).send(`

Google Drive Authorization Failed

${error || 'No authorization code returned.'}

`); + } + + try { + const cfg = getBackupConfig(); + const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`; + const tokenParams = new URLSearchParams({ + code: String(code), + client_id: cfg.gdriveClientId, + client_secret: cfg.gdriveClientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code' + }); + + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams.toString() + }); + const tokenData = await tokenRes.json().catch(() => ({})); + if (!tokenRes.ok || (!tokenData.refresh_token && !tokenData.access_token)) { + throw new Error(tokenData.error_description || tokenData.error || 'Failed to exchange authorization code.'); + } + + if (tokenData.refresh_token) { + db.prepare('UPDATE backup_config SET gdriveRefreshToken = ?, gdriveEnabled = 1 WHERE id = 1').run(tokenData.refresh_token); + } + + res.send(` + + + Google Drive Connected + +

✓ Google Drive Connected Successfully!

+

Your refresh token has been saved to ForkFolio. You can close this window.

+ + + + `); + } catch (err) { + res.status(500).send(`

Google Drive Connection Error

${err.message}

`); + } +}); + +// Trigger immediate manual cloud backup (uploads to S3 / Google Drive, no local files kept) +app.post('/api/admin/backups/run', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const record = await executeCloudBackup('manual'); + res.json({ success: true, backup: record }); + } catch (error) { + console.error('Manual cloud backup failed:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Manual Download Backup directly to Admin's Local Computer (.db or .json), zero files kept on server +app.get('/api/admin/backups/download-local', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + const format = req.query.format === 'json' ? 'json' : 'db'; + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + + try { + if (format === 'json') { + const cfg = getBackupConfig(); + const archive = await createJsonArchiveData(cfg ? cfg.embedRemoteImages : true); + const filename = `forkfolio-backup-${stamp}.json`; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', String(archive.buffer.length)); + return res.send(archive.buffer); + } else { + const dbBuf = createSqliteSnapshotBuffer(); + const filename = `forkfolio-backup-${stamp}.db`; + res.setHeader('Content-Type', 'application/x-sqlite3'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', String(dbBuf.length)); + return res.send(dbBuf); + } + } catch (error) { + console.error('Error generating local computer download backup:', error); + res.status(500).json({ error: 'Failed to generate backup download.' }); + } +}); + +// 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) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + const format = req.query.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.' }); + + const cfg = getBackupConfig(); + const s3List = JSON.parse(row.s3Objects || '[]'); + const gdList = JSON.parse(row.gdriveFiles || '[]'); + + const s3Target = s3List.find(o => o.format === format) || s3List[0]; + const gdTarget = gdList.find(f => f.format === format) || gdList[0]; + + 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) { + const token = await getGoogleDriveAccessToken(cfg); + fileBuf = await gdriveDownloadFile(token, gdTarget.id); + downloadName = gdTarget.name || downloadName; + } else { + return res.status(400).json({ error: 'Cloud provider for this backup is not currently enabled.' }); + } + + res.setHeader('Content-Type', downloadName.endsWith('.json') ? 'application/json' : 'application/x-sqlite3'); + res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`); + res.setHeader('Content-Length', String(fileBuf.length)); + res.send(fileBuf); + } catch (error) { + console.error('Error downloading cloud backup:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Restore database from a Cloud Backup record (Wasabi S3 or Google Drive) +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.' }); + } + 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.' }); + + const cfg = getBackupConfig(); + 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]; + + 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) { + const token = await getGoogleDriveAccessToken(cfg); + fileBuf = await gdriveDownloadFile(token, gdTarget.id); + filename = gdTarget.name || ''; + } else { + return res.status(400).json({ error: 'No enabled cloud provider found to retrieve this backup.' }); + } + + const counts = restoreDatabaseFromBuffer(fileBuf, filename); + res.json({ success: true, restored: counts }); + } catch (error) { + console.error('Error restoring from cloud backup:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Restore database from an uploaded file from Admin's local computer (.db or .json base64 payload) +app.post('/api/admin/backups/restore-upload', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + const { filename, base64Data } = req.body || {}; + if (!base64Data) { + return res.status(400).json({ error: 'Backup file payload is required.' }); + } + try { + const fileBuffer = Buffer.from(base64Data, 'base64'); + const counts = restoreDatabaseFromBuffer(fileBuffer, filename || ''); + res.json({ success: true, restored: counts }); + } catch (error) { + console.error('Error restoring uploaded backup file:', error); + res.status(500).json({ error: `Restore failed: ${error.message}` }); + } +}); + +// Delete a cloud backup record and its remote objects in Wasabi S3 / Google Drive +app.delete('/api/admin/backups/:id', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + 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.' }); + + const cfg = getBackupConfig(); + const s3List = JSON.parse(row.s3Objects || '[]'); + const gdList = JSON.parse(row.gdriveFiles || '[]'); + + if (cfg.s3Enabled && s3List.length > 0) { + for (const obj of s3List) { + await s3DeleteObject(cfg, obj.key).catch(e => console.warn('S3 delete warning:', e.message)); + } + } + if (cfg.gdriveEnabled && gdList.length > 0) { + const token = await getGoogleDriveAccessToken(cfg).catch(() => null); + if (token) { + for (const f of gdList) { + await gdriveDeleteFile(token, f.id).catch(e => console.warn('GDrive delete warning:', e.message)); + } + } + } + + db.prepare('DELETE FROM backup_history WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting cloud backup:', error); + res.status(500).json({ error: 'Failed to delete backup.' }); + } +}); + // Launch server app.listen(PORT, () => { console.log(`Recipe Manager server is active at http://localhost:${PORT}`);