From 2b66f6d889f94848202faf02116efa94ade9bd12 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:37:17 +0200 Subject: [PATCH] feat(backup): upload + restore endpoint for .picpeak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/backup/picpeak/import — multipart upload of a .picpeak, streamed to a temp file (after auth, so unauthenticated requests can't push a large file to disk), then restored via picpeakImportService with currentAdminId = the logged-in operator (preserved across the override). Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to reconfigure the external-media mount. Temp upload is always unlinked. Completes the backend half of the GUI-only roundtrip (export download + import upload). Multipart is already allowed by the CSRF content-type guard. --- backend/src/routes/adminBackup.js | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index e7904a2a..d58a7751 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -158,6 +158,44 @@ router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), asy } }); +// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER +// auth so an unauthenticated request can't push a large file to disk. +const os = require('os'); +const multer = require('multer'); +const picpeakUpload = multer({ + storage: multer.diskStorage({ + destination: (req, file, cb) => cb(null, os.tmpdir()), + filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`), + }), + limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large +}); + +// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of +// all data except the current logged-in account (the client shows an explicit +// confirmation before calling this). Returns `usesExternalMedia` so the UI can +// prompt the admin to reconfigure the external-media mount afterwards. +router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => { + const fsSync = require('fs'); + if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' }); + const picpeakPath = req.file.path; + try { + const { importFromPicpeak } = require('../services/picpeakImportService'); + const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id }); + res.json({ + success: true, + tables: result.tables, + filesRestored: result.filesRestored, + usesExternalMedia: result.usesExternalMedia, + }); + } catch (error) { + const status = error.statusCode || 500; + logger.error('[picpeak-import] restore failed', { error: error.message }); + res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation }); + } finally { + fsSync.unlink(picpeakPath, () => {}); + } +}); + // Get backup run details router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try {