EventBuzz_Backend/src/utils/s3EventPhotos.js

322 lines
9.4 KiB
JavaScript

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,
};