Files
picpeak/backend/src/services/galleryQueryService.js
T
Paul Nothaft f0e6d2dfb1 fix: enforce gallery access and consolidate gallery workflows (#1357)
Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
2026-09-08 15:34:09 +02:00

568 lines
30 KiB
JavaScript

const { toIso } = require('../utils/dateNormalize');
const { db } = require('../database/db');
const { parseBooleanInput } = require('../utils/parsers');
const { getAppSetting } = require('../utils/appSettings');
const { formatBoolean } = require('../utils/dbCompat');
const { SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels');
const watermarkService = require('./watermarkService');
const logger = require('../utils/logger');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions');
const { resolveHeroLogoVisible, originalNeedsPreview } = require('./galleryModel');
const { applyFeedbackFilter } = require('./galleryPhotoQuery');
async function getGalleryPhotos({ event, query = {}, identity, accessLevel, adminPreview, hiddenForGuest, slug }) {
// Get filter and sort parameters from query
// `guest_id` is deliberately NOT read from the query string: the viewer's
// own feedback is resolved from the request identity instead (see the
// filter block). The frontend still sends it; it is ignored.
const { filter, sort = 'upload_date', order = 'desc' } = query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
const isClient = accessLevel === 'client';
let photosQuery = db('photos')
.where('photos.event_id', event.id)
// Guests/clients never see photos still being processed by the
// background worker — the original is on disk but the thumbnail
// / dimensions / EXIF haven't landed yet. Photos with a NULL
// processing_status are pre-async-migration rows and are treated
// as complete (the migration's column default is 'complete' so
// this is just defensive against partial migration states).
.where(function() {
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
})
.select('photos.*');
// Guests only see visible photos; clients see all
if (!isClient) {
photosQuery = photosQuery.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
// viewer can't widen the set: when the event pins show_category_id, the
// slideshow only sees that category. NULL = all photos (unchanged).
if (accessLevel === 'slideshow' && event.show_category_id) {
photosQuery = photosQuery.where('photos.category_id', event.show_category_id);
}
// Apply sort option.
//
// Every branch carries photos.id as a tiebreaker (#1172). Without one the
// order within a tie is whatever the engine happens to return, and ties are
// the normal case rather than the exception: a bulk import writes hundreds
// of rows inside the same second, so uploaded_at collapses — and with
// captured_at NULL the COALESCE below collapses onto it too. The visible
// symptom is a grid that reshuffles between page loads. id is insertion
// order, so it also makes the fallback ordering meaningful rather than
// arbitrary.
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null.
//
// On SQLite that fallback cannot be a plain COALESCE, because the two
// columns do not hold one type. photos.captured_at ends up carrying three
// different storage classes:
//
// integer managed uploads — photoProcessor.js:488 writes a Date, which
// the sqlite3 binding stores as epoch milliseconds
// text external imports and the backfill, which write ISO-8601
// ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that
// Dates must not be handed to the binding in tests
// null no capture date, so the sort falls through to uploaded_at —
// usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape,
// but epoch milliseconds on rows written by a legacy archive
// restore (see __tests__/integration/sqliteEpochTimestamps.js),
// so that column needs the same two branches
//
// SQLite orders INTEGER before TEXT unconditionally, so every managed
// photo carrying EXIF sorted ahead of every photo that did not, whatever
// the actual dates — a 2027 capture landing before a 2020 one. Among the
// text values the 'T' separator (0x54) also outranks the space (0x20), so
// a same-day ISO 01:15 sorted after a fallback 23:00.
//
// Normalising in the ORDER BY rather than rewriting the column: the data
// fix would have to touch every existing row and every writer, which is a
// much heavier change than the sort it is meant to correct. The cost here
// is that this sort stops using idx_photos_captured_at on SQLite — an
// acceptable trade on the fallback engine, where the alternative is an
// index-assisted wrong answer.
//
// Postgres is untouched: captured_at is a real timestamp there, so
// COALESCE already compares correctly.
if (db.client.config.client === 'pg') {
photosQuery = photosQuery
.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
} else {
photosQuery = photosQuery.orderByRaw(`CASE
WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch')
WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '')
WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch')
ELSE substr(photos.uploaded_at, 1, 19)
END ${sortOrder}`);
}
photosQuery = photosQuery.orderBy('photos.id', sortOrder);
} else if (sort === 'filename') {
photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder);
} else {
// Default: sort by upload date
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder);
}
// Reveal mode (#838): while the gallery is hidden, plain guests get
// the event shell with an empty photo/category set plus the
// hidden_until_reveal flag — the frontend renders the upload-only view
// from it. Slideshow, client access and the admin preview bypass
// (guestBlockedByReveal). Enforced here, not just in the UI.
// Check if feedback should be visible to guests. Read BEFORE the filter
// block, not after: the filters below consult it, because a filter that
// selects on other people's feedback is a way of reading that feedback.
const feedbackService = require('./feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
// One identity-less colour tag per photo, any guest may overwrite it
// (#1197). Read in three places below: the colour filters, the per-viewer
// badge, and the "other viewers" dots that must not double-render it.
const sharedColorMode = feedbackSettings?.identity_mode === 'shared';
applyFeedbackFilter(photosQuery, { filter, event, identity, sharedColorMode, showFeedbackToGuests });
const limit = query.limit === undefined ? null : Math.min(250, Math.max(1, parseInt(query.limit, 10) || 100));
const page = Math.max(1, parseInt(query.page, 10) || 1);
const countRow = hiddenForGuest ? { total: 0 } : await photosQuery.clone().clearSelect().clearOrder().count('photos.id as total').first();
const total = Number(countRow.total);
if (limit) photosQuery.limit(limit).offset((page - 1) * limit);
const photos = hiddenForGuest ? [] : await photosQuery;
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
.where('feedback_type', 'comment')
.where('is_approved', formatBoolean(true))
.where('is_hidden', formatBoolean(false))
.groupBy('photo_id')
.select('photo_id', db.raw('COUNT(*) as comment_count'));
// Create a map for quick lookup
const commentMap = {};
commentCounts.forEach(c => {
commentMap[c.photo_id] = parseInt(c.comment_count);
});
// Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the
// gallery grid used to reset every heart to empty because the lifted
// likedPhotoIds state started as a fresh Set on mount — even photos
// the viewer had actually liked. Surface a per-viewer flag so the
// frontend can seed correctly. Prefers identity.guestId when a verified
// guest token is present (per-person identity), falls back to the
// IP+UA hash that the original like was recorded under — same model
// the /my-feedback endpoint uses.
//
// NOT gated on showFeedbackToGuests (#1286). This query is filtered to
// the VIEWER — by guest_id or by their own identifier — so what it
// returns is their own selection, not shared aggregate data. Gating it
// emptied every heart the guest had set themselves on a gallery with
// sharing off, which reads as the gallery silently discarding their
// choices. Same reasoning the colour-label block below already applies;
// this was the one per-viewer field that disagreed with it.
const likedPhotoIds = new Set();
if (photos.length > 0) {
const likeQuery = db('photo_feedback')
// Hidden rows are not there, for the viewer's OWN feedback as much as
// anyone's (#1150). getPhotoFeedback drops them, the filter drops them
// and updatePhotoFeedbackStats does not count them — leaving the heart
// filled was the one place that disagreed, so a like the photographer
// had hidden still showed as liked on a photo whose like_count was 0.
.where({ event_id: event.id, feedback_type: 'like', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id));
if (identity.guestId) {
likeQuery.where('guest_id', identity.guestId);
} else {
likeQuery.where('guest_identifier', identity.guestIdentifier);
}
const likedRows = await likeQuery.select('photo_id');
likedRows.forEach(row => likedPhotoIds.add(row.photo_id));
}
// Per-viewer colour label (#1044), same identity resolution as the likes
// above. NOT gated on showFeedbackToGuests: a guest's own label is their
// own selection, not shared aggregate data, and hiding it would blank the
// grid badges on every refresh in a gallery with sharing switched off.
//
// In shared identity mode (#1197) there is no per-viewer label to read:
// the photo carries one tag and it belongs to everyone, so it arrives on
// this same field. The badge, the lightbox swatch and the keyboard
// shortcuts then work unchanged — they were already reading "the colour on
// this photo, from my point of view", which is precisely what the shared
// tag is.
const myColorLabelByPhoto = {};
if (photos.length > 0 && sharedColorMode) {
Object.assign(
myColorLabelByPhoto,
await feedbackService.getSharedColorLabels(event.id, photos.map(p => p.id)),
);
} else if (photos.length > 0) {
const colorQuery = db('photo_feedback')
// Same rule as the heart above (#1150).
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id));
if (identity.guestId) {
colorQuery.where('guest_id', identity.guestId);
} else {
colorQuery.where('guest_identifier', identity.guestIdentifier);
}
const colorRows = await colorQuery.select('photo_id', 'color_label');
colorRows.forEach(row => {
if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label;
});
}
// OTHER viewers' colour labels, per photo (#1178).
//
// The lightbox has always shown these — /photos/:id/feedback returns
// per-colour tallies across everyone — but the grid had no field carrying
// them, so a label set by one guest was visible in fullscreen and invisible
// on the tile. With sharing on, that is just a hole.
//
// DISTINCT colours, not counts: a tile has room for a couple of dots, and
// "who else marked this, and how" is a lightbox question. The viewer's own
// colour is excluded here so the badge and the dots never say the same
// thing twice — the frontend renders `my_color_label` as the badge and
// these beside it.
//
// Gated on showFeedbackToGuests, like every other aggregate: this is other
// people's feedback, unlike my_color_label above.
//
// Skipped entirely in shared mode (#1197). There are no other viewers'
// labels there — there is one tag, already delivered as my_color_label
// above. Without this the shared row would come back here too (its
// reserved identity is not the viewer's), and every tile would render the
// same colour twice: once as the badge, once as a dot beside it.
const otherColorLabelsByPhoto = {};
if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) {
const othersQuery = db('photo_feedback')
.where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) })
.whereIn('photo_id', photos.map(p => p.id))
.whereNotNull('color_label')
// The other direction of the same rule (#1197): an event switched back
// out of shared mode keeps its shared tag, and it is nobody's — so
// without this it would show up as an anonymous other viewer's dot on
// every tile that still carries one.
.where(function () {
this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier');
});
if (identity.guestId) {
othersQuery.where(function () {
this.whereNot('guest_id', identity.guestId).orWhereNull('guest_id');
});
} else {
const mine = identity.guestIdentifier;
othersQuery.where(function () {
this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier');
});
}
const otherRows = await othersQuery.distinct('photo_id', 'color_label');
otherRows.forEach(row => {
if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = [];
if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) {
otherColorLabelsByPhoto[row.photo_id].push(row.color_label);
}
});
}
// People in each photo (#1074). Two independent gates: the feature must
// be on for this event AND, for a plain guest, the photographer must have
// left the strip visible. A client (PIN access) is the photographer's own
// view, so faces_visible_to_guests doesn't restrict them.
//
// `photos` is already visibility-filtered above, and this only ever asks
// about ids in that set, so it cannot widen what the caller sees.
let peopleEnabled = false;
let personIdsByPhoto = new Map();
try {
const { isEnabledForEvent, areFacesVisibleToGuests } = require('./faceSettings');
if (photos.length > 0 && await isEnabledForEvent(event)) {
peopleEnabled = isClient || areFacesVisibleToGuests(event);
if (peopleEnabled) {
const { getPersonIdsByPhoto } = require('./facePeopleService');
personIdsByPhoto = await getPersonIdsByPhoto(
event.id,
photos.map(p => p.id),
{ forAdmin: isClient }
);
}
}
} catch (err) {
// A face-feature failure must never take down the gallery payload.
logger.warn(`gallery: person_ids lookup failed for event ${event.id}`, { error: err.message });
peopleEnabled = false;
personIdsByPhoto = new Map();
}
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = hiddenForGuest ? [] : await db('photos')
.where('event_id', event.id)
.whereNotNull('category_id')
.distinct('category_id')
.pluck('category_id');
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
// Resolved category order (#782): per-event override, else global
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'],
});
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null,
// Per-category download flag (#640). false explicitly disables; the
// gallery hides the download button. Defaults true so categories
// created before migration 135 keep working.
allow_downloads: parseBooleanInput(cat.allow_downloads, true),
// Folder vs filter (#1160). true = the category CONTAINS its photos:
// they leave the root grid and only render inside the folder. Defaults
// false so categories predating migration 185 keep filtering.
is_folder: parseBooleanInput(cat.is_folder, false)
}));
}
// Build a map for quick category lookup
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.id] = cat;
});
// Include protection settings in response
const protectionSettings = {
protection_level: event.protection_level || 'standard',
image_quality: event.image_quality || 85,
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
overlay_protection: parseBooleanInput(event.overlay_protection, true)
};
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
const downloadPolicy = await resolveEventDownloadPolicy(event);
return {
pagination: { page, limit: limit || total, total, has_more: !!limit && page * limit < total },
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
hero_photo_id: event.hero_photo_id,
// Defaults match /info: downloads on unless explicitly disabled,
// uploads off unless explicitly enabled (#1028).
allow_downloads: parseBooleanInput(event.allow_downloads, true),
allow_user_uploads: parseBooleanInput(event.allow_user_uploads, false),
// Download resolutions (#858). `choices` drives the picker modal and is
// empty when the picker is off, so the UI can never offer a size the
// server would reject.
download_resolution: {
standard: downloadPolicy.standard,
picker_enabled: downloadPolicy.pickerEnabled,
choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [],
},
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
// polling so a re-hide propagates without a manual reload.
reveal_armed: parseBooleanInput(event.reveal_mode, false),
disable_right_click: parseBooleanInput(event.disable_right_click, false),
watermark_downloads: parseBooleanInput(event.watermark_downloads, false),
watermark_text: event.watermark_text,
enable_devtools_protection: parseBooleanInput(event.enable_devtools_protection, false),
use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false),
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
// Promo banner override (#440). GalleryView has always read
// promo_mode from THIS payload, but it was never sent — so every
// per-event promo override silently resolved to 'inherit' and a
// gallery set to 'off' still showed the global banner.
promo_mode: event.promo_mode || 'inherit',
promo_markdown: event.promo_markdown || null,
// Info banner override (#932). GalleryAuthContext refreshes its cached
// event from THIS payload, so the fields have to travel here — /info
// alone isn't enough, the context stops reading it once the guest is
// authenticated.
info_mode: event.info_mode || 'inherit',
info_markdown: event.info_markdown || null,
download_zip_ready: !!(event.download_zip_path && event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
// "People in this gallery" (#1074). False whenever the global flag
// is off, detection is off for this event, or the photographer chose
// to keep the strip to themselves — the frontend renders no face UI
// at all in that case.
people_enabled: peopleEnabled,
...protectionSettings
},
// Reveal mode (#838): the guest UI switches to the upload-only view
// on this flag; reveal_at lets it show the scheduled time.
hidden_until_reveal: hiddenForGuest,
reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined,
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
// Watermark version (cache-busting) + admin-preview flag (#868). In
// preview mode no gallery cookie is minted, so each <img> request must
// re-assert the admin session — thread the flag onto every /api/gallery
// image URL so the browser sends it (the admin_token cookie rides along
// same-origin).
const imgQuery = [wmVersion, adminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&');
const wmQuery = imgQuery ? `?${imgQuery}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
: null,
// Slideshow source (#1015). Same preview tier, but emitted
// unconditionally: the slideshow has no `url` fallback worth
// taking (originals are projector-sized) and must never land on
// `hero_url`, which is cover-cropped to 16:9 — that made the
// "no crop" fit letterbox an already-cropped frame. The preview
// route generates lazily and redirects to the original on any
// failure, so this is safe even where no preview exists yet.
slideshow_url: photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
// Per-category download permission (#640). Defaults true for photos
// without a category or for categories that pre-date migration 135.
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
: true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
// toIso: on SQLite installs rows written with a raw Date (e.g.
// the pre-fix archive-restore path) hold epoch numbers — the
// Timeline layout's parseISO() crashes on those (#485 class).
uploaded_at: toIso(photo.uploaded_at),
// Image dimensions for layout calculations
width: photo.width || null,
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// EXIF capture date
captured_at: toIso(photo.captured_at) || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
duration: photo.duration || null,
// Feedback data (hidden when show_feedback_to_guests is disabled)
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
// Per-viewer flag (#590 follow-up) — true when this viewer has
// an active like row for this photo, false otherwise. Lets the
// grid seed its lifted likedPhotoIds correctly on hard refresh.
// Survives show_feedback_to_guests being off (#1286): the viewer's
// own heart is theirs, and the like_count beside it stays hidden.
is_liked: likedPhotoIds.has(photo.id),
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Colour labels (#1044). The COUNT is aggregate data and follows
// show_feedback_to_guests like its siblings; the viewer's OWN label
// is not aggregate and must survive with sharing off, otherwise the
// grid badge disappears on refresh for the very guest who set it.
color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0,
my_color_label: myColorLabelByPhoto[photo.id] || null,
// Distinct colours other viewers put on this photo (#1178), so the
// grid can show them beside the viewer's own badge. Empty with
// sharing off — it is other people's feedback.
other_color_labels: otherColorLabelsByPhoto[photo.id] || [],
// People in this photo (#1074). Empty array when the feature is
// off for this event or hidden from guests, so the frontend has
// one shape to handle. Riding along on this payload is what keeps
// face filtering client-side and instant, like the category and
// liked/rated filters.
person_ids: personIdsByPhoto.get(photo.id) || [],
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
};
}
module.exports = { getGalleryPhotos };