diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fee26a0b..8bf5482b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2512,7 +2512,7 @@ packages: '@babel/traverse': 7.21.2(supports-color@5.5.0) '@babel/types': 7.21.2 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@9.2.2) + debug: 4.3.4(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.0 @@ -26063,7 +26063,7 @@ packages: resolution: {integrity: sha512-nGjm89lHja+T/b8cybAby6H0YgA4qYC/lx6UlwvHGqvTq8bDaNeCwl1sY8uRELrNbVWJzIihxVd+vphGGn1vBw==} engines: {node: '>=12.0.0'} dependencies: - debug: 4.3.4(supports-color@9.2.2) + debug: 4.3.4(supports-color@5.5.0) regexp-clone: 1.0.0 sliced: 1.0.1 transitivePeerDependencies: @@ -27913,7 +27913,7 @@ packages: dependencies: lilconfig: 2.0.6 postcss: 8.4.21 - ts-node: 10.9.1(@types/node@15.14.9)(typescript@4.9.5) + ts-node: 10.9.1(@types/node@18.16.1)(typescript@4.9.5) yaml: 1.10.2 dev: false @@ -34370,6 +34370,7 @@ packages: typescript: 4.9.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + dev: true /ts-node@10.9.1(@types/node@16.11.7)(typescript@4.7.4): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} @@ -34554,7 +34555,6 @@ packages: typescript: 4.9.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - dev: true /ts-pnp@1.2.0(typescript@4.7.4): resolution: {integrity: sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md new file mode 100644 index 00000000..2faf8b58 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/README.md @@ -0,0 +1,4 @@ +fork from https://github.com/NathanAdhitya/express-mongoose-ra-json-server + +modify: +- count logic in get `/` diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts new file mode 100644 index 00000000..7807343c --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/index.ts @@ -0,0 +1,259 @@ +import { RequestHandler, Router } from 'express'; +import { LeanDocument } from 'mongoose'; +import statusMessages from './statusMessages'; +import { ADPBaseModel, ADPBaseSchema } from './utils/baseModel.interface'; +import castFilter from './utils/castFilter'; +import convertId from './utils/convertId'; +import filterGetList from './utils/filterGetList'; +import { filterReadOnly } from './utils/filterReadOnly'; +import parseQuery from './utils/parseQuery'; +import virtualId from './utils/virtualId'; + +// Export certain helper functions for custom reuse. +export { default as virtualId } from './utils/virtualId'; +export { default as convertId } from './utils/convertId'; +export { default as castFilter } from './utils/castFilter'; +export { default as parseQuery } from './utils/parseQuery'; +export { default as filterGetList } from './utils/filterGetList'; +export { filterReadOnly } from './utils/filterReadOnly'; +export { default as statusMessages } from './statusMessages'; + +export interface raExpressMongooseCapabilities { + list?: boolean; + get?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +} + +export interface raExpressMongooseOptions { + /** Fields to search from ?q (used for autofill and search) */ + q?: string[]; + + /** Base name for ACLs (e.g. list operation does baseName.list) */ + aclName?: string; + + /** Fields to allow regex based search (non-exact search) */ + allowedRegexFields?: string[]; + + /** Read-only fields to filter out during create and update */ + readOnlyFields?: string[]; + + /** Function to transform inputs received in create and update */ + inputTransformer?: (input: Partial) => Promise>; + + /** Additional queries for list, e.g. deleted/hidden flag. */ + listQuery?: Record; + + /** Max rows from a get operation to prevent accidental server suicide (default 100) */ + maxRows?: number; + + /** Extra selects for mongoose queries (in the case that certain fields are hidden by default) */ + extraSelects?: string; + + /** Disable or enable certain parts. */ + capabilities?: raExpressMongooseCapabilities; + + /** Specify a custom express.js router */ + router?: Router; + + /** Specify an ACL middleware to check against permissions */ + ACLMiddleware?: (name: string) => RequestHandler; +} + +export function raExpressMongoose( + model: T, + options?: raExpressMongooseOptions +) { + const { + q, + allowedRegexFields = [], + readOnlyFields, + inputTransformer = (input: any) => input, + listQuery, + extraSelects, + maxRows = 100, + capabilities, + aclName, + router = Router(), + ACLMiddleware, + } = options ?? {}; + + const { + list: canList = true, + get: canGet = true, + create: canCreate = true, + update: canUpdate = true, + delete: canDelete = true, + } = capabilities ?? {}; + + /** getList, getMany, getManyReference */ + if (canList) + router.get( + '/', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.list`) + : (req, res, next) => next(), + async (req, res) => { + const filterQuery = { + ...listQuery, + ...parseQuery( + castFilter( + convertId(filterGetList(req.query)), + model, + allowedRegexFields + ), + model, + allowedRegexFields, + q + ), + }; + let query = model.find(filterQuery); + + if (req.query._sort && req.query._order) + query = query.sort({ + [typeof req.query._sort === 'string' + ? req.query._sort === 'id' + ? '_id' + : req.query._sort + : '_id']: req.query._order === 'ASC' ? 1 : -1, + }); + + if (req.query._start) + query = query.skip( + parseInt( + typeof req.query._start === 'string' ? req.query._start : '0' + ) + ); + + if (req.query._end) + query = query.limit( + Math.min( + parseInt( + typeof req.query._end === 'string' ? req.query._end : '0' + ) - + (req.query._start + ? parseInt( + typeof req.query._start === 'string' + ? req.query._start + : '0' + ) + : 0), + maxRows + ) + ); + else query = query.limit(maxRows); + + if (extraSelects) query = query.select(extraSelects); + + if (Object.keys(filterQuery).length === 0) { + res.set( + 'X-Total-Count', + (await model.estimatedDocumentCount()).toString() + ); + } else { + res.set( + 'X-Total-Count', + (await model.countDocuments(filterQuery)).toString() + ); + } + + return res.json( + virtualId((await query.lean()) as LeanDocument) + ); + } + ); + + /** getOne, getMany */ + if (canGet) + router.get( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.list`) + : (req, res, next) => next(), + async (req, res) => { + await model + .findById(req.params.id) + .select(extraSelects) + .lean() + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 400, e); + }); + } + ); + + /** create */ + if (canCreate) + router.post( + '/', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.create`) + : (req, res, next) => next(), + async (req, res) => { + // eslint-disable-next-line new-cap + const result = convertId( + await inputTransformer(filterReadOnly(req.body, readOnlyFields)) + ); + const newData = { + ...result, + }; + + const newEntry = new model(newData); + await newEntry + .save() + .then((result) => res.json(virtualId(result))) + .catch((e: any) => { + return statusMessages.error(res, 400, e, 'Bad request'); + }); + } + ); + + /** update */ + if (canUpdate) + router.put( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.edit`) + : (req, res, next) => next(), + async (req, res) => { + const updateData = { + ...(await convertId( + await inputTransformer(filterReadOnly(req.body, readOnlyFields)) + )), + }; + + await model + .findOneAndUpdate({ _id: req.params.id }, updateData, { + new: true, + runValidators: true, + }) + .lean() + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 400, e, 'Bad request'); + }); + } + ); + + /** + * delete + */ + if (canDelete) + router.delete( + '/:id', + aclName && ACLMiddleware + ? ACLMiddleware(`${aclName}.delete`) + : (req, res, next) => next(), + async (req, res) => { + await model + .findOneAndDelete({ _id: req.params.id }) + .then((result) => res.json(virtualId(result))) + .catch((e) => { + return statusMessages.error(res, 404, e, 'Element does not exist'); + }); + } + ); + + return router; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts new file mode 100644 index 00000000..058f54da --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/statusMessages.ts @@ -0,0 +1,24 @@ +/** + * @file statusMessages + * @description handles status messages / error responses + */ + +import { Response } from 'express'; + +/** + * Handles rejections other than errors. 400, 401, etc. + */ +function reject(res: Response, status: number, reason?: any) { + return res.status(status).json({ message: reason ?? 'Invalid request' }); +} + +/** + * Handles errors + */ +function error(res: Response, status: number, e: Error, message?: string) { + if (process.env.NODE_ENV !== 'production') { + return res.status(status).json({ message, error: e.message }); + } +} + +export default { reject, error }; diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts new file mode 100644 index 00000000..dc09895f --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/baseModel.interface.ts @@ -0,0 +1,7 @@ +import { Model, Document } from 'mongoose'; + +export interface ADPBaseSchema { + _id: string; +} + +export type ADPBaseModel = Model; diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts new file mode 100644 index 00000000..46572515 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/castFilter.ts @@ -0,0 +1,35 @@ +import { ADPBaseModel } from './baseModel.interface'; + +/** + * Turns all the params into their proper types, string into regexes. + * Only works with shallow objects. + * Mutates original object and returns mutated object. + */ +export default function castFilter( + obj: Record, + model: T, + allowedRegexes: string[] = [] +) { + const { path } = model.schema; + Object.keys(obj).forEach((key) => { + try { + obj[key] = path(key).cast(obj[key], null, null); + } catch (e) {} + + if (allowedRegexes.includes(key) && typeof obj[key] === 'string') { + obj[key] = new RegExp(escapeStringRegexp(obj[key])); + } + }); + + return obj; +} + +function escapeStringRegexp(string) { + if (typeof string !== 'string') { + throw new TypeError('Expected a string'); + } + + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar. + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts new file mode 100644 index 00000000..ae5b7860 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/convertId.ts @@ -0,0 +1,14 @@ +/** Turns id into _id for search queries */ +export default function convertId>(obj: T) { + if (obj.id) { + const newObject = { + _id: obj.id, + ...obj, + }; + + delete newObject.id; + return newObject; + } else { + return obj; + } +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts new file mode 100644 index 00000000..470c9482 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterGetList.ts @@ -0,0 +1,17 @@ +export const filterGetListParams = [ + '_sort', + '_order', + '_start', + '_end', +] as const; + +/** Removes _sort, _order, _start, _end from a query. */ +export default function filterGetList>( + obj: T +) { + const filtered: any = {}; + Object.entries(obj).forEach(([index, value]) => { + if (!filterGetListParams.includes(index as any)) filtered[index] = value; + }); + return filtered as Omit; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts new file mode 100644 index 00000000..57906468 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/filterReadOnly.ts @@ -0,0 +1,13 @@ +/** Makes sure that it does not modify crucial and sacred parts mutates the original object. */ +export function filterReadOnly( + obj: T, + readOnlyFields?: string[] +) { + if (!readOnlyFields) return obj as T; + + readOnlyFields.forEach((v) => { + delete obj[v]; + }); + + return obj as Partial; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts new file mode 100644 index 00000000..fe937bbc --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/parseQuery.ts @@ -0,0 +1,33 @@ +import { ADPBaseModel } from './baseModel.interface'; +import castFilter from './castFilter'; + +interface parseQueryParam { + q?: string; + $or?: any; +} + +/** + * Turns ?q into $or queries, deletes q + * @param {Object} results Original object with the q field + * @param {string[]} fields Fields to apply q to + */ +export default function parseQuery< + T extends parseQueryParam, + M extends ADPBaseModel +>( + result: T, + model: M, + allowedRegexes: string[], + fields?: string[] +): T & { $or?: any } { + if (!fields) return result; + if (result.q) { + if (!Array.isArray(result.$or)) result.$or = []; + fields.forEach((field) => { + const newFilter = { [field]: result.q }; + result.$or.push(castFilter(newFilter, model, allowedRegexes)); + }); + delete result.q; + } + return result; +} diff --git a/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts new file mode 100644 index 00000000..308a2b01 --- /dev/null +++ b/server/admin-next/src/server/middleware/express-mongoose-ra-json-server/utils/virtualId.ts @@ -0,0 +1,21 @@ +export default function virtualId( + arr: T[] +): Array; +export default function virtualId( + doc: T +): T & { id: string }; + +/** Virtual ID (_id to id) for react-admin */ +export default function virtualId(el: Array | T) { + if (Array.isArray(el)) { + return el.map((e) => { + return { + id: e._id, + ...e, + _id: undefined, + }; + }); + } + + return { id: el._id, ...el, _id: undefined }; +} diff --git a/server/admin-next/src/server/router/api.ts b/server/admin-next/src/server/router/api.ts index 62aa3d77..d6e66bcb 100644 --- a/server/admin-next/src/server/router/api.ts +++ b/server/admin-next/src/server/router/api.ts @@ -1,5 +1,4 @@ import { Router } from 'express'; -import raExpressMongoose from 'express-mongoose-ra-json-server'; import jwt from 'jsonwebtoken'; import { callBrokerAction } from '../broker'; import { adminAuth, auth, authSecret } from '../middleware/auth'; @@ -8,6 +7,7 @@ import { networkRouter } from './network'; import { fileRouter } from './file'; import dayjs from 'dayjs'; import messageModel from '../../../../models/chat/message'; +import { raExpressMongoose } from '../middleware/express-mongoose-ra-json-server'; const router = Router();