// Slide data access helpers, including rich-text normalization and payload building. const { fetchTemplateById } = require('./templates'); const { parseJsonSafe, validateMaxLength } = require('./utils'); const TITLE_MAX_LENGTH = 255; const { buildQrCodeContent } = require('./qr-code'); const DEFAULT_FONT_SIZE = 32; const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; function sanitizeRichTextAttributes(tagName, attrText) { const allowedAttributes = { a: ['href', 'title', 'target', 'rel', 'class', 'style'], blockquote: ['class', 'style'], div: ['class', 'style'], figure: ['class', 'style'], figcaption: ['class', 'style'], h1: ['class', 'style'], h2: ['class', 'style'], h3: ['class', 'style'], h4: ['class', 'style'], h5: ['class', 'style'], h6: ['class', 'style'], img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'], li: ['class', 'style'], ol: ['class', 'style', 'start'], p: ['class', 'style'], pre: ['class', 'style'], span: ['class', 'style'], table: ['class', 'style'], td: ['class', 'style', 'colspan', 'rowspan'], th: ['class', 'style', 'colspan', 'rowspan', 'scope'], tr: ['class', 'style'], ul: ['class', 'style'] }; const allowed = allowedAttributes[tagName] || []; if (!allowed.length) { return ''; } const attrs = []; String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => { const lowerKey = String(key || '').toLowerCase(); if (!allowed.includes(lowerKey)) { return ''; } const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { return ''; } if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { return ''; } if (lowerKey === 'target') { const targetValue = String(value || '').trim(); if (targetValue === '_blank') { attrs.push(' target="_blank"'); if (!attrs.includes(' rel="noreferrer noopener"')) { attrs.push(' rel="noreferrer noopener"'); } return ''; } } attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"'); return ''; }); return attrs.join(''); } 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\S]*?)(\/?)>$/i); if (!match) { return ''; } const closing = Boolean(match[1]); const name = String(match[2] || '').toLowerCase(); const attrText = String(match[3] || ''); if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) { return ''; } if (closing) { return ``; } return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`; }); } function stripEditorOnlyMarkup(value) { return String(value || '') .replace(/]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') .replace(/]*class="[^"]*api-region-sample-accordion[^"]*"[^>]*>[\s\S]*?<\/details>/gi, '') .trim(); } function normalizeEditorMarkup(value) { return String(value === undefined || value === null ? '' : value).trim(); } async function fetchSlideById(pool, id) { const [slides] = await pool.query(` SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height FROM c_slides s LEFT JOIN c_templates st ON st.id = s.template_id LEFT JOIN c_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 getSubmittedValue(body, key, fallback) { if (body && Object.prototype.hasOwnProperty.call(body, key)) { return String(body[key] || '').trim(); } return fallback; } 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 getQrRegionStyle(body, region, existingContent) { const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const styleKeys = [ 'qr_margin', 'qr_background_color', 'qr_background_transparent', 'qr_background_color_mode', 'qr_background_gradient_type', 'qr_background_gradient_rotation', 'qr_background_gradient_color_1', 'qr_background_gradient_color_2', 'qr_dots_color', 'qr_dots_type', 'qr_dots_color_mode', 'qr_dots_gradient_type', 'qr_dots_gradient_rotation', 'qr_dots_gradient_color_1', 'qr_dots_gradient_color_2', 'qr_corners_square_color', 'qr_corners_square_type', 'qr_corners_square_color_mode', 'qr_corners_square_gradient_type', 'qr_corners_square_gradient_rotation', 'qr_corners_square_gradient_color_1', 'qr_corners_square_gradient_color_2', 'qr_corners_dot_color', 'qr_corners_dot_type', 'qr_corners_dot_color_mode', 'qr_corners_dot_gradient_type', 'qr_corners_dot_gradient_rotation', 'qr_corners_dot_gradient_color_1', 'qr_corners_dot_gradient_color_2', 'qr_image', 'qr_image_size', 'qr_image_margin', 'qr_image_hide_background_dots', 'qr_border_radius' ]; const style = {}; styleKeys.forEach((key) => { const fieldNames = [`region_${key}_${region.id}`, `${key}_${region.id}`]; let fieldName = fieldNames[0]; let hasSubmittedValue = false; let submitted; for (let index = 0; index < fieldNames.length; index += 1) { const candidate = fieldNames[index]; if (Object.prototype.hasOwnProperty.call(body || {}, candidate)) { fieldName = candidate; hasSubmittedValue = true; submitted = body[candidate]; break; } } const value = String(submitted || '').trim(); if (key === 'qr_background_transparent' || key === 'qr_image_hide_background_dots') { style[key] = hasSubmittedValue; return; } if (key === 'qr_background_color_mode' || key === 'qr_dots_color_mode' || key === 'qr_corners_square_color_mode' || key === 'qr_corners_dot_color_mode' || key === 'qr_background_gradient_type' || key === 'qr_dots_gradient_type' || key === 'qr_corners_square_gradient_type' || key === 'qr_corners_dot_gradient_type') { style[key] = value === 'none' ? 'none' : value; return; } if (key === 'qr_image') { style[key] = value; return; } if (submitted === undefined) { if (existing[key] !== undefined && existing[key] !== null && String(existing[key]).trim() !== '') { style[key] = existing[key]; } return; } if (value !== '') { if (key === 'qr_margin') { const parsedMargin = Number(value); if (Number.isFinite(parsedMargin)) { style[key] = Math.max(0, Math.round(parsedMargin)); } return; } if (key === 'qr_image_size') { const parsedSize = Number(value); if (Number.isFinite(parsedSize)) { style[key] = Math.max(0, Math.min(1, parsedSize)); } return; } if (key === 'qr_image_margin' || key === 'qr_border_radius') { const parsedImageMargin = Number(value); if (Number.isFinite(parsedImageMargin)) { style[key] = Math.max(0, Math.round(parsedImageMargin)); } return; } style[key] = value; } }); return style; } async function getRssFeedItemCount(pool, feedId) { const [rows] = await pool.query( 'SELECT COUNT(*) AS count FROM i_rss_feed_items WHERE rss_feed_id = ?', [feedId] ); return Number(rows && rows[0] && rows[0].count) || 0; } async function buildTemplateContent(pool, template, body, filesByField, existingContent) { const content = {}; for (const region of template.regions) { if (region.region_type === 'image') { const uploaded = filesByField[`region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'image', value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current) }; } else if (region.region_type === 'video') { const uploaded = filesByField[`region_video_${region.id}`]; const existing = body[`existing_region_video_${region.id}`]; const durationValue = body[`existing_region_video_duration_${region.id}`]; const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0; const parsedDuration = Number(durationValue || existingDuration || 0); const normalizedDuration = Math.round(parsedDuration * 1000) / 1000; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : ''; content[region.region_key] = { type: 'video', value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_video_${region.id}`, current), duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null }; } 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 === 'qr-code') { const uploadedImage = filesByField[`region_qr_image_${region.id}`]; const submitted = body[`region_qr_code_${region.id}`]; const submittedSvg = body[`region_qr_svg_${region.id}`]; const submittedPreview = body[`region_qr_preview_${region.id}`]; const existingImage = body[`existing_region_qr_image_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim(); const qrStyle = getQrRegionStyle(body, region, existingContent); delete qrStyle.qr_image; content[region.region_key] = { type: 'qr-code', value: nextValue, qr_svg: submittedSvg === undefined ? String(current.qr_svg || '').trim() : String(submittedSvg || '').trim(), qr_preview: submittedPreview === undefined ? String(current.qr_preview || '').trim() : String(submittedPreview || '').trim(), qr_image: uploadedImage ? `/media/uploads/${uploadedImage.filename}` : String(existingImage === undefined ? String(current.qr_image || '').trim() : String(existingImage || '').trim()), ...qrStyle }; } 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 === 'time-date') { const submitted = body[`region_text_${region.id}`]; const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const timezoneValue = body[`region_timezone_${region.id}`]; content[region.region_key] = { type: 'time-date', value: submitted === undefined ? String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '') : String(submitted || ''), timezone: timezoneValue === undefined || timezoneValue === null ? String(current.timezone || current.time_zone || '') : String(timezoneValue || '').trim() }; } else if (region.region_type === 'timetable') { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; const suffix = '_' + region.id; const generic = {}; const submittedText = body[`region_text_${region.id}`]; const normalizedText = submittedText === undefined ? String(current.text !== undefined ? current.text : current.value !== undefined ? current.value : '') : String(submittedText || ''); Object.keys(body || {}).forEach((key) => { if (!key.startsWith('region_') || !key.endsWith(suffix)) { return; } const field = key.slice('region_'.length, -suffix.length); if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') { return; } generic[field] = body[key]; }); if (Object.prototype.hasOwnProperty.call(generic, 'timetable_display_mode')) { generic.display_mode = generic.timetable_display_mode; delete generic.timetable_display_mode; } if (Object.prototype.hasOwnProperty.call(generic, 'timetable_max_items')) { generic.max_items = generic.timetable_max_items; delete generic.timetable_max_items; } Object.keys(current).forEach((key) => { if (generic[key] === undefined) { generic[key] = current[key]; } }); delete generic.timetable_display_mode; delete generic.timetable_max_items; generic.text = normalizedText; generic.value = normalizedText; generic.type = region.region_type; content[region.region_key] = generic; } 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)); const normalizedFeedId = feedId === undefined || feedId === null || feedId === '' ? Number(current.feed_id || 0) : Number(feedId); const itemCount = normalizedFeedId > 0 ? Math.max(1, await getRssFeedItemCount(pool, normalizedFeedId) || 1) : 1; content[region.region_key] = { type: 'rss', value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)), item_number: Math.min(itemCount, 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 itemsPath = body[`region_api_items_path_${region.id}`]; const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'api', value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(), variable_name: 'item', font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else if (region.region_type === 'weather') { const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {}; const locationId = body[`region_weather_location_id_${region.id}`]; const submittedForecastMode = body[`region_weather_forecast_mode_${region.id}`]; const forecastMode = ['current', 'hourly'].includes(submittedForecastMode) ? submittedForecastMode : 'daily'; const style = getTextRegionStyle(body, region, existingContent); content[region.region_key] = { type: 'weather', value: body[`region_text_${region.id}`] !== undefined ? String(body[`region_text_${region.id}`] || '') : String(current.value || ''), weather_location_id: locationId === undefined || locationId === null ? (current.weather_location_id || null) : (locationId === '' ? null : Number(locationId)), forecast_mode: body[`region_weather_forecast_mode_${region.id}`] === undefined ? (['current', 'hourly'].includes(current.forecast_mode) ? current.forecast_mode : 'daily') : forecastMode, font_family: style.font_family, font_size: style.font_size, font_color: style.font_color }; } else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) { const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {}; const suffix = '_' + region.id; const generic = {}; Object.keys(body || {}).forEach((key) => { if (!key.startsWith('region_') || !key.endsWith(suffix)) { return; } const field = key.slice('region_'.length, -suffix.length); if (!field || field === 'type' || field === 'key' || field === 'name' || field === 'label') { return; } generic[field] = body[key]; }); Object.keys(filesByField || {}).forEach((fieldName) => { if (!fieldName.startsWith('region_') || !fieldName.endsWith(suffix)) { return; } const field = fieldName.slice('region_'.length, -suffix.length); if (!field) { return; } generic[field] = `/media/uploads/${filesByField[fieldName].filename}`; }); Object.keys(current).forEach((key) => { if (generic[key] === undefined) { generic[key] = current[key]; } }); generic.type = region.region_type; content[region.region_key] = generic; } 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: sanitizeRichText(stripEditorOnlyMarkup(normalizeEditorMarkup(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 = validateMaxLength(req.body.title || '', TITLE_MAX_LENGTH, 'Slide title'); 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) { const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent); await Promise.all(Object.keys(content).map(async function (regionKey) { const region = template.regions.find(function (item) { return String(item.region_key || '').trim() === regionKey; }); if (!region || region.region_type !== 'qr-code') { return; } content[regionKey] = await buildQrCodeContent(content[regionKey]); })); return { title, templateId: template.id, contentJson: JSON.stringify(content) }; } return { title, templateId: null, contentJson: existingSlide ? existingSlide.content_json : null }; } module.exports = { fetchSlideById, buildSlidePayload };