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
357
public/app.js
357
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()
|
||||
openScopedRestoreModal({
|
||||
mode: 'history',
|
||||
historyId: id,
|
||||
format: 'db',
|
||||
titleLabel: `Cloud Backup (${dateLabel})`
|
||||
});
|
||||
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) {
|
||||
|
|
@ -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({
|
||||
openScopedRestoreModal({
|
||||
mode: 'upload',
|
||||
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) {
|
||||
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 = `<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) {
|
||||
const btn = elements.btnSyncCloudBackups;
|
||||
const origHtml = btn ? btn.innerHTML : '';
|
||||
|
|
@ -3907,12 +4097,12 @@ function renderCloudRestoreModalList() {
|
|||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
${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>
|
||||
</button>
|
||||
` : ''}
|
||||
|
|
@ -3921,12 +4111,12 @@ function renderCloudRestoreModalList() {
|
|||
|
||||
const btnRestoreDb = card.querySelector('.btn-modal-restore-db');
|
||||
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');
|
||||
if (btnRestoreJson) {
|
||||
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr, btnRestoreJson));
|
||||
btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr));
|
||||
}
|
||||
|
||||
elements.cloudRestoreFilesContainer.appendChild(card);
|
||||
|
|
@ -3935,72 +4125,35 @@ function renderCloudRestoreModalList() {
|
|||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) {
|
||||
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
|
||||
function handleRestoreDirectCloudFile(snap, format, dateLabel) {
|
||||
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 filename = format === 'json' ? snap.filenameJson : snap.filenameDb;
|
||||
|
||||
let res;
|
||||
if (gdriveId) {
|
||||
res = await fetch('/api/admin/backups/restore-cloud-file', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename })
|
||||
openScopedRestoreModal({
|
||||
mode: 'direct',
|
||||
provider: 'gdrive',
|
||||
fileId: gdriveId,
|
||||
filename,
|
||||
titleLabel: `Google Drive (${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 })
|
||||
openScopedRestoreModal({
|
||||
mode: 'direct',
|
||||
provider: 's3',
|
||||
objectKey: s3Key,
|
||||
filename,
|
||||
titleLabel: `Wasabi S3 (${dateLabel} · .${format.toUpperCase()})`
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ format })
|
||||
openScopedRestoreModal({
|
||||
mode: 'history',
|
||||
historyId: snap.id,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -574,6 +574,57 @@
|
|||
</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 -->
|
||||
<div class="modal-overlay" id="save-modal">
|
||||
<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
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue