feat: support selective single-user data restore from cloud and local backups

This commit is contained in:
Chris 2026-10-31 01:06:07 -04:00
parent 6e8e948ce1
commit 906d448dba
3 changed files with 610 additions and 143 deletions

View file

@ -100,6 +100,16 @@ const elements = {
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'), btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'), cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'), cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'),
restoreScopeModal: document.getElementById('restore-scope-modal'),
restoreScopeBackupSummary: document.getElementById('restore-scope-backup-summary'),
restoreScopeCardAll: document.getElementById('restore-scope-card-all'),
restoreScopeAll: document.getElementById('restore-scope-all'),
restoreScopeAllDesc: document.getElementById('restore-scope-all-desc'),
restoreScopeCardSingle: document.getElementById('restore-scope-card-single'),
restoreScopeSingle: document.getElementById('restore-scope-single'),
restoreTargetUserSelect: document.getElementById('restore-target-user-select'),
restoreScopeStatus: document.getElementById('restore-scope-status'),
btnConfirmScopedRestore: document.getElementById('btn-confirm-scoped-restore'),
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
// Admin Create User Modal // Admin Create User Modal
@ -346,6 +356,15 @@ function registerEvents() {
if (elements.cloudRestoreProviderFilter) { if (elements.cloudRestoreProviderFilter) {
elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList()); elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList());
} }
if (elements.restoreScopeAll) {
elements.restoreScopeAll.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.restoreScopeSingle) {
elements.restoreScopeSingle.addEventListener('change', updateRestoreScopeModeUI);
}
if (elements.btnConfirmScopedRestore) {
elements.btnConfirmScopedRestore.addEventListener('click', executeScopedRestore);
}
} }
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
@ -3658,29 +3677,12 @@ async function downloadCloudBackupItem(id, format, fallbackName) {
} }
async function restoreCloudBackupItem(id, dateLabel) { async function restoreCloudBackupItem(id, dateLabel) {
const confirmed = window.confirm( openScopedRestoreModal({
`Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.` mode: 'history',
); historyId: id,
if (!confirmed) return; format: 'db',
titleLabel: `Cloud Backup (${dateLabel})`
try {
showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info');
const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, {
method: 'POST',
headers: getAuthHeaders()
}); });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
showBackupStatus(
`Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
'success'
);
await fetchRecipes();
await fetchShoppingLists();
fetchAdminBackupConfig();
} catch (err) {
showBackupStatus(err.message, 'error');
}
} }
async function deleteCloudBackupItem(id, dateLabel) { async function deleteCloudBackupItem(id, dateLabel) {
@ -3706,13 +3708,8 @@ async function handleRestoreLocalFile(e) {
e.target.value = ''; e.target.value = '';
if (!file) return; if (!file) return;
const confirmed = window.confirm(
`Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.`
);
if (!confirmed) return;
try { try {
showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info'); showBackupStatus(`Reading "${file.name}"...`, 'info');
const base64Data = await new Promise((resolve, reject) => { const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@ -3724,23 +3721,12 @@ async function handleRestoreLocalFile(e) {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
const res = await fetch('/api/admin/backups/restore-upload', { openScopedRestoreModal({
method: 'POST', mode: 'upload',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: file.name, filename: file.name,
dataBase64: base64Data base64Data,
}) titleLabel: `Local File: "${file.name}"`
}); });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.');
showBackupStatus(
`Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
'success'
);
await fetchRecipes();
await fetchShoppingLists();
fetchAdminBackupConfig();
} catch (err) { } catch (err) {
showBackupStatus(err.message, 'error'); showBackupStatus(err.message, 'error');
} }
@ -3751,6 +3737,7 @@ async function handleRestoreLocalFile(e) {
// ========================================== // ==========================================
let cachedCloudBackups = []; let cachedCloudBackups = [];
let activeRestoreScopeContext = null;
function showCloudRestoreModalStatus(message, type = 'info') { function showCloudRestoreModalStatus(message, type = 'info') {
const box = elements.cloudRestoreModalStatus; const box = elements.cloudRestoreModalStatus;
@ -3772,6 +3759,209 @@ function showCloudRestoreModalStatus(message, type = 'info') {
box.textContent = message; box.textContent = message;
} }
function showRestoreScopeStatus(message, type = 'info') {
const box = elements.restoreScopeStatus;
if (!box) return;
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;
}
function updateRestoreScopeModeUI() {
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
if (elements.restoreScopeCardAll) {
elements.restoreScopeCardAll.style.borderColor = isSingle ? 'var(--border)' : 'var(--accent)';
}
if (elements.restoreScopeCardSingle) {
elements.restoreScopeCardSingle.style.borderColor = isSingle ? 'var(--accent)' : 'var(--border)';
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.disabled = !isSingle;
}
}
async function openScopedRestoreModal(context) {
activeRestoreScopeContext = context;
if (elements.restoreScopeStatus) {
elements.restoreScopeStatus.classList.add('hidden');
}
if (elements.restoreScopeAll) elements.restoreScopeAll.checked = true;
if (elements.restoreScopeSingle) elements.restoreScopeSingle.checked = false;
updateRestoreScopeModeUI();
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `<strong>Source:</strong> ${context.titleLabel}<br><span style="font-size:0.82rem;">Inspecting users and recipes inside backup...</span>`;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = `<option value="">Loading users from backup...</option>`;
}
openModal('restore-scope-modal');
lucide.createIcons();
try {
const inspectBody = {};
if (context.mode === 'upload') {
inspectBody.filename = context.filename;
inspectBody.base64Data = context.base64Data;
} else if (context.mode === 'direct') {
inspectBody.provider = context.provider;
inspectBody.fileId = context.fileId;
inspectBody.objectKey = context.objectKey;
inspectBody.filename = context.filename;
} else if (context.mode === 'history') {
inspectBody.historyId = context.historyId;
inspectBody.format = context.format || 'db';
}
const res = await fetch('/api/admin/backups/inspect', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(inspectBody)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to inspect backup contents.');
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `
<div style="color:var(--text-main);font-weight:600;margin-bottom:2px;">${context.titleLabel}</div>
<div>Contains <strong>${data.totalUsers}</strong> users · <strong>${data.totalRecipes}</strong> recipes · <strong>${data.totalShoppingLists}</strong> shopping lists</div>
`;
}
if (elements.restoreScopeAllDesc) {
elements.restoreScopeAllDesc.textContent = `Replaces all users (${data.totalUsers}), recipes (${data.totalRecipes}), images, and shopping lists (${data.totalShoppingLists}) with this backup.`;
}
if (elements.restoreTargetUserSelect) {
elements.restoreTargetUserSelect.innerHTML = '';
const users = Array.isArray(data.users) ? data.users : [];
if (users.length === 0) {
elements.restoreTargetUserSelect.innerHTML = `<option value="">No individual users found in backup</option>`;
} else {
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.username;
const roleLabel = u.isAdmin ? ' (Admin)' : u.isRecipeManager ? ' (Manager)' : '';
opt.textContent = `@${u.username}${roleLabel} — ${u.recipeCount} ${u.recipeCount === 1 ? 'recipe' : 'recipes'}, ${u.shoppingListCount} ${u.shoppingListCount === 1 ? 'shopping list' : 'shopping lists'}`;
elements.restoreTargetUserSelect.appendChild(opt);
});
}
}
} catch (err) {
if (elements.restoreScopeBackupSummary) {
elements.restoreScopeBackupSummary.innerHTML = `<div style="color:var(--text-main);font-weight:600;">${context.titleLabel}</div>`;
}
showRestoreScopeStatus(`Could not pre-inspect user list: ${err.message}`, 'error');
}
}
async function executeScopedRestore() {
if (!activeRestoreScopeContext) return;
const isSingle = elements.restoreScopeSingle && elements.restoreScopeSingle.checked;
const targetUser = isSingle
? (elements.restoreTargetUserSelect ? elements.restoreTargetUserSelect.value : '')
: 'all';
if (isSingle && !targetUser) {
showRestoreScopeStatus('Please select which user to restore from the dropdown.', 'error');
return;
}
const btn = elements.btnConfirmScopedRestore;
const origHtml = btn ? btn.innerHTML : '';
if (btn) {
btn.disabled = true;
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> <span>Restoring...</span>`;
lucide.createIcons();
}
try {
showRestoreScopeStatus(
isSingle
? `Restoring only @${targetUser}'s recipes, images, and shopping lists...`
: 'Restoring entire database (all users, recipes, and shopping lists)...',
'info'
);
const ctx = activeRestoreScopeContext;
let res;
if (ctx.mode === 'upload') {
res = await fetch('/api/admin/backups/restore-upload', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
filename: ctx.filename,
dataBase64: ctx.base64Data,
targetUser
})
});
} else if (ctx.mode === 'direct') {
res = await fetch('/api/admin/backups/restore-cloud-file', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
provider: ctx.provider,
fileId: ctx.fileId,
objectKey: ctx.objectKey,
filename: ctx.filename,
targetUser
})
});
} else {
res = await fetch(`/api/admin/backups/${ctx.historyId}/restore-cloud`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
format: ctx.format || 'db',
targetUser
})
});
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Restore failed.');
const r = data.restored || {};
const msg =
r.mode === 'single_user'
? `✓ Restored data for @${r.targetUsername} (${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists) without modifying other users!`
: `✓ Full database restored (${r.userCount} users, ${r.recipeCount} recipes, ${r.shoppingListCount} shopping lists)!`;
showRestoreScopeStatus(msg, 'success');
showBackupStatus(msg, 'success');
await fetchRecipes();
await fetchShoppingLists();
await fetchAdminBackupConfig();
setTimeout(() => {
closeModal('restore-scope-modal');
closeModal('restore-cloud-modal');
}, 1100);
} catch (err) {
showRestoreScopeStatus(`Restore failed: ${err.message}`, 'error');
showBackupStatus(`Restore failed: ${err.message}`, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = origHtml;
lucide.createIcons();
}
}
}
async function handleSyncCloudBackups(silent = false) { async function handleSyncCloudBackups(silent = false) {
const btn = elements.btnSyncCloudBackups; const btn = elements.btnSyncCloudBackups;
const origHtml = btn ? btn.innerHTML : ''; const origHtml = btn ? btn.innerHTML : '';
@ -3907,12 +4097,12 @@ function renderCloudRestoreModalList() {
</div> </div>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
${hasDb ? ` ${hasDb ? `
<button type="button" class="btn btn-primary btn-sm btn-modal-restore-db" title="Restore full SQLite database snapshot from cloud"> <button type="button" class="btn btn-primary btn-sm btn-modal-restore-db" title="Restore full database or a single user from .DB snapshot">
<i data-lucide="rotate-ccw" style="width:13px;height:13px;"></i> <span>Restore (.DB)</span> <i data-lucide="rotate-ccw" style="width:13px;height:13px;"></i> <span>Restore (.DB)</span>
</button> </button>
` : ''} ` : ''}
${hasJson ? ` ${hasJson ? `
<button type="button" class="btn ${hasDb ? 'btn-outline' : 'btn-primary'} btn-sm btn-modal-restore-json" title="Restore from JSON archive with embedded images"> <button type="button" class="btn ${hasDb ? 'btn-outline' : 'btn-primary'} btn-sm btn-modal-restore-json" title="Restore full database or a single user from .JSON archive">
<i data-lucide="file-json" style="width:13px;height:13px;"></i> <span>Restore (.JSON)</span> <i data-lucide="file-json" style="width:13px;height:13px;"></i> <span>Restore (.JSON)</span>
</button> </button>
` : ''} ` : ''}
@ -3921,12 +4111,12 @@ function renderCloudRestoreModalList() {
const btnRestoreDb = card.querySelector('.btn-modal-restore-db'); const btnRestoreDb = card.querySelector('.btn-modal-restore-db');
if (btnRestoreDb) { if (btnRestoreDb) {
btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr, btnRestoreDb)); btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr));
} }
const btnRestoreJson = card.querySelector('.btn-modal-restore-json'); const btnRestoreJson = card.querySelector('.btn-modal-restore-json');
if (btnRestoreJson) { if (btnRestoreJson) {
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr, btnRestoreJson)); btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr));
} }
elements.cloudRestoreFilesContainer.appendChild(card); elements.cloudRestoreFilesContainer.appendChild(card);
@ -3935,72 +4125,35 @@ function renderCloudRestoreModalList() {
lucide.createIcons(); lucide.createIcons();
} }
async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) { function handleRestoreDirectCloudFile(snap, format, dateLabel) {
const confirmed = window.confirm(
`Are you sure you want to restore your ForkFolio database from the cloud ${format.toUpperCase()} backup created on ${dateLabel}?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this cloud backup.`
);
if (!confirmed) return;
const origHtml = btnEl ? btnEl.innerHTML : '';
if (btnEl) {
btnEl.disabled = true;
btnEl.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> <span>Restoring...</span>`;
lucide.createIcons();
}
try {
showCloudRestoreModalStatus(`Downloading ${format.toUpperCase()} backup from cloud and restoring database...`, 'info');
// Determine provider & identifier
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson); const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson); const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb; const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
let res;
if (gdriveId) { if (gdriveId) {
res = await fetch('/api/admin/backups/restore-cloud-file', { openScopedRestoreModal({
method: 'POST', mode: 'direct',
headers: getAuthHeaders(), provider: 'gdrive',
body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename }) fileId: gdriveId,
filename,
titleLabel: `Google Drive (${dateLabel} · .${format.toUpperCase()})`
}); });
} else if (s3Key) { } else if (s3Key) {
res = await fetch('/api/admin/backups/restore-cloud-file', { openScopedRestoreModal({
method: 'POST', mode: 'direct',
headers: getAuthHeaders(), provider: 's3',
body: JSON.stringify({ provider: 's3', objectKey: s3Key, filename }) objectKey: s3Key,
filename,
titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})`
}); });
} else { } else {
res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, { openScopedRestoreModal({
method: 'POST', mode: 'history',
headers: getAuthHeaders(), historyId: snap.id,
body: JSON.stringify({ format }) format,
titleLabel: `Cloud Backup (${dateLabel} · .${format.toUpperCase()})`
}); });
} }
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
const msg = `✓ Database restored from cloud (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`;
showCloudRestoreModalStatus(msg, 'success');
showBackupStatus(msg, 'success');
await fetchRecipes();
await fetchShoppingLists();
await fetchAdminBackupConfig();
setTimeout(() => {
closeModal('restore-cloud-modal');
}, 1400);
} catch (err) {
showCloudRestoreModalStatus(`Restore failed: ${err.message}`, 'error');
showBackupStatus(`Restore failed: ${err.message}`, 'error');
} finally {
if (btnEl) {
btnEl.disabled = false;
btnEl.innerHTML = origHtml;
lucide.createIcons();
}
}
} }

View file

@ -574,6 +574,57 @@
</div> </div>
</div> </div>
<!-- RESTORE SCOPE MODAL (ALL USERS VS SINGLE USER) -->
<div class="modal-overlay" id="restore-scope-modal">
<div class="modal-box" style="max-width: 540px;">
<div class="modal-header">
<h3 style="display: flex; align-items: center; gap: 8px;">
<i data-lucide="rotate-ccw" style="color: var(--accent);"></i>
<span>Choose What to Restore</span>
</h3>
<button class="close-btn" onclick="closeModal('restore-scope-modal')"><i data-lucide="x"></i></button>
</div>
<div class="modal-body" style="display: flex; flex-direction: column; gap: 14px;">
<div id="restore-scope-backup-summary" style="background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; font-size: 0.88rem; color: var(--text-muted);">
Inspecting backup contents...
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
<label id="restore-scope-card-all" style="display: flex; align-items: flex-start; gap: 12px; background: var(--bg-main); border: 1px solid var(--accent); border-radius: 10px; padding: 14px; cursor: pointer;">
<input type="radio" name="restore-scope-mode" id="restore-scope-all" value="all" checked style="margin-top: 4px; cursor: pointer;">
<div>
<div style="font-weight: 600; color: var(--text-main); font-size: 0.95rem;">Restore Entire Database (All Users)</div>
<div id="restore-scope-all-desc" style="font-size: 0.82rem; color: var(--text-muted); margin-top: 2px;">
Replaces all users, recipes, images, and shopping lists with this backup.
</div>
</div>
</label>
<label id="restore-scope-card-single" style="display: flex; align-items: flex-start; gap: 12px; background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px; cursor: pointer;">
<input type="radio" name="restore-scope-mode" id="restore-scope-single" value="single" style="margin-top: 4px; cursor: pointer;">
<div style="flex: 1;">
<div style="font-weight: 600; color: var(--text-main); font-size: 0.95rem;">Restore a Single User's Data Only</div>
<div style="font-size: 0.82rem; color: var(--text-muted); margin-top: 2px; margin-bottom: 10px;">
Restores only the selected user's account, recipes, images, and shopping lists. All other users remain untouched.
</div>
<select id="restore-target-user-select" class="shopping-select" style="width: 100%;" disabled>
<option value="">Loading users from backup...</option>
</select>
</div>
</label>
</div>
<div id="restore-scope-status" class="hidden" style="font-size: 0.85rem; padding: 10px 12px; border-radius: 8px;"></div>
</div>
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="btn btn-outline" onclick="closeModal('restore-scope-modal')">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-confirm-scoped-restore">
<i data-lucide="rotate-ccw"></i> <span>Restore Selected Data</span>
</button>
</div>
</div>
</div>
<!-- SCRAPE PREVIEW & EDIT DIALOG --> <!-- SCRAPE PREVIEW & EDIT DIALOG -->
<div class="modal-overlay" id="save-modal"> <div class="modal-overlay" id="save-modal">
<div class="modal-box max-width-lg"> <div class="modal-box max-width-lg">

323
server.js
View file

@ -2862,13 +2862,13 @@ async function pruneOldCloudBackups(cfg) {
} }
} }
// Restore database from a Buffer (.db SQLite file or .json archive) without leaving local files // Parse a backup Buffer (.db SQLite file or .json archive) into structured tables without leaving local files
function restoreDatabaseFromBuffer(fileBuffer, filename = '') { function parseBackupBufferToData(fileBuffer, filename = '') {
const isSqliteHeader = const isSqliteHeader =
fileBuffer.length > 16 && fileBuffer.length > 16 &&
fileBuffer.subarray(0, 15).toString('utf8') === 'SQLite format 3'; fileBuffer.subarray(0, 15).toString('utf8') === 'SQLite format 3';
if (isSqliteHeader || filename.toLowerCase().endsWith('.db')) { if (isSqliteHeader || filename.toLowerCase().endsWith('.db') || filename.toLowerCase().endsWith('.sqlite')) {
const tempRestorePath = path.join( const tempRestorePath = path.join(
os.tmpdir(), os.tmpdir(),
`forkfolio_restore_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db` `forkfolio_restore_${Date.now()}_${crypto.randomBytes(6).toString('hex')}.db`
@ -2892,7 +2892,7 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
try { recipeVisits = sourceDb.prepare('SELECT * FROM recipe_shares_visits').all(); } catch (e) {} try { recipeVisits = sourceDb.prepare('SELECT * FROM recipe_shares_visits').all(); } catch (e) {}
try { listVisits = sourceDb.prepare('SELECT * FROM shopping_list_shares_visits').all(); } catch (e) {} try { listVisits = sourceDb.prepare('SELECT * FROM shopping_list_shares_visits').all(); } catch (e) {}
applyParsedRestoreData({ return {
users, users,
recipes, recipes,
shoppingLists, shoppingLists,
@ -2900,15 +2900,6 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
settings, settings,
recipeSharesVisits: recipeVisits, recipeSharesVisits: recipeVisits,
shoppingListSharesVisits: listVisits shoppingListSharesVisits: listVisits
});
return {
users: users.length,
recipes: recipes.length,
shoppingLists: shoppingLists.length,
userCount: users.length,
recipeCount: recipes.length,
shoppingListCount: shoppingLists.length
}; };
} finally { } finally {
if (sourceDb) { if (sourceDb) {
@ -2919,31 +2910,235 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
} catch (e) {} } catch (e) {}
} }
} else { } else {
// Parse as JSON archive
const parsed = JSON.parse(fileBuffer.toString('utf8')); const parsed = JSON.parse(fileBuffer.toString('utf8'));
const data = parsed.data || parsed; const data = parsed.data || parsed;
if (!Array.isArray(data.users) && !Array.isArray(data.recipes)) { if (!Array.isArray(data.users) && !Array.isArray(data.recipes)) {
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);
const uLen = (data.users || []).length;
const rLen = (data.recipes || []).length;
const sLen = (data.shoppingLists || []).length;
return { return {
users: uLen, users: data.users || [],
recipes: rLen, recipes: data.recipes || [],
shoppingLists: sLen, shoppingLists: data.shoppingLists || [],
userCount: uLen, shoppingListItems: data.shoppingListItems || [],
recipeCount: rLen, settings: data.settings || [],
shoppingListCount: sLen recipeSharesVisits: data.recipeSharesVisits || [],
shoppingListSharesVisits: data.shoppingListSharesVisits || []
}; };
} }
} }
function applyParsedRestoreData(data) { function inspectBackupUsersFromData(data) {
const users = Array.isArray(data.users) ? data.users : [];
const recipes = Array.isArray(data.recipes) ? data.recipes : [];
const shoppingLists = Array.isArray(data.shoppingLists) ? data.shoppingLists : [];
const userSummaries = users.map(u => {
const unameLower = String(u.username || '').toLowerCase();
const rCount = recipes.filter(
r => r.userId === u.id || (r.createdBy && String(r.createdBy).toLowerCase() === unameLower)
).length;
const lCount = shoppingLists.filter(
l => l.userId === u.id || (l.createdBy && String(l.createdBy).toLowerCase() === unameLower)
).length;
return {
id: u.id,
username: u.username,
isAdmin: Boolean(u.isAdmin),
isRecipeManager: Boolean(u.isRecipeManager),
recipeCount: rCount,
shoppingListCount: lCount
};
});
return {
users: userSummaries,
totalUsers: users.length,
totalRecipes: recipes.length,
totalShoppingLists: shoppingLists.length
};
}
// Restore database (all users or a single user) from a Buffer (.db SQLite file or .json archive) without leaving local files
function restoreDatabaseFromBuffer(fileBuffer, filename = '', targetUser = 'all') {
const data = parseBackupBufferToData(fileBuffer, filename);
return applyParsedRestoreData(data, targetUser);
}
function applyParsedRestoreData(data, targetUser = 'all') {
const isSingleUser = Boolean(targetUser && targetUser !== 'all');
db.pragma('foreign_keys = OFF'); db.pragma('foreign_keys = OFF');
try { try {
let resultSummary = null;
const tx = db.transaction(() => { const tx = db.transaction(() => {
if (isSingleUser) {
const targetLower = String(targetUser).toLowerCase();
const backupUser = (data.users || []).find(
u => u.id === targetUser || String(u.username || '').toLowerCase() === targetLower
);
if (!backupUser) {
throw new Error(`User "${targetUser}" was not found in this backup archive.`);
}
const backupUnameLower = String(backupUser.username || '').toLowerCase();
const userRecipes = (data.recipes || []).filter(
r => r.userId === backupUser.id || (r.createdBy && String(r.createdBy).toLowerCase() === backupUnameLower)
);
const userLists = (data.shoppingLists || []).filter(
l => l.userId === backupUser.id || (l.createdBy && String(l.createdBy).toLowerCase() === backupUnameLower)
);
const userListIds = new Set(userLists.map(l => l.id));
const userListItems = (data.shoppingListItems || []).filter(item => userListIds.has(item.listId));
// Check if this user already exists in the live DB (by username or id)
const liveUser =
db.prepare('SELECT * FROM users WHERE lower(username) = lower(?)').get(backupUser.username) ||
db.prepare('SELECT * FROM users WHERE id = ?').get(backupUser.id);
const effectiveUserId = liveUser ? liveUser.id : backupUser.id;
const effectiveUsername = liveUser ? liveUser.username : backupUser.username;
if (!liveUser) {
db.prepare(`
INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, defaultConsolidateLists, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
backupUser.id,
backupUser.username,
backupUser.salt,
backupUser.passwordHash,
backupUser.isAdmin ? 1 : 0,
backupUser.isRecipeManager ? 1 : 0,
backupUser.defaultConsolidateLists !== 0 ? 1 : 0,
backupUser.createdAt || new Date().toISOString()
);
} else {
db.prepare(`
UPDATE users
SET defaultConsolidateLists = ?
WHERE id = ?
`).run(backupUser.defaultConsolidateLists !== 0 ? 1 : 0, effectiveUserId);
}
// Remove only this user's existing shopping lists and their items
const existingUserLists = db.prepare(
'SELECT id FROM shopping_lists WHERE userId = ? OR lower(createdBy) = lower(?)'
).all(effectiveUserId, effectiveUsername);
const delListItemsStmt = db.prepare('DELETE FROM shopping_list_items WHERE listId = ?');
for (const el of existingUserLists) {
delListItemsStmt.run(el.id);
}
db.prepare('DELETE FROM shopping_lists WHERE userId = ? OR lower(createdBy) = lower(?)').run(
effectiveUserId,
effectiveUsername
);
// Insert restored shopping lists & items for this user
const insList = db.prepare(`
INSERT OR REPLACE INTO shopping_lists (id, userId, createdBy, name, shareToken, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const l of userLists) {
insList.run(
l.id,
effectiveUserId,
effectiveUsername,
l.name,
l.shareToken || null,
l.createdAt || new Date().toISOString(),
l.updatedAt || null
);
}
const insItem = db.prepare(`
INSERT OR REPLACE INTO shopping_list_items (
id, listId, rawText, itemName, quantity, unit, category, recipeId, recipeTitle, checked, createdAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const item of userListItems) {
insItem.run(
item.id,
item.listId,
item.rawText,
item.itemName || item.rawText,
item.quantity !== undefined ? item.quantity : null,
item.unit || '',
item.category || 'Other',
item.recipeId || null,
item.recipeTitle || null,
item.checked ? 1 : 0,
item.createdAt || new Date().toISOString()
);
}
// Remove only this user's existing recipes and insert their restored recipes
db.prepare('DELETE FROM recipes WHERE userId = ? OR lower(createdBy) = lower(?)').run(
effectiveUserId,
effectiveUsername
);
const insRecipe = db.prepare(`
INSERT OR REPLACE INTO recipes (
id, userId, createdBy, isPublic, title, description, servings,
prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of userRecipes) {
const coverImg = r.image || r.embeddedCoverImage || '';
insRecipe.run(
r.id,
effectiveUserId,
effectiveUsername,
r.isPublic !== 0 && r.isPublic !== false ? 1 : 0,
r.title || 'Untitled Recipe',
r.description || '',
r.servings || '',
r.prepTime || '',
r.cookTime || '',
r.totalTime || '',
coverImg,
typeof r.images === 'string' ? r.images : JSON.stringify(r.images || []),
typeof r.ingredients === 'string' ? r.ingredients : JSON.stringify(r.ingredients || []),
typeof r.instructions === 'string' ? r.instructions : JSON.stringify(r.instructions || []),
r.sourceUrl || '',
r.shareToken || null,
r.createdAt || new Date().toISOString(),
r.updatedAt || null
);
}
// Restore share visits for this user's restored recipes & lists
const recipeTokens = new Set(userRecipes.map(r => r.shareToken).filter(Boolean));
if (recipeTokens.size > 0 && Array.isArray(data.recipeSharesVisits)) {
const insVisit = db.prepare('INSERT OR IGNORE INTO recipe_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)');
for (const v of data.recipeSharesVisits) {
if (recipeTokens.has(v.shareToken)) {
insVisit.run(v.shareToken, v.visitorIp, v.visitedAt);
}
}
}
const listTokens = new Set(userLists.map(l => l.shareToken).filter(Boolean));
if (listTokens.size > 0 && Array.isArray(data.shoppingListSharesVisits)) {
const insListVisit = db.prepare('INSERT OR IGNORE INTO shopping_list_shares_visits (shareToken, visitorIp, visitedAt) VALUES (?, ?, ?)');
for (const v of data.shoppingListSharesVisits) {
if (listTokens.has(v.shareToken)) {
insListVisit.run(v.shareToken, v.visitorIp, v.visitedAt);
}
}
}
resultSummary = {
mode: 'single_user',
targetUsername: effectiveUsername,
users: 1,
recipes: userRecipes.length,
shoppingLists: userLists.length,
userCount: 1,
recipeCount: userRecipes.length,
shoppingListCount: userLists.length
};
return;
}
if (Array.isArray(data.users) && data.users.length > 0) { if (Array.isArray(data.users) && data.users.length > 0) {
db.prepare('DELETE FROM users').run(); db.prepare('DELETE FROM users').run();
const insUser = db.prepare(` const insUser = db.prepare(`
@ -3047,6 +3242,19 @@ function applyParsedRestoreData(data) {
insListVisit.run(v.shareToken, v.visitorIp, v.visitedAt); insListVisit.run(v.shareToken, v.visitorIp, v.visitedAt);
} }
} }
const uLen = (data.users || []).length;
const rLen = (data.recipes || []).length;
const sLen = (data.shoppingLists || []).length;
resultSummary = {
mode: 'all',
users: uLen,
recipes: rLen,
shoppingLists: sLen,
userCount: uLen,
recipeCount: rLen,
shoppingListCount: sLen
};
}); });
tx(); tx();
@ -3065,6 +3273,8 @@ function applyParsedRestoreData(data) {
}); });
} }
} }
return resultSummary;
} finally { } finally {
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
} }
@ -3759,12 +3969,64 @@ app.get('/api/admin/backups/cloud-files', requireAuth, async (req, res) => {
} }
}); });
// Inspect users and item counts inside a cloud or uploaded backup before restoring
app.post('/api/admin/backups/inspect', requireAuth, async (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Administrator privileges required.' });
}
const { provider, fileId, objectKey, historyId, format, filename, base64Data, dataBase64 } = req.body || {};
try {
const cfg = getBackupConfig();
let fileBuf = null;
let resolvedName = filename || 'backup.db';
const rawBase64 = base64Data || dataBase64;
if (rawBase64) {
fileBuf = Buffer.from(rawBase64, 'base64');
} else if (provider === 'gdrive' && fileId) {
const token = await getGoogleDriveAccessToken(cfg);
fileBuf = await gdriveDownloadFile(token, fileId);
} else if (provider === 's3' && objectKey) {
fileBuf = await s3GetObject(cfg, objectKey);
resolvedName = filename || objectKey;
} else if (historyId) {
const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(historyId);
if (!row) return res.status(404).json({ error: 'Backup record not found.' });
const preferredFormat = format === 'json' ? 'json' : 'db';
const s3List = JSON.parse(row.s3Objects || '[]');
const gdList = JSON.parse(row.gdriveFiles || '[]');
const s3Target = s3List.find(o => o.format === preferredFormat) || s3List.find(o => o.format === 'db') || s3List[0];
const gdTarget = gdList.find(f => f.format === preferredFormat) || gdList.find(f => f.format === 'db') || gdList[0];
if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
const token = await getGoogleDriveAccessToken(cfg);
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
resolvedName = gdTarget.name || resolvedName;
} else if (s3Target && cfg.s3Enabled) {
fileBuf = await s3GetObject(cfg, s3Target.key);
resolvedName = s3Target.name || s3Target.key;
} else {
return res.status(400).json({ error: 'No connected cloud provider found to inspect this backup.' });
}
} else {
return res.status(400).json({ error: 'Backup source is required to inspect contents.' });
}
const parsedData = parseBackupBufferToData(fileBuf, resolvedName);
const inspection = inspectBackupUsersFromData(parsedData);
res.json({ success: true, ...inspection });
} catch (error) {
console.error('Error inspecting backup:', error);
res.status(500).json({ error: error.message });
}
});
// Restore directly from a specified cloud file (Google Drive fileId or Wasabi S3 objectKey) // Restore directly from a specified cloud file (Google Drive fileId or Wasabi S3 objectKey)
app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res) => { app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (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 { provider, fileId, objectKey, filename } = req.body || {}; const { provider, fileId, objectKey, filename, targetUser } = req.body || {};
try { try {
const cfg = getBackupConfig(); const cfg = getBackupConfig();
let fileBuf = null; let fileBuf = null;
@ -3780,7 +4042,7 @@ app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res)
return res.status(400).json({ error: 'Valid cloud provider and file identifier are required.' }); return res.status(400).json({ error: 'Valid cloud provider and file identifier are required.' });
} }
const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName); const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName, targetUser || 'all');
res.json({ success: true, restored: counts }); res.json({ success: true, restored: counts });
} catch (error) { } catch (error) {
console.error('Error restoring from direct cloud file:', error); console.error('Error restoring from direct cloud file:', error);
@ -3835,6 +4097,7 @@ app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) =
return res.status(403).json({ error: 'Administrator privileges required.' }); return res.status(403).json({ error: 'Administrator privileges required.' });
} }
const preferredFormat = req.body?.format === 'json' ? 'json' : 'db'; const preferredFormat = req.body?.format === 'json' ? 'json' : 'db';
const targetUser = req.body?.targetUser || 'all';
try { try {
const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Backup record not found.' }); if (!row) return res.status(404).json({ error: 'Backup record not found.' });
@ -3860,7 +4123,7 @@ app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) =
return res.status(400).json({ error: 'No connected cloud provider found to retrieve this backup.' }); return res.status(400).json({ error: 'No connected cloud provider found to retrieve this backup.' });
} }
const counts = restoreDatabaseFromBuffer(fileBuf, filename); const counts = restoreDatabaseFromBuffer(fileBuf, filename, targetUser);
res.json({ success: true, restored: counts }); res.json({ success: true, restored: counts });
} catch (error) { } catch (error) {
console.error('Error restoring from cloud backup:', error); console.error('Error restoring from cloud backup:', error);
@ -3873,14 +4136,14 @@ 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, dataBase64 } = req.body || {}; const { filename, base64Data, dataBase64, targetUser } = req.body || {};
const payload = base64Data || dataBase64; const payload = base64Data || dataBase64;
if (!payload) { 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(payload, 'base64'); const fileBuffer = Buffer.from(payload, 'base64');
const counts = restoreDatabaseFromBuffer(fileBuffer, filename || ''); const counts = restoreDatabaseFromBuffer(fileBuffer, filename || '', targetUser || 'all');
res.json({ success: true, restored: counts }); res.json({ success: true, restored: counts });
} catch (error) { } catch (error) {
console.error('Error restoring uploaded backup file:', error); console.error('Error restoring uploaded backup file:', error);