const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); function getEnv(name, fallback) { const v = process.env[name]; if (v == null || String(v).trim() === '') return fallback; return String(v).trim(); } function sanitizeKeySegment(segment) { const s = String(segment).trim(); return s.replace(/[^a-zA-Z0-9._-]/g, '_'); } /** Activity folder names under {eventId}/… */ const PHOTO_ACTIVITIES = { photoWall: 'photo-wall', photoSearch: 'photo-search', aiAvatar: 'ai-avatar', socialShare: 'social-share', engagement: 'engagement', }; /** Kind folder names under {eventId}/{activity}/… */ const PHOTO_KINDS = { originals: 'originals', framed: 'framed', gallery: 'gallery', searchTmp: 'search-tmp', uploads: 'uploads', results: 'results', }; /** * Build standardized S3 key: * {eventId}/{activity}/{kind}/{filename} */ function buildActivityPhotoKey({ eventId, activity, kind, filename }) { const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); const safeActivity = sanitizeKeySegment(activity || 'misc'); const safeKind = sanitizeKeySegment(kind || 'uploads'); const safeFilename = sanitizeKeySegment(filename || 'file'); return `${safeEventId}/${safeActivity}/${safeKind}/${safeFilename}`; } /** * Legacy helper — prefer buildActivityPhotoKey. * Pattern: {eventId}/{filename} or {eventId}/{subdir}/{filename} * When subdir contains `/`, each segment is sanitized separately. */ function buildEventPhotoKey({ eventId, filename, subdir }) { const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); const safeFilename = sanitizeKeySegment(filename || 'file'); if (!subdir) return `${safeEventId}/${safeFilename}`; const safeSubdir = String(subdir) .split('/') .map((part) => sanitizeKeySegment(part)) .filter(Boolean) .join('/'); if (!safeSubdir) return `${safeEventId}/${safeFilename}`; return `${safeEventId}/${safeSubdir}/${safeFilename}`; } /** * Photo wall keys — standardized layout: * - original: {eventId}/photo-wall/originals/{filename} * - framed: {eventId}/photo-wall/framed/{filename} */ function buildPhotoWallKey({ eventId, filename, framed = false }) { return buildActivityPhotoKey({ eventId, activity: PHOTO_ACTIVITIES.photoWall, kind: framed ? PHOTO_KINDS.framed : PHOTO_KINDS.originals, filename, }); } function picturesCloudfrontBase() { // Pictures CDN (eventbuzz-pictures + DIT). Do not fall back to AWS_URL (rapportapp media CDN). return getEnv('AWS_URL_PICTURES', 'https://pictures.eventbuzz.live').replace(/\/+$/, ''); } function mediaCloudfrontBase() { return getEnv('AWS_URL', 'https://did94hhhcgq1d.cloudfront.net').replace(/\/+$/, ''); } function hostnameFromBase(base) { if (!base) return ''; try { return new URL(base.includes('://') ? base : `https://${base}`).hostname.toLowerCase(); } catch { return ''; } } function isPicturesHost(hostname) { const picturesHost = hostnameFromBase(picturesCloudfrontBase()); const host = String(hostname || '').toLowerCase(); return Boolean(picturesHost && host === picturesHost); } /** Media-library keys from s3Media.js: eventbuzz/{orgId}/{eventId}/{module}/{file} */ function isLegacyMediaKey(key) { return /^eventbuzz\//i.test(String(key || '').trim()); } function parseAbsoluteUrl(urlOrKey) { const raw = String(urlOrKey || '').trim(); if (!/^https?:\/\//i.test(raw)) return null; try { return new URL(raw); } catch { return null; } } /** * Normalize a stored pictures url/key to an S3 object key. * Strips absolute CDN hosts (custom domain + legacy CloudFront) and fit-in prefixes. */ function toPicturesKey(urlOrKey) { let raw = String(urlOrKey || '').trim(); if (!raw) return ''; if (/^https?:\/\//i.test(raw)) { try { const u = new URL(raw); raw = u.pathname || ''; } catch { /* keep raw */ } } raw = raw.replace(/^\/+/, ''); // DIT Thumbor-style path: fit-in/400x400/{key} raw = raw.replace(/^fit-in\/\d+x\d+\//i, ''); return raw; } /** * Absolute CloudFront URL for a pictures-bucket key (original, no resize). * Accepts a bare key or an absolute URL (rewritten onto current AWS_URL_PICTURES). */ function cloudfrontUrlForKey(key) { const objectKey = toPicturesKey(key); if (!objectKey) return ''; const base = picturesCloudfrontBase(); if (!base) { console.warn('[s3EventPhotos] AWS_URL_PICTURES is not set — returning S3 key as url'); return objectKey; } return `${base}/${objectKey}`; } function mediaUrlForKey(key) { const objectKey = String(key || '').replace(/^\/+/, ''); if (!objectKey) return ''; const base = mediaCloudfrontBase(); if (!base) return objectKey; return `${base}/${objectKey}`; } /** * Normalize stored url/framedUrl that may be a bare key (legacy / misconfigured env). * Absolute URLs on the old media CDN (rapportapp / AWS_URL) are left unchanged — * those files are not in the eventbuzz-pictures bucket. */ function resolvePhotoPublicUrl(urlOrKey) { if (urlOrKey == null || String(urlOrKey).trim() === '') return null; const raw = String(urlOrKey).trim(); const absolute = parseAbsoluteUrl(raw); if (absolute) { if (isPicturesHost(absolute.hostname)) { return cloudfrontUrlForKey(raw) || raw; } return raw; } if (isLegacyMediaKey(raw)) { return mediaUrlForKey(raw) || raw; } const url = cloudfrontUrlForKey(raw); return url || null; } /** * On-the-fly resized picture URL via Dynamic Image Transformation (Thumbor fit-in). * Example: https://pictures.eventbuzz.live/fit-in/400x400/{key} * Legacy media-CDN URLs cannot be resized on the pictures DIT; return the original. */ function resolvePhotoThumbUrl(urlOrKey, { w = 400, h = 400 } = {}) { if (urlOrKey == null || String(urlOrKey).trim() === '') return null; const raw = String(urlOrKey).trim(); const absolute = parseAbsoluteUrl(raw); if (absolute && !isPicturesHost(absolute.hostname)) { return raw; } if (!absolute && isLegacyMediaKey(raw)) { return mediaUrlForKey(raw) || raw; } const objectKey = toPicturesKey(raw); if (!objectKey) return null; const width = Math.max(1, Math.min(4096, Math.round(Number(w) || 400))); const height = Math.max(1, Math.min(4096, Math.round(Number(h) || 400))); const base = picturesCloudfrontBase(); if (!base) return null; return `${base}/fit-in/${width}x${height}/${objectKey}`; } let cachedS3Client = null; function getS3Client() { if (cachedS3Client) return cachedS3Client; const region = getEnv('AWS_DEFAULT_REGION', 'ap-south-1'); const accessKeyId = getEnv('AWS_ACCESS_KEY_ID'); const secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY'); cachedS3Client = new S3Client({ region, credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined, }); return cachedS3Client; } async function putPicturesObject({ key, buffer, contentType }) { const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); await getS3Client().send( new PutObjectCommand({ Bucket: bucket, Key: key, Body: buffer, ContentType: contentType || 'application/octet-stream', }), ); return { s3Key: key, url: cloudfrontUrlForKey(key) }; } /** * Upload a photo buffer to the eventbuzz-pictures S3 bucket. * Prefer `{ activity, kind }` for the standardized layout: * {eventId}/{activity}/{kind}/{filename} * Legacy `{ subdir }` still supported (segment-safe). */ async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, activity, kind, subdir }) { let key; if (activity && kind) { key = buildActivityPhotoKey({ eventId, activity, kind, filename }); } else { key = buildEventPhotoKey({ eventId, filename, subdir }); } return putPicturesObject({ key, buffer, contentType }); } /** * Upload a photo-wall image (original or framed composite) to the pictures bucket. * Keys: {eventId}/photo-wall/originals|framed/{filename} */ async function uploadPhotoWallPhotoToS3({ buffer, contentType, eventId, filename, framed = false }) { const key = buildPhotoWallKey({ eventId, filename, framed }); return putPicturesObject({ key, buffer, contentType }); } /** * Delete a photo from eventbuzz-pictures S3 bucket by key. */ async function deleteEventPhotoFromS3({ s3Key }) { const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); if (!bucket) return; if (!s3Key) return; await getS3Client().send( new DeleteObjectCommand({ Bucket: bucket, Key: s3Key, }), ); } /** * Get a photo buffer from eventbuzz-pictures S3 bucket by key. */ async function getEventPhotoBufferFromS3(s3Key) { const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); if (!s3Key) throw new Error('s3Key required'); const response = await getS3Client().send( new GetObjectCommand({ Bucket: bucket, Key: s3Key, }), ); const chunks = []; for await (const chunk of response.Body) { chunks.push(chunk); } return Buffer.concat(chunks); } module.exports = { PHOTO_ACTIVITIES, PHOTO_KINDS, uploadEventPhotoToS3, uploadPhotoWallPhotoToS3, deleteEventPhotoFromS3, getEventPhotoBufferFromS3, buildActivityPhotoKey, buildEventPhotoKey, buildPhotoWallKey, cloudfrontUrlForKey, toPicturesKey, resolvePhotoPublicUrl, resolvePhotoThumbUrl, };