@vulkano/core 1.23.2 → 1.24.1

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.
@@ -1,9 +1,205 @@
1
+ // Field names that are never exposed through populate select, whatever the
2
+ // caller asks for — a coarse, name-based backstop for common secret-shaped
3
+ // fields (defense in depth on top of the schema's own `select: false`, in
4
+ // case a referenced model forgets to mark a sensitive field itself).
5
+ const SENSITIVE_FIELD_HINTS = [
6
+ 'password', 'passwd', 'secret', 'token', 'apikey', 'api_key',
7
+ 'privatekey', 'private_key', 'hash', 'salt', 'ssn', 'creditcard',
8
+ 'credit_card', 'cvv', 'pin'
9
+ ];
10
+
11
+ function isSensitiveByName(field) {
12
+ const lower = field.toLowerCase();
13
+ return SENSITIVE_FIELD_HINTS.some((hint) => lower.includes(hint));
14
+ }
15
+
1
16
  module.exports = {
2
17
 
18
+ /**
19
+ * Look up the referenced model on the same connection this model was
20
+ * compiled on (models here are registered via `db.model(...)`, not the
21
+ * global `mongoose.model(...)` registry — see database/mongodb.js — so
22
+ * resolving by connection is required for this to ever find it).
23
+ *
24
+ * @param {String} refModelName mongoose ref (the "ref" schema option)
25
+ * @returns {Object|null}
26
+ */
27
+ _resolveRefModel(refModelName) {
28
+
29
+ try {
30
+ return (this.db && this.db.model(refModelName)) || mongoose.model(refModelName);
31
+ } catch {
32
+ return null;
33
+ }
34
+
35
+ },
36
+
37
+ /**
38
+ * A field is safe to expose through populate select when it's neither
39
+ * name-flagged as sensitive (SENSITIVE_FIELD_HINTS) nor marked
40
+ * `select: false` on the REFERENCED model's own schema. If the referenced
41
+ * model can't be resolved (e.g. in isolated unit tests), only the
42
+ * name-based check applies.
43
+ *
44
+ * @param {String} refModelName mongoose ref (the "ref" schema option)
45
+ * @param {String} field
46
+ * @returns {Boolean}
47
+ */
48
+ _isFieldSafeToExpose(refModelName, field) {
49
+
50
+ if (isSensitiveByName(field)) {
51
+ return false;
52
+ }
53
+
54
+ const RefModel = this._resolveRefModel(refModelName);
55
+ const refPath = RefModel && RefModel.schema.paths[field];
56
+
57
+ if (refPath && refPath.options && refPath.options.select === false) {
58
+ return false;
59
+ }
60
+
61
+ return true;
62
+
63
+ },
64
+
65
+ /**
66
+ * Parse the populate param into one entry per relation: `name` (trimmed,
67
+ * lowercased) plus an optional `fields` list. Syntax: relations are
68
+ * comma-separated; a relation can carry its own field list after a colon,
69
+ * with `|` separating multiple fields — e.g.
70
+ * `?populate=school:name|address,teacher` populates `school` (selecting
71
+ * only name+address) and `teacher` (full doc). Kept as its own param
72
+ * (not `?school=...`) so it can never collide with a real filter/field
73
+ * query param that happens to share the relation's name.
74
+ *
75
+ * @param {Object} props (populate)
76
+ * @returns {Array} [{ name, fields }]
77
+ */
78
+ _parsePopulateEntries(props) {
79
+
80
+ return ((props || {}).populate || '')
81
+ .split(',')
82
+ .map((raw) => {
83
+
84
+ const [rawName, rawFields] = raw.split(':');
85
+ const name = (rawName || '').trim().toLowerCase();
86
+
87
+ if (!name) {
88
+ return null;
89
+ }
90
+
91
+ const fields = rawFields
92
+ ? rawFields.split('|').map((item) => item.trim()).filter(Boolean)
93
+ : null;
94
+
95
+ return { name, fields };
96
+
97
+ })
98
+ .filter(Boolean);
99
+
100
+ },
101
+
102
+ /**
103
+ * Sanitize populate param: trim + lowercase each relation name
104
+ * (field-selection suffix, if any, is dropped — see _parsePopulateEntries)
105
+ *
106
+ * @param {Object} props (populate)
107
+ * @returns {Array}
108
+ */
109
+ _getSanitizedPopulate(props) {
110
+
111
+ return this._parsePopulateEntries(props).map((entry) => entry.name);
112
+
113
+ },
114
+
115
+ /**
116
+ * Build populate array by auto-detecting the model's own relations that
117
+ * were opted in — either via `autopopulate: true` on the attribute
118
+ * definition, or via the `extra` allowlist passed here for a one-off call.
119
+ * A ref field with neither is never populated, no matter what the caller
120
+ * asks for. This is the security gate: a relation only becomes reachable
121
+ * through ?populate= when its attribute or the calling code says so.
122
+ *
123
+ * @param {Object} props (populate — see _parsePopulateEntries for its syntax)
124
+ * @param {Array} [extra] field names to allow even without autopopulate: true
125
+ * @returns {Array}
126
+ */
127
+ _buildPopulate(props, extra) {
128
+
129
+ const entries = this._parsePopulateEntries(props);
130
+
131
+ if (entries.length === 0) {
132
+ return [];
133
+ }
134
+
135
+ const extraLower = (Array.isArray(extra) ? extra : []).map((item) => item.toLowerCase());
136
+
137
+ const { paths } = this.schema;
138
+
139
+ const fieldByLowerName = {};
140
+ Object.keys(paths).forEach((field) => {
141
+ fieldByLowerName[field.toLowerCase()] = field;
142
+ });
143
+
144
+ return entries
145
+ .map((entry) => {
146
+
147
+ const field = fieldByLowerName[entry.name];
148
+
149
+ if (!field) {
150
+ return null;
151
+ }
152
+
153
+ const path = paths[field];
154
+ const opts = path.options || {};
155
+ const casterOpts = (path.caster && path.caster.options) || {};
156
+ const ref = opts.ref || casterOpts.ref;
157
+ const autopopulate = opts.autopopulate
158
+ || casterOpts.autopopulate
159
+ || extraLower.includes(entry.name);
160
+
161
+ if (!ref || !autopopulate) {
162
+ return null;
163
+ }
164
+
165
+ const populateProps = { path: field };
166
+
167
+ if (entry.fields && entry.fields.length > 0) {
168
+
169
+ // Explicit field list: keep only what's safe, as a pure inclusion
170
+ // select. If everything requested turns out sensitive, fall back
171
+ // to "_id" only — never to the unfiltered full document.
172
+ const safeFields = entry.fields.filter((f) => this._isFieldSafeToExpose(ref, f));
173
+ populateProps.select = safeFields.length > 0 ? safeFields.join(' ') : '_id';
174
+
175
+ } else {
176
+
177
+ // Full-doc populate: still must not leak sensitive fields, so
178
+ // build an exclusion select for whatever the ref schema/name-hints
179
+ // flag on that model — skipped only if nothing needs excluding.
180
+ const RefModel = this._resolveRefModel(ref);
181
+
182
+ const toExclude = RefModel
183
+ ? Object.keys(RefModel.schema.paths).filter((f) => !this._isFieldSafeToExpose(ref, f))
184
+ : [];
185
+
186
+ if (toExclude.length > 0) {
187
+ populateProps.select = toExclude.map((f) => `-${f}`).join(' ');
188
+ }
189
+
190
+ }
191
+
192
+ return populateProps;
193
+
194
+ })
195
+ .filter(Boolean);
196
+
197
+ },
198
+
3
199
  /**
4
200
  * Method to get all records by page
5
201
  *
6
- * @param {Object} props (page, perPage, search, sort)
202
+ * @param {Object} props (page, perPage, search, sort, populate)
7
203
  * @returns {Promise}
8
204
  */
9
205
  getAll(props) {
@@ -13,15 +209,17 @@ module.exports = {
13
209
  sort: 'createdAt|DESC',
14
210
  searchBy: [],
15
211
  filter: {
16
- active: true
212
+ active: true // soft-delete
17
213
  },
18
214
  };
19
215
 
216
+ const populate = this._buildPopulate(props);
217
+
20
218
  // Query to Run
21
219
  const query = Paginate.serializeQuery(defaultProps, props);
22
220
 
23
221
  // Pagination
24
- return Paginate.get(this, query);
222
+ return Paginate.get(this, query, populate);
25
223
 
26
224
  },
27
225
 
@@ -29,9 +227,10 @@ module.exports = {
29
227
  * Method to get a record by id
30
228
  *
31
229
  * @param {ObjectID} id
230
+ * @param {Object} props (populate)
32
231
  * @returns {Promise}
33
232
  */
34
- getByField(value, field) {
233
+ getByField(value, field, props) {
35
234
 
36
235
  // This is to prevent error while run the findOne
37
236
  if (!(/^[a-fA-F0-9]{24}$/).test(value) && !field) {
@@ -41,7 +240,11 @@ module.exports = {
41
240
  const toSearch = { active: true };
42
241
  toSearch[field || '_id'] = value;
43
242
 
44
- return this.findOne(toSearch)
243
+ const query = this.findOne(toSearch);
244
+
245
+ this._buildPopulate(props).forEach((p) => query.populate(p));
246
+
247
+ return query
45
248
  .then( (r) => {
46
249
 
47
250
  if (!r) {
@@ -18,6 +18,13 @@ module.exports = {
18
18
  type: String,
19
19
  required: true
20
20
  },
21
+ school: {
22
+ type: mongoose.Schema.Types.ObjectId,
23
+ ref: 'School',
24
+ // Opts this relation into ?populate=school — a ref field without this
25
+ // flag is never populated, no matter what the caller asks for
26
+ autopopulate: true
27
+ },
21
28
  age: {
22
29
  type: Number,
23
30
  required: false,
@@ -44,7 +51,7 @@ module.exports = {
44
51
  /**
45
52
  * Method to get all records by page
46
53
  *
47
- * @param {Object} props (page, perPage, search, sort)
54
+ * @param {Object} props (page, perPage, search, sort, populate — see _buildPopulate below)
48
55
  * @returns {Promise}
49
56
  */
50
57
  getAll(props) {
@@ -53,14 +60,21 @@ module.exports = {
53
60
  const defaultProps = {
54
61
  sort: 'createdAt|DESC',
55
62
  searchBy: ['name'],
56
- fields: ['name', 'age', 'active', 'createdAt', 'updatedAt'],
63
+ fields: ['name', 'school', 'age', 'active', 'createdAt', 'updatedAt'],
57
64
  filter: {
58
65
  active: true
59
66
  },
60
67
  };
61
68
 
62
- // Populate
63
- const populate = [];
69
+ // Populate: only relations opted in via `autopopulate: true` on the
70
+ // attribute (see `school` above) ever get expanded, and only when asked
71
+ // for through ?populate=. Syntax (see database/scaffold.js#_buildPopulate):
72
+ // ?populate=school → full School doc
73
+ // ?populate=school:name → only { _id, name }
74
+ // ?populate=school:name|address → only { _id, name, address }
75
+ // A relation NOT marked autopopulate can still be opened for one call by
76
+ // passing it as a second arg: this._buildPopulate(props, ['someRef'])
77
+ const populate = this._buildPopulate(props);
64
78
 
65
79
  // Query to Run
66
80
  const query = Paginate.serializeQuery(defaultProps, props);
@@ -74,16 +88,22 @@ module.exports = {
74
88
  * Method to get a record by id
75
89
  *
76
90
  * @param {ObjectID} id
91
+ * @param {Object} _props (populate — see getAll above for the ?populate= syntax)
77
92
  * @returns {Promise}
78
93
  */
79
- getExample(_id) {
94
+ getExample(_id, _props) {
80
95
 
81
96
  // This is to prevent error while run the findOne
82
97
  if (!(/^[a-fA-F0-9]{24}$/).test(_id)) {
83
98
  return VSError.reject('Invalid ID. Record not found', 404);
84
99
  }
85
100
 
86
- return Example.findOne({ _id })
101
+ const query = Example.findOne({ _id });
102
+
103
+ // Populate (only relations marked autopopulate: true in attributes)
104
+ this._buildPopulate(_props).forEach((p) => query.populate(p));
105
+
106
+ return query
87
107
  .then( (r) => {
88
108
 
89
109
  if (!r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vulkano/core",
3
- "version": "1.23.2",
3
+ "version": "1.24.1",
4
4
  "description": "A MVC framework using Express 4",
5
5
  "license": "MIT",
6
6
  "author": "Vulkano Team",
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Bridges the global `i18n` instance (set by bootstrap/services.js) into the
3
+ * Nunjucks environment. Without this, `i18n` exists only in JS scope — views
4
+ * never receive it, since nunjucks.js only auto-adds `app` as a global.
5
+ *
6
+ * Usage in templates: {{ i18n.t('key') }}
7
+ */
8
+ module.exports = i18n;