feat: guest selections with per-person identity (#292)

Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
This commit is contained in:
Paul Nothaft
2026-04-11 07:48:23 +02:00
parent d4b4dc628f
commit ad4e5a7506
41 changed files with 3609 additions and 66 deletions
+609
View File
@@ -0,0 +1,609 @@
const express = require('express');
const crypto = require('crypto');
const archiver = require('archiver');
const router = express.Router();
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const feedbackService = require('../services/feedbackService');
const logger = require('../utils/logger');
const FRONTEND_URL = process.env.FRONTEND_URL || '';
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async function loadGuestOr404(eventId, guestId, res) {
const guest = await db('gallery_guests')
.where({ id: guestId, event_id: eventId, is_deleted: false })
.first();
if (!guest) {
res.status(404).json({ error: 'Guest not found' });
return null;
}
return guest;
}
function serializeGuest(row) {
return {
id: row.id,
name: row.name,
email: row.email,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
email_verified_at: row.email_verified_at,
is_deleted: row.is_deleted,
};
}
function escapeCsvCell(value) {
const str = value == null ? '' : String(value);
if (/[,"\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests — list guests with aggregated counts
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const rows = await db('gallery_guests')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
})
.where('gallery_guests.event_id', eventId)
.where('gallery_guests.is_deleted', false)
.groupBy('gallery_guests.id')
.select(
'gallery_guests.id',
'gallery_guests.name',
'gallery_guests.email',
'gallery_guests.created_at',
'gallery_guests.last_seen_at',
'gallery_guests.email_verified_at',
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
const guests = rows.map((r) => ({
...serializeGuest(r),
stats: {
likes: parseInt(r.likes, 10) || 0,
favorites: parseInt(r.favorites, 10) || 0,
comments: parseInt(r.comments, 10) || 0,
ratings: parseInt(r.ratings, 10) || 0,
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
},
}));
res.json({ guests });
} catch (error) {
logger.error('Error listing guests:', error);
res.status(500).json({ error: 'Failed to list guests' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
// guest pick count (Phase 2 aggregate view)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/aggregate',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const photos = await db('photos')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.photo_id', '=', 'photos.id')
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
.andOnNotNull('photo_feedback.guest_id');
})
.where('photos.event_id', eventId)
.groupBy('photos.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
)
.orderBy('picker_count', 'desc')
.orderBy('photos.id', 'desc');
res.json({
photos: photos
.filter((p) => parseInt(p.picker_count, 10) > 0)
.map((p) => ({
id: p.id,
filename: p.filename,
original_filename: p.original_filename,
url: `/admin/photos/${eventId}/photo/${p.id}`,
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${p.id}`,
picker_count: parseInt(p.picker_count, 10),
})),
});
} catch (error) {
logger.error('Error fetching aggregate view:', error);
res.status(500).json({ error: 'Failed to fetch aggregate view' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const rows = await db('guest_invites')
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
.where('guest_invites.event_id', eventId)
.select(
'guest_invites.id',
'guest_invites.token',
'guest_invites.created_at',
'guest_invites.redeemed_at',
'guest_invites.revoked_at',
'gallery_guests.id as guest_id',
'gallery_guests.name as guest_name',
'gallery_guests.email as guest_email'
)
.orderBy('guest_invites.created_at', 'desc');
const invites = rows.map((r) => ({
id: r.id,
token: r.token,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
created_at: r.created_at,
redeemed_at: r.redeemed_at,
revoked_at: r.revoked_at,
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
guest: {
id: r.guest_id,
name: r.guest_name,
email: r.guest_email,
},
}));
res.json({ invites });
} catch (error) {
logger.error('Error listing invites:', error);
res.status(500).json({ error: 'Failed to list invites' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/invites — create guest + invite
// Body: { name, email? }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const name = String(req.body?.name || '').trim().slice(0, 100);
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const identifier = crypto.randomUUID();
const inviteToken = crypto.randomBytes(24).toString('hex');
let guestId;
let inviteId;
await db.transaction(async (trx) => {
const [guestRow] = await trx('gallery_guests')
.insert({
event_id: eventId,
name,
email: email || null,
identifier,
})
.returning(['id']);
guestId = guestRow.id;
const [inviteRow] = await trx('guest_invites')
.insert({
event_id: eventId,
guest_id: guestId,
token: inviteToken,
created_by_admin_id: req.admin.id,
})
.returning(['id']);
inviteId = inviteRow.id;
});
await logActivity(
'guest_invite_created',
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const event = await db('events').where({ id: eventId }).first();
res.json({
invite: {
id: inviteId,
token: inviteToken,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
status: 'pending',
guest: { id: guestId, name, email: email || null },
},
});
} catch (error) {
logger.error('Error creating invite:', error);
res.status(500).json({ error: 'Failed to create invite' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/invites/:inviteId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, inviteId } = req.params;
const updated = await db('guest_invites')
.where({ id: inviteId, event_id: eventId })
.whereNull('revoked_at')
.update({ revoked_at: db.fn.now() });
if (!updated) {
return res.status(404).json({ error: 'Invite not found or already revoked' });
}
await logActivity(
'guest_invite_revoked',
{ event_id: eventId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true });
} catch (error) {
logger.error('Error revoking invite:', error);
res.status(500).json({ error: 'Failed to revoke invite' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
// Query: format=txt|csv|json (default: csv)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/export-all',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
const guests = await db('gallery_guests')
.where({ event_id: eventId, is_deleted: false })
.select('id', 'name', 'email');
if (guests.length === 0) {
return res.status(404).json({ error: 'No guests to export' });
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="event-${eventId}-guests.zip"`
);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err) => {
logger.error('Archive error:', err);
res.status(500).end();
});
archive.pipe(res);
for (const g of guests) {
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', g.id)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
const filename = `${safeName}.${format}`;
let body;
if (format === 'json') {
body = JSON.stringify({ guest: g, selections }, null, 2);
} else if (format === 'csv') {
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
body = [header, ...rows].join('\n');
} else {
// txt — just filenames
body = selections.map((s) => s.original_filename || s.filename).join('\n');
}
archive.append(body, { name: filename });
}
await archive.finalize();
} catch (error) {
logger.error('Error exporting all guests:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to export guests' });
}
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
// (Phase 2)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const feedback = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.select(
'photo_feedback.id as feedback_id',
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.created_at',
'photos.id as photo_id',
'photos.filename',
'photos.original_filename',
'photos.type'
)
.orderBy('photo_feedback.created_at', 'desc');
const photoFor = (row) => ({
id: row.photo_id,
filename: row.filename,
original_filename: row.original_filename,
type: row.type,
url: `/admin/photos/${eventId}/photo/${row.photo_id}`,
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
});
const selections = {
liked: [],
favorited: [],
rated: [],
commented: [],
};
for (const row of feedback) {
if (row.feedback_type === 'like') {
selections.liked.push(photoFor(row));
} else if (row.feedback_type === 'favorite') {
selections.favorited.push(photoFor(row));
} else if (row.feedback_type === 'rating') {
selections.rated.push({ photo: photoFor(row), rating: row.rating });
} else if (row.feedback_type === 'comment') {
selections.commented.push({
photo: photoFor(row),
comment: row.comment_text,
created_at: row.created_at,
});
}
}
res.json({
guest: {
...serializeGuest(guest),
stats: {
likes: selections.liked.length,
favorites: selections.favorited.length,
comments: selections.commented.length,
ratings: selections.rated.length,
},
},
selections,
});
} catch (error) {
logger.error('Error fetching guest detail:', error);
res.status(500).json({ error: 'Failed to fetch guest detail' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
// Query: format=txt|csv|json
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
const filename = `${safeName}.${format}`;
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
}
if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
return res.send([header, ...rows].join('\n'));
}
// txt — one filename per line
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
} catch (error) {
logger.error('Error exporting guest:', error);
res.status(500).json({ error: 'Failed to export guest' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const result = await feedbackService.anonymizeGuestFeedback(guestId);
await db('gallery_guests').where({ id: guestId }).update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
await logActivity(
'guest_deleted',
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error deleting guest:', error);
res.status(500).json({ error: 'Failed to delete guest' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
// Body: { mergeIds: number[] }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/:keepId/merge',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, keepId } = req.params;
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
if (mergeIds.length === 0) {
return res.status(400).json({ error: 'mergeIds is required' });
}
if (mergeIds.includes(Number(keepId))) {
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
}
// Sanity check: all guests belong to this event.
const all = await db('gallery_guests')
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
.where({ event_id: eventId });
if (all.length !== mergeIds.length + 1) {
return res.status(400).json({ error: 'All guests must belong to the same event' });
}
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
// Soft-delete the merged (source) guests.
await db('gallery_guests')
.whereIn('id', mergeIds.map(Number))
.update({ is_deleted: true, last_seen_at: db.fn.now() });
await logActivity(
'guest_merged',
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error merging guests:', error);
res.status(500).json({ error: 'Failed to merge guests' });
}
}
);
module.exports = router;
+4 -2
View File
@@ -1199,10 +1199,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
res.json({
feedback_enabled: settings.feedback_enabled || false,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
show_feedback_to_guests: settings.show_feedback_to_guests
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
});
} catch (error) {
console.error('Error fetching feedback settings:', error);
+63 -31
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
try {
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
// Convert SQLite boolean values (0/1) to proper booleans
const guestSettings = {
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple'
};
res.json(guestSettings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
checkValidation,
async (req, res) => {
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
// Get feedback settings first so we can enforce identity_mode.
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// In guest identity mode, a valid guest token is required. The server
// never trusts guest_name/guest_email from the body in this mode — it
// reads them from the verified token via req.guest.
if (settings.identity_mode === 'guest') {
if (!req.guest || req.guest.eventId !== event.id) {
return res.status(401).json({
error: 'Guest identity required',
code: 'GUEST_IDENTITY_REQUIRED'
});
}
}
const guestIdentifier = generateGuestIdentifier(req);
// Check if specific feedback type is allowed
const feedbackType = req.body.feedback_type;
const typeAllowed = {
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
comment: settings.allow_comments,
favorite: settings.allow_favorites
};
if (!typeAllowed[feedbackType]) {
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Validate guest requirements
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
// Validate guest requirements only in simple mode. In guest mode, the
// identity is already provided via the token and verified above.
if (settings.identity_mode !== 'guest') {
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
}
}
// Apply rate limiting based on feedback type
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
await new Promise((resolve, reject) => {
@@ -192,17 +212,19 @@ router.post('/:slug/photos/:photoId/feedback',
else resolve();
});
});
// If we got here and response was sent (rate limited), return
if (res.headersSent) return;
// Prepare feedback data
// Prepare feedback data. In guest mode, use the verified token as the
// source of truth for name/email — never the body.
const feedbackData = {
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
moderate_comments: settings.moderate_comments
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
// Get user's own feedback for all photos
router.get('/:slug/my-feedback',
verifyGalleryAccess,
resolveGuest,
async (req, res) => {
try {
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
const myFeedback = await db('photo_feedback')
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id)
.where('photo_feedback.guest_identifier', guestIdentifier)
.where('photo_feedback.event_id', event.id);
// Prefer guest_id lookup when a verified guest token is present
// (per-person identity). Fall back to the device hash otherwise.
if (req.guest?.id) {
query.where('photo_feedback.guest_id', req.guest.id);
} else {
const guestIdentifier = generateGuestIdentifier(req);
query.where('photo_feedback.guest_identifier', guestIdentifier);
}
const myFeedback = await query
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
)
.orderBy('photo_feedback.created_at', 'desc');
res.json(myFeedback);
} catch (error) {
logger.error('Error getting user feedback:', error);
+409
View File
@@ -0,0 +1,409 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const guestRecovery = require('../services/guestRecoveryService');
const MAX_NAME_LEN = 100;
const MAX_EMAIL_LEN = 255;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// In-memory rate limit for guest registration (20 per hour per IP). Simple
// sliding window; on process restart the counters reset which is acceptable.
const registrationAttempts = new Map();
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
const REGISTRATION_MAX = 20;
function checkRegistrationRate(ip) {
const now = Date.now();
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
registrationAttempts.set(ip, entry);
return entry.count <= REGISTRATION_MAX;
}
function sanitizeName(value) {
if (typeof value !== 'string') return '';
// Strip HTML/control chars, collapse whitespace.
const cleaned = value
.replace(/[<>&"']/g, '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned.slice(0, MAX_NAME_LEN);
}
function sanitizeEmail(value) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
}
/**
* POST /gallery/:slug/guest
* Body: { name, email? }
*
* Registers a new per-person guest identity for this gallery. Returns a JWT
* that the frontend must send as the x-guest-token header on subsequent
* feedback requests.
*/
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkRegistrationRate(ip)) {
return res.status(429).json({ error: 'Too many registration attempts' });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Guest registration is only meaningful when feedback is enabled.
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
}
const name = sanitizeName(req.body?.name);
if (!name || name.length < 1) {
return res.status(400).json({ error: 'Name is required', field: 'name' });
}
let email = sanitizeEmail(req.body?.email);
if (email && !EMAIL_REGEX.test(email)) {
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
}
if (settings.require_name_email && !email) {
return res.status(400).json({ error: 'Email is required', field: 'email' });
}
const identifier = crypto.randomUUID();
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
const [row] = await db('gallery_guests')
.insert({
event_id: event.id,
name,
email: email || null,
identifier,
ip_address_last: ip.substring(0, 45),
user_agent_last: userAgent,
})
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
const token = signGuestToken({
guestId: row.id,
eventId: event.id,
identifier: row.identifier,
name: row.name,
});
logger.info('Guest registered', {
eventId: event.id,
guestId: row.id,
name: row.name,
});
return res.json({
guest: {
id: row.id,
name: row.name,
email: row.email,
identifier: row.identifier,
},
token,
});
} catch (error) {
logger.error('Guest registration failed', { error: error.message });
return res.status(500).json({ error: 'Failed to register guest' });
}
});
/**
* GET /gallery/:slug/guest/me
* Returns the current guest profile from a valid guest token. 401 otherwise.
*/
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
// Update last_seen_at on each profile fetch (cheap and useful for admin).
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return res.json({
guest: {
id: req.guest.id,
name: req.guest.name,
email: req.guest.email,
identifier: req.guest.identifier,
},
});
} catch (error) {
logger.error('Guest profile fetch failed', { error: error.message });
return res.status(500).json({ error: 'Failed to fetch guest profile' });
}
});
/**
* DELETE /gallery/:slug/guest/me
*
* "Forget me" — soft-deletes the guest row and anonymizes their feedback so
* aggregate counts remain stable but personal data is removed.
*/
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
await feedbackService.anonymizeGuestFeedback(req.guest.id);
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
logger.info('Guest self-forgot', {
eventId: req.event.id,
guestId: req.guest.id,
});
return res.json({ success: true });
} catch (error) {
logger.error('Guest forget-me failed', { error: error.message });
return res.status(500).json({ error: 'Failed to forget guest' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.2 — Email-based identity recovery
// ---------------------------------------------------------------------------
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
const recoveryAttempts = new Map();
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
const VERIFY_MAX = 20;
function checkRecoveryRate(ip) {
const now = Date.now();
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
recoveryAttempts.set(ip, entry);
return entry.count <= VERIFY_MAX;
}
/**
* POST /gallery/:slug/guest/recover
* Body: { email }
*
* Sends a 6-digit code to the email if it matches an existing guest. Returns
* 200 regardless of whether a matching guest exists (prevents enumeration).
*/
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many recovery attempts' });
}
const email = sanitizeEmail(req.body?.email);
if (!email || !EMAIL_REGEX.test(email)) {
// Still return 200 to avoid leaking validity of the email field.
return res.json({ success: true });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
return res.json({ success: true });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (guest) {
try {
const code = await guestRecovery.createCode(event.id, email);
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
} catch (sendError) {
logger.error('Failed to send recovery email', { error: sendError.message });
// Still return 200 so clients can't distinguish failures.
}
}
return res.json({ success: true });
} catch (error) {
logger.error('Guest recovery request failed', { error: error.message });
return res.json({ success: true });
}
});
/**
* POST /gallery/:slug/guest/verify
* Body: { email, code }
*
* Exchanges a valid verification code for a guest token. Reuses the existing
* guest row associated with the email (the guest continues where they left
* off, cross-device).
*/
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many verification attempts' });
}
const email = sanitizeEmail(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!email || !code) {
return res.status(400).json({ error: 'Email and code are required' });
}
const event = req.event;
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
if (!verifyResult.ok) {
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (!guest) {
return res.status(404).json({ error: 'Guest not found' });
}
await db('gallery_guests')
.where({ id: guest.id })
.update({
email_verified_at: guest.email_verified_at || db.fn.now(),
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
});
const token = signGuestToken({
guestId: guest.id,
eventId: event.id,
identifier: guest.identifier,
name: guest.name,
});
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
return res.json({
guest: {
id: guest.id,
name: guest.name,
email: guest.email,
identifier: guest.identifier,
},
token,
});
} catch (error) {
logger.error('Guest verify failed', { error: error.message });
return res.status(500).json({ error: 'Failed to verify code' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.3 — Invite token redemption
// ---------------------------------------------------------------------------
/**
* POST /gallery/:slug/guest/redeem
* Body: { inviteToken }
*
* Redeems a pre-minted invite token (created by admin). Single use.
*/
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
try {
const inviteToken = String(req.body?.inviteToken || '').trim();
if (!inviteToken) {
return res.status(400).json({ error: 'Invite token required' });
}
const event = req.event;
const result = await db.transaction(async (trx) => {
const invite = await trx('guest_invites')
.where({ token: inviteToken, event_id: event.id })
.first();
if (!invite) return { error: 'not_found' };
if (invite.revoked_at) return { error: 'revoked' };
if (invite.redeemed_at) return { error: 'already_redeemed' };
const guest = await trx('gallery_guests')
.where({ id: invite.guest_id, is_deleted: false })
.first();
if (!guest) return { error: 'guest_missing' };
await trx('guest_invites')
.where({ id: invite.id })
.update({ redeemed_at: trx.fn.now() });
await trx('gallery_guests')
.where({ id: guest.id })
.update({
last_seen_at: trx.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return { guest };
});
if (result.error) {
const statusMap = {
not_found: 404,
revoked: 410,
already_redeemed: 409,
guest_missing: 404,
};
return res.status(statusMap[result.error] || 400).json({ error: result.error });
}
const token = signGuestToken({
guestId: result.guest.id,
eventId: event.id,
identifier: result.guest.identifier,
name: result.guest.name,
});
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
return res.json({
guest: {
id: result.guest.id,
name: result.guest.name,
email: result.guest.email,
identifier: result.guest.identifier,
},
token,
});
} catch (error) {
logger.error('Invite redemption failed', { error: error.message });
return res.status(500).json({ error: 'Failed to redeem invite' });
}
});
module.exports = router;