feat: add one-click Google Drive authorization and folder picker with zero manual credential input

This commit is contained in:
Chris 2026-10-31 00:40:59 -04:00
parent 90373d3cc3
commit 12dbf04b8e
3 changed files with 714 additions and 152 deletions

View file

@ -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 = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
elements.backupStatusAlert.classList.remove('hidden');
banner.innerHTML = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
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 = `<option value="">Authorize with Google first to select a folder...</option>`;
}
}
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('<body style="font-family:sans-serif;background:#0f172a;color:#f8fafc;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;"><h3>Connecting to Google Drive...</h3></body>');
}
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');
}
}

View file

@ -436,7 +436,7 @@
<div class="backup-card-header">
<div class="backup-card-title">
<i data-lucide="cloud"></i>
<h4>Google Drive API Connection</h4>
<h4>Google Drive Connection</h4>
</div>
<label class="shopping-consolidate-toggle">
<input type="checkbox" id="backup-gdrive-enabled">
@ -445,32 +445,47 @@
</label>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-gdrive-client-id">OAuth2 Client ID</label>
<input type="text" id="backup-gdrive-client-id" placeholder="...apps.googleusercontent.com" autocomplete="off">
<!-- Step 1: One-Click Google Account Connection -->
<div id="gdrive-connection-box" style="background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 10px;">
<div id="gdrive-status-dot" style="width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0;"></div>
<div>
<div id="gdrive-status-title" style="font-weight: 600; font-size: 0.92rem; color: var(--text-main);">Not Connected to Google Drive</div>
<div id="gdrive-status-subtitle" style="font-size: 0.8rem; color: var(--text-muted);">Click Authorize with Google to link your account in one click.</div>
</div>
</div>
<div class="form-group">
<label for="backup-gdrive-client-secret">OAuth2 Client Secret</label>
<input type="password" id="backup-gdrive-client-secret" placeholder="GOCSPX-..." autocomplete="off">
<div style="display: flex; align-items: center; gap: 8px;">
<button type="button" class="btn btn-primary btn-sm" id="btn-backup-authorize-gdrive">
<i data-lucide="log-in"></i> <span id="btn-authorize-gdrive-label">Authorize with Google</span>
</button>
<button type="button" class="btn btn-outline btn-sm hidden" id="btn-backup-disconnect-gdrive" title="Disconnect Google Drive Account">
<i data-lucide="unlink"></i> <span>Disconnect</span>
</button>
</div>
</div>
<div class="form-grid-2">
<div class="form-group">
<label for="backup-gdrive-refresh-token">OAuth2 Refresh Token</label>
<input type="password" id="backup-gdrive-refresh-token" placeholder="1//04... (or click Authorize below)" autocomplete="off">
</div>
<div class="form-group">
<label for="backup-gdrive-folder-id">Target Drive Folder ID (Optional)</label>
<input type="text" id="backup-gdrive-folder-id" placeholder="Auto-creates 'ForkFolio Backups' if blank">
<!-- Step 2: Select Where Backups Should Go -->
<div id="gdrive-folder-picker-section" style="margin-top: 6px;">
<div class="form-group" style="margin-bottom: 8px;">
<label for="backup-gdrive-folder-select">Select Backup Destination Folder in Google Drive</label>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<select id="backup-gdrive-folder-select" style="flex: 1; min-width: 200px;" disabled>
<option value="">Authorize with Google first to select a folder...</option>
</select>
<button type="button" class="btn btn-outline btn-sm" id="btn-gdrive-new-folder" disabled title="Create a new folder in Google Drive">
<i data-lucide="folder-plus"></i> <span>New Folder</span>
</button>
<button type="button" class="btn btn-outline btn-sm" id="btn-gdrive-refresh-folders" disabled title="Refresh folder list from Google Drive">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<small class="form-hint" id="gdrive-folder-hint">Once connected, choose any folder in your Google Drive and it saves automatically.</small>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px;">
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-authorize-gdrive" title="Open Google OAuth consent screen to connect your Google Drive account">
<i data-lucide="key-round"></i> <span>Authorize with Google</span>
</button>
<div id="gdrive-inline-status" class="hidden" style="font-size: 0.84rem; padding: 8px 12px; border-radius: 8px; margin-top: 4px;"></div>
<div style="display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 6px;">
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-test-gdrive">
<i data-lucide="plug-zap"></i> <span>Test Drive Connection</span>
</button>

469
server.js
View file

@ -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 `
<!DOCTYPE html>
<html>
<head><title>Google Drive Connected</title></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; text-align: center; padding: 20px;">
<div style="background: #1e293b; border: 1px solid rgba(16,185,129,0.4); border-radius: 16px; padding: 32px; max-width: 420px;">
<h2 style="color: #10b981; margin-top: 0;">✓ Google Drive Connected!</h2>
<p style="color: #94a3b8; line-height: 1.5;">Your Google account is now linked to ForkFolio. You can now select your backup folder in the admin panel.</p>
<p style="color: #64748b; font-size: 0.85rem;">This window will close automatically...</p>
</div>
<script>
try {
if (window.opener) {
window.opener.postMessage({ type: 'GDRIVE_OAUTH_SUCCESS' }, '*');
}
} catch (e) {}
setTimeout(() => window.close(), 1200);
</script>
</body>
</html>
`;
}
// 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(`<h3>Google Drive Authorization Cancelled</h3><p>${error}</p>`);
}
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(`<h3>Google Drive Connection Error</h3><p>${err.message}</p>`);
}
});
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(`<h3>Google Drive Authorization Failed</h3><p>${error || 'No authorization code returned.'}</p>`);
}
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(`
<!DOCTYPE html>
<html>
<head><title>Google Drive Connected</title></head>
<body style="font-family: sans-serif; background: #0f172a; color: #f8fafc; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0;">
<h2 style="color: #10b981;">✓ Google Drive Connected Successfully!</h2>
<p>Your refresh token has been saved to ForkFolio. You can close this window.</p>
<script>
if (window.opener) {
window.opener.postMessage({ type: 'GDRIVE_OAUTH_SUCCESS' }, '*');
setTimeout(() => window.close(), 1500);
}
</script>
</body>
</html>
`);
await completeGoogleDriveOAuthCode(code, redirectUri);
res.send(renderOAuthSuccessHtml());
} catch (err) {
res.status(500).send(`<h3>Google Drive Connection Error</h3><p>${err.message}</p>`);
}
});
// 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) {