Closes #1300. Fragmentation was configurable, stored per event, served to the gallery client, and consumed by nothing. It was not unbuilt scaffolding — both halves exist and are individually coherent — but they were never connected, and they disagree: the server cut a fixed 3x3 grid while the client reassembled a 4x4 one, so wiring them together as they stood would have produced scrambled images rather than protection. Removed rather than finished, because finishing it buys nothing. The client fetches the whole image and then redraws it in pieces on a canvas, so the full original has already crossed the wire before any "protection" is applied — that is obfuscation, not a control. The per-fragment canvas work also lands on mobile, which is the memory profile under investigation in #1287. Goes: secureImageService.fragmentImageBuffer and its branch, the ?fragment=N delivery path and handleFragmentedImage in secureImages, the fragmented-JSON response in protectedImages, fragmentation_level in the gallery payload, the default_fragmentation_level setting, the PUT validator, the ProtectedImage fragment renderer, and the operator control with its strings in all eight locales. No migration. `events.fragmentation_level` and the app_settings row stay — dropping a column is irreversible and the stored values are harmless once nothing reads them. If they should go, that is a deliberate data decision and its own migration. `fragmentGrid` on AuthenticatedImage and the layouts is deliberately untouched: #1299 already removes it as part of the inert prop surface, and doing it here would only collide.
474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
const express = require('express');
|
||
const { resolvePhotoContentType } = require('../utils/photoContentType');
|
||
const { db } = require('../database/db');
|
||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
|
||
const secureImageService = require('../services/secureImageService');
|
||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||
const logger = require('../utils/logger');
|
||
const { formatBoolean } = require('../utils/dbCompat');
|
||
const { parseBooleanInput } = require('../utils/parsers');
|
||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||
const { withLocalCopy } = require('../services/imageProcessor');
|
||
const { getStorage } = require('../services/storage');
|
||
const {
|
||
getUseOriginalFilenames,
|
||
pickRawDownloadName,
|
||
} = require('../services/downloadFilenameService');
|
||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
|
||
|
||
const router = express.Router();
|
||
|
||
/**
|
||
* Generate secure token for image access
|
||
*/
|
||
router.post('/:slug/generate-token', async (req, res, next) => {
|
||
// Add slug to request for verifyGalleryAccess
|
||
req.requestedSlug = req.params.slug;
|
||
next();
|
||
}, verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
|
||
try {
|
||
const { photoId, accessType = 'view' } = req.body;
|
||
|
||
if (!photoId) {
|
||
return res.status(400).json({ error: 'Photo ID required' });
|
||
}
|
||
|
||
// Verify photo exists and belongs to event
|
||
const photo = await db('photos')
|
||
.where({ id: photoId, event_id: req.event.id })
|
||
.first();
|
||
|
||
if (!photo) {
|
||
return res.status(404).json({ error: 'Photo not found' });
|
||
}
|
||
|
||
// Don't mint a secure-image capability for a hidden/client-only photo
|
||
// when the caller isn't a client (the token is reusable up to 3×).
|
||
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
|
||
return res.status(403).json({ error: 'Photo not available' });
|
||
}
|
||
|
||
// Create client fingerprint
|
||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||
|
||
// Get protection level from event settings
|
||
const protectionLevel = req.event.protection_level || 'standard';
|
||
|
||
// Generate secure token with appropriate settings
|
||
const tokenOptions = {
|
||
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
|
||
maxUses: accessType === 'download' ? 1 : 3,
|
||
clientFingerprint,
|
||
protectionLevel,
|
||
// Reveal mode (#838): recorded in the token so a re-hide invalidates
|
||
// in-flight guest tokens at serve time without breaking the slideshow.
|
||
revealBypass: bypassesReveal(req),
|
||
// TOCTOU: a client's token keeps serving a photo hidden after minting;
|
||
// a guest's stops the moment it's hidden (checked at the serve route).
|
||
clientBypass: canSeeHiddenPhotos(req.accessLevel)
|
||
};
|
||
|
||
const token = secureImageService.generateSecureToken(
|
||
photoId,
|
||
req.sessionID || 'anonymous',
|
||
tokenOptions
|
||
);
|
||
|
||
// Log token generation
|
||
await secureImageService.logImageAccess(
|
||
photoId,
|
||
req.event.id,
|
||
{
|
||
ip: req.ip,
|
||
userAgent: req.get('User-Agent'),
|
||
fingerprint: clientFingerprint
|
||
},
|
||
'token_generated'
|
||
);
|
||
|
||
res.json({
|
||
token,
|
||
expiresIn: tokenOptions.expiresIn,
|
||
maxUses: tokenOptions.maxUses,
|
||
protectionLevel
|
||
});
|
||
|
||
} catch (error) {
|
||
logger.error('Error generating secure token', {
|
||
error: error.message,
|
||
photoId: req.body.photoId,
|
||
eventId: req.event?.id
|
||
});
|
||
res.status(500).json({ error: 'Failed to generate secure token' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Serve protected image with security measures
|
||
*/
|
||
router.get('/:slug/secure/:photoId/:token',
|
||
secureImageMiddleware.secureImageAccess,
|
||
async (req, res) => {
|
||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||
|
||
try {
|
||
logger.debug('Secure image route hit', {
|
||
slug,
|
||
photoId,
|
||
tokenLength: token?.length,
|
||
hasAuthHeader: Boolean(req.headers.authorization),
|
||
});
|
||
// Verify secure token
|
||
const tokenValidation = secureImageService.verifySecureToken(
|
||
token,
|
||
req.clientInfo.fingerprint
|
||
);
|
||
|
||
if (!tokenValidation.valid) {
|
||
// Get event for logging (best effort)
|
||
const event = await db('events').where({ slug }).first();
|
||
await secureImageService.logImageAccess(
|
||
photoId,
|
||
event?.id || 0,
|
||
req.clientInfo,
|
||
'token_invalid'
|
||
);
|
||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
// Get event from slug
|
||
const event = await db('events')
|
||
.where({
|
||
slug,
|
||
is_active: formatBoolean(true),
|
||
is_archived: formatBoolean(false)
|
||
})
|
||
.first();
|
||
|
||
if (!event) {
|
||
return res.status(404).json({ error: 'Gallery not found' });
|
||
}
|
||
|
||
// Bind the token to the gallery + photo it was minted for
|
||
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
|
||
// token in the URL, so it can't require verifyGalleryAccess like the
|
||
// download sibling does. Instead enforce the scope already inside the
|
||
// token: it is minted for one photoId (and photos belong to exactly
|
||
// one gallery), and its sessionId records the minting gallery's id.
|
||
// Without this, a token minted on any PUBLIC gallery reads every other
|
||
// gallery's photos with no password.
|
||
const tokenPhotoId = Number(tokenValidation.data?.photoId);
|
||
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
|
||
await secureImageService.logImageAccess(
|
||
photoId, event.id, req.clientInfo, 'photo_mismatch'
|
||
);
|
||
return res.status(403).json({ error: 'Token not valid for this photo' });
|
||
}
|
||
// Defense in depth: the sessionId embeds the gallery the token was
|
||
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
|
||
// token whose gallery is parseable and differs from this one.
|
||
const sessionEventId = Number(
|
||
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
|
||
);
|
||
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
|
||
await secureImageService.logImageAccess(
|
||
photoId, event.id, req.clientInfo, 'gallery_mismatch'
|
||
);
|
||
return res.status(403).json({ error: 'Token not valid for this gallery' });
|
||
}
|
||
|
||
// Reveal mode (#838): tokens minted by plain guests die the moment the
|
||
// gallery is (re-)hidden — bypass contexts keep working.
|
||
if (isGalleryHidden(event) && !tokenValidation.data?.revealBypass) {
|
||
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
|
||
}
|
||
|
||
// Verify photo exists and 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' });
|
||
}
|
||
|
||
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
|
||
// token was minted must stop serving, unless the token was minted by a
|
||
// client (clientBypass) — mirroring the reveal-mode check above.
|
||
if (photo.visibility === 'hidden' && !tokenValidation.data?.clientBypass) {
|
||
return res.status(403).json({ error: 'Photo not available' });
|
||
}
|
||
|
||
// Resolve photo through storage backend (managed) or fall back to local
|
||
// path (external reference mode). secureImageService needs a local file,
|
||
// so we materialize a tmp copy via withLocalCopy in S3 mode.
|
||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||
|
||
// Get protection settings for this event
|
||
const protectionSettings = {
|
||
protectionLevel: event.protection_level || 'standard',
|
||
quality: event.image_quality || 85,
|
||
addFingerprint: event.add_fingerprint !== false
|
||
};
|
||
|
||
let processedImage;
|
||
try {
|
||
const runProcessing = (lp) => secureImageService.processProtectedImage(lp, protectionSettings);
|
||
processedImage = storageKey
|
||
? await withLocalCopy(storageKey, runProcessing)
|
||
: await runProcessing(resolvePhotoFilePath(event, photo));
|
||
} catch (resolveError) {
|
||
logger.error('Failed to process secure image', {
|
||
slug: req.params.slug,
|
||
photoId,
|
||
eventId: event.id,
|
||
error: resolveError.message,
|
||
});
|
||
return res.status(404).json({ error: 'Photo file not found' });
|
||
}
|
||
|
||
// Log successful access
|
||
await secureImageService.logImageAccess(
|
||
photoId,
|
||
event.id,
|
||
req.clientInfo,
|
||
'view'
|
||
);
|
||
|
||
// Set content type and security headers
|
||
res.set({
|
||
'Content-Type': resolvePhotoContentType(photo),
|
||
'Content-Length': processedImage.length,
|
||
'X-Protection-Level': protectionSettings.protectionLevel,
|
||
'X-Remaining-Uses': tokenValidation.remaining
|
||
});
|
||
|
||
res.send(processedImage);
|
||
|
||
} catch (error) {
|
||
logger.error('Error serving secure image', {
|
||
error: error.message,
|
||
photoId,
|
||
slug,
|
||
clientFingerprint: req.clientInfo?.fingerprint
|
||
});
|
||
res.status(500).json({ error: 'Failed to serve image' });
|
||
}
|
||
}
|
||
);
|
||
|
||
/**
|
||
* Download protected image with watermark
|
||
*/
|
||
router.get('/:slug/secure-download/:photoId/:token',
|
||
secureImageMiddleware.secureImageAccess,
|
||
async (req, res, next) => {
|
||
// Add slug to request for verifyGalleryAccess
|
||
req.requestedSlug = req.params.slug;
|
||
next();
|
||
},
|
||
verifyGalleryAccess,
|
||
blockHiddenGallery,
|
||
denySlideshowToken,
|
||
async (req, res) => {
|
||
try {
|
||
const { photoId, token } = req.params;
|
||
|
||
// Check if downloads are allowed. SQLite stores the flag as 0/1, so a
|
||
// strict `=== false` never fired there and the guard was inert (#1028).
|
||
if (!parseBooleanInput(req.event.allow_downloads, true)) {
|
||
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||
}
|
||
|
||
// Verify secure token
|
||
const tokenValidation = secureImageService.verifySecureToken(
|
||
token,
|
||
req.clientInfo.fingerprint
|
||
);
|
||
|
||
if (!tokenValidation.valid) {
|
||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
// Bind the token to the photo it was minted for (GHSA-crxv) — the
|
||
// /secure serve route does this, but secure-download did not, so a
|
||
// token minted for photo A could download photo B (incl. a hidden one).
|
||
const tokenPhotoId = Number(tokenValidation.data?.photoId);
|
||
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
|
||
return res.status(403).json({ error: 'Token not valid for this photo' });
|
||
}
|
||
|
||
// Verify photo exists
|
||
const photo = await db('photos')
|
||
.where({ id: photoId, event_id: req.event.id })
|
||
.first();
|
||
|
||
if (!photo) {
|
||
return res.status(404).json({ error: 'Photo not found' });
|
||
}
|
||
|
||
// Block guest access to hidden/client-only photos.
|
||
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
|
||
return res.status(403).json({ error: 'Photo not available' });
|
||
}
|
||
|
||
// Per-category download opt-out (#640) — the regular single-photo
|
||
// download enforces this too; the secure path skipped it. SQLite
|
||
// returns the boolean as numeric 0, so check both forms.
|
||
if (photo.category_id) {
|
||
const cat = await db('photo_categories')
|
||
.where('id', photo.category_id)
|
||
.first('allow_downloads');
|
||
if (cat && (cat.allow_downloads === false || cat.allow_downloads === 0)) {
|
||
return res.status(403).json({ error: 'Downloads are disabled for this category' });
|
||
}
|
||
}
|
||
|
||
// Resolve photo through storage backend (managed) or local disk (external).
|
||
const storageKey = resolvePhotoStorageKey(req.event, photo);
|
||
|
||
const watermarkService = require('../services/watermarkService');
|
||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||
const wantsWatermark = watermarkSettings && watermarkSettings.enabled;
|
||
|
||
let fileBuffer;
|
||
try {
|
||
if (wantsWatermark) {
|
||
fileBuffer = storageKey
|
||
? await withLocalCopy(storageKey, (lp) => watermarkService.applyWatermark(lp, watermarkSettings))
|
||
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), watermarkSettings);
|
||
} else if (storageKey) {
|
||
const stream = await getStorage().get(storageKey);
|
||
const chunks = [];
|
||
for await (const chunk of stream) chunks.push(chunk);
|
||
fileBuffer = Buffer.concat(chunks);
|
||
} else {
|
||
const fs = require('fs').promises;
|
||
fileBuffer = await fs.readFile(resolvePhotoFilePath(req.event, photo));
|
||
}
|
||
} catch (resolveError) {
|
||
logger.error('Failed to fetch photo for secure download', {
|
||
slug: req.params.slug,
|
||
photoId,
|
||
eventId: req.event.id,
|
||
error: resolveError.message,
|
||
});
|
||
return res.status(404).json({ error: 'Photo file not found' });
|
||
}
|
||
|
||
// Update download count
|
||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||
|
||
// Log download
|
||
await secureImageService.logImageAccess(
|
||
photoId,
|
||
req.event.id,
|
||
req.clientInfo,
|
||
'download'
|
||
);
|
||
|
||
// #493/#507: respect the original-filename toggle here too. The
|
||
// regular `/gallery/:slug/download/:photoId` route already does
|
||
// this — secure-images was missed in the original PR and ran
|
||
// even when the admin had opted into original camera filenames.
|
||
const useOriginal = await getUseOriginalFilenames();
|
||
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||
|
||
res.set({
|
||
'Content-Type': resolvePhotoContentType(photo),
|
||
'Content-Disposition': buildContentDisposition(downloadName),
|
||
'Content-Length': fileBuffer.length,
|
||
'X-Download-Protected': 'true'
|
||
});
|
||
|
||
res.send(fileBuffer);
|
||
|
||
} catch (error) {
|
||
logger.error('Error serving secure download', {
|
||
error: error.message,
|
||
photoId: req.params.photoId
|
||
});
|
||
res.status(500).json({ error: 'Failed to download image' });
|
||
}
|
||
}
|
||
);
|
||
|
||
/**
|
||
* Get security statistics for monitoring
|
||
*/
|
||
const { adminAuth } = require('../middleware/auth');
|
||
const { requirePermission } = require('../middleware/permissions');
|
||
|
||
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||
try {
|
||
|
||
// Get security statistics
|
||
const stats = {
|
||
middleware: secureImageMiddleware.getSecurityStatus(),
|
||
recentAccess: await getRecentAccessStats(),
|
||
suspiciousActivity: await getSuspiciousActivityStats()
|
||
};
|
||
|
||
res.json(stats);
|
||
|
||
} catch (error) {
|
||
logger.error('Error getting security stats', { error: error.message });
|
||
res.status(500).json({ error: 'Failed to get security stats' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Get recent access statistics
|
||
*/
|
||
async function getRecentAccessStats() {
|
||
try {
|
||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||
|
||
const stats = await db('image_access_logs')
|
||
.where('accessed_at', '>', hourAgo)
|
||
.select('access_type')
|
||
.count('* as count')
|
||
.groupBy('access_type');
|
||
|
||
return stats.reduce((acc, stat) => {
|
||
acc[stat.access_type] = parseInt(stat.count);
|
||
return acc;
|
||
}, {});
|
||
} catch (error) {
|
||
logger.error('Error getting recent access stats:', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get suspicious activity statistics
|
||
*/
|
||
async function getSuspiciousActivityStats() {
|
||
try {
|
||
const hourAgo = new Date(Date.now() - 3600000).toISOString();
|
||
|
||
const suspiciousCount = await db('image_access_logs')
|
||
.where('accessed_at', '>', hourAgo)
|
||
.where('access_type', 'like', '%suspicious%')
|
||
.count('* as count')
|
||
.first();
|
||
|
||
const uniqueIPs = await db('image_access_logs')
|
||
.where('accessed_at', '>', hourAgo)
|
||
.countDistinct('client_ip as count')
|
||
.first();
|
||
|
||
return {
|
||
suspiciousEvents: parseInt(suspiciousCount.count),
|
||
uniqueIPs: parseInt(uniqueIPs.count)
|
||
};
|
||
} catch (error) {
|
||
logger.error('Error getting suspicious activity stats:', error);
|
||
return { suspiciousEvents: 0, uniqueIPs: 0 };
|
||
}
|
||
}
|
||
|
||
module.exports = router;
|