@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.
- package/database/scaffold.js +208 -5
- package/examples/models/Example.js +26 -6
- package/package.json +1 -1
- package/views/helpers/i18n.js +8 -0
package/database/scaffold.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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;
|