Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m12s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 30s
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
// RSS feed data access, pagination, and item normalization helpers.
|
|
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
|
|
|
const NAME_MAX_LENGTH = 255;
|
|
const URL_MAX_LENGTH = 1024;
|
|
|
|
function normalizeUpdateIntervalUnit(value) {
|
|
const unit = String(value || '').trim().toLowerCase();
|
|
if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') {
|
|
return unit;
|
|
}
|
|
return 'minutes';
|
|
}
|
|
|
|
async function fetchRssFeedsData(pool) {
|
|
const [rssFeeds] = await pool.query(
|
|
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC'
|
|
);
|
|
|
|
return { rssFeeds: rssFeeds };
|
|
}
|
|
|
|
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
|
const paged = await fetchPagedRows(pool, {
|
|
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds ORDER BY modified_at DESC, id DESC',
|
|
countSql: 'SELECT COUNT(*) AS count FROM i_rss_feeds',
|
|
searchColumns: ['name', 'feed_url'],
|
|
searchTerm: searchTerm,
|
|
sortColumns: {
|
|
name: 'name',
|
|
url: 'feed_url',
|
|
interval: ['update_interval_value', 'update_interval_unit'],
|
|
items: 'item_limit',
|
|
created: 'created_at',
|
|
modified: 'modified_at'
|
|
},
|
|
sortKey: sortKey,
|
|
sortDirection: sortDirection,
|
|
page: page,
|
|
pageSize: pageSize
|
|
});
|
|
|
|
return Object.assign({ rssFeeds: paged.rows }, paged);
|
|
}
|
|
|
|
async function fetchRssFeedById(pool, id) {
|
|
const [rows] = await pool.query(
|
|
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM i_rss_feeds WHERE id = ?',
|
|
[id]
|
|
);
|
|
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function fetchRssFeedItemsByFeedId(pool, rssFeedId) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, rss_feed_id, position, item_json, created_at, modified_at
|
|
FROM i_rss_feed_items
|
|
WHERE rss_feed_id = ?
|
|
ORDER BY position ASC, id ASC`,
|
|
[rssFeedId]
|
|
);
|
|
|
|
return rows.map(function (row) {
|
|
return normalizeRssFeedItem(row);
|
|
});
|
|
}
|
|
|
|
function normalizeRssFeedItem(row) {
|
|
let itemJson = null;
|
|
if (row && row.item_json) {
|
|
try {
|
|
itemJson = JSON.parse(row.item_json);
|
|
} catch (_error) {
|
|
itemJson = null;
|
|
}
|
|
}
|
|
|
|
return Object.assign({}, row || {}, itemJson || {}, {
|
|
itemJson: itemJson
|
|
});
|
|
}
|
|
|
|
async function loadUrlText(urlValue) {
|
|
if (typeof fetch === 'function') {
|
|
const response = await fetch(urlValue, {
|
|
headers: {
|
|
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
|
'User-Agent': 'Pulse Signage RSS Reader'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to load RSS feed (${response.status}).`);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
|
|
return await new Promise(function (resolve, reject) {
|
|
const url = new URL(urlValue);
|
|
const transport = url.protocol === 'https:' ? https : http;
|
|
const request = transport.get(url, {
|
|
headers: {
|
|
Accept: 'application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8',
|
|
'User-Agent': 'Pulse Signage RSS Reader'
|
|
}
|
|
}, function (response) {
|
|
if (response.statusCode && response.statusCode >= 400) {
|
|
reject(new Error(`Unable to load RSS feed (${response.statusCode}).`));
|
|
response.resume();
|
|
return;
|
|
}
|
|
|
|
response.setEncoding('utf8');
|
|
let body = '';
|
|
response.on('data', function (chunk) {
|
|
body += chunk;
|
|
});
|
|
response.on('end', function () {
|
|
resolve(body);
|
|
});
|
|
});
|
|
|
|
request.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function decodeXmlEntities(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function stripCdata(value) {
|
|
return String(value || '').replace(/^<!\[CDATA\[|\]\]>$/g, '');
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function extractXmlTag(content, tagName) {
|
|
const safeTagName = escapeRegExp(tagName);
|
|
const pattern = new RegExp(`<${safeTagName}(?:\\s[^>]*)?>([\\s\\S]*?)<\/${safeTagName}>`, 'i');
|
|
const match = pattern.exec(content || '');
|
|
if (!match) {
|
|
return '';
|
|
}
|
|
return decodeXmlEntities(stripCdata(match[1]).trim());
|
|
}
|
|
|
|
function extractAtomLink(content) {
|
|
const linkMatch = /<link\b[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '')
|
|
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '')
|
|
|| /<link\b[^>]*href=["']([^"']+)["'][^>]*>/i.exec(content || '');
|
|
if (!linkMatch) {
|
|
return '';
|
|
}
|
|
return decodeXmlEntities(String(linkMatch[1] || '').trim());
|
|
}
|
|
|
|
function parseRssItems(xmlText, itemLimit) {
|
|
const normalizedXml = String(xmlText || '');
|
|
const itemMatches = Array.from(normalizedXml.matchAll(/<item\b[\s\S]*?<\/item>|<entry\b[\s\S]*?<\/entry>/gi)).map(function (match) {
|
|
return String(match[0] || '');
|
|
});
|
|
|
|
if (!itemMatches.length) {
|
|
return [];
|
|
}
|
|
|
|
return itemMatches.slice(0, Math.max(1, Number(itemLimit) || 1)).map(function (itemXml, index) {
|
|
const title = extractXmlTag(itemXml, 'title') || `Item ${index + 1}`;
|
|
const link = extractXmlTag(itemXml, 'link') || extractAtomLink(itemXml);
|
|
const pubDate = extractXmlTag(itemXml, 'pubDate') || extractXmlTag(itemXml, 'updated') || extractXmlTag(itemXml, 'dc:date');
|
|
const description = extractXmlTag(itemXml, 'description') || extractXmlTag(itemXml, 'summary') || extractXmlTag(itemXml, 'content:encoded');
|
|
const author = extractXmlTag(itemXml, 'author') || extractXmlTag(itemXml, 'dc:creator');
|
|
const comments = extractXmlTag(itemXml, 'comments');
|
|
const guidMatch = /<guid\b([^>]*)>([\s\S]*?)<\/guid>/i.exec(itemXml || '');
|
|
const guid = guidMatch ? decodeXmlEntities(stripCdata(String(guidMatch[2] || '').trim())) : '';
|
|
const guidIsPermaLinkMatch = guidMatch && /\bisPermaLink\s*=\s*(["']?)(true|false)\1/i.exec(String(guidMatch[1] || ''));
|
|
const categories = Array.from(String(itemXml || '').matchAll(/<category\b([^>]*)>([\s\S]*?)<\/category>/gi)).map(function (match) {
|
|
const categoryAttributes = String(match[1] || '');
|
|
const categoryDomainMatch = /\bdomain\s*=\s*(["'])([^"']+)\1/i.exec(categoryAttributes);
|
|
return {
|
|
value: decodeXmlEntities(stripCdata(String(match[2] || '').trim())),
|
|
domain: categoryDomainMatch ? decodeXmlEntities(String(categoryDomainMatch[2] || '').trim()) : ''
|
|
};
|
|
}).filter(function (category) {
|
|
return Boolean(category.value);
|
|
});
|
|
const enclosureMatch = /<enclosure\b([^>]*)\/?>(?:\s*)/i.exec(itemXml || '');
|
|
const enclosureUrlMatch = enclosureMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
|
const enclosureLengthMatch = enclosureMatch ? /\blength\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
|
const enclosureTypeMatch = enclosureMatch ? /\btype\s*=\s*(["'])([^"']+)\1/i.exec(String(enclosureMatch[1] || '')) : null;
|
|
const sourceMatch = /<source\b([^>]*)>([\s\S]*?)<\/source>/i.exec(itemXml || '');
|
|
const sourceUrlMatch = sourceMatch ? /\burl\s*=\s*(["'])([^"']+)\1/i.exec(String(sourceMatch[1] || '')) : null;
|
|
|
|
const parsedItem = {
|
|
title: title,
|
|
link: link,
|
|
description: description,
|
|
author: author,
|
|
comments: comments,
|
|
guid: guid,
|
|
guidIsPermaLink: guidIsPermaLinkMatch ? String(guidIsPermaLinkMatch[2] || '').toLowerCase() === 'true' : null,
|
|
pubDate: pubDate,
|
|
categories: categories,
|
|
enclosure: enclosureMatch ? {
|
|
url: enclosureUrlMatch ? decodeXmlEntities(String(enclosureUrlMatch[2] || '').trim()) : '',
|
|
length: enclosureLengthMatch ? Number(enclosureLengthMatch[2]) || null : null,
|
|
type: enclosureTypeMatch ? decodeXmlEntities(String(enclosureTypeMatch[2] || '').trim()) : ''
|
|
} : null,
|
|
source: sourceMatch ? {
|
|
title: decodeXmlEntities(stripCdata(String(sourceMatch[2] || '').trim())),
|
|
url: sourceUrlMatch ? decodeXmlEntities(String(sourceUrlMatch[2] || '').trim()) : ''
|
|
} : null,
|
|
rawXml: itemXml
|
|
};
|
|
|
|
return parsedItem;
|
|
});
|
|
}
|
|
|
|
async function fetchRssFeedItems(feedUrl, itemLimit) {
|
|
const xmlText = await loadUrlText(feedUrl);
|
|
return parseRssItems(xmlText, itemLimit);
|
|
}
|
|
|
|
async function replaceRssFeedItems(connection, rssFeedId, items) {
|
|
const normalizedItems = Array.isArray(items) ? items : [];
|
|
await connection.query('DELETE FROM i_rss_feed_items WHERE rss_feed_id = ?', [rssFeedId]);
|
|
|
|
if (!normalizedItems.length) {
|
|
return 0;
|
|
}
|
|
|
|
const insertValues = normalizedItems.map(function (item, index) {
|
|
return [
|
|
rssFeedId,
|
|
index + 1,
|
|
JSON.stringify(item || {})
|
|
];
|
|
});
|
|
|
|
await connection.query(
|
|
'INSERT INTO i_rss_feed_items (rss_feed_id, position, item_json) VALUES ?',
|
|
[insertValues]
|
|
);
|
|
|
|
return insertValues.length;
|
|
}
|
|
|
|
function buildRssFeedPayload(req, existingRssFeed) {
|
|
const fallback = existingRssFeed || {};
|
|
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));
|
|
|
|
if (!name) {
|
|
const error = new Error('RSS feed name is required.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!feedUrl) {
|
|
const error = new Error('RSS feed URL is required.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
let parsedUrl;
|
|
try {
|
|
parsedUrl = new URL(feedUrl);
|
|
} catch (_error) {
|
|
const error = new Error('Enter a valid RSS feed URL.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
const error = new Error('RSS feed URL must start with http or https.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!Number.isFinite(updateIntervalValue)) {
|
|
const error = new Error('Update interval must be a number.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
if (!Number.isFinite(itemLimit)) {
|
|
const error = new Error('Item count must be a number.');
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
name: name,
|
|
feedUrl: parsedUrl.toString(),
|
|
updateIntervalValue: Math.floor(updateIntervalValue),
|
|
updateIntervalUnit: updateIntervalUnit,
|
|
itemLimit: Math.floor(itemLimit)
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
fetchRssFeedsData,
|
|
fetchRssFeedsPage,
|
|
fetchRssFeedById,
|
|
fetchRssFeedItemsByFeedId,
|
|
normalizeRssFeedItem,
|
|
buildRssFeedPayload,
|
|
fetchRssFeedItems,
|
|
replaceRssFeedItems
|
|
}; |