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 ? `
+
+ .DB
+
+
+ .JSON
+
+
+ Restore
+
+ ` : ''}
+
+
+
+
+ `;
+
+ 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 @@
-
+
Chef Accounts
Public Shared Links
+
+ Database Backups
+
@@ -272,6 +275,238 @@
+
+
+
+
+
+
+
+
+
+
+
+
Cloud Backup History
+
+
+
+
+ Backup Date
+ Trigger
+ Contents
+ Size
+ Cloud Destinations
+ Status
+ Actions
+
+
+
+
+
+
+
+
+
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}`);