Files
picpeak/backend/src/services/archiveService.js
T
Paul Nothaft e4e79a0b3a 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`)
2026-06-18 22:18:54 +02:00

277 lines
11 KiB
JavaScript

const archiver = require('archiver');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { db } = require('../database/db');
const { queueEmail, getSupportEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const feedbackService = require('./feedbackService');
const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const {
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
async function archiveEvent(event) {
const storage = getStorage();
const archiveName = `${event.slug}.zip`;
const archiveRelKey = path.posix.join('events/archived', archiveName);
const eventPrefix = path.posix.join('events/active', event.slug);
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-archive-'));
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);
if (feedbackSettings.feedback_enabled) {
try {
logger.info(`Exporting feedback data for event ${event.slug}`);
const feedbackData = await feedbackService.exportEventFeedback(event.id);
if (feedbackData && feedbackData.length > 0) {
feedbackEntries.push({
name: 'feedback_data.json',
buffer: Buffer.from(JSON.stringify(feedbackData, null, 2), 'utf8'),
});
feedbackEntries.push({
name: 'feedback_data.csv',
buffer: Buffer.from(convertToCSV(feedbackData), 'utf8'),
});
const summary = await feedbackService.getEventFeedbackSummary(event.id);
feedbackEntries.push({
name: 'feedback_summary.json',
buffer: Buffer.from(JSON.stringify(summary, null, 2), 'utf8'),
});
logger.info(`Feedback data exported: ${feedbackData.length} entries`);
}
} catch (error) {
logger.error(`Error exporting feedback for event ${event.slug}:`, error);
// Continue with archiving even if feedback export fails
}
}
// Stream every photo (and any other content under events/active/{slug}/) into
// the zip directly from the storage backend.
const photoEntries = await storage.list(eventPrefix);
// #493: optionally rename zip entries to use original camera filenames.
// Build a Map<storage_key, original_filename> from the photos table so we
// can swap the basename of each entry while keeping the folder structure
// (e.g. `individual/DSC_1234.jpg` instead of `individual/slug_001.jpg`).
const useOriginal = await getUseOriginalFilenames();
const originalsByKey = new Map();
if (useOriginal) {
const photoRows = await db('photos').where('event_id', event.id).select('*');
for (const photoRow of photoRows) {
if (!photoRow.original_filename) continue;
try {
const key = resolvePhotoStorageKey(event, photoRow);
if (key) originalsByKey.set(key, photoRow.original_filename);
} catch {
// External-mode rows have no managed key; skip silently.
}
}
}
// Compute (subfolder, displayName) up front so collisions across the
// whole zip can be resolved deterministically with `_N` suffixes.
const photoNames = photoEntries.map((entry) => {
const rel = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
if (!useOriginal) return rel;
const originalBase = originalsByKey.get(entry.key);
if (!originalBase) return rel;
const sep = rel.lastIndexOf('/');
const folder = sep >= 0 ? rel.slice(0, sep + 1) : '';
return `${folder}${sanitizeForZipEntry(originalBase)}`;
});
const dedupedNames = uniquifyZipNames(photoNames);
let totalBytes = 0;
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpArchive);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => {
totalBytes = archive.pointer();
resolve();
});
archive.on('error', reject);
archive.pipe(output);
const append = async () => {
for (let i = 0; i < photoEntries.length; i += 1) {
const entry = photoEntries[i];
const nameInZip = dedupedNames[i];
const stream = await storage.get(entry.key);
archive.append(stream, { name: nameInZip });
}
for (const f of feedbackEntries) {
archive.append(f.buffer, { name: f.name });
}
if (photosManifestEntry) {
archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name });
}
archive.finalize();
};
append().catch(reject);
});
// Upload the finalized zip to the storage backend.
await storage.putFromFile(archiveRelKey, tmpArchive, { contentType: 'application/zip' });
logger.info(`Archive created: ${archiveName} (${totalBytes} bytes)`);
// Update DB BEFORE deleting originals so a crash mid-cleanup leaves the
// archive accessible rather than orphaning the photos.
await db('events').where('id', event.id).update({
is_archived: true,
archive_path: archiveRelKey,
archived_at: new Date(),
});
// Fire event.archived webhook (#327). Receivers infer per-photo loss
// from this event — we deliberately do NOT fire photo.deleted for each
// archived photo to avoid flooding subscribers on bulk archives.
// Canonical event subject (#341) so the shape matches event.created /
// event.published / event.expired; archive_path is an event.archived-
// specific extra.
try {
const webhookService = require('./webhookService');
await webhookService.fire('event.archived', {
event: {
...webhookService.buildEventSubject({
id: event.id,
slug: event.slug,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
share_token: event.share_token,
customer_name: event.customer_name || event.host_name,
customer_email: event.customer_email || event.host_email,
customer_phone: event.customer_phone,
}),
archive_path: archiveRelKey,
},
});
} catch (e) { /* non-fatal */ }
// Delete the originals from storage.
for (const entry of photoEntries) {
await storage.delete(entry.key).catch((err) =>
logger.warn(`Failed to delete archived original ${entry.key}: ${err.message}`)
);
}
// Delete derived images (thumbnails / heroes / previews / watermarks)
// for this event's photos. The originals are inside the zip; the
// derived tiers are throwaway and will be regenerated lazily on
// restore (or not at all for archived events that nobody opens).
const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) {
if (photo.thumbnail_path) {
await storage.delete(photo.thumbnail_path).catch(() => {});
}
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived
// semantics as thumbnails / heroes — wipe on archive.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {});
}
}
// Queue completion email — admin_email is nullable on events (migration 073);
// skip queueing rather than violating email_queue.recipient_email NOT NULL.
//
// The shipped EN/DE templates (legacy 028) and NL/PT/RU (core 075) reference
// {{host_name}}, {{photo_count}}, {{archive_date}} and {{support_email}};
// without these the recipient saw literal {{...}} placeholders.
if (event.admin_email) {
const supportEmail = await getSupportEmail();
await queueEmail(event.id, event.admin_email, 'archive_complete', {
host_name: event.customer_name || event.host_name || 'Admin',
event_name: event.event_name,
event_date: event.event_date,
photo_count: photoEntries.length,
archive_size: (totalBytes / 1024 / 1024).toFixed(2) + ' MB',
archive_date: new Date(),
support_email: supportEmail
});
} else {
logger.info(`Skipping archive_complete email for event ${event.slug}: no admin_email set`);
}
} catch (error) {
logger.error(`Error archiving event ${event.slug}:`, error);
throw error;
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
// Helper function to convert JSON to CSV
function convertToCSV(data) {
if (!data || data.length === 0) return '';
const headers = Object.keys(data[0]);
const csvHeaders = headers.join(',');
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
}).join(',');
});
return [csvHeaders, ...csvRows].join('\n');
}
module.exports = { archiveEvent };