e4e79a0b3a
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`)
277 lines
11 KiB
JavaScript
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 };
|