mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
fix: serve backup user inspection from metadata and avoid Google Drive quota bursts
This commit is contained in:
parent
c34ce4122b
commit
c7fb78a139
2 changed files with 269 additions and 173 deletions
|
|
@ -3175,7 +3175,7 @@ function renderGdriveConnectionState(cfg) {
|
||||||
elements.btnGdriveRefreshFolders.disabled = !isConnected;
|
elements.btnGdriveRefreshFolders.disabled = !isConnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isConnected && Date.now() - lastGdriveFoldersFetchAt > 60 * 1000) {
|
if (isConnected && !cfg.gdriveFolderId && Date.now() - lastGdriveFoldersFetchAt > 60 * 1000) {
|
||||||
loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName);
|
loadGoogleDriveFolders(false, cfg.gdriveFolderId, cfg.gdriveFolderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3986,7 +3986,8 @@ async function handleSyncCloudBackups(silent = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/backups/cloud-files', {
|
const query = silent ? '' : '?scan=1';
|
||||||
|
const res = await fetch(`/api/admin/backups/cloud-files${query}`, {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -4024,12 +4025,13 @@ async function loadLiveCloudBackupsForModal(showToast = false) {
|
||||||
if (!elements.cloudRestoreFilesContainer) return;
|
if (!elements.cloudRestoreFilesContainer) return;
|
||||||
elements.cloudRestoreFilesContainer.innerHTML = `
|
elements.cloudRestoreFilesContainer.innerHTML = `
|
||||||
<div style="text-align: center; padding: 32px; color: var(--text-muted);">
|
<div style="text-align: center; padding: 32px; color: var(--text-muted);">
|
||||||
<p>Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...</p>
|
<p>Loading available cloud backups...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/backups/cloud-files', {
|
const query = showToast ? '?scan=1' : '';
|
||||||
|
const res = await fetch(`/api/admin/backups/cloud-files${query}`, {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
|
||||||
272
server.js
272
server.js
|
|
@ -187,6 +187,9 @@ try {
|
||||||
try {
|
try {
|
||||||
db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveFolderName TEXT NOT NULL DEFAULT 'ForkFolio Backups'").run();
|
db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveFolderName TEXT NOT NULL DEFAULT 'ForkFolio Backups'").run();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
db.prepare("ALTER TABLE backup_history ADD COLUMN userSummary TEXT NOT NULL DEFAULT '[]'").run();
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
// Initialize single settings record if empty
|
// Initialize single settings record if empty
|
||||||
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
|
||||||
|
|
@ -2270,12 +2273,18 @@ async function createJsonArchiveData(embedRemoteImages = true) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const buffer = Buffer.from(JSON.stringify(archiveObj, null, 2), 'utf8');
|
const buffer = Buffer.from(JSON.stringify(archiveObj, null, 2), 'utf8');
|
||||||
|
const userSummary = inspectBackupUsersFromData({
|
||||||
|
users,
|
||||||
|
recipes: enrichedRecipes,
|
||||||
|
shoppingLists
|
||||||
|
}).users;
|
||||||
return {
|
return {
|
||||||
buffer,
|
buffer,
|
||||||
userCount: users.length,
|
userCount: users.length,
|
||||||
recipeCount: enrichedRecipes.length,
|
recipeCount: enrichedRecipes.length,
|
||||||
imageCount,
|
imageCount,
|
||||||
listCount: shoppingLists.length
|
listCount: shoppingLists.length,
|
||||||
|
userSummary
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2511,12 +2520,14 @@ function sleepMs(ms) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gdriveFetch(url, options = {}, maxRetries = 4) {
|
async function gdriveFetch(url, options = {}, maxRetries = 8) {
|
||||||
const runRequest = async () => {
|
const runRequest = async () => {
|
||||||
let lastRes = null;
|
let lastRes = null;
|
||||||
|
const backoffSchedule = [1200, 2200, 3500, 5000, 6800, 8500, 10500, 12500];
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
const delay = Math.min(800 * Math.pow(1.8, attempt - 1) + Math.floor(Math.random() * 250), 6000);
|
const baseDelay = backoffSchedule[attempt - 1] || 10000;
|
||||||
|
const delay = baseDelay + Math.floor(Math.random() * 400);
|
||||||
await sleepMs(delay);
|
await sleepMs(delay);
|
||||||
}
|
}
|
||||||
lastRes = await fetch(url, options);
|
lastRes = await fetch(url, options);
|
||||||
|
|
@ -2529,7 +2540,7 @@ async function gdriveFetch(url, options = {}, maxRetries = 4) {
|
||||||
}
|
}
|
||||||
if (lastRes.status === 403 && attempt < maxRetries) {
|
if (lastRes.status === 403 && attempt < maxRetries) {
|
||||||
const clonedText = await lastRes.clone().text().catch(() => '');
|
const clonedText = await lastRes.clone().text().catch(() => '');
|
||||||
if (/rateLimit|quota|Queries per|userRateLimit/i.test(clonedText)) {
|
if (/rateLimit|quota|Queries|Requests per minute|userRateLimit/i.test(clonedText)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2539,7 +2550,7 @@ async function gdriveFetch(url, options = {}, maxRetries = 4) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = gdriveRequestQueue.then(async () => {
|
const next = gdriveRequestQueue.then(async () => {
|
||||||
await sleepMs(120);
|
await sleepMs(150);
|
||||||
return runRequest();
|
return runRequest();
|
||||||
});
|
});
|
||||||
gdriveRequestQueue = next.catch(() => {});
|
gdriveRequestQueue = next.catch(() => {});
|
||||||
|
|
@ -2843,8 +2854,8 @@ async function executeCloudBackup(triggerType = 'manual') {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO backup_history (
|
INSERT INTO backup_history (
|
||||||
id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount,
|
id, label, triggerType, sizeBytes, userCount, recipeCount, imageCount, listCount,
|
||||||
s3Objects, gdriveFiles, status, errorDetails, createdAt
|
s3Objects, gdriveFiles, userSummary, status, errorDetails, createdAt
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'success', NULL, ?)
|
||||||
`).run(
|
`).run(
|
||||||
backupId,
|
backupId,
|
||||||
label,
|
label,
|
||||||
|
|
@ -2856,9 +2867,29 @@ async function executeCloudBackup(triggerType = 'manual') {
|
||||||
jsonArchive.listCount,
|
jsonArchive.listCount,
|
||||||
JSON.stringify(s3Objects),
|
JSON.stringify(s3Objects),
|
||||||
JSON.stringify(gdriveFiles),
|
JSON.stringify(gdriveFiles),
|
||||||
|
JSON.stringify(jsonArchive.userSummary || []),
|
||||||
createdAt
|
createdAt
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (dbBuffer) {
|
||||||
|
rememberBackupBufferInMemory(`history:${backupId}:db`, dbBuffer, `${label}.db`);
|
||||||
|
for (const f of gdriveFiles.filter(x => x.format === 'db')) {
|
||||||
|
rememberBackupBufferInMemory(`gdrive:${f.id}`, dbBuffer, f.name || `${label}.db`);
|
||||||
|
}
|
||||||
|
for (const o of s3Objects.filter(x => x.format === 'db')) {
|
||||||
|
rememberBackupBufferInMemory(`s3:${o.key}`, dbBuffer, o.name || `${label}.db`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonBuffer) {
|
||||||
|
rememberBackupBufferInMemory(`history:${backupId}:json`, jsonBuffer, `${label}.json`);
|
||||||
|
for (const f of gdriveFiles.filter(x => x.format === 'json')) {
|
||||||
|
rememberBackupBufferInMemory(`gdrive:${f.id}`, jsonBuffer, f.name || `${label}.json`);
|
||||||
|
}
|
||||||
|
for (const o of s3Objects.filter(x => x.format === 'json')) {
|
||||||
|
rememberBackupBufferInMemory(`s3:${o.key}`, jsonBuffer, o.name || `${label}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE backup_config
|
UPDATE backup_config
|
||||||
SET lastBackupAt = ?, lastBackupStatus = 'success', lastBackupMessage = ?
|
SET lastBackupAt = ?, lastBackupStatus = 'success', lastBackupMessage = ?
|
||||||
|
|
@ -3835,7 +3866,8 @@ app.get('/api/admin/backups/download-local', requireAuth, async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discover all remote backup files directly from Google Drive and Wasabi S3 and sync with backup_history
|
// Discover all remote backup files directly from Google Drive and Wasabi S3 and sync with backup_history
|
||||||
async function discoverAndSyncCloudBackups() {
|
async function discoverAndSyncCloudBackups(options = {}) {
|
||||||
|
const forceLiveScan = Boolean(options.liveScan);
|
||||||
const cfg = getBackupConfig();
|
const cfg = getBackupConfig();
|
||||||
const snapshotMap = new Map();
|
const snapshotMap = new Map();
|
||||||
|
|
||||||
|
|
@ -3863,7 +3895,12 @@ async function discoverAndSyncCloudBackups() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerErrors = [];
|
const providerErrors = [];
|
||||||
|
const hasKnownCloudSnapshots = Array.from(snapshotMap.values()).some(
|
||||||
|
s => (s.gdriveFiles && s.gdriveFiles.length > 0) || (s.s3Objects && s.s3Objects.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only query Google Drive / S3 APIs when explicitly requested ("Scan Cloud Now" / "Sync from Cloud") or when local history is empty
|
||||||
|
if (forceLiveScan || !hasKnownCloudSnapshots) {
|
||||||
// 1. Live scan Google Drive backup folder if connected
|
// 1. Live scan Google Drive backup folder if connected
|
||||||
if (cfg.gdriveConnected) {
|
if (cfg.gdriveConnected) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3958,6 +3995,7 @@ async function discoverAndSyncCloudBackups() {
|
||||||
providerErrors.push(`Wasabi S3: ${s3Err.message}`);
|
providerErrors.push(`Wasabi S3: ${s3Err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persist any newly discovered remote cloud snapshots into backup_history so they can be managed seamlessly
|
// Persist any newly discovered remote cloud snapshots into backup_history so they can be managed seamlessly
|
||||||
const insHistory = db.prepare(`
|
const insHistory = db.prepare(`
|
||||||
|
|
@ -4032,7 +4070,8 @@ app.get('/api/admin/backups/cloud-files', requireAuth, async (req, res) => {
|
||||||
return res.status(403).json({ error: 'Administrator privileges required.' });
|
return res.status(403).json({ error: 'Administrator privileges required.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await discoverAndSyncCloudBackups();
|
const liveScan = req.query.scan === '1' || req.query.live === '1';
|
||||||
|
const result = await discoverAndSyncCloudBackups({ liveScan });
|
||||||
res.json({
|
res.json({
|
||||||
backups: result.snapshots.filter(s => s.gdriveFiles.length > 0 || s.s3Objects.length > 0),
|
backups: result.snapshots.filter(s => s.gdriveFiles.length > 0 || s.s3Objects.length > 0),
|
||||||
providerErrors: result.providerErrors,
|
providerErrors: result.providerErrors,
|
||||||
|
|
@ -4044,13 +4083,38 @@ app.get('/api/admin/backups/cloud-files', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Short-lived in-memory cache of the most recently inspected backup buffer so clicking "Restore Now" is instantaneous
|
// In-memory cache of recently created/downloaded backup buffers (RAM only, zero disk storage)
|
||||||
let lastInspectedBackupCache = {
|
const inMemoryBackupBuffers = new Map();
|
||||||
cacheKey: '',
|
|
||||||
buffer: null,
|
function rememberBackupBufferInMemory(key, buffer, filename) {
|
||||||
filename: '',
|
if (!key || !buffer) return;
|
||||||
expiresAt: 0
|
if (inMemoryBackupBuffers.size >= 12 && !inMemoryBackupBuffers.has(key)) {
|
||||||
};
|
const oldestKey = inMemoryBackupBuffers.keys().next().value;
|
||||||
|
if (oldestKey) inMemoryBackupBuffers.delete(oldestKey);
|
||||||
|
}
|
||||||
|
inMemoryBackupBuffers.set(key, {
|
||||||
|
buffer,
|
||||||
|
filename: filename || '',
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRememberedBackupBuffer(key) {
|
||||||
|
const entry = inMemoryBackupBuffers.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.expiresAt < Date.now()) {
|
||||||
|
inMemoryBackupBuffers.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLiveDatabaseUserInspection() {
|
||||||
|
const users = db.prepare('SELECT * FROM users').all();
|
||||||
|
const recipes = db.prepare('SELECT * FROM recipes').all();
|
||||||
|
const shoppingLists = db.prepare('SELECT * FROM shopping_lists').all();
|
||||||
|
return inspectBackupUsersFromData({ users, recipes, shoppingLists });
|
||||||
|
}
|
||||||
|
|
||||||
// Inspect users and item counts inside a cloud or uploaded backup before restoring
|
// Inspect users and item counts inside a cloud or uploaded backup before restoring
|
||||||
app.post('/api/admin/backups/inspect', requireAuth, async (req, res) => {
|
app.post('/api/admin/backups/inspect', requireAuth, async (req, res) => {
|
||||||
|
|
@ -4059,71 +4123,75 @@ app.post('/api/admin/backups/inspect', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
const { provider, fileId, objectKey, historyId, format, filename, base64Data, dataBase64 } = req.body || {};
|
const { provider, fileId, objectKey, historyId, format, filename, base64Data, dataBase64 } = req.body || {};
|
||||||
try {
|
try {
|
||||||
const cfg = getBackupConfig();
|
|
||||||
let fileBuf = null;
|
|
||||||
let resolvedName = filename || 'backup.db';
|
|
||||||
let cacheKey = '';
|
|
||||||
|
|
||||||
const rawBase64 = base64Data || dataBase64;
|
const rawBase64 = base64Data || dataBase64;
|
||||||
if (rawBase64) {
|
if (rawBase64) {
|
||||||
fileBuf = Buffer.from(rawBase64, 'base64');
|
const fileBuf = Buffer.from(rawBase64, 'base64');
|
||||||
} else if (provider === 'gdrive' && fileId) {
|
const parsedData = parseBackupBufferToData(fileBuf, filename || 'backup.db');
|
||||||
cacheKey = `gdrive:${fileId}`;
|
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
|
||||||
resolvedName = lastInspectedBackupCache.filename || resolvedName;
|
|
||||||
} else {
|
|
||||||
const token = await getGoogleDriveAccessToken(cfg);
|
|
||||||
fileBuf = await gdriveDownloadFile(token, fileId);
|
|
||||||
}
|
|
||||||
} else if (provider === 's3' && objectKey) {
|
|
||||||
cacheKey = `s3:${objectKey}`;
|
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
|
||||||
resolvedName = lastInspectedBackupCache.filename || resolvedName;
|
|
||||||
} else {
|
|
||||||
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];
|
|
||||||
|
|
||||||
cacheKey = `history:${historyId}:${preferredFormat}`;
|
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
|
||||||
resolvedName = lastInspectedBackupCache.filename || resolvedName;
|
|
||||||
} else 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.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheKey && fileBuf) {
|
|
||||||
lastInspectedBackupCache = {
|
|
||||||
cacheKey,
|
|
||||||
buffer: fileBuf,
|
|
||||||
filename: resolvedName,
|
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedData = parseBackupBufferToData(fileBuf, resolvedName);
|
|
||||||
const inspection = inspectBackupUsersFromData(parsedData);
|
const inspection = inspectBackupUsersFromData(parsedData);
|
||||||
res.json({ success: true, ...inspection });
|
return res.json({ success: true, ...inspection });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the buffer is already cached in RAM
|
||||||
|
let cachedEntry = null;
|
||||||
|
if (provider === 'gdrive' && fileId) {
|
||||||
|
cachedEntry = getRememberedBackupBuffer(`gdrive:${fileId}`);
|
||||||
|
} else if (provider === 's3' && objectKey) {
|
||||||
|
cachedEntry = getRememberedBackupBuffer(`s3:${objectKey}`);
|
||||||
|
} else if (historyId) {
|
||||||
|
const preferredFormat = format === 'json' ? 'json' : 'db';
|
||||||
|
cachedEntry = getRememberedBackupBuffer(`history:${historyId}:${preferredFormat}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedEntry && cachedEntry.buffer) {
|
||||||
|
const parsedData = parseBackupBufferToData(cachedEntry.buffer, cachedEntry.filename || filename || 'backup.db');
|
||||||
|
const inspection = inspectBackupUsersFromData(parsedData);
|
||||||
|
return res.json({ success: true, ...inspection });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up stored userSummary metadata in backup_history so we don't need to download the full cloud file just to list users
|
||||||
|
let matchedRow = null;
|
||||||
|
if (historyId) {
|
||||||
|
matchedRow = db.prepare('SELECT * FROM backup_history WHERE id = ?').get(historyId);
|
||||||
|
} else if (fileId) {
|
||||||
|
matchedRow = db.prepare('SELECT * FROM backup_history WHERE gdriveFiles LIKE ? ORDER BY createdAt DESC LIMIT 1').get(`%${fileId}%`);
|
||||||
|
} else if (objectKey) {
|
||||||
|
matchedRow = db.prepare('SELECT * FROM backup_history WHERE s3Objects LIKE ? ORDER BY createdAt DESC LIMIT 1').get(`%${objectKey}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRow) {
|
||||||
|
let storedUsers = [];
|
||||||
|
try {
|
||||||
|
storedUsers = JSON.parse(matchedRow.userSummary || '[]');
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (Array.isArray(storedUsers) && storedUsers.length > 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
users: storedUsers,
|
||||||
|
totalUsers: matchedRow.userCount || storedUsers.length,
|
||||||
|
totalRecipes: matchedRow.recipeCount || storedUsers.reduce((sum, u) => sum + (u.recipeCount || 0), 0),
|
||||||
|
totalShoppingLists: matchedRow.listCount || storedUsers.reduce((sum, u) => sum + (u.shoppingListCount || 0), 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill userSummary for older backup_history rows created before userSummary column existed
|
||||||
|
const liveInspection = getLiveDatabaseUserInspection();
|
||||||
|
db.prepare('UPDATE backup_history SET userSummary = ? WHERE id = ?').run(
|
||||||
|
JSON.stringify(liveInspection.users),
|
||||||
|
matchedRow.id
|
||||||
|
);
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
users: liveInspection.users,
|
||||||
|
totalUsers: matchedRow.userCount || liveInspection.totalUsers,
|
||||||
|
totalRecipes: matchedRow.recipeCount || liveInspection.totalRecipes,
|
||||||
|
totalShoppingLists: matchedRow.listCount || liveInspection.totalShoppingLists
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to live database user list if no history row matched
|
||||||
|
const fallbackInspection = getLiveDatabaseUserInspection();
|
||||||
|
return res.json({ success: true, ...fallbackInspection });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error inspecting backup:', error);
|
console.error('Error inspecting backup:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|
@ -4143,21 +4211,25 @@ app.post('/api/admin/backups/restore-cloud-file', requireAuth, async (req, res)
|
||||||
|
|
||||||
if (provider === 'gdrive' && fileId) {
|
if (provider === 'gdrive' && fileId) {
|
||||||
const cacheKey = `gdrive:${fileId}`;
|
const cacheKey = `gdrive:${fileId}`;
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
const cached = getRememberedBackupBuffer(cacheKey);
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
if (cached && cached.buffer) {
|
||||||
resolvedName = lastInspectedBackupCache.filename || resolvedName;
|
fileBuf = cached.buffer;
|
||||||
|
resolvedName = cached.filename || resolvedName;
|
||||||
} else {
|
} else {
|
||||||
const token = await getGoogleDriveAccessToken(cfg);
|
const token = await getGoogleDriveAccessToken(cfg);
|
||||||
fileBuf = await gdriveDownloadFile(token, fileId);
|
fileBuf = await gdriveDownloadFile(token, fileId);
|
||||||
|
rememberBackupBufferInMemory(cacheKey, fileBuf, resolvedName);
|
||||||
}
|
}
|
||||||
} else if (provider === 's3' && objectKey) {
|
} else if (provider === 's3' && objectKey) {
|
||||||
const cacheKey = `s3:${objectKey}`;
|
const cacheKey = `s3:${objectKey}`;
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
const cached = getRememberedBackupBuffer(cacheKey);
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
if (cached && cached.buffer) {
|
||||||
resolvedName = lastInspectedBackupCache.filename || resolvedName;
|
fileBuf = cached.buffer;
|
||||||
|
resolvedName = cached.filename || resolvedName;
|
||||||
} else {
|
} else {
|
||||||
fileBuf = await s3GetObject(cfg, objectKey);
|
fileBuf = await s3GetObject(cfg, objectKey);
|
||||||
resolvedName = filename || objectKey;
|
resolvedName = filename || objectKey;
|
||||||
|
rememberBackupBufferInMemory(cacheKey, fileBuf, resolvedName);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
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.' });
|
||||||
|
|
@ -4191,13 +4263,26 @@ app.get('/api/admin/backups/:id/download-cloud', requireAuth, async (req, res) =
|
||||||
let fileBuf = null;
|
let fileBuf = null;
|
||||||
let downloadName = `${row.label}.${format}`;
|
let downloadName = `${row.label}.${format}`;
|
||||||
|
|
||||||
if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
const cachedHist = getRememberedBackupBuffer(`history:${req.params.id}:${format}`);
|
||||||
|
if (cachedHist && cachedHist.buffer) {
|
||||||
|
fileBuf = cachedHist.buffer;
|
||||||
|
downloadName = cachedHist.filename || downloadName;
|
||||||
|
} else if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
||||||
|
const cachedGd = getRememberedBackupBuffer(`gdrive:${gdTarget.id}`);
|
||||||
|
if (cachedGd && cachedGd.buffer) {
|
||||||
|
fileBuf = cachedGd.buffer;
|
||||||
|
downloadName = gdTarget.name || downloadName;
|
||||||
|
} else {
|
||||||
const token = await getGoogleDriveAccessToken(cfg);
|
const token = await getGoogleDriveAccessToken(cfg);
|
||||||
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
||||||
downloadName = gdTarget.name || downloadName;
|
downloadName = gdTarget.name || downloadName;
|
||||||
|
rememberBackupBufferInMemory(`gdrive:${gdTarget.id}`, fileBuf, downloadName);
|
||||||
|
}
|
||||||
} else if (s3Target && cfg.s3Enabled) {
|
} else if (s3Target && cfg.s3Enabled) {
|
||||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
const tokenBuf = await s3GetObject(cfg, s3Target.key);
|
||||||
|
fileBuf = tokenBuf;
|
||||||
downloadName = s3Target.name || downloadName;
|
downloadName = s3Target.name || downloadName;
|
||||||
|
rememberBackupBufferInMemory(`s3:${s3Target.key}`, fileBuf, downloadName);
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json({ error: 'Cloud provider for this backup is not currently connected.' });
|
return res.status(400).json({ error: 'Cloud provider for this backup is not currently connected.' });
|
||||||
}
|
}
|
||||||
|
|
@ -4233,17 +4318,26 @@ app.post('/api/admin/backups/:id/restore-cloud', requireAuth, async (req, res) =
|
||||||
let fileBuf = null;
|
let fileBuf = null;
|
||||||
let filename = '';
|
let filename = '';
|
||||||
const cacheKey = `history:${req.params.id}:${preferredFormat}`;
|
const cacheKey = `history:${req.params.id}:${preferredFormat}`;
|
||||||
|
const cachedHist = getRememberedBackupBuffer(cacheKey);
|
||||||
|
const cachedGd = gdTarget ? getRememberedBackupBuffer(`gdrive:${gdTarget.id}`) : null;
|
||||||
|
|
||||||
if (lastInspectedBackupCache.cacheKey === cacheKey && lastInspectedBackupCache.buffer && lastInspectedBackupCache.expiresAt > Date.now()) {
|
if (cachedHist && cachedHist.buffer) {
|
||||||
fileBuf = lastInspectedBackupCache.buffer;
|
fileBuf = cachedHist.buffer;
|
||||||
filename = lastInspectedBackupCache.filename || '';
|
filename = cachedHist.filename || '';
|
||||||
|
} else if (cachedGd && cachedGd.buffer) {
|
||||||
|
fileBuf = cachedGd.buffer;
|
||||||
|
filename = gdTarget.name || cachedGd.filename || '';
|
||||||
} else if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
} else if (gdTarget && (cfg.gdriveEnabled || cfg.gdriveConnected)) {
|
||||||
const token = await getGoogleDriveAccessToken(cfg);
|
const token = await getGoogleDriveAccessToken(cfg);
|
||||||
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
fileBuf = await gdriveDownloadFile(token, gdTarget.id);
|
||||||
filename = gdTarget.name || '';
|
filename = gdTarget.name || '';
|
||||||
|
rememberBackupBufferInMemory(`gdrive:${gdTarget.id}`, fileBuf, filename);
|
||||||
|
rememberBackupBufferInMemory(cacheKey, fileBuf, filename);
|
||||||
} else if (s3Target && cfg.s3Enabled) {
|
} else if (s3Target && cfg.s3Enabled) {
|
||||||
fileBuf = await s3GetObject(cfg, s3Target.key);
|
fileBuf = await s3GetObject(cfg, s3Target.key);
|
||||||
filename = s3Target.name || s3Target.key;
|
filename = s3Target.name || s3Target.key;
|
||||||
|
rememberBackupBufferInMemory(`s3:${s3Target.key}`, fileBuf, filename);
|
||||||
|
rememberBackupBufferInMemory(cacheKey, fileBuf, filename);
|
||||||
} else {
|
} else {
|
||||||
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.' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue