mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: support selective single-user data restore from cloud and local backups
This commit is contained in:
parent
6e8e948ce1
commit
906d448dba
3 changed files with 610 additions and 143 deletions
379
public/app.js
379
public/app.js
|
|
@ -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(),
|
filename: file.name,
|
||||||
body: JSON.stringify({
|
base64Data,
|
||||||
filename: file.name,
|
titleLabel: `Local File: "${file.name}"`
|
||||||
dataBase64: base64Data
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
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,71 +4125,34 @@ function renderCloudRestoreModalList() {
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) {
|
function handleRestoreDirectCloudFile(snap, format, dateLabel) {
|
||||||
const confirmed = window.confirm(
|
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
|
||||||
`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.`
|
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
|
||||||
);
|
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
const origHtml = btnEl ? btnEl.innerHTML : '';
|
if (gdriveId) {
|
||||||
if (btnEl) {
|
openScopedRestoreModal({
|
||||||
btnEl.disabled = true;
|
mode: 'direct',
|
||||||
btnEl.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> <span>Restoring...</span>`;
|
provider: 'gdrive',
|
||||||
lucide.createIcons();
|
fileId: gdriveId,
|
||||||
}
|
filename,
|
||||||
|
titleLabel: `Google Drive (${dateLabel} · .${format.toUpperCase()})`
|
||||||
try {
|
});
|
||||||
showCloudRestoreModalStatus(`Downloading ${format.toUpperCase()} backup from cloud and restoring database...`, 'info');
|
} else if (s3Key) {
|
||||||
|
openScopedRestoreModal({
|
||||||
// Determine provider & identifier
|
mode: 'direct',
|
||||||
const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson);
|
provider: 's3',
|
||||||
const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson);
|
objectKey: s3Key,
|
||||||
const filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
|
filename,
|
||||||
|
titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})`
|
||||||
let res;
|
});
|
||||||
if (gdriveId) {
|
} else {
|
||||||
res = await fetch('/api/admin/backups/restore-cloud-file', {
|
openScopedRestoreModal({
|
||||||
method: 'POST',
|
mode: 'history',
|
||||||
headers: getAuthHeaders(),
|
historyId: snap.id,
|
||||||
body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename })
|
format,
|
||||||
});
|
titleLabel: `Cloud Backup (${dateLabel} · .${format.toUpperCase()})`
|
||||||
} else if (s3Key) {
|
});
|
||||||
res = await fetch('/api/admin/backups/restore-cloud-file', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ provider: 's3', objectKey: s3Key, filename })
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ format })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
323
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
|
// 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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue