fix(archives): stream-extract restore for >2 GiB + preserve original_filename via manifest (#640)
Two related backup-integrity fixes from 8digit's fork (issue #640 items #3 + #4), bundled because they touch the same two files and ship better together than apart. ### Stream-extract restore for >2 GiB archives `adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap, so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and since the frontend `onError` toast is the generic "Something went wrong", the cause stays invisible. Real-world wedding archives routinely cross 2 GiB; affected restores have likely been silent failures. Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape: ```js const zip = new StreamZip.async({ file: archivePath }); const entries = Object.values(await zip.entries()); await zip.extract(null, eventDir); await zip.close(); ``` Re-import logic (photos, categories, sizes) unchanged; only field rename `entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6. ### Preserve `original_filename` via photos manifest Archive → restore round-trip currently loses `original_filename` (the post-#508 column tracking the camera-side name) because the gallery filenames are renamed on upload and can't be derived from the extracted files. This matters now that the Lightroom export (#623) depends on `original_filename` — a restored event lost that signal. - **`archiveService.js`**: writes `photos_manifest.json` into the archive containing per-photo `{filename, original_filename, type, uploaded_at, category_name}`. Non-fatal: a manifest write failure falls through to legacy behaviour (filename used as original_filename, same as before). - **`adminArchives.js`**: reads the manifest on restore, builds a `Map<filename → manifest>`, and assigns `original_filename = manifest?.original_filename || filename`. Archives produced before this lands have no manifest — restore logs a one-shot notice and falls back to filename, preserving backward compat. Credit: 8digit/picpeak@eb018aa. ### Deps - Removed `adm-zip ^0.5.16` - Added `node-stream-zip ^1.15.0` ### What's NOT in this PR 8digit's commit also fixed the production compose healthcheck (`curl` isn't in our Alpine image); that's already been addressed upstream in the meantime. The frontend `onError` swallow on the restore toast is a separate small follow-up. ### Test plan - [x] `node -c` on both files clean - [x] `node-stream-zip` async API verified at load time - [ ] Manual: archive a multi-GB event → restore → confirm photos re-import with original_filename preserved - [ ] Manual: restore an archive produced before this lands → confirm fallback to filename works (no manifest path crashes) - [ ] Manual: confirm the new photos_manifest.json is inside the generated archive (`unzip -l <archive>.zip | grep manifest`)
This commit is contained in:
Generated
+25
-12
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.60.6-beta.0",
|
"version": "3.65.1-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.60.6-beta.0",
|
"version": "3.65.1-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -39,6 +38,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -314,6 +314,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
|
||||||
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha1-browser": "5.2.0",
|
"@aws-crypto/sha1-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
@@ -1042,6 +1043,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3804,6 +3806,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3821,15 +3824,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -4373,6 +4367,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"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.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -5765,6 +5761,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -6791,6 +6788,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -9058,6 +9056,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
@@ -10621,6 +10633,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||||
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"parseley": "~0.13.1"
|
"parseley": "~0.13.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -45,6 +44,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { slugify } = require('../utils/slug');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const AdmZip = require('adm-zip');
|
const StreamZip = require('node-stream-zip');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
const { requireEventOwnership } = require('../middleware/ownership');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -167,32 +167,62 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
|
|
||||||
// Extract the archive
|
// Extract the archive
|
||||||
try {
|
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 eventsDir = path.join(storagePath, 'events/active');
|
||||||
const eventDir = path.join(eventsDir, archive.slug);
|
const eventDir = path.join(eventsDir, archive.slug);
|
||||||
|
|
||||||
// Create event directory if it doesn't exist
|
// Create event directory if it doesn't exist
|
||||||
await fs.mkdir(eventDir, { recursive: true });
|
await fs.mkdir(eventDir, { recursive: true });
|
||||||
|
|
||||||
// Log ZIP contents for debugging
|
// Log ZIP contents for debugging
|
||||||
console.log(`Extracting archive to: ${eventDir}`);
|
console.log(`Extracting archive to: ${eventDir}`);
|
||||||
const entries = zip.getEntries();
|
const entries = Object.values(await zip.entries());
|
||||||
console.log(`Archive contains ${entries.length} entries`);
|
console.log(`Archive contains ${entries.length} entries`);
|
||||||
|
|
||||||
// Extract files to the event directory
|
// Stream-extract everything to disk
|
||||||
zip.extractAllTo(eventDir, true);
|
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
|
// Get list of extracted files to update database
|
||||||
const extractedPhotos = [];
|
const extractedPhotos = [];
|
||||||
|
|
||||||
// First, collect all category information from the ZIP structure
|
// First, collect all category information from the ZIP structure
|
||||||
const categoriesMap = new Map();
|
const categoriesMap = new Map();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||||
const filename = path.basename(entry.entryName);
|
const filename = path.basename(entry.name);
|
||||||
const dirPath = path.dirname(entry.entryName);
|
const dirPath = path.dirname(entry.name);
|
||||||
const actualFilePath = path.join(eventDir, entry.entryName);
|
const actualFilePath = path.join(eventDir, entry.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if file was extracted successfully
|
// Check if file was extracted successfully
|
||||||
@@ -239,10 +269,14 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
if (!existingPhoto) {
|
if (!existingPhoto) {
|
||||||
// Store relative path from storage root
|
// Store relative path from storage root
|
||||||
const relativePath = path.relative(storagePath, actualFilePath);
|
const relativePath = path.relative(storagePath, actualFilePath);
|
||||||
|
const manifestEntry = manifestByFilename.get(filename);
|
||||||
extractedPhotos.push({
|
extractedPhotos.push({
|
||||||
event_id: archive.id,
|
event_id: archive.id,
|
||||||
filename: filename,
|
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,
|
path: relativePath,
|
||||||
thumbnail_path: null, // Will be regenerated by thumbnail service
|
thumbnail_path: null, // Will be regenerated by thumbnail service
|
||||||
type: path.extname(filename).substring(1).toLowerCase(),
|
type: path.extname(filename).substring(1).toLowerCase(),
|
||||||
@@ -253,7 +287,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
|||||||
}
|
}
|
||||||
} catch (statError) {
|
} catch (statError) {
|
||||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
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);
|
console.error('Error:', statError.message);
|
||||||
// Skip this file if we can't stat it
|
// Skip this file if we can't stat it
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -26,6 +26,36 @@ async function archiveEvent(event) {
|
|||||||
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
|
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
|
||||||
|
|
||||||
try {
|
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.
|
// Collect feedback data first so it can be included as in-memory entries.
|
||||||
const feedbackEntries = [];
|
const feedbackEntries = [];
|
||||||
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
||||||
@@ -116,6 +146,9 @@ async function archiveEvent(event) {
|
|||||||
for (const f of feedbackEntries) {
|
for (const f of feedbackEntries) {
|
||||||
archive.append(f.buffer, { name: f.name });
|
archive.append(f.buffer, { name: f.name });
|
||||||
}
|
}
|
||||||
|
if (photosManifestEntry) {
|
||||||
|
archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name });
|
||||||
|
}
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user