211 lines
8.6 KiB
JavaScript
211 lines
8.6 KiB
JavaScript
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(/<script[\s\S]*?<\/script>/gi, '');
|
|
output = output.replace(/<style[\s\S]*?<\/style>/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 '<br>';
|
|
}
|
|
return closing ? `</${name}>` : `<${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
|
|
};
|