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