340 lines
11 KiB
JavaScript
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,
|
|
};
|