const mongoose = require('mongoose'); const Event = require('../models/Event'); const EventStaticMicrosite = require('../models/EventStaticMicrosite'); const MicrositeV2Template = require('../models/MicrositeV2Template'); const { success, error } = require('../utils/response'); const { normalizeSubdomain, subdomainAvailability } = require('../utils/subdomainAvailability'); const { uploadTemplateFile, uploadTemplateAsset, templateAssetsBaseUrl, templateKey, cloudfrontUrlForKey, replaceSlugInS3Key, copyTemplateFilesBetweenSlugs, } = require('../utils/s3MicrositeV2'); const { parseTemplateSchema, buildDefaultConfig } = require('../utils/templateSchema'); const { renderStaticMicrositeHtml, publishStaticMicrosite, loadTemplateSchema, } = require('../utils/publishStaticMicrosite'); const { renderHandlebarsTemplate } = require('../utils/handlebarsRender'); const { isLocalMicrositeV2TemplatesEnabled, localPreviewAssetsBaseUrl, resolveLocalAssetFile, mimeForLocalAsset, } = require('../utils/localMicrositeV2Templates'); const fs = require('fs'); function requirePlatformAdmin(user) { return user.platform_admin === 'yes'; } function toObjectId(id) { if (id == null || id === '') return null; const s = String(id); if (!mongoose.Types.ObjectId.isValid(s)) return null; return new mongoose.Types.ObjectId(s); } function orgIdOf(user) { return toObjectId(user.organization?._id || user.organization); } function refIdString(ref) { if (ref == null) return ''; if (typeof ref === 'object' && ref._id != null) return String(ref._id); return String(ref); } function templateAccessibleToOrg(template, orgId) { if (!template || template.visibility !== 'private') return true; const oid = toObjectId(orgId); if (!oid) return false; const target = String(oid); const allowed = template.allowedOrganizations || []; return allowed.some((id) => refIdString(id) === target); } function normalizeOrgIds(raw) { if (!Array.isArray(raw)) return []; const ids = raw.map((id) => toObjectId(id)).filter(Boolean); const unique = [...new Set(ids.map((oid) => oid.toString()))]; return unique.map((s) => new mongoose.Types.ObjectId(s)); } function buildTemplateCatalogQuery(organizationId) { const base = { isActive: true }; const publicClause = [{ visibility: { $exists: false } }, { visibility: 'public' }]; if (!organizationId) { return { ...base, $or: publicClause }; } return { ...base, $or: [...publicClause, { visibility: 'private', allowedOrganizations: organizationId }], }; } async function resolveOrganizationForTemplateList(request) { const user = request.auth.credentials.user; const { eventId } = request.query || {}; if (eventId && mongoose.Types.ObjectId.isValid(eventId)) { const { event, forbidden } = await loadEventForUser(eventId, user); if (forbidden) return { error: 'FORBIDDEN' }; if (!event) return { error: 'NOT_FOUND' }; return { organizationId: toObjectId(event.organization) }; } return { organizationId: orgIdOf(user) }; } async function loadEventForUser(eventId, user) { const event = await Event.findById(eventId); if (!event) return { event: null, forbidden: false }; if (orgMismatch(event, user)) return { event: null, forbidden: true }; return { event, forbidden: false }; } function extractBufferPart(part, fallbackName) { if (!part) return null; if (Buffer.isBuffer(part)) { return { buffer: part, filename: fallbackName, mimeType: 'application/octet-stream' }; } if (typeof part === 'object' && part.payload && Buffer.isBuffer(part.payload)) { return { buffer: part.payload, filename: part.filename || fallbackName, mimeType: part.headers?.['content-type'] || 'application/octet-stream', }; } if (typeof part === 'object' && part._data && Buffer.isBuffer(part._data)) { return { buffer: part._data, filename: part.filename || fallbackName, mimeType: part.headers?.['content-type'] || 'application/octet-stream', }; } return null; } function orgMismatch(event, user) { if (user.role === 'super_admin') return false; return event.organization?.toString() !== (user.organization?._id || user.organization)?.toString(); } // --- Template catalog (read) --- async function assertTemplateReadable(request, template) { const user = request.auth.credentials.user; if (requirePlatformAdmin(user)) return null; const { eventId } = request.query || {}; if (eventId && mongoose.Types.ObjectId.isValid(eventId)) { const { event, forbidden } = await loadEventForUser(eventId, user); if (!forbidden && event) { const existingSite = await EventStaticMicrosite.findOne({ event: event._id, template: template._id, }) .select('_id') .lean(); if (existingSite) return null; } const scoped = await resolveOrganizationForTemplateList(request); if (scoped.error === 'FORBIDDEN') { return { status: 403, message: 'Access denied' }; } if (scoped.error === 'NOT_FOUND') { return { status: 404, message: 'Event not found' }; } if (templateAccessibleToOrg(template, scoped.organizationId)) { return null; } } if (templateAccessibleToOrg(template, orgIdOf(user))) { return null; } return { status: 403, message: 'This template is not available for your organization', }; } const listTemplates = async (request, h) => { const scoped = await resolveOrganizationForTemplateList(request); if (scoped.error === 'FORBIDDEN') { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } if (scoped.error === 'NOT_FOUND') { return h.response(error('NOT_FOUND', 'Event not found')).code(404); } const query = buildTemplateCatalogQuery(scoped.organizationId); const rows = await MicrositeV2Template.find(query) .sort({ createdAt: -1 }) .select('slug name description thumbnailUrl version isActive visibility createdAt') .lean(); return h.response(success(rows)); }; const listAdminTemplates = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only platform admins can list all templates')).code(403); } const rows = await MicrositeV2Template.find({}) .sort({ createdAt: -1 }) .populate('createdBy', 'name email') .populate('allowedOrganizations', 'name email') .lean(); return h.response(success(rows)); }; const getTemplate = async (request, h) => { const template = await MicrositeV2Template.findById(request.params.id) .populate('allowedOrganizations', 'name email') .lean(); if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); const access = await assertTemplateReadable(request, template); if (access) { return h.response(error('FORBIDDEN', access.message)).code(access.status); } let schema; try { schema = await loadTemplateSchema(template); } catch { schema = template.schema || null; } return h.response(success({ ...template, schema })); }; // --- Template admin --- const createTemplate = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only platform admins can create templates')).code(403); } const { slug, name, description, version, visibility, allowedOrganizations } = request.payload; const normalizedSlug = normalizeSubdomain(slug); if (!normalizedSlug) { return h.response(error('VALIDATION_ERROR', 'Valid slug is required')).code(400); } const vis = visibility === 'private' ? 'private' : 'public'; const orgIds = vis === 'private' ? normalizeOrgIds(allowedOrganizations) : []; if (vis === 'private' && orgIds.length === 0) { return h.response(error('VALIDATION_ERROR', 'Select at least one organization for a private template')).code(400); } const existing = await MicrositeV2Template.findOne({ slug: normalizedSlug }).lean(); if (existing) { return h.response(error('CONFLICT', 'Template slug already exists')).code(409); } const template = await MicrositeV2Template.create({ slug: normalizedSlug, name: name || normalizedSlug, description: description || '', version: version || 1, visibility: vis, allowedOrganizations: orgIds, isActive: false, createdBy: user._id, }); return h.response(success(template, 'Template created', 201)).code(201); }; const updateTemplate = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only super_admin can update templates')).code(403); } const template = await MicrositeV2Template.findById(request.params.id); if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); const { name, description, isActive, version, visibility, allowedOrganizations } = request.payload; if (name !== undefined) template.name = name; if (description !== undefined) template.description = description; if (isActive !== undefined) template.isActive = isActive; if (version !== undefined) template.version = version; if (visibility !== undefined) { template.visibility = visibility === 'private' ? 'private' : 'public'; } if (allowedOrganizations !== undefined) { template.allowedOrganizations = normalizeOrgIds(allowedOrganizations); } if (template.visibility === 'private' && (!template.allowedOrganizations || template.allowedOrganizations.length === 0)) { return h.response(error('VALIDATION_ERROR', 'Select at least one organization for a private template')).code(400); } if (template.visibility === 'public') { template.allowedOrganizations = []; } await template.save(); const populated = await MicrositeV2Template.findById(template._id) .populate('allowedOrganizations', 'name email') .lean(); return h.response(success(populated)); }; const duplicateTemplate = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only super_admin can duplicate templates')).code(403); } const source = await MicrositeV2Template.findById(request.params.id); if (!source) return h.response(error('NOT_FOUND', 'Template not found')).code(404); const payload = request.payload || {}; let newSlug = payload.slug ? normalizeSubdomain(payload.slug) : `${source.slug}-copy`; if (!newSlug) { return h.response(error('VALIDATION_ERROR', 'Valid slug is required')).code(400); } const baseSlug = newSlug; let suffix = 1; while (await MicrositeV2Template.findOne({ slug: newSlug }).lean()) { suffix += 1; newSlug = `${baseSlug}-${suffix}`; } const newName = (payload.name || `${source.name} (Copy)`).trim(); if (source.htmlS3Key || source.schemaS3Key || source.assetsS3Prefix) { await copyTemplateFilesBetweenSlugs(source.slug, newSlug); } const doc = source.toObject(); delete doc._id; delete doc.createdAt; delete doc.updatedAt; doc.slug = newSlug; doc.name = newName; doc.createdBy = user._id; doc.htmlS3Key = replaceSlugInS3Key(doc.htmlS3Key, source.slug, newSlug); doc.schemaS3Key = replaceSlugInS3Key(doc.schemaS3Key, source.slug, newSlug); if (doc.assetsS3Prefix) { doc.assetsS3Prefix = replaceSlugInS3Key(doc.assetsS3Prefix, source.slug, newSlug); } doc.assetsBaseUrl = templateAssetsBaseUrl(newSlug); if (source.thumbnailUrl) { const extMatch = String(source.thumbnailUrl).match(/thumbnail\.(\w+)/i); const ext = extMatch ? extMatch[1] : 'jpg'; doc.thumbnailUrl = cloudfrontUrlForKey(templateKey(newSlug, `thumbnail.${ext}`)); } const created = await MicrositeV2Template.create(doc); const populated = await MicrositeV2Template.findById(created._id) .populate('allowedOrganizations', 'name email') .lean(); return h.response(success(populated, 'Template duplicated', 201)).code(201); }; const removeTemplate = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only super_admin can delete templates')).code(403); } const template = await MicrositeV2Template.findById(request.params.id); if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); const inUse = await EventStaticMicrosite.countDocuments({ template: template._id }); if (inUse > 0) { return h.response(error('CONFLICT', 'Template is in use by event microsites')).code(409); } await template.deleteOne(); return h.response(success(null, 'Template deleted')); }; const uploadTemplateFiles = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only super_admin can upload templates')).code(403); } const template = await MicrositeV2Template.findById(request.params.id); if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); const payload = request.payload || {}; const slug = template.slug; const htmlPart = extractBufferPart(payload.templateHtml, 'template.html'); const jsonPart = extractBufferPart(payload.templateJson, 'template.json'); const thumbPart = extractBufferPart(payload.thumbnail, 'thumbnail.jpg'); const zipPart = extractBufferPart(payload.assetsZip, 'assets.zip'); if (htmlPart) { const up = await uploadTemplateFile(slug, 'template.html', htmlPart.buffer, 'text/html; charset=utf-8'); template.htmlS3Key = up.s3Key; } if (jsonPart) { const schema = parseTemplateSchema(jsonPart.buffer); const up = await uploadTemplateFile(slug, 'template.json', jsonPart.buffer, 'application/json'); template.schemaS3Key = up.s3Key; template.schema = schema; if (htmlPart) { try { const htmlStr = htmlPart.buffer.toString('utf8'); const defaultConfig = buildDefaultConfig(schema); renderHandlebarsTemplate(htmlStr, { meta: defaultConfig.meta, theme: defaultConfig.theme, sections: defaultConfig.sections, assetsBaseUrl: template.assetsBaseUrl || templateAssetsBaseUrl(slug), eventId: '', subdomain: '', templateSlug: slug, }); } catch (e) { return h.response(error('VALIDATION_ERROR', `Handlebars compile failed: ${e.message}`)).code(400); } } } if (thumbPart) { const ext = (thumbPart.filename || '').split('.').pop() || 'jpg'; const mime = thumbPart.mimeType.startsWith('image/') ? thumbPart.mimeType : `image/${ext}`; const up = await uploadTemplateFile(slug, `thumbnail.${ext}`, thumbPart.buffer, mime); template.thumbnailUrl = up.url; } if (zipPart) { // Lazy require — only needed for super-admin template zip upload. // eslint-disable-next-line global-require const AdmZip = require('adm-zip'); const zip = new AdmZip(zipPart.buffer); const entries = zip.getEntries(); for (const entry of entries) { if (entry.isDirectory) continue; const rel = entry.entryName.replace(/^\/+/, ''); const data = entry.getData(); const mime = rel.endsWith('.css') ? 'text/css' : rel.endsWith('.js') ? 'application/javascript' : 'application/octet-stream'; await uploadTemplateAsset(slug, rel, data, mime); } template.assetsS3Prefix = `templates/${slug}/assets`; } template.assetsBaseUrl = templateAssetsBaseUrl(slug); if (template.htmlS3Key && template.schemaS3Key) { template.isActive = true; } await template.save(); return h.response(success(template, 'Template files uploaded')); }; const previewTemplateHtml = async (request, h) => { const user = request.auth.credentials.user; if (!requirePlatformAdmin(user)) { return h.response(error('FORBIDDEN', 'Only super_admin can preview templates')).code(403); } const template = await MicrositeV2Template.findById(request.params.id).lean(); if (!template) return h.response(error('NOT_FOUND', 'Template not found')).code(404); try { const schema = await loadTemplateSchema(template); const defaultConfig = buildDefaultConfig(schema); const assetsBaseUrl = localPreviewAssetsBaseUrl(template.slug, request) || undefined; const html = await renderStaticMicrositeHtml({ template, config: defaultConfig, assetsBaseUrl, }); return h.response(html).type('text/html'); } catch (e) { return h.response(error('RENDER_ERROR', e.message)).code(400); } }; /** * Serve template CSS/JS/images from local disk when MICROSITE_V2_READ_FROM_LOCAL is on. * Public (no JWT) so blob-URL previews can load assets. */ const serveLocalTemplateAsset = async (request, h) => { if (!isLocalMicrositeV2TemplatesEnabled()) { return h.response(error('NOT_FOUND', 'Local microsite assets are disabled')).code(404); } const slug = String(request.params.slug || '').trim().toLowerCase(); const assetPath = Array.isArray(request.params.assetPath) ? request.params.assetPath.join('/') : String(request.params.assetPath || ''); const fullPath = resolveLocalAssetFile(slug, assetPath); if (!fullPath) { return h.response(error('NOT_FOUND', 'Asset not found')).code(404); } try { const buf = fs.readFileSync(fullPath); return h .response(buf) .type(mimeForLocalAsset(fullPath)) .header('Cache-Control', 'no-store, max-age=0') .header('Access-Control-Allow-Origin', '*'); } catch (e) { return h.response(error('SERVER_ERROR', e.message || 'Failed to read asset')).code(500); } }; // --- Event static microsite --- const getForEvent = async (request, h) => { const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user); if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const site = await EventStaticMicrosite.findOne({ event: event._id }) .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl') .lean(); if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404); return h.response(success(site)); }; const upsertForEvent = async (request, h) => { const user = request.auth.credentials.user; const { event, forbidden } = await loadEventForUser(request.params.eventId, user); if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const { subdomain, templateId, config } = request.payload; const normalized = normalizeSubdomain(subdomain); if (!normalized) { return h.response(error('VALIDATION_ERROR', 'Valid subdomain is required')).code(400); } if (!templateId || !mongoose.Types.ObjectId.isValid(templateId)) { return h.response(error('VALIDATION_ERROR', 'Valid template id is required')).code(400); } const template = await MicrositeV2Template.findOne({ _id: templateId, isActive: true }); if (!template) return h.response(error('NOT_FOUND', 'Template not found or inactive')).code(404); if (!templateAccessibleToOrg(template, event.organization)) { return h.response(error('FORBIDDEN', 'This template is not available for your organization')).code(403); } let site = await EventStaticMicrosite.findOne({ event: event._id }); const avail = await subdomainAvailability(normalized, event._id.toString(), site?._id?.toString()); if (!avail.available) { return h.response(error('VALIDATION_ERROR', avail.message)).code(400); } if (!site) { site = new EventStaticMicrosite({ event: event._id, organization: event.organization, subdomain: normalized, template: template._id, config: config || {}, updatedBy: user._id, }); } else { site.subdomain = normalized; site.template = template._id; if (config !== undefined) site.config = config; site.updatedBy = user._id; } const wasNew = site.isNew; await site.save(); const populated = await EventStaticMicrosite.findById(site._id) .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl') .lean(); return h.response(success(populated, wasNew ? 'Created' : 'Updated')); }; const checkSubdomain = async (request, h) => { const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user); if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const normalized = normalizeSubdomain(request.query.subdomain); const site = await EventStaticMicrosite.findOne({ event: event._id }).select('_id').lean(); const avail = await subdomainAvailability( normalized, event._id.toString(), site?._id?.toString(), ); return h.response(success(avail)); }; const publishForEvent = async (request, h) => { const user = request.auth.credentials.user; const { event, forbidden } = await loadEventForUser(request.params.eventId, user); if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template'); if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404); try { const result = await publishStaticMicrosite({ staticMicrosite: site, template: site.template, userId: user._id, }); Object.assign(site, result); await site.save(); const populated = await EventStaticMicrosite.findById(site._id) .populate('template', 'slug name description thumbnailUrl version assetsBaseUrl') .lean(); return h.response(success({ ...populated, publicUrl: result.publicUrl }, 'Published')); } catch (e) { return h.response(error('PUBLISH_ERROR', e.message)).code(400); } }; const previewForEvent = async (request, h) => { const { event, forbidden } = await loadEventForUser(request.params.eventId, request.auth.credentials.user); if (forbidden) return h.response(error('FORBIDDEN', 'Access denied')).code(403); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const site = await EventStaticMicrosite.findOne({ event: event._id }).populate('template'); if (!site) return h.response(error('NOT_FOUND', 'Static microsite not configured')).code(404); try { const assetsBaseUrl = localPreviewAssetsBaseUrl(site.template?.slug, request) || undefined; const html = await renderStaticMicrositeHtml({ template: site.template, config: site.config, eventId: event._id, subdomain: site.subdomain, assetsBaseUrl, }); return h.response(html).type('text/html'); } catch (e) { return h.response(error('RENDER_ERROR', e.message)).code(400); } }; module.exports = { listTemplates, listAdminTemplates, getTemplate, createTemplate, updateTemplate, duplicateTemplate, removeTemplate, uploadTemplateFiles, previewTemplateHtml, serveLocalTemplateAsset, getForEvent, upsertForEvent, checkSubdomain, publishForEvent, previewForEvent, };