Merge pull request #455 from the-luap/fix/photo-dimensions-and-default-fit

fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
This commit is contained in:
Paul Nothaft
2026-05-11 10:07:07 +02:00
committed by GitHub
15 changed files with 194 additions and 21 deletions
@@ -1,5 +1,5 @@
/**
* Migration 094: Add `customerPortal` to feature_flags.
* Migration 095: Add `customerPortal` to feature_flags.
*
* The customer portal (#354) is the foundation feature for the
* customer-side UI surface — login, dashboard, profile, password reset,
@@ -0,0 +1,102 @@
/**
* Migration: Backfill photo dimensions (v2)
*
* Re-runs the dimension backfill from migration 064 for any rows that are
* still NULL. Migration 064 only ran once at upgrade time; new photos
* imported via fileWatcher.js or s3AutoImporter.js between then and now
* had their width/height columns left NULL because those code paths did
* not capture metadata on insert. This PR fixes both writers, but
* pre-existing rows still need a backfill — that is what this does.
*
* Without dimensions, MasonryGalleryLayout falls back to a hard-coded
* 800×600 default, which is why every card in masonry mode looks like
* the same 4:3 box (#447).
*
* Local-fs only — S3 deployments cannot read source objects in a
* migration without instantiating the storage backend. Those
* deployments rely on the writer fix in s3AutoImporter.js for new
* photos and can run a one-shot script if a backfill is needed.
*/
const path = require('path');
const fs = require('fs');
exports.up = async function(knex) {
const hasWidth = await knex.schema.hasColumn('photos', 'width');
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (!hasWidth || !hasHeight) {
console.log('[Migration 090] width/height columns not present, skipping');
return;
}
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
if (backend !== 'local') {
console.log(`[Migration 090] STORAGE_BACKEND=${backend} — backfill skipped (S3 deployments not supported in-migration)`);
return;
}
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const photos = await knex('photos')
.where(function () {
this.whereNull('width').orWhereNull('height');
})
.andWhere(function () {
// Skip videos — sharp can't handle them; they need ffprobe.
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.select('id', 'path', 'filename');
if (photos.length === 0) {
console.log('[Migration 090] no photos missing dimensions');
return;
}
console.log(`[Migration 090] backfilling ${photos.length} photos`);
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.error('[Migration 090] sharp unavailable, skipping:', err.message);
return;
}
let updated = 0;
let failed = 0;
for (const photo of photos) {
try {
if (!photo.path) {
failed++;
continue;
}
const fullPath = path.join(storagePath, 'events/active', photo.path);
if (!fs.existsSync(fullPath)) {
failed++;
continue;
}
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await knex('photos').where('id', photo.id).update({
width: metadata.width,
height: metadata.height,
});
updated++;
if (updated % 100 === 0) {
console.log(`[Migration 090] ${updated}/${photos.length}`);
}
} else {
failed++;
}
} catch (err) {
console.error(`[Migration 090] photo ${photo.id}: ${err.message}`);
failed++;
}
}
console.log(`[Migration 090] done — ${updated} updated, ${failed} skipped`);
};
exports.down = async function() {
// Data-only migration; no rollback action.
};
+6
View File
@@ -48,6 +48,12 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// ---- login -------------------------------------------------------------
// The customerPortal feature flag deliberately does NOT gate this route.
// Flipping the master toggle off in Settings → Features hides the admin
// UI surface (sidebar entry, /admin/customers page) but does not revoke
// access for customers who already accepted an invitation. To lock out
// existing customers, deactivate their accounts individually
// (customer_accounts.is_active = false) — which IS enforced below.
router.post('/login', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(),
@@ -220,6 +220,12 @@ async function acceptInvitation({ token, name, password, profile }) {
country_code: merged.country_code || null,
password_hash: passwordHash,
is_active: formatBoolean(true),
// must_change_password is decorative today — accept-invite always
// sets a customer-chosen password, so this flag is never true and
// customerAuth doesn't read it. TODO when we ship an "admin
// pre-loads a temporary password" flow: surface a code in the
// login response (mirroring adminAuth's MUST_CHANGE_PASSWORD) and
// add a /change-password gate to customerAuth.
must_change_password: formatBoolean(false),
// Leave password_changed_at NULL on initial accept. Setting it here
// creates a millisecond/second-rounding race with the JWT issued
+20 -2
View File
@@ -1,6 +1,7 @@
const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
@@ -91,7 +92,23 @@ async function processNewPhoto(filePath) {
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Capture image dimensions so aspect-aware layouts (masonry / mosaic /
// justified) can size each card to the photo's real proportions
// instead of the 800×600 fallback in MasonryGalleryLayout (#447).
// Skip videos — those would need ffprobe.
let dimensions = null;
if (!isVideo) {
try {
const metadata = await sharp(filePath).metadata();
if (metadata.width && metadata.height) {
dimensions = { width: metadata.width, height: metadata.height };
}
} catch (err) {
logger.debug(`Could not read image dimensions for ${filename}: ${err.message}`);
}
}
// Check if photo already exists (by filename or path, to handle replacements)
const existingPhoto = await db('photos')
.where({ event_id: event.id })
@@ -110,7 +127,8 @@ async function processNewPhoto(filePath) {
thumbnail_path: relativeThumbPath,
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType
mime_type: mimeType,
...(dimensions && { width: dimensions.width, height: dimensions.height })
}).returning('id');
const photoId = insertResult[0]?.id || insertResult[0];
+7 -1
View File
@@ -15,7 +15,13 @@ sharp.concurrency(2); // Limit concurrent operations
// Default thumbnail settings
const DEFAULT_THUMBNAIL_WIDTH = 300;
const DEFAULT_THUMBNAIL_HEIGHT = 300;
const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops
// 'inside' preserves the source aspect ratio (output ≤ width × height).
// This is the right default for masonry / mosaic / justified layouts —
// the gallery sizes each card from photo.width/height and renders the
// thumbnail with object-cover, so a thumb that already matches the
// source aspect doesn't get re-cropped (#447). Admins who want
// uniform 1:1 grid tiles can switch to 'cover' in the thumbnail settings.
const DEFAULT_THUMBNAIL_FIT = 'inside';
const DEFAULT_THUMBNAIL_QUALITY = 85;
const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
+23
View File
@@ -17,9 +17,11 @@
const path = require('path');
const mime = require('mime-types');
const sharp = require('sharp');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getStorage } = require('./storage');
const { withLocalCopy } = require('./imageProcessor');
const logger = require('../utils/logger');
const POLL_INTERVAL_MS = parseInt(process.env.STORAGE_AUTO_IMPORT_INTERVAL_MS || `${5 * 60 * 1000}`, 10);
@@ -98,6 +100,26 @@ async function processEvent(event, storage) {
const isVideo = mimeType.startsWith('video/');
if (!isImage && !isVideo) continue;
// Capture image dimensions so aspect-aware layouts (masonry /
// mosaic / justified) can size each card to the photo's real
// proportions instead of the 800×600 fallback (#447). Materialize
// a tmp local copy via withLocalCopy — withLocalCopy handles the
// S3 download + cleanup. Skip videos (would need ffprobe).
let dimensions = null;
if (isImage) {
try {
dimensions = await withLocalCopy(entry.key, async (localPath) => {
const metadata = await sharp(localPath).metadata();
if (metadata.width && metadata.height) {
return { width: metadata.width, height: metadata.height };
}
return null;
});
} catch (err) {
logger.debug(`[s3AutoImporter] could not read dimensions for ${entry.key}: ${err.message}`);
}
}
try {
const insertResult = await db('photos').insert({
event_id: event.id,
@@ -110,6 +132,7 @@ async function processEvent(event, storage) {
mime_type: mimeType,
source_origin: 'managed',
uploaded_at: new Date().toISOString(),
...(dimensions && { width: dimensions.width, height: dimensions.height }),
}).returning('id');
const photoId = insertResult[0]?.id || insertResult[0];