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);
+};