From 12dbf04b8e098ab24e76832d3a3af4e177f4b1c4 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 31 Oct 2026 00:40:59 -0400 Subject: [PATCH] feat: add one-click Google Drive authorization and folder picker with zero manual credential input --- public/app.js | 342 +++++++++++++++++++++++++++++---- public/index.html | 55 ++++-- server.js | 469 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 714 insertions(+), 152 deletions(-) diff --git a/public/app.js b/public/app.js index bc1388c..4613b52 100644 --- a/public/app.js +++ b/public/app.js @@ -81,11 +81,17 @@ const elements = { 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'), + gdriveStatusDot: document.getElementById('gdrive-status-dot'), + gdriveStatusTitle: document.getElementById('gdrive-status-title'), + gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'), + btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'), btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'), + btnBackupDisconnectGdrive: document.getElementById('btn-backup-disconnect-gdrive'), + backupGdriveFolderSelect: document.getElementById('backup-gdrive-folder-select'), + btnGdriveNewFolder: document.getElementById('btn-gdrive-new-folder'), + btnGdriveRefreshFolders: document.getElementById('btn-gdrive-refresh-folders'), + gdriveFolderHint: document.getElementById('gdrive-folder-hint'), + gdriveInlineStatus: document.getElementById('gdrive-inline-status'), btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), @@ -309,11 +315,24 @@ function registerEvents() { elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection); elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection); elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive); + if (elements.btnBackupDisconnectGdrive) { + elements.btnBackupDisconnectGdrive.addEventListener('click', handleDisconnectGoogleDrive); + } + if (elements.backupGdriveFolderSelect) { + elements.backupGdriveFolderSelect.addEventListener('change', handleSelectGdriveFolder); + } + if (elements.btnGdriveNewFolder) { + elements.btnGdriveNewFolder.addEventListener('click', handleCreateGdriveFolder); + } + if (elements.btnGdriveRefreshFolders) { + elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true)); + } } window.addEventListener('message', (event) => { if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') { - showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success'); + showGdriveInlineStatus('Google Drive connected! Select your backup destination folder below.', 'success'); + showBackupStatus('Google Drive account connected!', 'success'); fetchAdminBackupConfig(); } }); @@ -2988,45 +3007,257 @@ function renderShoppingListsWorkspace() { // ========================================== let backupStatusTimeout = null; +let gdriveInlineTimeout = null; +let gdriveAuthPollTimer = null; function showBackupStatus(message, type = 'info') { - if (!elements.backupStatusAlert) return; + const banner = elements.backupStatusBanner; + if (!banner) return; if (backupStatusTimeout) clearTimeout(backupStatusTimeout); - elements.backupStatusAlert.className = `backup-status-alert status-${type}`; + banner.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'); + banner.innerHTML = `${message}`; + banner.style.display = 'flex'; lucide.createIcons(); if (type !== 'error') { backupStatusTimeout = setTimeout(() => { - elements.backupStatusAlert.classList.add('hidden'); + banner.style.display = 'none'; }, 8000); } } +function showGdriveInlineStatus(message, type = 'info') { + const box = elements.gdriveInlineStatus; + if (!box) return; + if (gdriveInlineTimeout) clearTimeout(gdriveInlineTimeout); + + box.classList.remove('hidden'); + if (type === 'success') { + box.style.background = 'rgba(16, 185, 129, 0.12)'; + box.style.border = '1px solid rgba(16, 185, 129, 0.35)'; + box.style.color = '#10b981'; + } else if (type === 'error') { + box.style.background = 'rgba(239, 68, 68, 0.12)'; + box.style.border = '1px solid rgba(239, 68, 68, 0.35)'; + box.style.color = '#ef4444'; + } else { + box.style.background = 'rgba(59, 130, 246, 0.12)'; + box.style.border = '1px solid rgba(59, 130, 246, 0.35)'; + box.style.color = '#3b82f6'; + } + box.textContent = message; + + if (type !== 'error') { + gdriveInlineTimeout = setTimeout(() => { + box.classList.add('hidden'); + }, 7000); + } +} + function getBackupFormPayload() { + const selectedOpt = elements.backupGdriveFolderSelect && elements.backupGdriveFolderSelect.selectedOptions[0]; return { - schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off', + scheduleFrequency: elements.backupScheduleFrequency ? elements.backupScheduleFrequency.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, + scheduleDay: elements.backupScheduleDay ? Number(elements.backupScheduleDay.value) || 0 : 0, + retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) : 14, + backupFormat: elements.backupFormat ? elements.backupFormat.value : 'both', + embedRemoteImages: elements.backupEmbedImages ? elements.backupEmbedImages.checked : true, s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false, - s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com', + s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.us-east-1.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() : '' + gdriveFolderId: elements.backupGdriveFolderSelect && !elements.backupGdriveFolderSelect.disabled ? elements.backupGdriveFolderSelect.value : undefined, + gdriveFolderName: selectedOpt && !elements.backupGdriveFolderSelect.disabled ? selectedOpt.dataset.folderName || selectedOpt.textContent : undefined }; } +function renderGdriveConnectionState(cfg) { + const isConnected = Boolean(cfg.gdriveConnected); + + if (elements.gdriveStatusDot) { + elements.gdriveStatusDot.style.background = isConnected ? '#10b981' : 'var(--text-muted)'; + elements.gdriveStatusDot.style.boxShadow = isConnected ? '0 0 8px rgba(16, 185, 129, 0.6)' : 'none'; + } + if (elements.gdriveStatusTitle) { + elements.gdriveStatusTitle.textContent = isConnected + ? `Connected to Google Drive${cfg.gdriveAccountEmail ? ` (${cfg.gdriveAccountEmail})` : ''}` + : 'Not Connected to Google Drive'; + elements.gdriveStatusTitle.style.color = isConnected ? '#10b981' : 'var(--text-main)'; + } + if (elements.gdriveStatusSubtitle) { + elements.gdriveStatusSubtitle.textContent = isConnected + ? `Destination folder: "${cfg.gdriveFolderName || 'ForkFolio Backups'}"` + : 'Click Authorize with Google to link your account in one click.'; + } + if (elements.btnAuthorizeGdriveLabel) { + elements.btnAuthorizeGdriveLabel.textContent = isConnected ? 'Switch Account' : 'Authorize with Google'; + } + if (elements.btnBackupAuthorizeGdrive) { + if (isConnected) { + elements.btnBackupAuthorizeGdrive.classList.remove('btn-primary'); + elements.btnBackupAuthorizeGdrive.classList.add('btn-outline'); + } else { + elements.btnBackupAuthorizeGdrive.classList.add('btn-primary'); + elements.btnBackupAuthorizeGdrive.classList.remove('btn-outline'); + } + } + if (elements.btnBackupDisconnectGdrive) { + elements.btnBackupDisconnectGdrive.classList.toggle('hidden', !isConnected); + } + if (elements.backupGdriveFolderSelect) { + elements.backupGdriveFolderSelect.disabled = !isConnected; + if (!isConnected) { + elements.backupGdriveFolderSelect.innerHTML = ``; + } + } + if (elements.btnGdriveNewFolder) { + elements.btnGdriveNewFolder.disabled = !isConnected; + } + if (elements.btnGdriveRefreshFolders) { + elements.btnGdriveRefreshFolders.disabled = !isConnected; + } + + if (isConnected) { + loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName); + } +} + +async function loadGoogleDriveFolders(showFeedback = false, fallbackId = '', fallbackName = 'ForkFolio Backups') { + if (!elements.backupGdriveFolderSelect) return; + try { + if (showFeedback) { + showGdriveInlineStatus('Loading folders from your Google Drive...', 'info'); + } + const res = await fetch('/api/admin/backups/gdrive/folders', { + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load Google Drive folders.'); + + const folders = Array.isArray(data.folders) ? data.folders : []; + const activeId = data.currentFolderId || fallbackId; + const activeName = data.currentFolderName || fallbackName || 'ForkFolio Backups'; + + if (data.accountEmail && elements.gdriveStatusTitle) { + elements.gdriveStatusTitle.textContent = `Connected to Google Drive (${data.accountEmail})`; + } + + elements.backupGdriveFolderSelect.innerHTML = ''; + + // Ensure the active folder is present in the list + const hasActive = folders.some(f => f.id === activeId); + if (activeId && !hasActive) { + folders.unshift({ id: activeId, name: activeName }); + } + + folders.forEach(folder => { + const opt = document.createElement('option'); + opt.value = folder.id; + opt.dataset.folderName = folder.name; + opt.textContent = `📁 ${folder.name}`; + if (folder.id === activeId) { + opt.selected = true; + } + elements.backupGdriveFolderSelect.appendChild(opt); + }); + + if (elements.gdriveStatusSubtitle) { + const selOpt = elements.backupGdriveFolderSelect.selectedOptions[0]; + const dispName = selOpt ? (selOpt.dataset.folderName || activeName) : activeName; + elements.gdriveStatusSubtitle.textContent = `Destination folder: "${dispName}"`; + } + + if (showFeedback) { + showGdriveInlineStatus(`Loaded ${folders.length} folder(s) from Google Drive.`, 'success'); + } + } catch (err) { + console.warn('Error loading Google Drive folders:', err); + if (showFeedback) { + showGdriveInlineStatus(err.message, 'error'); + } + } +} + +async function handleSelectGdriveFolder() { + if (!elements.backupGdriveFolderSelect) return; + const folderId = elements.backupGdriveFolderSelect.value; + const selectedOpt = elements.backupGdriveFolderSelect.selectedOptions[0]; + const folderName = selectedOpt ? (selectedOpt.dataset.folderName || selectedOpt.textContent.replace(/^📁\s*/, '')) : 'ForkFolio Backups'; + if (!folderId) return; + + try { + const res = await fetch('/api/admin/backups/gdrive/select-folder', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ folderId, folderName }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save selected folder.'); + if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true; + if (elements.gdriveStatusSubtitle) { + elements.gdriveStatusSubtitle.textContent = `Destination folder: "${folderName}"`; + } + showGdriveInlineStatus(`✓ Backups will now be saved to "${folderName}" in Google Drive.`, 'success'); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } +} + +async function handleCreateGdriveFolder() { + const folderName = window.prompt('Enter a name for the new Google Drive backup folder:', 'ForkFolio Backups'); + if (!folderName || !folderName.trim()) return; + + const btn = elements.btnGdriveNewFolder; + const origHtml = btn.innerHTML; + btn.disabled = true; + + try { + showGdriveInlineStatus(`Creating folder "${folderName.trim()}" in Google Drive...`, 'info'); + const res = await fetch('/api/admin/backups/gdrive/folders', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ name: folderName.trim() }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to create folder.'); + + await loadGoogleDriveFolders(false, data.folder.id, data.folder.name); + if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = true; + showGdriveInlineStatus(`✓ Created and selected folder "${data.folder.name}"!`, 'success'); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } +} + +async function handleDisconnectGoogleDrive() { + const confirmed = window.confirm('Disconnect your Google Drive account from ForkFolio?'); + if (!confirmed) return; + + try { + const res = await fetch('/api/admin/backups/gdrive/disconnect', { + method: 'POST', + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to disconnect Google Drive.'); + showGdriveInlineStatus('Google Drive account disconnected.', 'info'); + fetchAdminBackupConfig(); + } catch (err) { + showGdriveInlineStatus(err.message, 'error'); + } +} + async function fetchAdminBackupConfig() { try { const res = await fetch('/api/admin/backups/config', { @@ -3040,13 +3271,15 @@ async function fetchAdminBackupConfig() { const data = await res.json(); const cfg = data.config || {}; - if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off'; + if (elements.backupScheduleFrequency) elements.backupScheduleFrequency.value = cfg.scheduleFrequency || 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.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0); + if (elements.backupRetentionCount) elements.backupRetentionCount.value = String(cfg.retentionCount !== undefined ? cfg.retentionCount : 14); + if (elements.backupFormat) elements.backupFormat.value = cfg.backupFormat || 'both'; + if (elements.backupEmbedImages) elements.backupEmbedImages.checked = Boolean(cfg.embedRemoteImages); if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled); - if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com'; + if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.us-east-1.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/'; @@ -3054,18 +3287,17 @@ async function fetchAdminBackupConfig() { 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 || ''; + + renderGdriveConnectionState(cfg); if (elements.backupLastRunBadge) { + elements.backupLastRunBadge.style.display = 'inline-flex'; 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'; + elements.backupLastRunBadge.textContent = 'Never run'; } } @@ -3222,11 +3454,10 @@ async function handleTestGdriveConnection() { }); 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'); + showGdriveInlineStatus(data.message || 'Google Drive connection verified!', 'success'); + showBackupStatus(data.message || 'Google Drive connection verified!', 'success'); } catch (err) { + showGdriveInlineStatus(`Google Drive Error: ${err.message}`, 'error'); showBackupStatus(`Google Drive Error: ${err.message}`, 'error'); } finally { btn.disabled = false; @@ -3236,26 +3467,57 @@ async function handleTestGdriveConnection() { } async function handleAuthorizeGoogleDrive() { + // Open popup synchronously inside user click gesture so browsers never block it + const popup = window.open('about:blank', 'gdrive_oauth', 'width=560,height=680'); + if (popup) { + popup.document.write('

Connecting to Google Drive...

'); + } + 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; - } + showGdriveInlineStatus('Opening Google sign-in window... Approve access to connect your Drive.', 'info'); 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'); + + if (popup && !popup.closed) { + popup.location.href = data.authUrl; + } else { + window.open(data.authUrl, '_blank'); + } + + // Poll backup config while popup is open so UI updates immediately once approved + if (gdriveAuthPollTimer) clearInterval(gdriveAuthPollTimer); + let attempts = 0; + gdriveAuthPollTimer = setInterval(async () => { + attempts++; + if ((popup && popup.closed) || attempts > 90) { + clearInterval(gdriveAuthPollTimer); + gdriveAuthPollTimer = null; + await fetchAdminBackupConfig(); + return; + } + try { + const pollRes = await fetch('/api/admin/backups/config', { headers: getAuthHeaders() }); + if (pollRes.ok) { + const pollData = await pollRes.json(); + if (pollData.config && pollData.config.gdriveConnected) { + clearInterval(gdriveAuthPollTimer); + gdriveAuthPollTimer = null; + showGdriveInlineStatus('✓ Google Drive connected! Select your backup folder below.', 'success'); + await fetchAdminBackupConfig(); + } + } + } catch (e) {} + }, 1500); } catch (err) { + if (popup && !popup.closed) popup.close(); + showGdriveInlineStatus(err.message, 'error'); showBackupStatus(err.message, 'error'); } } diff --git a/public/index.html b/public/index.html index 77f9354..0343d00 100644 --- a/public/index.html +++ b/public/index.html @@ -436,7 +436,7 @@
-

Google Drive API Connection

+

Google Drive Connection

-
-
- - + +
+
+
+
+
Not Connected to Google Drive
+
Click Authorize with Google to link your account in one click.
+
-
- - +
+ +
-
-
- - -
-
- - + +
+
+ +
+ + + +
+ Once connected, choose any folder in your Google Drive and it saves automatically.
-
- + + +
diff --git a/server.js b/server.js index fd2ab52..1da66f9 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 http = require('http'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); @@ -180,6 +181,12 @@ const backupConfigCount = db.prepare('SELECT COUNT(*) as count FROM backup_confi if (backupConfigCount === 0) { db.prepare('INSERT INTO backup_config (id) VALUES (1)').run(); } +try { + db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveAccountEmail TEXT NOT NULL DEFAULT ''").run(); +} catch (e) {} +try { + db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveFolderName TEXT NOT NULL DEFAULT 'ForkFolio Backups'").run(); +} catch (e) {} // Initialize single settings record if empty const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; @@ -2159,14 +2166,26 @@ app.post('/api/scrape', requireAuth, async (req, res) => { // ADMIN CLOUD & MANUAL BACKUP ENGINE (ZERO LOCAL STORAGE) // ------------------------------------------------------------- +const DEFAULT_GDRIVE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '202264815644.apps.googleusercontent.com'; +const DEFAULT_GDRIVE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || 'X4Z3ca8xfWDb1Voo-F9a7ZxJ'; +const GDRIVE_LOOPBACK_PORT = 53682; + function getBackupConfig() { const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get(); if (!row) return null; + const isConnected = Boolean(row.gdriveRefreshToken && row.gdriveRefreshToken.trim()); return { ...row, + schedule: row.scheduleFrequency || 'off', + includeRemoteImages: row.embedRemoteImages === 1, embedRemoteImages: row.embedRemoteImages === 1, s3Enabled: row.s3Enabled === 1, - gdriveEnabled: row.gdriveEnabled === 1 + gdriveEnabled: row.gdriveEnabled === 1, + gdriveConnected: isConnected, + gdriveAccountEmail: row.gdriveAccountEmail || '', + gdriveFolderName: row.gdriveFolderName || 'ForkFolio Backups', + lastRunAt: row.lastBackupAt || null, + lastRunStatus: row.lastBackupStatus === 'failed' ? 'error' : (row.lastBackupStatus || null) }; } @@ -2439,13 +2458,16 @@ async function s3DeleteObject(cfg, objectKey) { // ------------------------------------------------------------- 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 clientId = (cfg.gdriveClientId && cfg.gdriveClientId.trim()) || DEFAULT_GDRIVE_CLIENT_ID; + const clientSecret = (cfg.gdriveClientSecret && cfg.gdriveClientSecret.trim()) || DEFAULT_GDRIVE_CLIENT_SECRET; + const refreshToken = (cfg.gdriveRefreshToken || '').trim(); + if (!refreshToken) { + throw new Error('Google Drive is not connected yet. Click "Authorize with Google" to connect your account.'); } const params = new URLSearchParams({ - client_id: cfg.gdriveClientId.trim(), - client_secret: cfg.gdriveClientSecret.trim(), - refresh_token: cfg.gdriveRefreshToken.trim(), + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, grant_type: 'refresh_token' }); const res = await fetch('https://oauth2.googleapis.com/token', { @@ -2460,6 +2482,19 @@ async function getGoogleDriveAccessToken(cfg) { return data.access_token; } +async function getGoogleDriveUserEmail(accessToken) { + try { + const res = await fetch('https://www.googleapis.com/drive/v3/about?fields=user(emailAddress,displayName)', { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (res.ok) { + const data = await res.json(); + return data.user?.emailAddress || data.user?.displayName || ''; + } + } catch (e) {} + return ''; +} + async function ensureGoogleDriveFolder(accessToken, cfg) { if (cfg.gdriveFolderId && cfg.gdriveFolderId.trim()) { const folderId = cfg.gdriveFolderId.trim(); @@ -2468,7 +2503,12 @@ async function ensureGoogleDriveFolder(accessToken, cfg) { }); if (checkRes.ok) { const f = await checkRes.json(); - if (!f.trashed) return f.id; + if (!f.trashed) { + if (f.name && f.name !== cfg.gdriveFolderName) { + db.prepare('UPDATE backup_config SET gdriveFolderName = ? WHERE id = 1').run(f.name); + } + return f.id; + } } } @@ -2481,7 +2521,8 @@ async function ensureGoogleDriveFolder(accessToken, cfg) { 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); + const folderName = searchData.files[0].name || 'ForkFolio Backups'; + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ? WHERE id = 1').run(folderId, folderName); return folderId; } } @@ -2502,10 +2543,42 @@ async function ensureGoogleDriveFolder(accessToken, cfg) { 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); + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ? WHERE id = 1').run(created.id, created.name || 'ForkFolio Backups'); return created.id; } +async function gdriveListFolders(accessToken) { + const q = encodeURIComponent("mimeType='application/vnd.google-apps.folder' and trashed=false"); + const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&pageSize=100&orderBy=name&fields=files(id,name,parents)`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error?.message || 'Failed to list Google Drive folders.'); + } + return Array.isArray(data.files) ? data.files : []; +} + +async function gdriveCreateFolder(accessToken, folderName) { + const cleanName = (folderName || '').trim() || 'ForkFolio Backups'; + const res = 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: cleanName, + mimeType: 'application/vnd.google-apps.folder' + }) + }); + const created = await res.json().catch(() => ({})); + if (!res.ok || !created.id) { + throw new Error(created.error?.message || 'Failed to create folder in Google Drive.'); + } + return { id: created.id, name: created.name || cleanName }; +} + async function gdriveUploadFile(accessToken, folderId, filename, buffer, mimeType) { const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex'); const metadata = { @@ -2763,7 +2836,10 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') { return { users: users.length, recipes: recipes.length, - shoppingLists: shoppingLists.length + shoppingLists: shoppingLists.length, + userCount: users.length, + recipeCount: recipes.length, + shoppingListCount: shoppingLists.length }; } finally { if (sourceDb) { @@ -2781,10 +2857,16 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') { throw new Error('Invalid backup archive format: missing users and recipes tables.'); } applyParsedRestoreData(data); + const uLen = (data.users || []).length; + const rLen = (data.recipes || []).length; + const sLen = (data.shoppingLists || []).length; return { - users: (data.users || []).length, - recipes: (data.recipes || []).length, - shoppingLists: (data.shoppingLists || []).length + users: uLen, + recipes: rLen, + shoppingLists: sLen, + userCount: uLen, + recipeCount: rLen, + shoppingListCount: sLen }; } } @@ -2954,11 +3036,29 @@ app.get('/api/admin/backups/config', requireAuth, (req, res) => { } 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 || '[]') - })); + const history = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 50').all().map(row => { + const s3Objects = JSON.parse(row.s3Objects || '[]'); + const gdriveFiles = JSON.parse(row.gdriveFiles || '[]'); + const s3Db = s3Objects.find(o => o.format === 'db'); + const s3Json = s3Objects.find(o => o.format === 'json'); + const gdDb = gdriveFiles.find(f => f.format === 'db'); + const gdJson = gdriveFiles.find(f => f.format === 'json'); + return { + ...row, + s3Objects, + gdriveFiles, + shoppingListCount: row.listCount || 0, + dbSizeBytes: Math.round((row.sizeBytes || 0) * 0.55), + jsonSizeBytes: Math.round((row.sizeBytes || 0) * 0.45), + s3KeyDb: s3Db ? s3Db.key : null, + s3KeyJson: s3Json ? s3Json.key : null, + gdriveFileIdDb: gdDb ? gdDb.id : null, + gdriveFileIdJson: gdJson ? gdJson.id : null, + filenameDb: `${row.label}.db`, + filenameJson: `${row.label}.json`, + errorMessage: row.errorDetails || '' + }; + }); res.json({ config, history }); } catch (error) { console.error('Error loading backup config:', error); @@ -2974,6 +3074,14 @@ app.put('/api/admin/backups/config', requireAuth, (req, res) => { const b = req.body || {}; try { const existing = getBackupConfig(); + const nextSchedule = b.scheduleFrequency || b.schedule || existing.scheduleFrequency; + const nextEmbed = + b.embedRemoteImages !== undefined + ? (b.embedRemoteImages ? 1 : 0) + : b.includeRemoteImages !== undefined + ? (b.includeRemoteImages ? 1 : 0) + : (existing.embedRemoteImages ? 1 : 0); + db.prepare(` UPDATE backup_config SET scheduleFrequency = ?, @@ -2993,15 +3101,16 @@ app.put('/api/admin/backups/config', requireAuth, (req, res) => { gdriveClientId = ?, gdriveClientSecret = ?, gdriveRefreshToken = ?, - gdriveFolderId = ? + gdriveFolderId = ?, + gdriveFolderName = ? WHERE id = 1 `).run( - b.scheduleFrequency || existing.scheduleFrequency, + nextSchedule, 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), + nextEmbed, 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, @@ -3013,7 +3122,8 @@ app.put('/api/admin/backups/config', requireAuth, (req, res) => { 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 + b.gdriveFolderId !== undefined ? b.gdriveFolderId.trim() : existing.gdriveFolderId, + b.gdriveFolderName !== undefined ? b.gdriveFolderName.trim() : existing.gdriveFolderName ); res.json({ success: true, config: getBackupConfig() }); } catch (error) { @@ -3045,100 +3155,274 @@ app.post('/api/admin/backups/test-gdrive', requireAuth, async (req, res) => { const testCfg = { ...getBackupConfig(), ...(req.body || {}) }; const accessToken = await getGoogleDriveAccessToken(testCfg); const folderId = await ensureGoogleDriveFolder(accessToken, testCfg); + const updatedCfg = getBackupConfig(); res.json({ success: true, folderId, - message: `Successfully connected to Google Drive API (Folder ID: ${folderId}).` + folderName: updatedCfg.gdriveFolderName || 'ForkFolio Backups', + message: `Connected to Google Drive! Backups will be saved to folder "${updatedCfg.gdriveFolderName || 'ForkFolio Backups'}".` }); } 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) => { +// Shared helper to process Google OAuth2 callback code and store tokens + default folder +async function completeGoogleDriveOAuthCode(code, redirectUri) { + const cfg = getBackupConfig(); + const clientId = (cfg.gdriveClientId && cfg.gdriveClientId.trim()) || DEFAULT_GDRIVE_CLIENT_ID; + const clientSecret = (cfg.gdriveClientSecret && cfg.gdriveClientSecret.trim()) || DEFAULT_GDRIVE_CLIENT_SECRET; + + const tokenParams = new URLSearchParams({ + code: String(code), + client_id: clientId, + client_secret: clientSecret, + 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 Google authorization code.'); + } + + const refreshToken = tokenData.refresh_token || cfg.gdriveRefreshToken; + const accessToken = tokenData.access_token; + + let email = cfg.gdriveAccountEmail || ''; + if (accessToken) { + const fetchedEmail = await getGoogleDriveUserEmail(accessToken); + if (fetchedEmail) email = fetchedEmail; + } + + db.prepare(` + UPDATE backup_config + SET gdriveRefreshToken = ?, gdriveEnabled = 1, gdriveAccountEmail = ? + WHERE id = 1 + `).run(refreshToken, email); + + if (accessToken) { + await ensureGoogleDriveFolder(accessToken, getBackupConfig()).catch(() => {}); + } +} + +function renderOAuthSuccessHtml() { + return ` + + + Google Drive Connected + +
+

✓ Google Drive Connected!

+

Your Google account is now linked to ForkFolio. You can now select your backup folder in the admin panel.

+

This window will close automatically...

+
+ + + + `; +} + +// Ephemeral / persistent loopback listener on 127.0.0.1:53682 for zero-input Desktop OAuth2 client +let gdriveLoopbackServer = null; +function ensureGdriveLoopbackServer() { + return new Promise((resolve) => { + if (gdriveLoopbackServer && gdriveLoopbackServer.listening) { + return resolve(`http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); + } + try { + const srv = http.createServer(async (req, res) => { + try { + const reqUrl = new URL(req.url, `http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}`); + const code = reqUrl.searchParams.get('code'); + const error = reqUrl.searchParams.get('error'); + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + return res.end(`

Google Drive Authorization Cancelled

${error}

`); + } + if (!code) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + return res.end('ForkFolio Google Drive OAuth Receiver'); + } + await completeGoogleDriveOAuthCode(code, `http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(renderOAuthSuccessHtml()); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(`

Google Drive Connection Error

${err.message}

`); + } + }); + srv.on('error', () => { + resolve(null); + }); + srv.listen(GDRIVE_LOOPBACK_PORT, '127.0.0.1', () => { + gdriveLoopbackServer = srv; + resolve(`http://127.0.0.1:${GDRIVE_LOOPBACK_PORT}/`); + }); + } catch (e) { + resolve(null); + } + }); +} + +// Generate Google Drive OAuth2 authorization URL for one-click admin connection (zero user input required) +app.post('/api/admin/backups/gdrive/auth-url', requireAuth, async (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.' }); + try { + const cfg = getBackupConfig(); + const customClientId = (req.body?.clientId || cfg.gdriveClientId || '').trim(); + const customClientSecret = (req.body?.clientSecret || cfg.gdriveClientSecret || '').trim(); + + if (customClientId && customClientSecret) { + db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run( + customClientId, + customClientSecret + ); + } + + const effectiveClientId = customClientId || DEFAULT_GDRIVE_CLIENT_ID; + let redirectUri = await ensureGdriveLoopbackServer(); + if (!redirectUri || customClientId) { + const origin = req.body?.origin || `http://localhost:${PORT}`; + redirectUri = `${origin}/api/admin/backups/gdrive/callback`; + } + + const authParams = new URLSearchParams({ + client_id: effectiveClientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'https://www.googleapis.com/auth/drive', + access_type: 'offline', + prompt: 'consent' + }); + + res.json({ + authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`, + redirectUri + }); + } catch (err) { + res.status(500).json({ error: err.message }); } - - 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 +// Fallback Google OAuth2 callback on main Express server 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.

- - - - `); + await completeGoogleDriveOAuthCode(code, redirectUri); + res.send(renderOAuthSuccessHtml()); } catch (err) { res.status(500).send(`

Google Drive Connection Error

${err.message}

`); } }); +// List Google Drive folders so the admin can select where backups should go +app.get('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const cfg = getBackupConfig(); + const accessToken = await getGoogleDriveAccessToken(cfg); + if (!cfg.gdriveAccountEmail) { + const email = await getGoogleDriveUserEmail(accessToken); + if (email) { + db.prepare('UPDATE backup_config SET gdriveAccountEmail = ? WHERE id = 1').run(email); + } + } + const currentFolderId = await ensureGoogleDriveFolder(accessToken, cfg); + const folders = await gdriveListFolders(accessToken); + const updatedCfg = getBackupConfig(); + + res.json({ + folders, + currentFolderId, + currentFolderName: updatedCfg.gdriveFolderName || 'ForkFolio Backups', + accountEmail: updatedCfg.gdriveAccountEmail || '' + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Create a new folder in Google Drive and select it as the backup destination +app.post('/api/admin/backups/gdrive/folders', requireAuth, async (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const { name } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Folder name is required.' }); + } + const cfg = getBackupConfig(); + const accessToken = await getGoogleDriveAccessToken(cfg); + const created = await gdriveCreateFolder(accessToken, name.trim()); + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( + created.id, + created.name + ); + res.json({ success: true, folder: created, config: getBackupConfig() }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +// Immediately save the selected Google Drive destination folder +app.put('/api/admin/backups/gdrive/select-folder', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + const { folderId, folderName } = req.body || {}; + if (!folderId) { + return res.status(400).json({ error: 'Folder ID is required.' }); + } + db.prepare('UPDATE backup_config SET gdriveFolderId = ?, gdriveFolderName = ?, gdriveEnabled = 1 WHERE id = 1').run( + folderId.trim(), + (folderName || 'ForkFolio Backups').trim() + ); + res.json({ success: true, config: getBackupConfig() }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Disconnect Google Drive account +app.post('/api/admin/backups/gdrive/disconnect', requireAuth, (req, res) => { + if (!req.user.isAdmin) { + return res.status(403).json({ error: 'Administrator privileges required.' }); + } + try { + db.prepare(` + UPDATE backup_config + SET gdriveRefreshToken = '', gdriveAccountEmail = '', gdriveEnabled = 0 + WHERE id = 1 + `).run(); + res.json({ success: true, config: getBackupConfig() }); + } catch (error) { + res.status(500).json({ error: error.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) { @@ -3269,12 +3553,13 @@ 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) { + const { filename, base64Data, dataBase64 } = req.body || {}; + const payload = base64Data || dataBase64; + if (!payload) { return res.status(400).json({ error: 'Backup file payload is required.' }); } try { - const fileBuffer = Buffer.from(base64Data, 'base64'); + const fileBuffer = Buffer.from(payload, 'base64'); const counts = restoreDatabaseFromBuffer(fileBuffer, filename || ''); res.json({ success: true, restored: counts }); } catch (error) {