const { fetchTemplateById } = require('./templates'); const { parseJsonSafe } = require('./utils'); const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li']; const DEFAULT_FONT_SIZE = 32; function sanitizeRichText(html) { let output = String(html || ''); output = output.replace(//gi, ''); output = output.replace(//gi, ''); return output.replace(/<[^>]+>/g, (tag) => { const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i); if (!match) { return ''; } const closing = Boolean(match[1]); const name = String(match[2] || '').toLowerCase(); if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { return ''; } if (name === 'br') { return '
'; } return closing ? `` : `<${name}>`; }); } async function fetchSlideById(pool, id) { const [slides] = await pool.query(` SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height FROM slides s LEFT JOIN slide_templates st ON st.id = s.template_id LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id WHERE s.id = ? `, [id]); if (!slides.length) { return null; } const slide = slides[0]; slide.content = parseJsonSafe(slide.content_json) || {}; if (slide.template_id) { slide.template = await fetchTemplateById(pool, slide.template_id); } return slide; } function getFilesByField(files) { const map = {}; (files || []).forEach((file) => { map[file.fieldname] = file; }); return map; } function sanitizeTextColor(value, fallback) { const raw = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) { return raw; } return fallback || '#000000'; } function sanitizeFontSize(value, fallback) { const raw = String(value || '').trim(); const parsed = Math.round(Number(raw.replace(/[^0-9.]/g, ''))); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } return Math.max(8, Number(fallback || DEFAULT_FONT_SIZE)); } function getTextRegionStyle(body, region, existingContent) { const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial'; const fontSize = sanitizeFontSize(body[`region_font_size_${region.id}`], existing.font_size || DEFAULT_FONT_SIZE); const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000'); return { font_family: fontFamily, font_size: fontSize, font_color: fontColor }; } function buildTemplateContent(template, body, filesByField, existingContent) { const content = {}; template.regions.forEach((region) => { if (region.region_type === 'image') { const uploaded = filesByField[`region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`]; content[region.region_key] = { type: 'image', value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '') }; } else if (region.region_type === 'webpage') { const submitted = body[`region_webpage_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'webpage', value: submitted === undefined ? current : String(submitted || '').trim() }; } else if (region.region_type === 'rtmp') { const submitted = body[`region_rtmp_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; content[region.region_key] = { type: 'rtmp', value: submitted === undefined ? String(current.value || '').trim() : String(submitted || '').trim(), disable_audio: Boolean(body[`region_disable_audio_${region.id}`]) }; } else if (region.region_type === 'html') { const submitted = body[`region_html_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'html', value: submitted === undefined ? current : String(submitted || '') }; } else if (region.region_type === 'rss') { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const style = getTextRegionStyle(body, region, existingContent); const feedId = body[`region_rss_feed_id_${region.id}`]; const itemNumber = body[`region_rss_item_number_${region.id}`]; const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'rss', value: submitted === undefined ? String(current.value || '') : String(submitted || ''), feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, variable_name: 'item', font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else if (region.region_type === 'api') { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const style = getTextRegionStyle(body, region, existingContent); const sourceId = body[`region_api_source_id_${region.id}`]; const itemNumber = body[`region_api_item_number_${region.id}`]; const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'api', value: submitted === undefined ? String(current.value || '') : String(submitted || ''), source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, variable_name: 'item', font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; const style = getTextRegionStyle(body, region, existingContent); content[region.region_key] = { type: 'text', value: submitted === undefined ? current : String(submitted), font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } }); return content; } async function buildSlidePayload(pool, req, existingSlide) { const title = String(req.body.title || '').trim(); const templateId = req.body.template_id ? Number(req.body.template_id) : null; const filesByField = getFilesByField(req.files || []); const template = templateId ? await fetchTemplateById(pool, templateId) : null; const existingContent = existingSlide ? parseJsonSafe(existingSlide.content_json) || {} : {}; if (!title) { const error = new Error('Slide title is required.'); error.statusCode = 400; throw error; } if (templateId && !template) { const error = new Error('Template not found.'); error.statusCode = 400; throw error; } if (template) { return { title, body: existingSlide ? existingSlide.body : null, templateId: template.id, contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)), mediaPath: null, mediaType: null }; } return { title, body: existingSlide ? existingSlide.body : null, templateId: null, contentJson: existingSlide ? existingSlide.content_json : null, mediaPath: existingSlide ? existingSlide.media_path : null, mediaType: existingSlide ? existingSlide.media_type : null }; } module.exports = { fetchSlideById, buildSlidePayload };