EventBuzz_Backend/src/controllers/publicSocialShare.controlle...

340 lines
11 KiB
JavaScript

const mongoose = require('mongoose');
const SocialShare = require('../models/SocialShare');
const SocialShareFrame = require('../models/SocialShareFrame');
const SocialShareOutput = require('../models/SocialShareOutput');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const { success, error } = require('../utils/response');
const {
uploadEventPhotoToS3,
getEventPhotoBufferFromS3,
resolvePhotoPublicUrl,
resolvePhotoThumbUrl,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('../utils/s3EventPhotos');
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
const { normalizeUploaderDisplayName } = require('../utils/uploaderDisplayName');
const {
EVENT_PUBLIC_SELECT,
buildPublicEventPayload,
normalizeEventHeaderDisplay,
} = require('../utils/formPublicHelpers');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractSingleFile(payload) {
const raw = payload?.file || payload?.files || payload?.photo;
if (!raw) return null;
const file = Array.isArray(raw) ? raw[0] : raw;
if (!file) return null;
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
return null;
}
const originalName = file.hapi?.filename || file.filename || 'selfie.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
return { buffer, originalName, mimeType };
}
async function parseStringField(payload, key) {
let raw = payload?.[key];
if (raw == null || raw === '') return '';
if (typeof raw === 'string') return raw.trim();
if (Buffer.isBuffer(raw)) return raw.toString('utf8').trim();
if (typeof raw.pipe === 'function' || typeof raw[Symbol.asyncIterator] === 'function') {
const buf = await streamToBuffer(raw);
return buf.toString('utf8').trim();
}
return String(raw).trim();
}
function safeDownloadFilename(name, fallback = 'social-share.jpg') {
const raw = String(name || fallback).trim() || fallback;
return raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback;
}
async function loadEnabledConfig(uploadToken) {
const token = String(uploadToken || '').trim();
if (!token) return null;
const config = await SocialShare.findOne({ uploadToken: token });
if (!config || !config.enabled) return null;
return config;
}
function frameAsWallShape(config, frame) {
return {
event: config.event,
frameEnabled: true,
frameImageUrl: frame.frameImageUrl,
frameImageLayout: frame.frameImageLayout,
frameCanvasWidth: frame.frameCanvasWidth,
frameCanvasHeight: frame.frameCanvasHeight,
frameLayout: frame.frameLayout,
};
}
const getUploadMeta = async (request, h) => {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean();
const ms = event
? await EventMicrosite.findOne({ event: config.event }).select('logo').lean()
: null;
const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null;
const frames = await SocialShareFrame.find({
socialShare: config._id,
enabled: true,
})
.sort({ sortOrder: 1, createdAt: 1 })
.lean();
return h
.response(
success({
eventId: config.event,
eventName: eventPublic?.name || event?.name || 'Event',
event: eventPublic,
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
accessMode: config.accessMode || 'open',
requireUploadCrop: Boolean(config.requireUploadCrop),
maxPhotoWidth: config.maxPhotoWidth ?? 1080,
maxPhotoHeight: config.maxPhotoHeight ?? 1080,
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
frames: frames.map((f) => {
const layout = Array.isArray(f.frameLayout) ? f.frameLayout : [];
const photoEl = layout.find(
(el) => el && el.kind === 'field' && el.field === 'photo',
);
let photoSlot = null;
if (photoEl) {
const w = Math.max(1, Math.round(Number(photoEl.w) || 400));
// Circles always crop square; otherwise use slot box.
const h =
photoEl.photoShape === 'circle'
? w
: Math.max(1, Math.round(Number(photoEl.h) || w));
photoSlot = { w, h };
}
return {
_id: String(f._id),
name: f.name || 'Frame',
frameImageUrl: resolvePhotoPublicUrl(f.frameImageUrl) || f.frameImageUrl || null,
frameCanvasWidth: f.frameCanvasWidth ?? 1080,
frameCanvasHeight: f.frameCanvasHeight ?? 1080,
photoSlot,
};
}),
}),
)
.code(200);
};
const uploadPhoto = async (request, h) => {
const uploadToken = String(request.params.uploadToken || '').trim();
const config = await loadEnabledConfig(uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const participantCred = request.auth?.credentials?.participant;
if (config.accessMode === 'login' && !participantCred?._id) {
return h.response(error('UNAUTHORIZED', 'Login required to upload')).code(401);
}
const frameId = await parseStringField(request.payload || {}, 'frameId');
if (!frameId || !mongoose.Types.ObjectId.isValid(frameId)) {
return h.response(error('VALIDATION_ERROR', 'Please select a frame')).code(400);
}
const displayName = normalizeUploaderDisplayName(
await parseStringField(request.payload || {}, 'displayName'),
);
if (!displayName) {
return h.response(error('VALIDATION_ERROR', 'Your name is required and must be 80 characters or fewer')).code(400);
}
const frame = await SocialShareFrame.findOne({
_id: frameId,
socialShare: config._id,
enabled: true,
}).lean();
if (!frame) {
return h.response(error('NOT_FOUND', 'Frame not found')).code(404);
}
if (!frame.frameImageUrl) {
return h.response(error('VALIDATION_ERROR', 'Selected frame is not configured yet')).code(400);
}
const file = await extractSingleFile(request.payload || {});
if (!file?.buffer?.length) {
return h.response(error('VALIDATION_ERROR', 'Photo file is required')).code(400);
}
if (!ALLOWED_MIME.has(String(file.mimeType || '').toLowerCase())) {
return h.response(error('VALIDATION_ERROR', 'Unsupported image type')).code(400);
}
if (file.buffer.length > 15 * 1024 * 1024) {
return h.response(error('VALIDATION_ERROR', 'Image must be under 15MB')).code(400);
}
const safeName = String(file.originalName || 'selfie.jpg')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 80);
const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
let uploaded;
try {
uploaded = await uploadEventPhotoToS3({
buffer: file.buffer,
contentType: file.mimeType,
eventId: String(config.event),
filename: storedFilename,
activity: PHOTO_ACTIVITIES.socialShare,
kind: PHOTO_KINDS.uploads,
});
} catch (err) {
console.error('[socialShare] S3 upload failed', err?.message);
return h.response(error('UPLOAD_FAILED', 'Failed to upload photo')).code(500);
}
const output = await SocialShareOutput.create({
socialShare: config._id,
event: config.event,
organization: config.organization,
frame: frame._id,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: file.mimeType,
size: file.buffer.length,
originalFileName: file.originalName,
displayName,
});
try {
const wallShape = frameAsWallShape(config, frame);
const framed = await renderAndUploadFramedPhoto(
{ wall: wallShape, photo: output },
{
activity: PHOTO_ACTIVITIES.socialShare,
kind: PHOTO_KINDS.framed,
},
);
output.framedUrl = framed.framedUrl;
output.framedS3Key = framed.framedS3Key;
await output.save();
} catch (err) {
console.error('[socialShare] frame composite failed', err?.message);
return h.response(error('FRAME_FAILED', 'Could not create framed photo. Try again.')).code(500);
}
const previewUrl =
resolvePhotoThumbUrl(output.framedS3Key || output.framedUrl, { w: 1080, h: 1080 }) ||
resolvePhotoPublicUrl(output.framedUrl) ||
output.framedUrl;
const fileUrl = resolvePhotoPublicUrl(output.framedS3Key || output.framedUrl) || previewUrl;
return h
.response(
success(
{
outputId: String(output._id),
previewUrl,
fileUrl,
framed: true,
},
'Photo created',
),
)
.code(201);
};
const downloadOutputFile = async (request, h) => {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const { outputId } = request.params;
if (!mongoose.Types.ObjectId.isValid(outputId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid output id')).code(400);
}
const output = await SocialShareOutput.findOne({
_id: outputId,
socialShare: config._id,
}).lean();
if (!output) return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
const s3Key = output.framedS3Key || output.s3Key;
if (!s3Key) return h.response(error('NOT_FOUND', 'Photo file not available')).code(404);
try {
const buf = await getEventPhotoBufferFromS3(s3Key);
const mime = output.framedS3Key ? 'image/jpeg' : output.mimeType || 'image/jpeg';
const filename = safeDownloadFilename(
output.framedS3Key ? 'social-share.jpg' : output.originalFileName || 'social-share.jpg',
);
const asAttachment =
String(request.query?.download || '').toLowerCase() === '1' ||
String(request.query?.download || '').toLowerCase() === 'true';
return h
.response(buf)
.type(mime)
.header('Cache-Control', 'private, max-age=300')
.header(
'Content-Disposition',
`${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`,
);
} catch (err) {
console.error('[socialShare] file proxy failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load photo')).code(500);
}
};
module.exports = {
getUploadMeta,
uploadPhoto,
downloadOutputFile,
};