diff --git a/backend/package-lock.json b/backend/package-lock.json index 04c4e230..ab876ed3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,17 +1,16 @@ { "name": "picpeak-backend", - "version": "3.60.6-beta.0", + "version": "3.65.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.60.6-beta.0", + "version": "3.65.1-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", - "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "1.15.2", "bcrypt": "6.0.0", @@ -39,6 +38,7 @@ "mime-types": "^3.0.1", "multer": "^2.0.2", "node-cron": "^3.0.2", + "node-stream-zip": "^1.15.0", "nodemailer": "^8.0.5", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", @@ -314,6 +314,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz", "integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -1042,6 +1043,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3804,6 +3806,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3821,15 +3824,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -4373,6 +4367,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5518,6 +5513,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5765,6 +5761,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -6791,6 +6788,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9058,6 +9056,19 @@ "dev": true, "license": "MIT" }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, "node_modules/nodemailer": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", @@ -9555,6 +9566,7 @@ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz", "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", "license": "MIT", + "peer": true, "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", @@ -10621,6 +10633,7 @@ "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", "license": "MIT", + "peer": true, "dependencies": { "parseley": "~0.13.1" }, diff --git a/backend/package.json b/backend/package.json index 5ac0ea74..9bac64d0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,6 @@ "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", - "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "1.15.2", "bcrypt": "6.0.0", @@ -45,6 +44,7 @@ "mime-types": "^3.0.1", "multer": "^2.0.2", "node-cron": "^3.0.2", + "node-stream-zip": "^1.15.0", "nodemailer": "^8.0.5", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.2", diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index d290e26e..b9735550 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -7,7 +7,7 @@ const { slugify } = require('../utils/slug'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const archiver = require('archiver'); -const AdmZip = require('adm-zip'); +const StreamZip = require('node-stream-zip'); const { requireEventOwnership } = require('../middleware/ownership'); const router = express.Router(); @@ -167,32 +167,62 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re // Extract the archive try { - const zip = new AdmZip(fullArchivePath); + // node-stream-zip streams each entry to disk on extract — adm-zip used + // to load the whole archive into a Node Buffer up front, which capped + // restore at 2 GiB (ERR_FS_FILE_TOO_LARGE). Real-world wedding archives + // routinely cross that line. Credit: 8digit/picpeak@69033c6. + const zip = new StreamZip.async({ file: fullArchivePath }); const eventsDir = path.join(storagePath, 'events/active'); const eventDir = path.join(eventsDir, archive.slug); - + // Create event directory if it doesn't exist await fs.mkdir(eventDir, { recursive: true }); - + // Log ZIP contents for debugging console.log(`Extracting archive to: ${eventDir}`); - const entries = zip.getEntries(); + const entries = Object.values(await zip.entries()); console.log(`Archive contains ${entries.length} entries`); - - // Extract files to the event directory - zip.extractAllTo(eventDir, true); - + + // Stream-extract everything to disk + await zip.extract(null, eventDir); + await zip.close(); + + // Load photos manifest if present. The gallery filenames are renamed on + // upload, so `original_filename` (and category linkage) can't be derived + // from the extracted files alone — they're only recoverable from the + // manifest the archive process writes. Older archives have no manifest; + // we fall back to filename for those. + const manifestByFilename = new Map(); + try { + const manifestRaw = await fs.readFile( + path.join(eventDir, 'photos_manifest.json'), 'utf8', + ); + const parsed = JSON.parse(manifestRaw); + if (Array.isArray(parsed)) { + for (const m of parsed) { + if (m && m.filename) manifestByFilename.set(m.filename, m); + } + } + console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`); + } catch (e) { + if (e.code !== 'ENOENT') { + console.warn('Photos manifest present but unreadable; falling back to filenames', e.message); + } else { + console.log('No photos manifest in archive (older archive); original_filename falls back to filename'); + } + } + // Get list of extracted files to update database const extractedPhotos = []; - + // First, collect all category information from the ZIP structure const categoriesMap = new Map(); - + for (const entry of entries) { - if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { - const filename = path.basename(entry.entryName); - const dirPath = path.dirname(entry.entryName); - const actualFilePath = path.join(eventDir, entry.entryName); + if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { + const filename = path.basename(entry.name); + const dirPath = path.dirname(entry.name); + const actualFilePath = path.join(eventDir, entry.name); try { // Check if file was extracted successfully @@ -239,10 +269,14 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re if (!existingPhoto) { // Store relative path from storage root const relativePath = path.relative(storagePath, actualFilePath); + const manifestEntry = manifestByFilename.get(filename); extractedPhotos.push({ event_id: archive.id, filename: filename, - original_filename: filename, + // Recover original_filename from the manifest if present; + // legacy archives without a manifest lose nothing (filename + // is what they had before). + original_filename: manifestEntry?.original_filename || filename, path: relativePath, thumbnail_path: null, // Will be regenerated by thumbnail service type: path.extname(filename).substring(1).toLowerCase(), @@ -253,7 +287,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re } } catch (statError) { console.error(`Failed to stat file: ${actualFilePath}`); - console.error(`Entry name was: ${entry.entryName}`); + console.error(`Entry name was: ${entry.name}`); console.error('Error:', statError.message); // Skip this file if we can't stat it continue; diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 9c3ef978..1d411f3e 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -26,6 +26,36 @@ async function archiveEvent(event) { const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`); try { + // Photos manifest — the gallery filenames are renamed on upload, so + // `original_filename` (and category linkage) can't be derived from the + // extracted files alone. Persisting a manifest inside the archive lets a + // future restore round-trip recover those fields. Falls back to bare + // filename for archives produced before this lands (see restore path). + let photosManifestEntry = null; + try { + const manifestRows = await db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', event.id) + .select( + 'photos.filename', + 'photos.original_filename', + 'photos.type', + 'photos.uploaded_at', + 'photo_categories.name as category_name', + ); + if (manifestRows.length > 0) { + photosManifestEntry = { + name: 'photos_manifest.json', + buffer: Buffer.from(JSON.stringify(manifestRows, null, 2), 'utf8'), + }; + logger.info(`Photos manifest prepared: ${manifestRows.length} entries`); + } + } catch (error) { + logger.error(`Error building photos manifest for event ${event.slug}:`, error); + // Non-fatal — restore will fall back to filename as original_filename + // for events archived without a manifest, same as the legacy behaviour. + } + // Collect feedback data first so it can be included as in-memory entries. const feedbackEntries = []; const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id); @@ -116,6 +146,9 @@ async function archiveEvent(event) { for (const f of feedbackEntries) { archive.append(f.buffer, { name: f.name }); } + if (photosManifestEntry) { + archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name }); + } archive.finalize(); };