// 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(); return unit === 'seconds' ? 'seconds' : '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(/^$/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 = /]*rel=["']alternate["'][^>]*href=["']([^"']+)["'][^>]*\/>/i.exec(content || '') || /]*href=["']([^"']+)["'][^>]*rel=["']alternate["'][^>]*\/>/i.exec(content || '') || /]*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(/|/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 = /]*)>([\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(/]*)>([\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 = /]*)\/?>(?:\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 = /]*)>([\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 };