Compare commits

...

2 Commits

Author SHA1 Message Date
fd636c6c82 Fixed product SKUs.
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
2026-06-20 22:27:21 +01:00
bec68488a7 Implemented RSS support. 2026-06-20 22:06:04 +01:00
7 changed files with 183 additions and 19 deletions

View File

@ -3,6 +3,7 @@ import { generateId } from '../../utils.js';
const { Schema } = mongoose; const { Schema } = mongoose;
const partSkuUsageSchema = new Schema({ const partSkuUsageSchema = new Schema({
part: { type: Schema.Types.ObjectId, ref: 'part', required: true },
partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true }, partSku: { type: Schema.Types.ObjectId, ref: 'partSku', required: true },
quantity: { type: Number, required: true }, quantity: { type: Number, required: true },
}); });

View File

@ -55,6 +55,7 @@ import {
userNotifierRoutes, userNotifierRoutes,
notificationRoutes, notificationRoutes,
odataRoutes, odataRoutes,
rssRoutes,
excelRoutes, excelRoutes,
csvRoutes, csvRoutes,
appLaunchRoutes, appLaunchRoutes,
@ -180,6 +181,7 @@ app.use('/notes', noteRoutes);
app.use('/usernotifiers', userNotifierRoutes); app.use('/usernotifiers', userNotifierRoutes);
app.use('/notifications', notificationRoutes); app.use('/notifications', notificationRoutes);
app.use('/odata', odataRoutes); app.use('/odata', odataRoutes);
app.use('/rss', rssRoutes);
app.use('/excel', excelRoutes); app.use('/excel', excelRoutes);
app.use('/csv', csvRoutes); app.use('/csv', csvRoutes);
app.use('/applaunch', appLaunchRoutes); app.use('/applaunch', appLaunchRoutes);

View File

@ -53,7 +53,7 @@ const lookupUser = async (preferredUsername) => {
/** /**
* Middleware to check if the user is authenticated. * Middleware to check if the user is authenticated.
* Supports: 1) Bearer token (Redis session), 2) Bearer token (email-render JWT for Puppeteer), * 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 isAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization; 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' }); 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 isAppAuthenticated = async (req, res, next) => {
const authHeader = req.headers.authorization || req.headers.Authorization; 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 ')) { if (authHeader?.startsWith('Basic ')) {
try { try {
logger.debug('Basic auth header present'); logger.debug('Basic auth header present');
@ -103,22 +124,11 @@ const isAppAuthenticated = async (req, res, next) => {
const colonIndex = credentials.indexOf(':'); const colonIndex = credentials.indexOf(':');
const username = credentials.substring(0, colonIndex).trim(); const username = credentials.substring(0, colonIndex).trim();
const secret = credentials.substring(colonIndex + 1).trim(); const secret = credentials.substring(colonIndex + 1).trim();
if (username && secret) { const user = await authenticateWithAppPassword(username, secret);
const user = await userModel.findOne({ username }); if (user) {
if (user) { req.user = user;
const appPasswords = await appPasswordModel req.session = { user };
.find({ user: user._id, active: true }) return next();
.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();
}
}
}
} }
} catch (error) { } catch (error) {
logger.error('Basic auth error:', error.message); 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' }); 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 = () => { const clearUserCache = () => {
userCache.flushAll(); userCache.flushAll();
logger.info('User cache cleared'); logger.info('User cache cleared');
@ -144,6 +170,7 @@ const removeUserFromCache = (username) => {
export { export {
isAuthenticated, isAuthenticated,
isAppAuthenticated, isAppAuthenticated,
isAppQueryAuthenticated,
lookupUser, lookupUser,
clearUserCache, clearUserCache,
getUserCacheStats, getUserCacheStats,

View File

@ -48,6 +48,7 @@ import noteRoutes from './misc/notes.js';
import userNotifierRoutes from './misc/usernotifiers.js'; import userNotifierRoutes from './misc/usernotifiers.js';
import notificationRoutes from './misc/notifications.js'; import notificationRoutes from './misc/notifications.js';
import odataRoutes from './misc/odata.js'; import odataRoutes from './misc/odata.js';
import rssRoutes from './misc/rss.js';
import excelRoutes from './misc/excel.js'; import excelRoutes from './misc/excel.js';
import csvRoutes from './misc/csv.js'; import csvRoutes from './misc/csv.js';
import appLaunchRoutes from './misc/applaunch.js'; import appLaunchRoutes from './misc/applaunch.js';
@ -103,6 +104,7 @@ export {
userNotifierRoutes, userNotifierRoutes,
notificationRoutes, notificationRoutes,
odataRoutes, odataRoutes,
rssRoutes,
excelRoutes, excelRoutes,
csvRoutes, csvRoutes,
appLaunchRoutes, appLaunchRoutes,

9
src/routes/misc/rss.js Normal file
View File

@ -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;

View File

@ -35,7 +35,7 @@ export const listProductSkusRouteHandler = async (
search, search,
sort, sort,
order, order,
populate: ['priceTaxRate', 'costTaxRate'], populate: ['priceTaxRate', 'costTaxRate', 'product'],
}); });
if (result?.error) { if (result?.error) {
@ -83,6 +83,8 @@ export const getProductSkuRouteHandler = async (req, res) => {
'priceTaxRate', 'priceTaxRate',
'costTaxRate', 'costTaxRate',
'parts.partSku', 'parts.partSku',
'parts.part',
'product',
], ],
}); });
if (result?.error) { if (result?.error) {

121
src/services/misc/rss.js Normal file
View File

@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 [
'<item>',
` <title>${escapeXml(getItemTitle(objectType, row || {}))}</title>`,
` <link>${escapeXml(itemLink)}</link>`,
` <guid isPermaLink="false">${escapeXml(`${objectType}:${rowId}`)}</guid>`,
` <description>${escapeXml(getItemDescription(row || {}))}</description>`,
` <pubDate>${escapeXml(pubDate)}</pubDate>`,
'</item>',
].join('\n');
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>${escapeXml(`FarmControl ${objectType} RSS Feed`)}</title>
<link>${escapeXml(feedUrl)}</link>
<description>${escapeXml(`Recent ${objectType} updates from FarmControl`)}</description>
<lastBuildDate>${escapeXml(now)}</lastBuildDate>
${itemsXml}
</channel>
</rss>`;
}
/**
* 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);
};