From bec68488a7e2c030e7bc198c3ddcc14e61e378fd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 20 Jun 2026 22:06:04 +0100 Subject: [PATCH] Implemented RSS support. --- src/index.js | 2 + src/keycloak.js | 63 ++++++++++++++------ src/routes/index.js | 2 + src/routes/misc/rss.js | 9 +++ src/services/misc/rss.js | 121 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 src/routes/misc/rss.js create mode 100644 src/services/misc/rss.js diff --git a/src/index.js b/src/index.js index 83d3d65..941c267 100644 --- a/src/index.js +++ b/src/index.js @@ -55,6 +55,7 @@ import { userNotifierRoutes, notificationRoutes, odataRoutes, + rssRoutes, excelRoutes, csvRoutes, appLaunchRoutes, @@ -180,6 +181,7 @@ app.use('/notes', noteRoutes); app.use('/usernotifiers', userNotifierRoutes); app.use('/notifications', notificationRoutes); app.use('/odata', odataRoutes); +app.use('/rss', rssRoutes); app.use('/excel', excelRoutes); app.use('/csv', csvRoutes); app.use('/applaunch', appLaunchRoutes); diff --git a/src/keycloak.js b/src/keycloak.js index ad7787c..3b79eb5 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -53,7 +53,7 @@ const lookupUser = async (preferredUsername) => { /** * Middleware to check if the user is authenticated. * Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), - * 3) HTTP Basic Auth (username + app password), 4) x-host-id + x-auth-code (host auth) + * 3) x-host-id + x-auth-code (host auth) */ const isAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; @@ -92,9 +92,30 @@ const isAuthenticated = async (req, res, next) => { return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; +const authenticateWithAppPassword = async (username, secret) => { + if (!username || !secret) return null; + + const user = await userModel.findOne({ username }); + if (!user) return null; + + const appPasswords = await appPasswordModel + .find({ user: user._id, active: true }) + .select('+secret') + .lean(); + + for (const appPassword of appPasswords) { + const storedHash = appPassword.secret; + if (storedHash && (await bcrypt.compare(secret, storedHash))) { + return user; + } + } + + return null; +}; + const isAppAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; - // Try HTTP Basic Auth (username + app password secret) + // Supports HTTP Basic Auth (username + app password secret) if (authHeader?.startsWith('Basic ')) { try { logger.debug('Basic auth header present'); @@ -103,22 +124,11 @@ const isAppAuthenticated = async (req, res, next) => { const colonIndex = credentials.indexOf(':'); const username = credentials.substring(0, colonIndex).trim(); const secret = credentials.substring(colonIndex + 1).trim(); - if (username && secret) { - const user = await userModel.findOne({ username }); - if (user) { - const appPasswords = await appPasswordModel - .find({ user: user._id, active: true }) - .select('+secret') - .lean(); - for (const appPassword of appPasswords) { - const storedHash = appPassword.secret; - if (storedHash && (await bcrypt.compare(secret, storedHash))) { - req.user = user; - req.session = { user }; - return next(); - } - } - } + const user = await authenticateWithAppPassword(username, secret); + if (user) { + req.user = user; + req.session = { user }; + return next(); } } catch (error) { logger.error('Basic auth error:', error.message); @@ -127,6 +137,22 @@ const isAppAuthenticated = async (req, res, next) => { return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; +const isAppQueryAuthenticated = async (req, res, next) => { + try { + const username = typeof req.query.u === 'string' ? req.query.u.trim() : ''; + const secret = typeof req.query.p === 'string' ? req.query.p : ''; + const user = await authenticateWithAppPassword(username, secret); + if (user) { + req.user = user; + req.session = { user }; + return next(); + } + } catch (error) { + logger.error('Query auth error:', error.message); + } + return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); +}; + const clearUserCache = () => { userCache.flushAll(); logger.info('User cache cleared'); @@ -144,6 +170,7 @@ const removeUserFromCache = (username) => { export { isAuthenticated, isAppAuthenticated, + isAppQueryAuthenticated, lookupUser, clearUserCache, getUserCacheStats, diff --git a/src/routes/index.js b/src/routes/index.js index bbdda16..a14411b 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -48,6 +48,7 @@ import noteRoutes from './misc/notes.js'; import userNotifierRoutes from './misc/usernotifiers.js'; import notificationRoutes from './misc/notifications.js'; import odataRoutes from './misc/odata.js'; +import rssRoutes from './misc/rss.js'; import excelRoutes from './misc/excel.js'; import csvRoutes from './misc/csv.js'; import appLaunchRoutes from './misc/applaunch.js'; @@ -103,6 +104,7 @@ export { userNotifierRoutes, notificationRoutes, odataRoutes, + rssRoutes, excelRoutes, csvRoutes, appLaunchRoutes, diff --git a/src/routes/misc/rss.js b/src/routes/misc/rss.js new file mode 100644 index 0000000..87d9f38 --- /dev/null +++ b/src/routes/misc/rss.js @@ -0,0 +1,9 @@ +import express from 'express'; +import { isAppQueryAuthenticated } from '../../keycloak.js'; +import { listRssRouteHandler } from '../../services/misc/rss.js'; + +const router = express.Router(); + +router.get('/:objectType', isAppQueryAuthenticated, listRssRouteHandler); + +export default router; diff --git a/src/services/misc/rss.js b/src/services/misc/rss.js new file mode 100644 index 0000000..fd0a99c --- /dev/null +++ b/src/services/misc/rss.js @@ -0,0 +1,121 @@ +import config from '../../config.js'; +import log4js from 'log4js'; +import { getModelByName } from './model.js'; +import { listObjectsOData } from '../../database/odata.js'; +import { getFilter } from '../../utils.js'; +import { getModelFilterFields, parseOrderBy } from './export.js'; + +const logger = log4js.getLogger('RSS'); +logger.level = config.server.logLevel; + +function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function getItemTitle(objectType, row) { + const preferredTitle = row.name || row.title || row.label || row.code; + if (preferredTitle) return String(preferredTitle); + return `${objectType} ${row._id || 'entry'}`; +} + +function getItemDescription(row) { + const ignoredKeys = new Set(['_id', 'createdAt', 'updatedAt']); + const summary = Object.entries(row || {}) + .filter( + ([key, val]) => !ignoredKeys.has(key) && val !== null && val !== undefined && val !== '' + ) + .slice(0, 10) + .map(([key, val]) => `${key}: ${typeof val === 'object' ? JSON.stringify(val) : String(val)}`) + .join(' | '); + + if (summary) return summary; + return JSON.stringify(row || {}); +} + +function buildRssXml({ objectType, feedUrl, rows }) { + const now = new Date().toUTCString(); + const itemsXml = rows + .map((row) => { + const rowId = row?._id ? String(row._id) : `${objectType}-${Date.now()}`; + const pubDateSource = row?.updatedAt || row?.createdAt; + const pubDate = pubDateSource ? new Date(pubDateSource).toUTCString() : now; + const itemLink = `${feedUrl}#${encodeURIComponent(rowId)}`; + return [ + '', + ` ${escapeXml(getItemTitle(objectType, row || {}))}`, + ` ${escapeXml(itemLink)}`, + ` ${escapeXml(`${objectType}:${rowId}`)}`, + ` ${escapeXml(getItemDescription(row || {}))}`, + ` ${escapeXml(pubDate)}`, + '', + ].join('\n'); + }) + .join('\n'); + + return ` + + + ${escapeXml(`FarmControl ${objectType} RSS Feed`)} + ${escapeXml(feedUrl)} + ${escapeXml(`Recent ${objectType} updates from FarmControl`)} + ${escapeXml(now)} +${itemsXml} + +`; +} + +/** + * Route handler for GET /rss/:objectType + * Supports OData-style query options: $top, $skip, $orderby and allowed filters. + */ +export const listRssRouteHandler = async (req, res) => { + const objectType = req.params.objectType; + const entry = getModelByName(objectType); + + if (!entry?.model) { + logger.warn(`RSS: unknown object type "${objectType}"`); + return res.status(404).send({ error: `Unknown object type: ${objectType}` }); + } + + const { $top, $skip, $orderby } = req.query; + const queryWithoutAuth = { ...req.query }; + delete queryWithoutAuth.u; + delete queryWithoutAuth.p; + const limit = $top ? Math.min(parseInt($top, 10) || 25, 1000) : 25; + const skip = $skip ? Math.max(0, parseInt($skip, 10) || 0) : 0; + const page = Math.floor(skip / limit) + 1; + const { sort, order } = parseOrderBy($orderby); + const allowedFilters = getModelFilterFields(objectType); + const filter = getFilter(queryWithoutAuth, allowedFilters); + + const result = await listObjectsOData({ + model: entry.model, + populate: [], + page, + limit, + filter, + sort, + order, + pagination: true, + project: '-__v', + count: false, + }); + + if (result?.error) { + logger.error('RSS list error:', result.error); + return res.status(result.code || 500).send(result); + } + + const baseUrl = config.app?.urlApi || ''; + const feedUrl = `${baseUrl}/rss/${objectType}`; + const rows = result?.value || []; + const xml = buildRssXml({ objectType, feedUrl, rows }); + + res.set('Content-Type', 'application/rss+xml; charset=utf-8'); + res.send(xml); +};