mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: add one-click Google Drive authorization and folder picker with zero manual credential input
This commit is contained in:
parent
90373d3cc3
commit
12dbf04b8e
3 changed files with 714 additions and 152 deletions
342
public/app.js
342
public/app.js
|
|
@ -81,11 +81,17 @@ const elements = {
|
||||||
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
|
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
|
||||||
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
|
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
|
||||||
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
|
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
|
||||||
backupGdriveClientId: document.getElementById('backup-gdrive-client-id'),
|
gdriveStatusDot: document.getElementById('gdrive-status-dot'),
|
||||||
backupGdriveClientSecret: document.getElementById('backup-gdrive-client-secret'),
|
gdriveStatusTitle: document.getElementById('gdrive-status-title'),
|
||||||
backupGdriveRefreshToken: document.getElementById('backup-gdrive-refresh-token'),
|
gdriveStatusSubtitle: document.getElementById('gdrive-status-subtitle'),
|
||||||
backupGdriveFolderId: document.getElementById('backup-gdrive-folder-id'),
|
btnAuthorizeGdriveLabel: document.getElementById('btn-authorize-gdrive-label'),
|
||||||
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
|
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'),
|
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
|
||||||
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
|
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
|
||||||
|
|
||||||
|
|
@ -309,11 +315,24 @@ function registerEvents() {
|
||||||
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
|
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
|
||||||
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
|
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
|
||||||
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
|
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) => {
|
window.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
|
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();
|
fetchAdminBackupConfig();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -2988,45 +3007,257 @@ function renderShoppingListsWorkspace() {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
let backupStatusTimeout = null;
|
let backupStatusTimeout = null;
|
||||||
|
let gdriveInlineTimeout = null;
|
||||||
|
let gdriveAuthPollTimer = null;
|
||||||
|
|
||||||
function showBackupStatus(message, type = 'info') {
|
function showBackupStatus(message, type = 'info') {
|
||||||
if (!elements.backupStatusAlert) return;
|
const banner = elements.backupStatusBanner;
|
||||||
|
if (!banner) return;
|
||||||
if (backupStatusTimeout) clearTimeout(backupStatusTimeout);
|
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';
|
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>`;
|
banner.innerHTML = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
|
||||||
elements.backupStatusAlert.classList.remove('hidden');
|
banner.style.display = 'flex';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
if (type !== 'error') {
|
if (type !== 'error') {
|
||||||
backupStatusTimeout = setTimeout(() => {
|
backupStatusTimeout = setTimeout(() => {
|
||||||
elements.backupStatusAlert.classList.add('hidden');
|
banner.style.display = 'none';
|
||||||
}, 8000);
|
}, 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() {
|
function getBackupFormPayload() {
|
||||||
|
const selectedOpt = elements.backupGdriveFolderSelect && elements.backupGdriveFolderSelect.selectedOptions[0];
|
||||||
return {
|
return {
|
||||||
schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off',
|
scheduleFrequency: elements.backupScheduleFrequency ? elements.backupScheduleFrequency.value : 'off',
|
||||||
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
|
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
|
||||||
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) || 14 : 14,
|
scheduleDay: elements.backupScheduleDay ? Number(elements.backupScheduleDay.value) || 0 : 0,
|
||||||
includeRemoteImages: elements.backupIncludeRemoteImages ? elements.backupIncludeRemoteImages.checked : true,
|
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,
|
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',
|
s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1',
|
||||||
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
|
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
|
||||||
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
|
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
|
||||||
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
|
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
|
||||||
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
|
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
|
||||||
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
|
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
|
||||||
gdriveClientId: elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '',
|
gdriveFolderId: elements.backupGdriveFolderSelect && !elements.backupGdriveFolderSelect.disabled ? elements.backupGdriveFolderSelect.value : undefined,
|
||||||
gdriveClientSecret: elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '',
|
gdriveFolderName: selectedOpt && !elements.backupGdriveFolderSelect.disabled ? selectedOpt.dataset.folderName || selectedOpt.textContent : undefined
|
||||||
gdriveRefreshToken: elements.backupGdriveRefreshToken ? elements.backupGdriveRefreshToken.value.trim() : '',
|
|
||||||
gdriveFolderId: elements.backupGdriveFolderId ? elements.backupGdriveFolderId.value.trim() : ''
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function fetchAdminBackupConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/backups/config', {
|
const res = await fetch('/api/admin/backups/config', {
|
||||||
|
|
@ -3040,13 +3271,15 @@ async function fetchAdminBackupConfig() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const cfg = data.config || {};
|
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.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
|
||||||
if (elements.backupRetentionCount) elements.backupRetentionCount.value = cfg.retentionCount || 14;
|
if (elements.backupScheduleDay) elements.backupScheduleDay.value = String(cfg.scheduleDay !== undefined ? cfg.scheduleDay : 0);
|
||||||
if (elements.backupIncludeRemoteImages) elements.backupIncludeRemoteImages.checked = Boolean(cfg.includeRemoteImages);
|
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.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.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
|
||||||
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
|
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
|
||||||
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
|
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.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
|
||||||
|
|
||||||
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
|
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 || '';
|
renderGdriveConnectionState(cfg);
|
||||||
if (elements.backupGdriveRefreshToken) elements.backupGdriveRefreshToken.value = cfg.gdriveRefreshToken || '';
|
|
||||||
if (elements.backupGdriveFolderId) elements.backupGdriveFolderId.value = cfg.gdriveFolderId || '';
|
|
||||||
|
|
||||||
if (elements.backupLastRunBadge) {
|
if (elements.backupLastRunBadge) {
|
||||||
|
elements.backupLastRunBadge.style.display = 'inline-flex';
|
||||||
if (cfg.lastRunAt) {
|
if (cfg.lastRunAt) {
|
||||||
const dt = new Date(cfg.lastRunAt).toLocaleString();
|
const dt = new Date(cfg.lastRunAt).toLocaleString();
|
||||||
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
|
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
|
||||||
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
|
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
|
||||||
} else {
|
} 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();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
|
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
|
||||||
if (data.folderId && elements.backupGdriveFolderId && !elements.backupGdriveFolderId.value) {
|
showGdriveInlineStatus(data.message || 'Google Drive connection verified!', 'success');
|
||||||
elements.backupGdriveFolderId.value = data.folderId;
|
showBackupStatus(data.message || 'Google Drive connection verified!', 'success');
|
||||||
}
|
|
||||||
showBackupStatus(data.message || 'Google Drive API connection verified!', 'success');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
showGdriveInlineStatus(`Google Drive Error: ${err.message}`, 'error');
|
||||||
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
|
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|
@ -3236,26 +3467,57 @@ async function handleTestGdriveConnection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAuthorizeGoogleDrive() {
|
async function handleAuthorizeGoogleDrive() {
|
||||||
try {
|
// Open popup synchronously inside user click gesture so browsers never block it
|
||||||
const clientId = elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '';
|
const popup = window.open('about:blank', 'gdrive_oauth', 'width=560,height=680');
|
||||||
const clientSecret = elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '';
|
if (popup) {
|
||||||
if (!clientId || !clientSecret) {
|
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>');
|
||||||
showBackupStatus('Please enter your Google OAuth2 Client ID and Client Secret first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showGdriveInlineStatus('Opening Google sign-in window... Approve access to connect your Drive.', 'info');
|
||||||
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
|
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
origin: window.location.origin
|
origin: window.location.origin
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.');
|
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) {
|
} catch (err) {
|
||||||
|
if (popup && !popup.closed) popup.close();
|
||||||
|
showGdriveInlineStatus(err.message, 'error');
|
||||||
showBackupStatus(err.message, 'error');
|
showBackupStatus(err.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,7 @@
|
||||||
<div class="backup-card-header">
|
<div class="backup-card-header">
|
||||||
<div class="backup-card-title">
|
<div class="backup-card-title">
|
||||||
<i data-lucide="cloud"></i>
|
<i data-lucide="cloud"></i>
|
||||||
<h4>Google Drive API Connection</h4>
|
<h4>Google Drive Connection</h4>
|
||||||
</div>
|
</div>
|
||||||
<label class="shopping-consolidate-toggle">
|
<label class="shopping-consolidate-toggle">
|
||||||
<input type="checkbox" id="backup-gdrive-enabled">
|
<input type="checkbox" id="backup-gdrive-enabled">
|
||||||
|
|
@ -445,32 +445,47 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid-2">
|
<!-- Step 1: One-Click Google Account Connection -->
|
||||||
<div class="form-group">
|
<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;">
|
||||||
<label for="backup-gdrive-client-id">OAuth2 Client ID</label>
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
<input type="text" id="backup-gdrive-client-id" placeholder="...apps.googleusercontent.com" autocomplete="off">
|
<div id="gdrive-status-dot" style="width: 10px; height: 10px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0;"></div>
|
||||||
</div>
|
<div>
|
||||||
<div class="form-group">
|
<div id="gdrive-status-title" style="font-weight: 600; font-size: 0.92rem; color: var(--text-main);">Not Connected to Google Drive</div>
|
||||||
<label for="backup-gdrive-client-secret">OAuth2 Client Secret</label>
|
<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>
|
||||||
<input type="password" id="backup-gdrive-client-secret" placeholder="GOCSPX-..." autocomplete="off">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<div class="form-grid-2">
|
<button type="button" class="btn btn-primary btn-sm" id="btn-backup-authorize-gdrive">
|
||||||
<div class="form-group">
|
<i data-lucide="log-in"></i> <span id="btn-authorize-gdrive-label">Authorize with Google</span>
|
||||||
<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">
|
|
||||||
</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>
|
</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>
|
||||||
|
|
||||||
|
<!-- 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 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">
|
<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>
|
<i data-lucide="plug-zap"></i> <span>Test Drive Connection</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
437
server.js
437
server.js
|
|
@ -3,6 +3,7 @@ const axios = require('axios');
|
||||||
const cheerio = require('cheerio');
|
const cheerio = require('cheerio');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
@ -180,6 +181,12 @@ const backupConfigCount = db.prepare('SELECT COUNT(*) as count FROM backup_confi
|
||||||
if (backupConfigCount === 0) {
|
if (backupConfigCount === 0) {
|
||||||
db.prepare('INSERT INTO backup_config (id) VALUES (1)').run();
|
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
|
// Initialize single settings record if empty
|
||||||
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
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)
|
// 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() {
|
function getBackupConfig() {
|
||||||
const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get();
|
const row = db.prepare('SELECT * FROM backup_config WHERE id = 1').get();
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const isConnected = Boolean(row.gdriveRefreshToken && row.gdriveRefreshToken.trim());
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
schedule: row.scheduleFrequency || 'off',
|
||||||
|
includeRemoteImages: row.embedRemoteImages === 1,
|
||||||
embedRemoteImages: row.embedRemoteImages === 1,
|
embedRemoteImages: row.embedRemoteImages === 1,
|
||||||
s3Enabled: row.s3Enabled === 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) {
|
async function getGoogleDriveAccessToken(cfg) {
|
||||||
if (!cfg.gdriveClientId || !cfg.gdriveClientSecret || !cfg.gdriveRefreshToken) {
|
const clientId = (cfg.gdriveClientId && cfg.gdriveClientId.trim()) || DEFAULT_GDRIVE_CLIENT_ID;
|
||||||
throw new Error('Google Drive Client ID, Client Secret, and Refresh Token are required.');
|
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({
|
const params = new URLSearchParams({
|
||||||
client_id: cfg.gdriveClientId.trim(),
|
client_id: clientId,
|
||||||
client_secret: cfg.gdriveClientSecret.trim(),
|
client_secret: clientSecret,
|
||||||
refresh_token: cfg.gdriveRefreshToken.trim(),
|
refresh_token: refreshToken,
|
||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
});
|
});
|
||||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
|
@ -2460,6 +2482,19 @@ async function getGoogleDriveAccessToken(cfg) {
|
||||||
return data.access_token;
|
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) {
|
async function ensureGoogleDriveFolder(accessToken, cfg) {
|
||||||
if (cfg.gdriveFolderId && cfg.gdriveFolderId.trim()) {
|
if (cfg.gdriveFolderId && cfg.gdriveFolderId.trim()) {
|
||||||
const folderId = cfg.gdriveFolderId.trim();
|
const folderId = cfg.gdriveFolderId.trim();
|
||||||
|
|
@ -2468,7 +2503,12 @@ async function ensureGoogleDriveFolder(accessToken, cfg) {
|
||||||
});
|
});
|
||||||
if (checkRes.ok) {
|
if (checkRes.ok) {
|
||||||
const f = await checkRes.json();
|
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();
|
const searchData = await searchRes.json();
|
||||||
if (Array.isArray(searchData.files) && searchData.files.length > 0) {
|
if (Array.isArray(searchData.files) && searchData.files.length > 0) {
|
||||||
const folderId = searchData.files[0].id;
|
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;
|
return folderId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2502,10 +2543,42 @@ async function ensureGoogleDriveFolder(accessToken, cfg) {
|
||||||
if (!createRes.ok || !created.id) {
|
if (!createRes.ok || !created.id) {
|
||||||
throw new Error('Failed to verify or create Google Drive backup folder.');
|
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;
|
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) {
|
async function gdriveUploadFile(accessToken, folderId, filename, buffer, mimeType) {
|
||||||
const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex');
|
const boundary = 'forkfolio_gdrive_' + crypto.randomBytes(12).toString('hex');
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|
@ -2763,7 +2836,10 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
|
||||||
return {
|
return {
|
||||||
users: users.length,
|
users: users.length,
|
||||||
recipes: recipes.length,
|
recipes: recipes.length,
|
||||||
shoppingLists: shoppingLists.length
|
shoppingLists: shoppingLists.length,
|
||||||
|
userCount: users.length,
|
||||||
|
recipeCount: recipes.length,
|
||||||
|
shoppingListCount: shoppingLists.length
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceDb) {
|
if (sourceDb) {
|
||||||
|
|
@ -2781,10 +2857,16 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
|
||||||
throw new Error('Invalid backup archive format: missing users and recipes tables.');
|
throw new Error('Invalid backup archive format: missing users and recipes tables.');
|
||||||
}
|
}
|
||||||
applyParsedRestoreData(data);
|
applyParsedRestoreData(data);
|
||||||
|
const uLen = (data.users || []).length;
|
||||||
|
const rLen = (data.recipes || []).length;
|
||||||
|
const sLen = (data.shoppingLists || []).length;
|
||||||
return {
|
return {
|
||||||
users: (data.users || []).length,
|
users: uLen,
|
||||||
recipes: (data.recipes || []).length,
|
recipes: rLen,
|
||||||
shoppingLists: (data.shoppingLists || []).length
|
shoppingLists: sLen,
|
||||||
|
userCount: uLen,
|
||||||
|
recipeCount: rLen,
|
||||||
|
shoppingListCount: sLen
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2954,11 +3036,29 @@ app.get('/api/admin/backups/config', requireAuth, (req, res) => {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const config = getBackupConfig();
|
const config = getBackupConfig();
|
||||||
const history = db.prepare('SELECT * FROM backup_history ORDER BY createdAt DESC LIMIT 50').all().map(row => ({
|
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,
|
...row,
|
||||||
s3Objects: JSON.parse(row.s3Objects || '[]'),
|
s3Objects,
|
||||||
gdriveFiles: JSON.parse(row.gdriveFiles || '[]')
|
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 });
|
res.json({ config, history });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading backup config:', 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 || {};
|
const b = req.body || {};
|
||||||
try {
|
try {
|
||||||
const existing = getBackupConfig();
|
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(`
|
db.prepare(`
|
||||||
UPDATE backup_config
|
UPDATE backup_config
|
||||||
SET scheduleFrequency = ?,
|
SET scheduleFrequency = ?,
|
||||||
|
|
@ -2993,15 +3101,16 @@ app.put('/api/admin/backups/config', requireAuth, (req, res) => {
|
||||||
gdriveClientId = ?,
|
gdriveClientId = ?,
|
||||||
gdriveClientSecret = ?,
|
gdriveClientSecret = ?,
|
||||||
gdriveRefreshToken = ?,
|
gdriveRefreshToken = ?,
|
||||||
gdriveFolderId = ?
|
gdriveFolderId = ?,
|
||||||
|
gdriveFolderName = ?
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`).run(
|
`).run(
|
||||||
b.scheduleFrequency || existing.scheduleFrequency,
|
nextSchedule,
|
||||||
b.scheduleTime || existing.scheduleTime,
|
b.scheduleTime || existing.scheduleTime,
|
||||||
b.scheduleDay !== undefined ? Number(b.scheduleDay) : existing.scheduleDay,
|
b.scheduleDay !== undefined ? Number(b.scheduleDay) : existing.scheduleDay,
|
||||||
b.retentionCount !== undefined ? Number(b.retentionCount) : existing.retentionCount,
|
b.retentionCount !== undefined ? Number(b.retentionCount) : existing.retentionCount,
|
||||||
b.backupFormat || existing.backupFormat,
|
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.s3Enabled !== undefined ? (b.s3Enabled ? 1 : 0) : (existing.s3Enabled ? 1 : 0),
|
||||||
b.s3Endpoint !== undefined ? b.s3Endpoint.trim() : existing.s3Endpoint,
|
b.s3Endpoint !== undefined ? b.s3Endpoint.trim() : existing.s3Endpoint,
|
||||||
b.s3Region !== undefined ? b.s3Region.trim() : existing.s3Region,
|
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.gdriveClientId !== undefined ? b.gdriveClientId.trim() : existing.gdriveClientId,
|
||||||
b.gdriveClientSecret !== undefined ? b.gdriveClientSecret.trim() : existing.gdriveClientSecret,
|
b.gdriveClientSecret !== undefined ? b.gdriveClientSecret.trim() : existing.gdriveClientSecret,
|
||||||
b.gdriveRefreshToken !== undefined ? b.gdriveRefreshToken.trim() : existing.gdriveRefreshToken,
|
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() });
|
res.json({ success: true, config: getBackupConfig() });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -3045,61 +3155,28 @@ app.post('/api/admin/backups/test-gdrive', requireAuth, async (req, res) => {
|
||||||
const testCfg = { ...getBackupConfig(), ...(req.body || {}) };
|
const testCfg = { ...getBackupConfig(), ...(req.body || {}) };
|
||||||
const accessToken = await getGoogleDriveAccessToken(testCfg);
|
const accessToken = await getGoogleDriveAccessToken(testCfg);
|
||||||
const folderId = await ensureGoogleDriveFolder(accessToken, testCfg);
|
const folderId = await ensureGoogleDriveFolder(accessToken, testCfg);
|
||||||
|
const updatedCfg = getBackupConfig();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
folderId,
|
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) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate Google Drive OAuth2 authorization URL for one-click admin connection
|
// Shared helper to process Google OAuth2 callback code and store tokens + default folder
|
||||||
app.post('/api/admin/backups/gdrive/auth-url', requireAuth, (req, res) => {
|
async function completeGoogleDriveOAuthCode(code, redirectUri) {
|
||||||
if (!req.user.isAdmin) {
|
|
||||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
|
||||||
}
|
|
||||||
const { clientId, clientSecret, origin } = req.body || {};
|
|
||||||
if (!clientId || !clientSecret) {
|
|
||||||
return res.status(400).json({ error: 'Please enter your Google OAuth Client ID and Client Secret first.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('UPDATE backup_config SET gdriveClientId = ?, gdriveClientSecret = ? WHERE id = 1').run(
|
|
||||||
clientId.trim(),
|
|
||||||
clientSecret.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
const redirectUri = `${origin || `http://localhost:${PORT}`}/api/admin/backups/gdrive/callback`;
|
|
||||||
const authParams = new URLSearchParams({
|
|
||||||
client_id: clientId.trim(),
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
response_type: 'code',
|
|
||||||
scope: 'https://www.googleapis.com/auth/drive.file',
|
|
||||||
access_type: 'offline',
|
|
||||||
prompt: 'consent'
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${authParams.toString()}`,
|
|
||||||
redirectUri
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Google OAuth2 callback to automatically capture and save Refresh Token
|
|
||||||
app.get('/api/admin/backups/gdrive/callback', async (req, res) => {
|
|
||||||
const { code, error } = req.query;
|
|
||||||
if (error || !code) {
|
|
||||||
return res.status(400).send(`<h3>Google Drive Authorization Failed</h3><p>${error || 'No authorization code returned.'}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cfg = getBackupConfig();
|
const cfg = getBackupConfig();
|
||||||
const redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`;
|
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({
|
const tokenParams = new URLSearchParams({
|
||||||
code: String(code),
|
code: String(code),
|
||||||
client_id: cfg.gdriveClientId,
|
client_id: clientId,
|
||||||
client_secret: cfg.gdriveClientSecret,
|
client_secret: clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
});
|
});
|
||||||
|
|
@ -3111,34 +3188,241 @@ app.get('/api/admin/backups/gdrive/callback', async (req, res) => {
|
||||||
});
|
});
|
||||||
const tokenData = await tokenRes.json().catch(() => ({}));
|
const tokenData = await tokenRes.json().catch(() => ({}));
|
||||||
if (!tokenRes.ok || (!tokenData.refresh_token && !tokenData.access_token)) {
|
if (!tokenRes.ok || (!tokenData.refresh_token && !tokenData.access_token)) {
|
||||||
throw new Error(tokenData.error_description || tokenData.error || 'Failed to exchange authorization code.');
|
throw new Error(tokenData.error_description || tokenData.error || 'Failed to exchange Google authorization code.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData.refresh_token) {
|
const refreshToken = tokenData.refresh_token || cfg.gdriveRefreshToken;
|
||||||
db.prepare('UPDATE backup_config SET gdriveRefreshToken = ?, gdriveEnabled = 1 WHERE id = 1').run(tokenData.refresh_token);
|
const accessToken = tokenData.access_token;
|
||||||
|
|
||||||
|
let email = cfg.gdriveAccountEmail || '';
|
||||||
|
if (accessToken) {
|
||||||
|
const fetchedEmail = await getGoogleDriveUserEmail(accessToken);
|
||||||
|
if (fetchedEmail) email = fetchedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send(`
|
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>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head><title>Google Drive Connected</title></head>
|
<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;">
|
<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;">
|
||||||
<h2 style="color: #10b981;">✓ Google Drive Connected Successfully!</h2>
|
<div style="background: #1e293b; border: 1px solid rgba(16,185,129,0.4); border-radius: 16px; padding: 32px; max-width: 420px;">
|
||||||
<p>Your refresh token has been saved to ForkFolio. You can close this window.</p>
|
<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>
|
<script>
|
||||||
|
try {
|
||||||
if (window.opener) {
|
if (window.opener) {
|
||||||
window.opener.postMessage({ type: 'GDRIVE_OAUTH_SUCCESS' }, '*');
|
window.opener.postMessage({ type: 'GDRIVE_OAUTH_SUCCESS' }, '*');
|
||||||
setTimeout(() => window.close(), 1500);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
setTimeout(() => window.close(), 1200);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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.' });
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 redirectUri = `${req.protocol}://${req.get('host')}/api/admin/backups/gdrive/callback`;
|
||||||
|
await completeGoogleDriveOAuthCode(code, redirectUri);
|
||||||
|
res.send(renderOAuthSuccessHtml());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).send(`<h3>Google Drive Connection Error</h3><p>${err.message}</p>`);
|
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)
|
// Trigger immediate manual cloud backup (uploads to S3 / Google Drive, no local files kept)
|
||||||
app.post('/api/admin/backups/run', requireAuth, async (req, res) => {
|
app.post('/api/admin/backups/run', requireAuth, async (req, res) => {
|
||||||
if (!req.user.isAdmin) {
|
if (!req.user.isAdmin) {
|
||||||
|
|
@ -3269,12 +3553,13 @@ app.post('/api/admin/backups/restore-upload', requireAuth, (req, res) => {
|
||||||
if (!req.user.isAdmin) {
|
if (!req.user.isAdmin) {
|
||||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
return res.status(403).json({ error: 'Administrator privileges required.' });
|
||||||
}
|
}
|
||||||
const { filename, base64Data } = req.body || {};
|
const { filename, base64Data, dataBase64 } = req.body || {};
|
||||||
if (!base64Data) {
|
const payload = base64Data || dataBase64;
|
||||||
|
if (!payload) {
|
||||||
return res.status(400).json({ error: 'Backup file payload is required.' });
|
return res.status(400).json({ error: 'Backup file payload is required.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const fileBuffer = Buffer.from(base64Data, 'base64');
|
const fileBuffer = Buffer.from(payload, 'base64');
|
||||||
const counts = restoreDatabaseFromBuffer(fileBuffer, filename || '');
|
const counts = restoreDatabaseFromBuffer(fileBuffer, filename || '');
|
||||||
res.json({ success: true, restored: counts });
|
res.json({ success: true, restored: counts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue