diff --git a/public/app.js b/public/app.js
index 6813560..c5938dc 100644
--- a/public/app.js
+++ b/public/app.js
@@ -100,6 +100,16 @@ const elements = {
btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'),
cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'),
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'),
// Admin Create User Modal
@@ -346,6 +356,15 @@ function registerEvents() {
if (elements.cloudRestoreProviderFilter) {
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) => {
@@ -3658,29 +3677,12 @@ async function downloadCloudBackupItem(id, format, fallbackName) {
}
async function restoreCloudBackupItem(id, dateLabel) {
- const confirmed = window.confirm(
- `Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.`
- );
- if (!confirmed) return;
-
- try {
- showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info');
- const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, {
- method: 'POST',
- headers: getAuthHeaders()
- });
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
- showBackupStatus(
- `Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
- 'success'
- );
- await fetchRecipes();
- await fetchShoppingLists();
- fetchAdminBackupConfig();
- } catch (err) {
- showBackupStatus(err.message, 'error');
- }
+ openScopedRestoreModal({
+ mode: 'history',
+ historyId: id,
+ format: 'db',
+ titleLabel: `Cloud Backup (${dateLabel})`
+ });
}
async function deleteCloudBackupItem(id, dateLabel) {
@@ -3706,13 +3708,8 @@ async function handleRestoreLocalFile(e) {
e.target.value = '';
if (!file) return;
- const confirmed = window.confirm(
- `Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.`
- );
- if (!confirmed) return;
-
try {
- showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info');
+ showBackupStatus(`Reading "${file.name}"...`, 'info');
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -3724,23 +3721,12 @@ async function handleRestoreLocalFile(e) {
reader.readAsDataURL(file);
});
- const res = await fetch('/api/admin/backups/restore-upload', {
- method: 'POST',
- headers: getAuthHeaders(),
- body: JSON.stringify({
- filename: file.name,
- dataBase64: base64Data
- })
+ openScopedRestoreModal({
+ mode: 'upload',
+ filename: file.name,
+ 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) {
showBackupStatus(err.message, 'error');
}
@@ -3751,6 +3737,7 @@ async function handleRestoreLocalFile(e) {
// ==========================================
let cachedCloudBackups = [];
+let activeRestoreScopeContext = null;
function showCloudRestoreModalStatus(message, type = 'info') {
const box = elements.cloudRestoreModalStatus;
@@ -3772,6 +3759,209 @@ function showCloudRestoreModalStatus(message, type = 'info') {
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 = `Source: ${context.titleLabel}
Inspecting users and recipes inside backup...`;
+ }
+ if (elements.restoreTargetUserSelect) {
+ elements.restoreTargetUserSelect.innerHTML = ``;
+ }
+
+ 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 = `
+
${context.titleLabel}
+ Contains ${data.totalUsers} users · ${data.totalRecipes} recipes · ${data.totalShoppingLists} shopping lists
+ `;
+ }
+ 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 = ``;
+ } 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 = `${context.titleLabel}
`;
+ }
+ 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 = ` Restoring...`;
+ 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) {
const btn = elements.btnSyncCloudBackups;
const origHtml = btn ? btn.innerHTML : '';
@@ -3907,12 +4097,12 @@ function renderCloudRestoreModalList() {
${hasDb ? `
-
+
+
+
+
+
+
+ Inspecting backup contents...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server.js b/server.js
index 0c2e675..f8df86c 100644
--- a/server.js
+++ b/server.js
@@ -2862,13 +2862,13 @@ async function pruneOldCloudBackups(cfg) {
}
}
-// Restore database from a Buffer (.db SQLite file or .json archive) without leaving local files
-function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
+// Parse a backup Buffer (.db SQLite file or .json archive) into structured tables without leaving local files
+function parseBackupBufferToData(fileBuffer, filename = '') {
const isSqliteHeader =
fileBuffer.length > 16 &&
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(
os.tmpdir(),
`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 { listVisits = sourceDb.prepare('SELECT * FROM shopping_list_shares_visits').all(); } catch (e) {}
- applyParsedRestoreData({
+ return {
users,
recipes,
shoppingLists,
@@ -2900,15 +2900,6 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
settings,
recipeSharesVisits: recipeVisits,
shoppingListSharesVisits: listVisits
- });
-
- return {
- users: users.length,
- recipes: recipes.length,
- shoppingLists: shoppingLists.length,
- userCount: users.length,
- recipeCount: recipes.length,
- shoppingListCount: shoppingLists.length
};
} finally {
if (sourceDb) {
@@ -2919,31 +2910,235 @@ function restoreDatabaseFromBuffer(fileBuffer, filename = '') {
} catch (e) {}
}
} else {
- // Parse as JSON archive
const parsed = JSON.parse(fileBuffer.toString('utf8'));
const data = parsed.data || parsed;
if (!Array.isArray(data.users) && !Array.isArray(data.recipes)) {
throw new Error('Invalid backup archive format: missing users and recipes tables.');
}
- applyParsedRestoreData(data);
- const uLen = (data.users || []).length;
- const rLen = (data.recipes || []).length;
- const sLen = (data.shoppingLists || []).length;
return {
- users: uLen,
- recipes: rLen,
- shoppingLists: sLen,
- userCount: uLen,
- recipeCount: rLen,
- shoppingListCount: sLen
+ users: data.users || [],
+ recipes: data.recipes || [],
+ shoppingLists: data.shoppingLists || [],
+ shoppingListItems: data.shoppingListItems || [],
+ settings: data.settings || [],
+ 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');
try {
+ let resultSummary = null;
+
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) {
db.prepare('DELETE FROM users').run();
const insUser = db.prepare(`
@@ -3047,6 +3242,19 @@ function applyParsedRestoreData(data) {
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();
@@ -3065,6 +3273,8 @@ function applyParsedRestoreData(data) {
});
}
}
+
+ return resultSummary;
} finally {
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)
app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res) => {
if (!req.user.isAdmin) {
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 {
const cfg = getBackupConfig();
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.' });
}
- const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName);
+ const counts = restoreDatabaseFromBuffer(fileBuf, resolvedName, targetUser || 'all');
res.json({ success: true, restored: counts });
} catch (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.' });
}
const preferredFormat = req.body?.format === 'json' ? 'json' : 'db';
+ const targetUser = req.body?.targetUser || 'all';
try {
const row = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Backup record not found.' });
@@ -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.' });
}
- const counts = restoreDatabaseFromBuffer(fileBuf, filename);
+ const counts = restoreDatabaseFromBuffer(fileBuf, filename, targetUser);
res.json({ success: true, restored: counts });
} catch (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) {
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;
if (!payload) {
return res.status(400).json({ error: 'Backup file payload is required.' });
}
try {
const fileBuffer = Buffer.from(payload, 'base64');
- const counts = restoreDatabaseFromBuffer(fileBuffer, filename || '');
+ const counts = restoreDatabaseFromBuffer(fileBuffer, filename || '', targetUser || 'all');
res.json({ success: true, restored: counts });
} catch (error) {
console.error('Error restoring uploaded backup file:', error);