diff --git a/src/database/odata.js b/src/database/odata.js new file mode 100644 index 0000000..6164e6e --- /dev/null +++ b/src/database/odata.js @@ -0,0 +1,164 @@ +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import config from '../config.js'; +import { listObjects } from './database.js'; + +const logger = log4js.getLogger('DatabaseOData'); +logger.level = config.server.logLevel; + +const EXCLUDED_PATHS = ['appPasswordHash', '__v']; + +/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */ +function isEmbeddedObject(path) { + if (!path) return false; + if (path.options?.type === mongoose.Schema.Types.ObjectId) return false; + if (path.instance === 'Array') return false; + return path.schema != null || path.caster?.schema != null; +} + +/** Get all top-level property keys declared in OData metadata for a schema. */ +function getDeclaredPropertyKeys(schema) { + if (!schema?.paths) return []; + return Object.keys(schema.paths) + .filter((k) => !k.includes('.')) + .filter((k) => !EXCLUDED_PATHS.includes(k)) + .filter((k) => schema.paths[k]?.options?.select !== false); +} + +/** + * Ensure all declared properties are present in each document. OData clients (e.g. Excel) + * expect every metadata property to exist; omit none. Use null for missing/invalid values. + */ +function normalizeODataResponse(data, schema) { + const declaredKeys = getDeclaredPropertyKeys(schema); + if (declaredKeys.length === 0) return data; + + function normalize(doc) { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc; + const result = { ...doc }; + for (const key of declaredKeys) { + const val = result[key]; + if (isEmbeddedObject(schema.paths[key])) { + // ComplexType: object or null only + const isValid = val != null && typeof val === 'object' && !Array.isArray(val); + result[key] = isValid ? val : null; + } else { + // Primitive, array, etc.: value or null (never omit) + result[key] = val !== undefined && val !== null ? val : null; + } + } + return result; + } + + return Array.isArray(data) ? data.map(normalize) : normalize(data); +} + +/** + * Convert expanded ObjectId refs ({ _id: x }) back to primitive strings for OData. + * OData metadata declares refs as Edm.String; clients expect primitives, not objects. + */ +function compactObjectIdsForOData(data) { + function isExpandedRef(val) { + return val && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 1 && '_id' in val; + } + function compact(val) { + if (Array.isArray(val)) return val.map(compact); + if (isExpandedRef(val)) return val._id?.toString?.() ?? val._id; + if (val instanceof Date || val instanceof Buffer) return val; // pass through primitives + if (val && typeof val === 'object') { + const result = {}; + for (const [k, v] of Object.entries(val)) result[k] = compact(v); + return result; + } + return val; + } + return Array.isArray(data) ? data.map(compact) : compact(data); +} + +/** + * Translate filter keys ending with ._id for Mongoose compatibility + * @param {Object} filter - Filter object (will be mutated) + */ +function translateFilterKeys(filter) { + Object.keys(filter).forEach((key) => { + if (key.endsWith('._id')) { + const baseKey = key.slice(0, -4); + filter[baseKey] = filter[key]; + delete filter[key]; + } + }); +} + +/** + * List objects with OData-compatible response format. + * Works like listObjects but returns { @odata.context, @odata.count?, value }. + * + * @param {Object} options - Same options as listObjects, plus: + * @param {boolean} [options.count=false] - When true, include @odata.count (total before $skip/$top) + * @returns {Promise} OData-formatted response or { error, code } on failure + */ +export const listObjectsOData = async ({ + model, + populate = [], + page = 1, + limit = 25, + filter = {}, + sort = '', + order = 'ascend', + pagination = true, + project, + count = false, +}) => { + try { + const filterCopy = JSON.parse(JSON.stringify(filter)); + translateFilterKeys(filterCopy); + + const [data, total] = await Promise.all([ + listObjects({ + model, + populate, + page, + limit, + filter: filterCopy, + sort, + order, + pagination, + project, + }), + count ? model.countDocuments(filterCopy) : Promise.resolve(undefined), + ]); + + if (data?.error) { + return data; + } + + // Strip __v from each document (Mongoose version key - causes OData client issues) + const sanitizedData = Array.isArray(data) + ? data.map(({ __v, ...doc }) => doc) + : data; + + // Convert expanded ObjectId refs ({ _id: x }) to primitive strings - OData clients expect Edm.String + let odataData = compactObjectIdsForOData(sanitizedData); + + // Ensure all declared properties are present; use null for missing values (Excel requires this) + odataData = normalizeODataResponse(odataData, model.schema); + + const baseUrl = config.app?.urlApi || ''; + const modelName = model.modelName; + const context = `${baseUrl}/odata/$metadata#${modelName}`; + + const response = { + '@odata.context': context, + value: odataData, + }; + + if (count && total !== undefined) { + response['@odata.count'] = total; + } + + return response; + } catch (error) { + logger.error('OData list error:', error); + return { error, code: 500 }; + } +}; diff --git a/src/database/schemas/management/user.schema.js b/src/database/schemas/management/user.schema.js index 6048038..c7b4d77 100644 --- a/src/database/schemas/management/user.schema.js +++ b/src/database/schemas/management/user.schema.js @@ -10,6 +10,7 @@ const userSchema = new mongoose.Schema( lastName: { required: false, type: String }, email: { required: true, type: String }, profileImage: { type: mongoose.SchemaTypes.ObjectId, ref: 'file', required: false }, + appPasswordHash: { type: String, required: false, select: false }, }, { timestamps: true } ); diff --git a/src/index.js b/src/index.js index f2e2421..efba8ab 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ import { salesOrderRoutes, userNotifierRoutes, notificationRoutes, + odataRoutes, } from './routes/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -149,6 +150,7 @@ app.use('/salesorders', salesOrderRoutes); app.use('/notes', noteRoutes); app.use('/usernotifiers', userNotifierRoutes); app.use('/notifications', notificationRoutes); +app.use('/odata', odataRoutes); // Start the application if (process.env.NODE_ENV !== 'test') { diff --git a/src/keycloak.js b/src/keycloak.js index 11d1245..49edd4a 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -5,6 +5,7 @@ import config, { getEnvironment } from './config.js'; import log4js from 'log4js'; import NodeCache from 'node-cache'; +import bcrypt from 'bcrypt'; import { userModel } from './database/schemas/management/user.schema.js'; import { getObject } from './database/database.js'; import { hostModel } from './database/schemas/management/host.schema.js'; @@ -50,7 +51,8 @@ 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) x-host-id + x-auth-code (host auth) + * 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) */ const isAuthenticated = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; @@ -77,6 +79,28 @@ const isAuthenticated = async (req, res, next) => { } } + // Try HTTP Basic Auth (username + app password) + if (authHeader && authHeader.startsWith('Basic ')) { + try { + logger.debug('Basic auth header:', authHeader); + const base64Credentials = authHeader.substring(6); + const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8'); + const [username, password] = credentials.split(':'); + logger.debug('Basic auth credentials:', { username, password }); + if (username && password) { + const user = await userModel.findOne({ username }).select('+appPasswordHash'); + if (user?.appPasswordHash && (await bcrypt.compare(password, user.appPasswordHash))) { + user.appPasswordHash = undefined; // don't expose hash downstream + req.user = user; + req.session = { user }; + return next(); + } + } + } catch (error) { + logger.error('Basic auth error:', error.message); + } + } + const hostId = req.headers['x-host-id']; const authCode = req.headers['x-auth-code']; if (hostId && authCode) { diff --git a/src/routes/index.js b/src/routes/index.js index c48a3fd..73b4ac2 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -36,6 +36,7 @@ import salesOrderRoutes from './sales/salesorders.js'; import noteRoutes from './misc/notes.js'; import userNotifierRoutes from './misc/usernotifiers.js'; import notificationRoutes from './misc/notifications.js'; +import odataRoutes from './misc/odata.js'; export { userRoutes, @@ -76,4 +77,5 @@ export { salesOrderRoutes, userNotifierRoutes, notificationRoutes, + odataRoutes, }; diff --git a/src/routes/management/users.js b/src/routes/management/users.js index 9a1dae6..e6ef36f 100644 --- a/src/routes/management/users.js +++ b/src/routes/management/users.js @@ -10,6 +10,7 @@ import { editUserRouteHandler, getUserStatsRouteHandler, getUserHistoryRouteHandler, + setAppPasswordRouteHandler, } from '../../services/management/users.js'; // list of document templates @@ -50,4 +51,8 @@ router.put('/:id', isAuthenticated, async (req, res) => { editUserRouteHandler(req, res); }); +router.post('/:id/setAppPassword', isAuthenticated, async (req, res) => { + setAppPasswordRouteHandler(req, res); +}); + export default router; diff --git a/src/routes/misc/odata.js b/src/routes/misc/odata.js new file mode 100644 index 0000000..6511089 --- /dev/null +++ b/src/routes/misc/odata.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { isAuthenticated } from '../../keycloak.js'; +import { listODataRouteHandler, metadataODataRouteHandler } from '../../services/misc/odata.js'; + +const router = express.Router(); + +router.get('/$metadata', isAuthenticated, metadataODataRouteHandler); +router.get('/:objectType', isAuthenticated, listODataRouteHandler); + +export default router; diff --git a/src/services/management/users.js b/src/services/management/users.js index 20d5aa7..656db0d 100644 --- a/src/services/management/users.js +++ b/src/services/management/users.js @@ -2,6 +2,8 @@ import config from '../../config.js'; import { userModel } from '../../database/schemas/management/user.schema.js'; import log4js from 'log4js'; import mongoose from 'mongoose'; +import bcrypt from 'bcrypt'; +import { nanoid } from 'nanoid'; import { listObjects, listObjectsByProperties, @@ -139,3 +141,50 @@ export const getUserHistoryRouteHandler = async (req, res) => { logger.trace('User history:', result); res.send(result); }; + +export const setAppPasswordRouteHandler = async (req, res) => { + console.log(req.user); + if (req.user._id.toString() !== req.params.id) { + return res + .status(403) + .send({ error: 'You are not authorized to set the app password for this user.' }); + } + + const id = new mongoose.Types.ObjectId(req.params.id); + + logger.trace(`Setting app password for user with ID: ${id}`); + + const userResult = await getObject({ + model: userModel, + id, + }); + + if (userResult?.error) { + logger.warn('User not found with supplied id.'); + return res.status(userResult.code).send(userResult); + } + + const appPassword = nanoid(32); + const appPasswordHash = await bcrypt.hash(appPassword, 10); + + const updateData = { + updatedAt: new Date(), + appPasswordHash, + }; + + const result = await editObject({ + model: userModel, + id, + updateData, + user: req.user, + }); + + if (result?.error) { + logger.error('Error setting app password:', result.error); + return res.status(result.code || 500).send(result); + } + + logger.debug(`Set app password for user with ID: ${id}`); + + res.send({ appPassword }); +}; diff --git a/src/services/misc/odata.js b/src/services/misc/odata.js new file mode 100644 index 0000000..75a45b9 --- /dev/null +++ b/src/services/misc/odata.js @@ -0,0 +1,360 @@ +import config from '../../config.js'; +import log4js from 'log4js'; +import mongoose from 'mongoose'; +import { getModelByName, getAllModels } from './model.js'; +import { listObjectsOData } from '../../database/odata.js'; +import { getFilter } from '../../utils.js'; + +const logger = log4js.getLogger('OData'); +logger.level = config.server.logLevel; + +const EXCLUDED_PATHS = ['appPasswordHash', '__v']; + +function mongooseTypeToEdm(path) { + const instance = path?.instance; + if (instance === 'String') return 'Edm.String'; + if (instance === 'Number') return 'Edm.Decimal'; + if (instance === 'Date') return 'Edm.DateTimeOffset'; + if (instance === 'Boolean') return 'Edm.Boolean'; + if (instance === 'ObjectID') return 'Edm.String'; + if (path?.options?.type === mongoose.Schema.Types.ObjectId) return 'Edm.String'; + if (instance === 'Object' || instance === 'Mixed') return 'Edm.String'; // JSON-serialized + if (instance === 'Buffer') return 'Edm.Binary'; + if (path?.options?.type === mongoose.Schema.Types.Mixed) return 'Edm.String'; + return 'Edm.String'; +} + +/** Check if path is an embedded object (has nested schema, not ObjectId ref). Excludes arrays. */ +function isEmbeddedObject(path) { + if (!path) return false; + if (path.options?.type === mongoose.Schema.Types.ObjectId) return false; + if (path.instance === 'Array') return false; + return path.schema != null || path.caster?.schema != null; +} + +/** Check if path is an array of subdocuments (embedded schemas). */ +function isArrayOfSubdocuments(path) { + if (!path || path.instance !== 'Array') return false; + const schema = path.caster?.schema; + return schema != null && schema.paths != null; +} + +/** Check if path is an array of ObjectIds. */ +function isArrayOfObjectIds(path) { + if (!path || path.instance !== 'Array') return false; + return ( + path.caster?.instance === 'ObjectID' || + path.caster?.options?.type === mongoose.Schema.Types.ObjectId + ); +} + +/** Get nested properties from embedded schema for ComplexType */ +function getEmbeddedProperties(path, parentPaths, parentKey) { + const schema = path?.schema ?? path?.caster?.schema; + if (schema?.paths) { + return Object.entries(schema.paths) + .filter(([key]) => !key.includes('.')) + .map(([key, p]) => ({ name: key, edmType: mongooseTypeToEdm(p) })); + } + // Fallback: derive from parent schema's dotted paths (e.g. state.type, state.message) + if (parentPaths && parentKey) { + const prefix = parentKey + '.'; + return Object.entries(parentPaths) + .filter(([key]) => key.startsWith(prefix) && !key.slice(prefix.length).includes('.')) + .map(([key, p]) => ({ name: key.slice(prefix.length), edmType: mongooseTypeToEdm(p) })); + } + return []; +} + +function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Build OData 4.0 CSDL $metadata document from Mongoose models. + * Handles embedded objects (e.g. state) as ComplexTypes. + */ +function buildMetadataXml() { + const baseUrl = config.app?.urlApi || ''; + const namespace = 'FarmControl'; + const schema = []; + const complexTypes = new Map(); // pathKey -> { typeName, properties } + + const entries = getAllModels().filter((e) => e.model != null); + + // First pass: collect ComplexTypes from embedded paths and array-of-subdocument paths (merge props when same key) + for (const entry of entries) { + const paths = entry.model.schema.paths; + for (const [key, path] of Object.entries(paths)) { + if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false) + continue; + if (isEmbeddedObject(path)) { + const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key); + if (embeddedProps.length === 0) continue; + const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type'; + const existing = complexTypes.get(key); + if (existing) { + const propNames = new Set(existing.properties.map((p) => p.name)); + for (const p of embeddedProps) { + if (!propNames.has(p.name)) { + existing.properties.push(p); + propNames.add(p.name); + } + } + } else { + complexTypes.set(key, { typeName, properties: embeddedProps }); + } + } else if (isArrayOfSubdocuments(path)) { + const embeddedProps = getEmbeddedProperties(path, entry.model.schema.paths, key); + if (embeddedProps.length === 0) continue; + const typeName = key.charAt(0).toUpperCase() + key.slice(1) + 'Type'; + const existing = complexTypes.get(key); + if (existing) { + const propNames = new Set(existing.properties.map((p) => p.name)); + for (const p of embeddedProps) { + if (!propNames.has(p.name)) { + existing.properties.push(p); + propNames.add(p.name); + } + } + } else { + complexTypes.set(key, { typeName, properties: embeddedProps }); + } + } + } + } + + // Second pass: Mongoose sometimes omits parent path when nested object has 'type' as first key + // (e.g. state: { type: String, progress: Number } yields state.type, state.progress but no state) + // Derive ComplexTypes from dotted paths where parent is not a top-level path + for (const entry of entries) { + const paths = entry.model.schema.paths; + const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.'))); + for (const [key, path] of Object.entries(paths)) { + if (!key.includes('.') || path.options?.select === false) continue; + const dotIdx = key.indexOf('.'); + const parentKey = key.slice(0, dotIdx); + const childKey = key.slice(dotIdx + 1); + if (childKey.includes('.')) continue; // only one level of nesting for this pass + if (topLevelKeys.has(parentKey)) continue; // parent exists, already handled + if (path.options?.type === mongoose.Schema.Types.ObjectId) continue; // skip ObjectId refs + const typeName = parentKey.charAt(0).toUpperCase() + parentKey.slice(1) + 'Type'; + const prop = { name: childKey, edmType: mongooseTypeToEdm(path) }; + const existing = complexTypes.get(parentKey); + if (existing) { + const propNames = new Set(existing.properties.map((p) => p.name)); + if (!propNames.has(prop.name)) { + existing.properties.push(prop); + } + } else { + complexTypes.set(parentKey, { typeName, properties: [prop] }); + } + } + } + + // Emit ComplexType definitions + for (const [, { typeName, properties }] of complexTypes) { + const props = properties + .map((p) => ` `) + .join('\n'); + schema.push(` `); + schema.push(props); + schema.push(` `); + } + + // EntityTypes + for (const entry of entries) { + const modelName = entry.model.modelName; + const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1); + const paths = entry.model.schema.paths; + const topLevelKeys = new Set(Object.keys(paths).filter((k) => !k.includes('.'))); + + const properties = []; + const addedKeys = new Set(); + for (const [key, path] of Object.entries(paths)) { + if (EXCLUDED_PATHS.includes(key) || key.includes('.') || path.options?.select === false) + continue; + addedKeys.add(key); + let edmType; + if (isEmbeddedObject(path)) { + const ct = complexTypes.get(key); + if (!ct) continue; + edmType = `${namespace}.${ct.typeName}`; + } else if (isArrayOfSubdocuments(path)) { + const ct = complexTypes.get(key); + if (!ct) continue; + edmType = `Collection(${namespace}.${ct.typeName})`; + } else if (isArrayOfObjectIds(path)) { + edmType = 'Collection(Edm.String)'; + } else if (path.instance === 'Array' && path.caster) { + edmType = `Collection(${mongooseTypeToEdm(path.caster)})`; + } else { + edmType = mongooseTypeToEdm(path); + } + properties.push(` `); + } + // Add properties for dotted-path-derived ComplexTypes (e.g. state from state.type, state.progress) + for (const [key, path] of Object.entries(paths)) { + if (!key.includes('.') || path?.options?.select === false) continue; + const parentKey = key.slice(0, key.indexOf('.')); + if (topLevelKeys.has(parentKey) || addedKeys.has(parentKey)) continue; + const ct = complexTypes.get(parentKey); + if (!ct) continue; + if (!addedKeys.has(parentKey)) { + addedKeys.add(parentKey); + properties.push( + ` ` + ); + } + } + if (properties.length === 0) continue; + + schema.push(` `); + schema.push(` `); + schema.push(properties.join('\n')); + schema.push(` `); + } + + const entitySets = entries + .map((e) => { + const modelName = e.model.modelName; + const entityName = modelName.charAt(0).toUpperCase() + modelName.slice(1); + return ` `; + }) + .filter(Boolean) + .join('\n'); + + return ` + + + +${schema.join('\n')} + +${entitySets} + + + +`; +} + +/** + * Route handler for GET /odata/$metadata + * Returns OData 4.0 CSDL metadata document for Power BI / OData clients. + */ +export const metadataODataRouteHandler = (req, res) => { + const xml = buildMetadataXml(); + res.set('OData-Version', '4.0'); + res.set('Content-Type', 'application/xml'); + res.send(xml); +}; + +/** + * Parse OData $orderby into sort and order. + * Supports "field asc", "field desc", or just "field" (defaults asc). + */ +function parseOrderBy(orderby) { + if (!orderby || typeof orderby !== 'string') { + return { sort: 'createdAt', order: 'ascend' }; + } + const trimmed = orderby.trim(); + const parts = trimmed.split(/\s+/); + const sort = parts[0] || 'createdAt'; + const dir = (parts[1] || 'asc').toLowerCase(); + const order = dir === 'desc' ? 'descend' : 'ascend'; + return { sort, order }; +} + +/** + * Route handler for GET /odata/:objectType + * Supports OData query options: $top, $skip, $orderby, $count, $select + * and passes through allowed filters from query params. + */ +export const listODataRouteHandler = async (req, res) => { + const objectType = req.params.objectType; + const entry = getModelByName(objectType); + + if (!entry?.model) { + logger.warn(`OData: unknown object type "${objectType}"`); + return res.status(404).send({ error: `Unknown object type: ${objectType}` }); + } + + const { $top, $skip, $orderby, $count, $select } = req.query; + + 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 includeCount = $count === 'true' || $count === true; + + const { sort, order } = parseOrderBy($orderby); + + const rawProject = $select + ? String($select) + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(' ') || undefined + : undefined; + // Always exclude __v (Mongoose version key) - causes OData client issues + const project = rawProject ? `${rawProject} -__v` : '-__v'; + + // Build filter from query params - allow common filter keys per model + const allowedFilters = getModelFilterFields(objectType); + const filter = getFilter(req.query, allowedFilters); + + const result = await listObjectsOData({ + model: entry.model, + populate: [], + page, + limit, + filter, + sort, + order, + pagination: true, + project, + count: includeCount, + }); + + if (result?.error) { + logger.error('OData list error:', result.error); + return res.status(result.code || 500).send(result); + } + + res.set('OData-Version', '4.0'); + res.set('Content-Type', 'application/json; odata.metadata=minimal'); + res.send(result); +}; + +/** + * Return allowed filter fields for a given object type. + * Extends a base set with type-specific fields. + */ +function getModelFilterFields(objectType) { + const base = ['_id']; + const byType = { + note: ['parent._id', 'noteType', 'user'], + notification: ['user'], + userNotifier: ['user', 'object', 'objectType'], + printer: ['host'], + job: ['printer', 'gcodeFile'], + subJob: ['job'], + filamentStock: ['filament'], + partStock: ['part'], + purchaseOrder: ['vendor'], + orderItem: ['order._id', 'orderType', 'item._id', 'itemType', 'shipment._id'], + shipment: ['order._id', 'orderType', 'courierService._id'], + stockEvent: ['parent._id', 'parentType', 'owner._id', 'ownerType'], + stockAudit: ['filamentStock._id', 'partStock._id'], + documentJob: ['documentTemplate', 'documentPrinter', 'object._id', 'objectType'], + documentTemplate: ['parent._id', 'documentSize._id'], + salesOrder: ['client'], + invoice: ['to._id', 'from._id', 'order._id', 'orderType'], + auditLog: ['parent._id', 'parentType', 'owner._id', 'ownerType'], + }; + const extra = byType[objectType] || []; + return [...base, ...extra]; +}