@aaroncadillac/crudify-mongo 1.8.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sanitizer.js ADDED
@@ -0,0 +1,264 @@
1
+ // Query and body sanitizer for Crudify. Dependency-free and usable on its own
2
+ // (`@aaroncadillac/crudify-mongo/sanitizer`) from custom handlers.
3
+ //
4
+ // `createSanitizer(config)` compiles the allowlists once and returns
5
+ // { filter, options, update, create }. Every function builds a NEW object from
6
+ // the input (which is never mutated) and throws a 400 `CrudifyError` on
7
+ // anything outside the allowed shape.
8
+
9
+ class CrudifyError extends Error {
10
+ constructor (status, message, { code, details } = {}) {
11
+ super(message)
12
+ this.name = 'CrudifyError'
13
+ this.statusCode = status
14
+ if (code) this.code = code
15
+ if (details) this.details = details
16
+ }
17
+ }
18
+
19
+ const SCALAR_OPS = new Set(['$eq', '$ne', '$gt', '$gte', '$lt', '$lte'])
20
+ const LIST_OPS = new Set(['$in', '$nin', '$all'])
21
+ const LOGICAL_OPS = new Set(['$or', '$and', '$nor'])
22
+ const SUPPORTED_OPS = new Set([...SCALAR_OPS, ...LIST_OPS, ...LOGICAL_OPS, '$exists', '$size'])
23
+ // Never configurable: arbitrary code, client regex (ReDoS) or text search.
24
+ const FORBIDDEN_OPS = new Set(['$where', '$expr', '$function', '$accumulator', '$regex', '$text', '$jsonSchema', '$elemMatch'])
25
+ const DEFAULT_OPERATORS = Object.freeze(['$eq', '$ne', '$in', '$nin', '$gt', '$gte', '$lt', '$lte', '$exists', '$or', '$and'])
26
+
27
+ const ALWAYS_DENIED = ['_id', '__v', 'createdAt', 'updatedAt']
28
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
29
+ const SORT_FIELD_RE = /^[A-Za-z0-9_.]+$/
30
+
31
+ const DEFAULTS = {
32
+ operators: DEFAULT_OPERATORS,
33
+ maxDepth: 4,
34
+ maxKeys: 100,
35
+ maxArray: 1000,
36
+ maxBranches: 20,
37
+ search: 'contains', // '/text/' -> /text/i ('contains'), /^text/ ('prefix') or left as a string (false)
38
+ maxSearchLength: 64,
39
+ filterable: null, // allowlist of root fields usable in filters and sort
40
+ defaultLimit: 50,
41
+ maxLimit: 100,
42
+ maxSort: 5,
43
+ populate: [], // paths a client may ask for in `options.populate`
44
+ writable: null, // allowlist of root fields for create/update
45
+ readonly: [], // root fields never written by the client
46
+ hidden: [] // never filterable, sortable or populable
47
+ }
48
+
49
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
50
+ const isPrimitive = (v) => v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
51
+ const toInt = (v) => { const n = Math.floor(Number(v)); return Number.isSafeInteger(n) ? n : 0 }
52
+ const bad = (message, code = 'invalid_query') => new CrudifyError(400, message, { code })
53
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
54
+
55
+ // Parses a JSON query param exactly once; a repeated param arrives as an array.
56
+ const parseJson = (raw, name) => {
57
+ if (raw === undefined || raw === '') return {}
58
+ if (typeof raw !== 'string') throw bad(`Invalid ${name}`)
59
+ let value
60
+ try { value = JSON.parse(raw) } catch { throw bad(`Invalid ${name} JSON`) }
61
+ if (!isPlainObject(value)) throw bad(`Invalid ${name}`)
62
+ return value
63
+ }
64
+
65
+ const assertNoOperators = (value, path) => {
66
+ if (Array.isArray(value)) return value.forEach(item => assertNoOperators(item, path))
67
+ if (!isPlainObject(value)) return
68
+ for (const key in value) {
69
+ if (key[0] === '$' || UNSAFE_KEYS.has(key)) throw bad(`Operator not allowed inside "${path}"`, 'invalid_body')
70
+ assertNoOperators(value[key], path)
71
+ }
72
+ }
73
+
74
+ const compile = (cfg = {}) => {
75
+ const c = {}
76
+ for (const key in DEFAULTS) c[key] = cfg[key] ?? DEFAULTS[key]
77
+ for (const op of c.operators) {
78
+ if (FORBIDDEN_OPS.has(op) || !SUPPORTED_OPS.has(op)) throw new TypeError(`Crudify sanitizer: operator not supported: ${op}`)
79
+ }
80
+ const ops = new Set(c.operators)
81
+ const hidden = new Set(c.hidden)
82
+ const filterable = c.filterable && new Set(c.filterable)
83
+ const writable = c.writable && new Set(c.writable)
84
+ const populable = new Set(c.populate)
85
+ const denied = new Set([...ALWAYS_DENIED, ...c.readonly])
86
+ const toSearch = c.search === 'prefix' ? (s) => new RegExp('^' + escapeRegex(s))
87
+ : c.search === 'contains' ? (s) => new RegExp(escapeRegex(s), 'i') : null
88
+ let budget = 0 // filter() is synchronous, so one counter per sanitizer is safe
89
+
90
+ const rootOf = (field) => {
91
+ const parts = field.split('.')
92
+ if (field.includes('$') || parts.some(p => !p || UNSAFE_KEYS.has(p))) throw bad(`Invalid field: ${field}`)
93
+ return parts[0]
94
+ }
95
+ const checkField = (field) => {
96
+ const root = rootOf(field)
97
+ if (hidden.has(root) || (filterable && !filterable.has(root))) throw bad(`Field not allowed: ${field}`)
98
+ }
99
+ const isList = (v) => Array.isArray(v) && v.length <= c.maxArray && v.every(isPrimitive)
100
+
101
+ // '/text/' is the search convention: a LITERAL match, never a client regex.
102
+ const convert = (v) => {
103
+ if (toSearch && typeof v === 'string' && v.length >= 2 && v[0] === '/' && v[v.length - 1] === '/') {
104
+ if (v.length - 2 > c.maxSearchLength) throw bad('Search text too long')
105
+ return toSearch(v.slice(1, -1))
106
+ }
107
+ return v
108
+ }
109
+
110
+ const walkValue = (field, value) => {
111
+ if (isPrimitive(value)) return convert(value)
112
+ if (Array.isArray(value)) {
113
+ if (!isList(value)) throw bad(`Invalid value for "${field}"`)
114
+ return value
115
+ }
116
+ if (!isPlainObject(value)) throw bad(`Invalid value for "${field}"`)
117
+ const out = {}
118
+ for (const op in value) {
119
+ const v = value[op]
120
+ if (!ops.has(op) || LOGICAL_OPS.has(op)) throw bad(`Operator not allowed: ${op}`)
121
+ if (SCALAR_OPS.has(op) ? !isPrimitive(v)
122
+ : LIST_OPS.has(op) ? !isList(v)
123
+ : op === '$exists' ? typeof v !== 'boolean'
124
+ : !Number.isInteger(v) || v < 0) throw bad(`Invalid value for ${op}`)
125
+ out[op] = v
126
+ }
127
+ return out
128
+ }
129
+
130
+ const walk = (node, depth) => {
131
+ if (depth > c.maxDepth) throw bad('Filter too deeply nested')
132
+ if (!isPlainObject(node)) throw bad('Filter must be an object')
133
+ const out = {}
134
+ for (const key in node) {
135
+ if (++budget > c.maxKeys) throw bad('Filter too large')
136
+ const value = node[key]
137
+ if (key[0] === '$') {
138
+ if (!LOGICAL_OPS.has(key) || !ops.has(key)) throw bad(`Operator not allowed: ${key}`)
139
+ if (!Array.isArray(value) || !value.length || value.length > c.maxBranches) throw bad(`${key} expects a non-empty array`)
140
+ out[key] = value.map(child => walk(child, depth + 1))
141
+ } else {
142
+ checkField(key) // before assigning: rejects `__proto__`
143
+ out[key] = walkValue(key, value)
144
+ }
145
+ }
146
+ return out
147
+ }
148
+
149
+ const direction = (d) => {
150
+ if (d === 1 || d === 'asc' || d === 'ascending') return 1
151
+ if (d === -1 || d === 'desc' || d === 'descending') return -1
152
+ throw bad('Invalid sort direction')
153
+ }
154
+
155
+ const parseSort = (raw) => {
156
+ const out = {}
157
+ let count = 0
158
+ const add = (field, dir) => {
159
+ if (!SORT_FIELD_RE.test(field)) throw bad('Invalid sort')
160
+ checkField(field)
161
+ if (++count > c.maxSort) throw bad('Too many sort fields')
162
+ out[field] = dir
163
+ }
164
+ if (typeof raw === 'string') {
165
+ for (const part of raw.split(/\s+/)) if (part) add(part.replace(/^[-+]/, ''), part[0] === '-' ? -1 : 1)
166
+ } else if (isPlainObject(raw)) {
167
+ for (const field in raw) add(field, direction(raw[field]))
168
+ } else throw bad('Invalid sort')
169
+ return out
170
+ }
171
+
172
+ // Only page / limit / sort (+ allowlisted populate) survive. `select`,
173
+ // `projection`, `lean`, `collation` and `pagination:false` are ignored.
174
+ const options = (raw) => {
175
+ const src = isPlainObject(raw) ? raw : {}
176
+ const asked = toInt(src.limit)
177
+ const out = {
178
+ page: Math.max(1, toInt(src.page)),
179
+ limit: Math.min(c.maxLimit, asked > 0 ? asked : c.defaultLimit),
180
+ pagination: true
181
+ }
182
+ if (src.sort !== undefined && src.sort !== null && src.sort !== '') out.sort = parseSort(src.sort)
183
+ if (populable.size && src.populate) {
184
+ const wanted = typeof src.populate === 'string' ? src.populate.split(/\s+/) : Array.isArray(src.populate) ? src.populate : []
185
+ const allowed = wanted.filter(p => populable.has(p))
186
+ if (allowed.length) out.populate = allowed
187
+ }
188
+ return out
189
+ }
190
+
191
+ // Protected roots are dropped silently (forms send the whole document back).
192
+ const accepts = (field, deny) => {
193
+ const root = rootOf(field)
194
+ return !deny.has(root) && (!writable || writable.has(root))
195
+ }
196
+ const denySet = (extraDeny) => extraDeny?.length ? new Set([...denied, ...extraDeny]) : denied
197
+
198
+ // Returns a FLAT update (plain keys, dotted paths allowed, optional $unset):
199
+ // Mongoose wraps it in $set, and model hooks reading `update.x` keep working.
200
+ const update = (body, extraDeny) => {
201
+ if (!isPlainObject(body)) throw bad('Invalid request body', 'invalid_body')
202
+ const deny = denySet(extraDeny)
203
+ const out = {}
204
+ const unset = {}
205
+ const put = (field, value) => { if (accepts(field, deny)) { assertNoOperators(value, field); out[field] = value } }
206
+ for (const key in body) {
207
+ const value = body[key]
208
+ if (key === '$set') {
209
+ if (!isPlainObject(value)) throw bad('Invalid $set', 'invalid_body')
210
+ for (const field in value) put(field, value[field])
211
+ } else if (key === '$unset') {
212
+ if (!isPlainObject(value)) throw bad('Invalid $unset', 'invalid_body')
213
+ for (const field in value) if (accepts(field, deny)) unset[field] = ''
214
+ } else if (key[0] === '$') {
215
+ throw bad(`Operator not allowed: ${key}`, 'invalid_body')
216
+ } else put(key, value)
217
+ }
218
+ if (Object.keys(unset).length) out.$unset = unset
219
+ if (!Object.keys(out).length) throw bad('No valid fields to update', 'invalid_body')
220
+ return out
221
+ }
222
+
223
+ const create = (item, extraDeny) => {
224
+ if (!isPlainObject(item)) throw bad('Invalid request body', 'invalid_body')
225
+ const deny = denySet(extraDeny)
226
+ const out = {}
227
+ for (const key in item) {
228
+ if (!accepts(key, deny)) continue
229
+ assertNoOperators(item[key], key)
230
+ out[key] = item[key]
231
+ }
232
+ return out
233
+ }
234
+
235
+ return { filter: (raw) => { budget = 0; return walk(raw ?? {}, 1) }, options, update, create }
236
+ }
237
+
238
+ const createSanitizer = (config) => Object.freeze(compile(config))
239
+
240
+ // Standalone helpers: compiled once per config object (or the defaults).
241
+ const compiled = new WeakMap()
242
+ const defaults = createSanitizer()
243
+ const forConfig = (config) => {
244
+ if (!config) return defaults
245
+ if (!compiled.has(config)) compiled.set(config, createSanitizer(config))
246
+ return compiled.get(config)
247
+ }
248
+ const sanitizeFilter = (raw, config) => forConfig(config).filter(raw)
249
+ const sanitizeOptions = (raw, config) => forConfig(config).options(raw)
250
+ const sanitizeUpdate = (body, config, extraDeny) => forConfig(config).update(body, extraDeny)
251
+ const sanitizeCreate = (item, config, extraDeny) => forConfig(config).create(item, extraDeny)
252
+
253
+ export {
254
+ CrudifyError,
255
+ DEFAULT_OPERATORS,
256
+ createSanitizer,
257
+ escapeRegex,
258
+ isPlainObject,
259
+ parseJson,
260
+ sanitizeCreate,
261
+ sanitizeFilter,
262
+ sanitizeOptions,
263
+ sanitizeUpdate
264
+ }
@@ -1,34 +0,0 @@
1
- name : Publish
2
-
3
- on:
4
- push:
5
- branches:
6
- - master
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- permissions:
12
- contents: write
13
- id-token: write
14
- steps:
15
- - name: Checkout
16
- id: checkout
17
- uses: actions/checkout@v4
18
- - name: Installing Node
19
- uses: actions/setup-node@v4
20
- with:
21
- node-version: 20
22
- registry-url: 'https://registry.npmjs.org'
23
- - name: Config git user & email
24
- run: |
25
- git config user.name "$(git log -n 1 --pretty=format:%an)"
26
- git config user.email "$(git log -n 1 --pretty=format:%ae)"
27
- - name: Run my Action
28
- id: run-action
29
- uses: iuccio/npm-semver-publish-action@v1.0.0
30
- with:
31
- target-branch: master
32
- provenance: true
33
- env:
34
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,21 +0,0 @@
1
- name: Run Tests
2
-
3
- on:
4
- pull_request:
5
- branches:
6
- - master
7
-
8
- jobs:
9
- test:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - name: Checkout repository
13
- uses: actions/checkout@v4
14
- - name: Set up Node.js
15
- uses: actions/setup-node@v4
16
- with:
17
- node-version: '20'
18
- - name: Install dependencies
19
- run: yarn
20
- - name: Run Jest tests
21
- run: yarn test
@@ -1,15 +0,0 @@
1
- import mongoose from 'mongoose';
2
-
3
- const dbConnection = async ( fastify ) => {
4
- try {
5
- await mongoose.connect(`${process.env.MONGODB_URL}/${process.env.MONGO_DATABASE}${process.env.MONGO_OPTIONS && `?${process.env.MONGO_OPTIONS}`}`);
6
- fastify.log.info('Connected to database');
7
- }
8
- catch (error) {
9
- fastify.log.error('Error connecting to database');
10
- }
11
- }
12
-
13
- export {
14
- dbConnection
15
- }
@@ -1,30 +0,0 @@
1
- import mongoose from 'mongoose';
2
- import paginate from 'mongoose-paginate-v2';
3
-
4
- const Schema = mongoose.Schema;
5
-
6
- const postsSchema = new Schema({
7
- title: {
8
- type: String,
9
- required: true
10
- },
11
- content: {
12
- type: String,
13
- required: true
14
- },
15
- author: {
16
- type: Schema.Types.ObjectId,
17
- ref: 'Users',
18
- required: true
19
- }
20
- }, {
21
- timestamps: true,
22
- });
23
-
24
- postsSchema.plugin(paginate);
25
-
26
- const PostsModel = mongoose.model('Users', postsSchema);
27
-
28
- export {
29
- PostsModel
30
- }
@@ -1,37 +0,0 @@
1
- import mongoose from 'mongoose';
2
- import paginate from 'mongoose-paginate-v2';
3
-
4
- const Schema = mongoose.Schema;
5
-
6
- const userSchema = new Schema({
7
- name: {
8
- type: String,
9
- required: true
10
- },
11
- lastName: {
12
- type: String,
13
- required: true
14
- },
15
- email: {
16
- type: String,
17
- required: true
18
- },
19
- username: {
20
- type: String,
21
- required: true
22
- },
23
- phone: {
24
- type: String,
25
- required: true
26
- }
27
- }, {
28
- timestamps: true
29
- });
30
-
31
- userSchema.plugin(paginate);
32
-
33
- const UserModel = mongoose.model('Users', userSchema);
34
-
35
- export {
36
- UserModel
37
- }
package/example/routes.js DELETED
@@ -1,15 +0,0 @@
1
- import { UserModel } from "./models/users.js"
2
-
3
- const API_PREFIX = '/api'
4
-
5
- const routes = ( fastify, opts, done ) => {
6
- fastify.register(AutoCrud, {
7
- url: `${API_PREFIX}/users`,
8
- Model: UserModel
9
- })
10
- done()
11
- }
12
-
13
- export {
14
- routes
15
- }
package/example/server.js DELETED
@@ -1,20 +0,0 @@
1
- import 'dotenv/config';
2
- import Fastify from 'fastify'
3
- import { dbConnection } from './db-connection.js';
4
- import { routes } from './routes.js';
5
-
6
- const fastify = Fastify({
7
- logger: true
8
- })
9
-
10
- fastify.register(await dbConnection)
11
- fastify.register(routes)
12
-
13
- fastify.listen({ port: process.env.PORT || 3000, host: process.env.HOST || 'localhost' }, function (err, address) {
14
- if (err) {
15
- fastify.log.error(err)
16
- process.exit(1)
17
- }
18
- })
19
-
20
-
@@ -1,11 +0,0 @@
1
- moduile.exports = {
2
- mongodbMemoryServerOptions: {
3
- binary: {
4
- version: '4.0.3',
5
- skipMD5: true,
6
- },
7
- autoStart: false,
8
- instance: {}
9
- },
10
- useSharedDBForAllJestWorkers: false,
11
- };
package/jest.config.js DELETED
@@ -1,7 +0,0 @@
1
- export default {
2
- preset: '@shelf/jest-mongodb',
3
- transformIgnorePatterns: [
4
- '<rootDir>/node_modules/fastify/',
5
- '<rootDir>/crudify.js'
6
- ]
7
- };
@@ -1,73 +0,0 @@
1
- import fastify from 'fastify';
2
- import { Crudify } from '../crudify';
3
- import mongoose, { connect, Schema, model } from 'mongoose';
4
- import paginate from 'mongoose-paginate-v2';
5
- import request from 'supertest';
6
-
7
- let app;
8
- let UserModel;
9
- let connection;
10
-
11
- beforeAll(async () => {
12
- app = fastify();
13
- await connect(global.__MONGO_URI__);
14
- connection = mongoose.connection
15
- const userSchema = new Schema({ name: String, age: Number, email: String });
16
- userSchema.plugin(paginate);
17
- UserModel = model('User', userSchema);
18
- app.register(Crudify, { url: '/users', Model: UserModel });
19
- await app.ready();
20
- });
21
-
22
- afterAll(async () => {
23
- await connection.dropDatabase();
24
- await connection.close();
25
- await app.close();
26
- });
27
-
28
- describe('CRUD Operations', () => {
29
- it('should create a user', async () => {
30
- const response = await request(app.server)
31
- .post('/users')
32
- .send({ name: 'John', age: 30, email: 'john@example.com' });
33
- expect(response.status).toBe(201);
34
- expect(response.body).toHaveProperty('_id');
35
- });
36
-
37
- it('should get all users', async () => {
38
- const response = await request(app.server).get('/users');
39
- expect(response.status).toBe(200);
40
- expect(response.body.docs).toBeInstanceOf(Array);
41
- });
42
-
43
- it('should get a user by id', async () => {
44
- const newUser = await request(app.server)
45
- .post('/users')
46
- .send({ name: 'Jane', age: 25, email: 'jane@example.com' });
47
- const userId = newUser.body._id;
48
- const response = await request(app.server).get(`/users/${userId}`);
49
- expect(response.status).toBe(200);
50
- expect(response.body).toHaveProperty('_id', userId);
51
- });
52
-
53
- it('should update a user', async () => {
54
- const newUser = await request(app.server)
55
- .post('/users')
56
- .send({ name: 'Mark', age: 28, email: 'mark@example.com' });
57
- const userId = newUser.body._id;
58
- const response = await request(app.server)
59
- .put(`/users/${userId}`)
60
- .send({ age: 29 });
61
- expect(response.status).toBe(200);
62
- expect(response.body).toHaveProperty('age', 29);
63
- });
64
-
65
- it('should delete a user', async () => {
66
- const newUser = await request(app.server)
67
- .post('/users')
68
- .send({ name: 'Paul', age: 35, email: 'paul@example.com' });
69
- const userId = newUser.body._id;
70
- const response = await request(app.server).delete(`/users/${userId}`);
71
- expect(response.status).toBe(200);
72
- });
73
- });