Release v2.5.6
This commit is contained in:
+1
-1
@@ -94,7 +94,7 @@ async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDi
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
|
||||
searchColumns: ['s.title', 'st.name', 's.content_json'],
|
||||
searchColumns: ['s.title', 'st.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
title: 's.title',
|
||||
|
||||
@@ -6,6 +6,9 @@ const {
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
|
||||
} = require('./announcement-icons');
|
||||
const { validateMaxLength } = require('./utils');
|
||||
|
||||
const SHORT_LABEL_MAX_LENGTH = 255;
|
||||
|
||||
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
|
||||
@@ -57,7 +60,7 @@ function normalizeAnnouncementScreenIds(value) {
|
||||
|
||||
function buildAnnouncementPayload(input) {
|
||||
const message = normalizeAnnouncementMessage(input && input.message);
|
||||
const shortLabel = normalizeAnnouncementShortLabel(input && input.short_label);
|
||||
const shortLabel = validateMaxLength(normalizeAnnouncementShortLabel(input && input.short_label), SHORT_LABEL_MAX_LENGTH, 'Announcement short description');
|
||||
const announcementType = normalizeAnnouncementType(input && input.announcement_type);
|
||||
const colorKey = normalizeAnnouncementColor(input && input.color_key);
|
||||
const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key);
|
||||
|
||||
+14
-9
@@ -2,7 +2,12 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
const AUTH_MAX_LENGTH = 255;
|
||||
const ITEMS_PATH_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -171,15 +176,15 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
|
||||
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
|
||||
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
|
||||
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim();
|
||||
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '');
|
||||
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim();
|
||||
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key';
|
||||
const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim();
|
||||
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim();
|
||||
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
|
||||
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
|
||||
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
|
||||
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
|
||||
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
|
||||
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const MAX_CANVAS_SIZE_DIMENSION = 16384;
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
@@ -33,10 +34,31 @@ async function fetchCanvasSizeById(pool, id) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function readCanvasDimension(rawValue, fallbackValue, fieldName) {
|
||||
const sourceValue = rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== ''
|
||||
? rawValue
|
||||
: fallbackValue;
|
||||
const numericValue = Number(sourceValue);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
const error = new Error('Canvas size ' + fieldName + ' must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (numericValue > MAX_CANVAS_SIZE_DIMENSION) {
|
||||
const error = new Error('Canvas size dimensions must be 16384 or less.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.max(1, numericValue);
|
||||
}
|
||||
|
||||
function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
|
||||
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
|
||||
const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
|
||||
const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Canvas size name is required.');
|
||||
@@ -55,5 +77,6 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizesPage,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION
|
||||
};
|
||||
|
||||
+5
-2
@@ -9,14 +9,16 @@ const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSo
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
@@ -66,6 +68,7 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload,
|
||||
|
||||
+552
-17
@@ -1,29 +1,561 @@
|
||||
const QRCode = require('qrcode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const QR_PNG_WIDTH = 2048;
|
||||
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
let qrBrowserPromise = null;
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return QRCode.toString(text, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M'
|
||||
function escapeXml(value) {
|
||||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[character];
|
||||
});
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
function serializeNode(node) {
|
||||
if (!node) {
|
||||
return '';
|
||||
}
|
||||
if (node.nodeType === 3) {
|
||||
return escapeXml(node.textContent || '');
|
||||
}
|
||||
|
||||
const attributeEntries = Object.keys(node.attributes || {}).map(function (name) {
|
||||
return name + '="' + escapeXml(node.attributes[name]) + '"';
|
||||
});
|
||||
if (node.tagName === 'svg' && node.namespaceURI === 'http://www.w3.org/2000/svg' && !Object.prototype.hasOwnProperty.call(node.attributes || {}, 'xmlns')) {
|
||||
attributeEntries.unshift('xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
const attributes = attributeEntries.join(' ');
|
||||
const children = (node.childNodes || []).map(serializeNode).join('') + escapeXml(node.textContent || '');
|
||||
return '<' + node.tagName + (attributes ? ' ' + attributes : '') + '>' + children + '</' + node.tagName + '>';
|
||||
}
|
||||
|
||||
function createDomNode(tagName, namespaceUri) {
|
||||
return {
|
||||
tagName: tagName,
|
||||
namespaceURI: namespaceUri || null,
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: null,
|
||||
nodeType: 1,
|
||||
textContent: '',
|
||||
style: {},
|
||||
setAttribute: function (name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
appendChild: function (child) {
|
||||
if (child) {
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
}
|
||||
return child;
|
||||
},
|
||||
removeChild: function (child) {
|
||||
this.childNodes = this.childNodes.filter(function (node) { return node !== child; });
|
||||
return child;
|
||||
},
|
||||
get firstChild() {
|
||||
return this.childNodes[0] || null;
|
||||
},
|
||||
get outerHTML() {
|
||||
return serializeNode(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return {
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
beginPath: function () {},
|
||||
closePath: function () {},
|
||||
clearRect: function () {},
|
||||
fillRect: function () {},
|
||||
strokeRect: function () {},
|
||||
moveTo: function () {},
|
||||
lineTo: function () {},
|
||||
arc: function () {},
|
||||
rect: function () {},
|
||||
fill: function () {},
|
||||
stroke: function () {},
|
||||
save: function () {},
|
||||
restore: function () {},
|
||||
translate: function () {},
|
||||
rotate: function () {},
|
||||
scale: function () {},
|
||||
setTransform: function () {},
|
||||
transform: function () {},
|
||||
drawImage: function () {},
|
||||
measureText: function (text) {
|
||||
return { width: String(text === undefined || text === null ? '' : text).length * 8 };
|
||||
},
|
||||
createLinearGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createRadialGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createPattern: function () {
|
||||
return null;
|
||||
},
|
||||
getImageData: function () {
|
||||
return { data: [] };
|
||||
},
|
||||
putImageData: function () {},
|
||||
clip: function () {}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasNode() {
|
||||
const node = createDomNode('canvas', 'http://www.w3.org/1999/xhtml');
|
||||
node.width = 0;
|
||||
node.height = 0;
|
||||
node.getContext = function () {
|
||||
return createCanvasContext();
|
||||
};
|
||||
node.toDataURL = function () {
|
||||
return '';
|
||||
};
|
||||
node.toBlob = function (callback) {
|
||||
if (typeof callback === 'function') {
|
||||
callback(null);
|
||||
}
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function createQrStylingWindow() {
|
||||
const document = {
|
||||
createElement: function (tagName) {
|
||||
if (String(tagName || '').toLowerCase() === 'canvas') {
|
||||
return createCanvasNode();
|
||||
}
|
||||
return createDomNode(tagName, 'http://www.w3.org/1999/xhtml');
|
||||
},
|
||||
createElementNS: function (namespaceUri, tagName) {
|
||||
return createDomNode(tagName, namespaceUri);
|
||||
},
|
||||
createTextNode: function (text) {
|
||||
return {
|
||||
nodeType: 3,
|
||||
textContent: String(text === undefined || text === null ? '' : text),
|
||||
parentNode: null
|
||||
};
|
||||
},
|
||||
body: createDomNode('body', 'http://www.w3.org/1999/xhtml'),
|
||||
head: createDomNode('head', 'http://www.w3.org/1999/xhtml')
|
||||
};
|
||||
|
||||
const window = {
|
||||
document: document,
|
||||
navigator: { userAgent: 'node' },
|
||||
URL: {
|
||||
createObjectURL: function () { return ''; },
|
||||
revokeObjectURL: function () {}
|
||||
}
|
||||
};
|
||||
|
||||
window.window = window;
|
||||
window.self = window;
|
||||
window.Image = class {
|
||||
constructor() {
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.width = 1;
|
||||
this.height = 1;
|
||||
this.naturalWidth = 1;
|
||||
this.naturalHeight = 1;
|
||||
this._src = '';
|
||||
}
|
||||
|
||||
set src(value) {
|
||||
this._src = String(value === undefined || value === null ? '' : value);
|
||||
if (typeof this.onload === 'function') {
|
||||
setTimeout(() => this.onload(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
};
|
||||
window.HTMLImageElement = window.Image;
|
||||
|
||||
window.XMLSerializer = class {
|
||||
serializeToString(node) {
|
||||
return serializeNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
return { window: window, document: document };
|
||||
}
|
||||
|
||||
async function renderStyledQrRawData(options, format) {
|
||||
const previousWindow = global.window;
|
||||
const previousDocument = global.document;
|
||||
const previousXMLSerializer = global.XMLSerializer;
|
||||
const dom = createQrStylingWindow();
|
||||
|
||||
global.window = dom.window;
|
||||
global.document = dom.document;
|
||||
global.XMLSerializer = dom.window.XMLSerializer;
|
||||
|
||||
try {
|
||||
const qrCode = new QRCodeStyling(options);
|
||||
const blob = await qrCode.getRawData(format);
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
return '';
|
||||
} finally {
|
||||
global.window = previousWindow;
|
||||
global.document = previousDocument;
|
||||
global.XMLSerializer = previousXMLSerializer;
|
||||
}
|
||||
}
|
||||
|
||||
function svgToDataUrl(svg) {
|
||||
const markup = String(svg === undefined || svg === null ? '' : svg).trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup);
|
||||
}
|
||||
|
||||
function normalizeQrStyleColor(value, fallback) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeQrStyleNumber(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildQrStylingOptions(source) {
|
||||
const text = String(source && source.value === undefined ? '' : source && source.value === null ? '' : source.value || '').trim();
|
||||
const qrStyle = source && typeof source === 'object' ? source : {};
|
||||
const dotsGradient = qrStyle.qr_dots_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_dots_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_dots_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersSquareGradient = qrStyle.qr_corners_square_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_square_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_square_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersDotGradient = qrStyle.qr_corners_dot_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_dot_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_dot_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const backgroundGradient = qrStyle.qr_background_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_background_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_background_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
|
||||
return {
|
||||
width: QR_PNG_WIDTH,
|
||||
height: QR_PNG_WIDTH,
|
||||
type: 'canvas',
|
||||
data: text,
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_margin, 10, 0, undefined),
|
||||
qrOptions: {},
|
||||
dotsOptions: {
|
||||
type: String(qrStyle.qr_dots_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000'),
|
||||
gradient: dotsGradient || undefined
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: String(qrStyle.qr_corners_square_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000'),
|
||||
gradient: cornersSquareGradient || undefined
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: String(qrStyle.qr_corners_dot_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000'),
|
||||
gradient: cornersDotGradient || undefined
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: qrStyle.qr_background_transparent ? 'transparent' : normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff'),
|
||||
gradient: backgroundGradient || undefined
|
||||
},
|
||||
image: String(qrStyle.qr_image || '').trim() || undefined,
|
||||
imageOptions: {
|
||||
hideBackgroundDots: Boolean(qrStyle.qr_image_hide_background_dots),
|
||||
imageSize: normalizeQrStyleNumber(qrStyle.qr_image_size, 0.4, 0, 1),
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_image_margin, 5, 0, undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getQrBrowser() {
|
||||
if (qrBrowserPromise) {
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
qrBrowserPromise = (async function () {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: usingSystemChromium
|
||||
? [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
]
|
||||
: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: usingSystemChromium
|
||||
? { width: QR_PNG_WIDTH, height: QR_PNG_WIDTH, deviceScaleFactor: 1 }
|
||||
: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: usingSystemChromium ? true : 'shell'
|
||||
});
|
||||
})();
|
||||
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return 'data:' + String(blob.type || 'image/png') + ';base64,' + Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function createStyledQrCodeDataUrl(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return svgToDataUrl(await createStyledQrCodeSvg(options.data));
|
||||
}
|
||||
|
||||
async function createStyledQrCodeSvg(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return renderStyledQrRawData(options, 'svg');
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrlPlain(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
return createStyledQrCodeSvg(value);
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrl(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value, options) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const forcePreviewRefresh = Boolean(options && options.forcePreviewRefresh);
|
||||
const text = String(source.value === undefined || source.value === null ? '' : source.value).trim();
|
||||
const hasBooleanField = function (key) {
|
||||
return Object.prototype.hasOwnProperty.call(source, key);
|
||||
};
|
||||
const margin = Number(source.qr_margin);
|
||||
const normalizeHex = function (value) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || undefined;
|
||||
};
|
||||
const normalizeNumber = function (value, min, max, round) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (round) {
|
||||
next = Math.round(next);
|
||||
}
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const style = {
|
||||
qr_margin: Number.isFinite(margin) && margin >= 0 ? Math.round(margin) : undefined,
|
||||
qr_border_radius: Number.isFinite(Number(source.qr_border_radius)) ? Math.max(0, Math.round(Number(source.qr_border_radius))) : undefined,
|
||||
qr_background_color: String(source.qr_background_color === undefined || source.qr_background_color === null ? '' : source.qr_background_color).trim() || undefined,
|
||||
qr_background_transparent: hasBooleanField('qr_background_transparent') ? Boolean(source.qr_background_transparent) : undefined,
|
||||
qr_background_color_mode: String(source.qr_background_color_mode === undefined || source.qr_background_color_mode === null ? '' : source.qr_background_color_mode).trim() || undefined,
|
||||
qr_background_gradient_type: String(source.qr_background_gradient_type === undefined || source.qr_background_gradient_type === null ? '' : source.qr_background_gradient_type).trim() || undefined,
|
||||
qr_background_gradient_rotation: normalizeNumber(source.qr_background_gradient_rotation),
|
||||
qr_background_gradient_color_1: normalizeHex(source.qr_background_gradient_color_1),
|
||||
qr_background_gradient_color_2: normalizeHex(source.qr_background_gradient_color_2),
|
||||
qr_dots_color: String(source.qr_dots_color === undefined || source.qr_dots_color === null ? '' : source.qr_dots_color).trim() || undefined,
|
||||
qr_dots_type: String(source.qr_dots_type === undefined || source.qr_dots_type === null ? '' : source.qr_dots_type).trim() || undefined,
|
||||
qr_dots_color_mode: String(source.qr_dots_color_mode === undefined || source.qr_dots_color_mode === null ? '' : source.qr_dots_color_mode).trim() || undefined,
|
||||
qr_dots_gradient_type: String(source.qr_dots_gradient_type === undefined || source.qr_dots_gradient_type === null ? '' : source.qr_dots_gradient_type).trim() || undefined,
|
||||
qr_dots_gradient_rotation: normalizeNumber(source.qr_dots_gradient_rotation),
|
||||
qr_dots_gradient_color_1: normalizeHex(source.qr_dots_gradient_color_1),
|
||||
qr_dots_gradient_color_2: normalizeHex(source.qr_dots_gradient_color_2),
|
||||
qr_corners_square_color: String(source.qr_corners_square_color === undefined || source.qr_corners_square_color === null ? '' : source.qr_corners_square_color).trim() || undefined,
|
||||
qr_corners_square_type: String(source.qr_corners_square_type === undefined || source.qr_corners_square_type === null ? '' : source.qr_corners_square_type).trim() || undefined,
|
||||
qr_corners_square_color_mode: String(source.qr_corners_square_color_mode === undefined || source.qr_corners_square_color_mode === null ? '' : source.qr_corners_square_color_mode).trim() || undefined,
|
||||
qr_corners_square_gradient_type: String(source.qr_corners_square_gradient_type === undefined || source.qr_corners_square_gradient_type === null ? '' : source.qr_corners_square_gradient_type).trim() || undefined,
|
||||
qr_corners_square_gradient_rotation: normalizeNumber(source.qr_corners_square_gradient_rotation),
|
||||
qr_corners_square_gradient_color_1: normalizeHex(source.qr_corners_square_gradient_color_1),
|
||||
qr_corners_square_gradient_color_2: normalizeHex(source.qr_corners_square_gradient_color_2),
|
||||
qr_corners_dot_color: String(source.qr_corners_dot_color === undefined || source.qr_corners_dot_color === null ? '' : source.qr_corners_dot_color).trim() || undefined,
|
||||
qr_corners_dot_type: String(source.qr_corners_dot_type === undefined || source.qr_corners_dot_type === null ? '' : source.qr_corners_dot_type).trim() || undefined,
|
||||
qr_corners_dot_color_mode: String(source.qr_corners_dot_color_mode === undefined || source.qr_corners_dot_color_mode === null ? '' : source.qr_corners_dot_color_mode).trim() || undefined,
|
||||
qr_corners_dot_gradient_type: String(source.qr_corners_dot_gradient_type === undefined || source.qr_corners_dot_gradient_type === null ? '' : source.qr_corners_dot_gradient_type).trim() || undefined,
|
||||
qr_corners_dot_gradient_rotation: normalizeNumber(source.qr_corners_dot_gradient_rotation),
|
||||
qr_corners_dot_gradient_color_1: normalizeHex(source.qr_corners_dot_gradient_color_1),
|
||||
qr_corners_dot_gradient_color_2: normalizeHex(source.qr_corners_dot_gradient_color_2),
|
||||
qr_image: String(source.qr_image === undefined || source.qr_image === null ? '' : source.qr_image).trim() || undefined,
|
||||
qr_image_size: Number.isFinite(Number(source.qr_image_size)) ? Math.max(0, Math.min(1, Number(source.qr_image_size))) : undefined,
|
||||
qr_image_margin: Number.isFinite(Number(source.qr_image_margin)) ? Math.max(0, Math.round(Number(source.qr_image_margin))) : undefined,
|
||||
qr_image_hide_background_dots: hasBooleanField('qr_image_hide_background_dots') ? Boolean(source.qr_image_hide_background_dots) : undefined
|
||||
};
|
||||
const content = {
|
||||
type: 'qr-code',
|
||||
value: text
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const svg = await createQrCodeSvg(text);
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
Object.keys(style).forEach((key) => {
|
||||
if (style[key] !== undefined) {
|
||||
content[key] = style[key];
|
||||
}
|
||||
});
|
||||
|
||||
content.qr_background_use_gradient = content.qr_background_gradient_type && content.qr_background_gradient_type !== 'none';
|
||||
content.qr_dots_use_gradient = content.qr_dots_gradient_type && content.qr_dots_gradient_type !== 'none';
|
||||
content.qr_corners_square_use_gradient = content.qr_corners_square_gradient_type && content.qr_corners_square_gradient_type !== 'none';
|
||||
content.qr_corners_dot_use_gradient = content.qr_corners_dot_gradient_type && content.qr_corners_dot_gradient_type !== 'none';
|
||||
|
||||
const svg = String(source.qr_svg === undefined || source.qr_svg === null ? '' : source.qr_svg).trim();
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
}
|
||||
|
||||
const preview = forcePreviewRefresh ? '' : String(source.qr_preview === undefined || source.qr_preview === null ? '' : source.qr_preview).trim();
|
||||
if (preview) {
|
||||
content.qr_preview = preview;
|
||||
}
|
||||
|
||||
if (text && !content.qr_preview) {
|
||||
const generatedSvg = await createQrCodeSvg(text);
|
||||
if (generatedSvg) {
|
||||
content.qr_svg = generatedSvg;
|
||||
}
|
||||
const generatedDataUrl = await createStyledQrCodeDataUrl(content);
|
||||
if (generatedDataUrl) {
|
||||
content.qr_preview = generatedDataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +564,8 @@ async function buildQrCodeContent(value) {
|
||||
|
||||
module.exports = {
|
||||
createQrCodeSvg,
|
||||
createStyledQrCodeSvg,
|
||||
createStyledQrCodeDataUrl,
|
||||
createQrCodeDataUrl,
|
||||
buildQrCodeContent
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -255,8 +258,8 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
|
||||
function buildRssFeedPayload(req, existingRssFeed) {
|
||||
const fallback = existingRssFeed || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
|
||||
const feedUrl = validateMaxLength(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '', URL_MAX_LENGTH, 'RSS feed URL');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeDisplayMode(value) {
|
||||
const mode = String(value || 'upcoming').trim().toLowerCase();
|
||||
@@ -95,8 +98,8 @@ async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
|
||||
|
||||
function buildTimetableGroupPayload(req, existingTimetableGroup) {
|
||||
const fallback = existingTimetableGroup || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name');
|
||||
const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Timetable group name is required.');
|
||||
|
||||
+144
-9
@@ -1,7 +1,9 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, validateMaxLength } = require('./utils');
|
||||
|
||||
const TITLE_MAX_LENGTH = 255;
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
@@ -112,9 +114,130 @@ function getTextRegionStyle(body, region, existingContent) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
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 = {};
|
||||
template.regions.forEach((region) => {
|
||||
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}`];
|
||||
@@ -144,12 +267,22 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
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
|
||||
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}`];
|
||||
@@ -182,11 +315,13 @@ function buildTemplateContent(template, body, filesByField, 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 || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
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,
|
||||
@@ -258,12 +393,12 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
font_color: style.font_color
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function buildSlidePayload(pool, req, existingSlide) {
|
||||
const title = String(req.body.title || '').trim();
|
||||
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;
|
||||
@@ -282,7 +417,7 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const content = buildTemplateContent(template, req.body, filesByField, existingContent);
|
||||
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;
|
||||
@@ -291,7 +426,7 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
return;
|
||||
}
|
||||
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey].value);
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey]);
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
|
||||
const TEMPLATE_NAME_MAX_LENGTH = 255;
|
||||
const REGION_NAME_MAX_LENGTH = 255;
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
@@ -137,10 +140,11 @@ function extractTemplateRegions(body) {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_key: regionName,
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
label: regionName,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||
x: Number(region.x || 0),
|
||||
@@ -167,7 +171,7 @@ function extractTemplateRegions(body) {
|
||||
const regions = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const name = String(names[i] || keys[i] || labels[i] || '').trim();
|
||||
const name = validateMaxLength(names[i] || keys[i] || labels[i] || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
const rawType = String(types[i] || 'text').trim();
|
||||
const regionType = normalizeTemplateRegionType(rawType);
|
||||
if (!name) {
|
||||
@@ -236,7 +240,7 @@ function getFilesByField(files) {
|
||||
}
|
||||
|
||||
async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = validateMaxLength(req.body.name || '', TEMPLATE_NAME_MAX_LENGTH, 'Template name');
|
||||
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
|
||||
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
|
||||
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
|
||||
|
||||
+100
-1
@@ -24,6 +24,26 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
function validateMaxLength(value, maxLength, fieldName) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (Number.isFinite(limit) && limit > 0 && text.length > limit) {
|
||||
const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function truncateToMaxLength(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -201,6 +221,75 @@ function findTopLevelWhereIndex(sql) {
|
||||
return lastWhereIndex;
|
||||
}
|
||||
|
||||
function findTopLevelGroupByIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastGroupByIndex = -1;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (character === '\'' && previousCharacter !== '\\') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (character === '"' && previousCharacter !== '\\') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
if (character === '`') {
|
||||
inBacktick = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\'') {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
inBacktick = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')' && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[gG]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^group\s+by\b/i.test(remaining)) {
|
||||
lastGroupByIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastGroupByIndex;
|
||||
}
|
||||
|
||||
function buildSearchFilter(searchColumns, searchTerm) {
|
||||
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||
return String(column || '').trim();
|
||||
@@ -238,17 +327,24 @@ async function fetchPagedRows(pool, options) {
|
||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||
}
|
||||
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||
const groupByIndex = findTopLevelGroupByIndex(selectSql);
|
||||
let baseSelectSql = selectSql;
|
||||
let orderBySql = '';
|
||||
let groupBySql = '';
|
||||
|
||||
if (orderByIndex >= 0) {
|
||||
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||
}
|
||||
|
||||
if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) {
|
||||
baseSelectSql = selectSql.slice(0, groupByIndex).trim();
|
||||
groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim();
|
||||
}
|
||||
|
||||
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
|
||||
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`;
|
||||
const countQuery = searchFilter.clause
|
||||
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||
: countSql;
|
||||
@@ -294,10 +390,13 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
buildSortOrderClause,
|
||||
findTopLevelOrderByIndex,
|
||||
findTopLevelGroupByIndex,
|
||||
buildSearchFilter,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
|
||||
Reference in New Issue
Block a user