@yunsoft/yuncms-core 0.1.3 → 0.1.5
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/package.json +1 -1
- package/src/bootstrap.js +4 -0
- package/src/cache.js +85 -0
- package/src/config.js +50 -0
- package/src/context.js +5 -2
- package/src/index.js +9 -0
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations.js +113 -3
- package/src/query.js +54 -12
- package/src/relation-expansion.js +191 -57
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +54 -1
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { assertIdentifier } from './identifier.js';
|
|
2
|
+
import { QUERY_LIMITS } from './query.js';
|
|
2
3
|
import { SchemaCache } from './schema.js';
|
|
3
4
|
import { ItemsService } from './services/items-service.js';
|
|
4
5
|
|
|
5
|
-
const MAX_EXPAND_FIELDS =
|
|
6
|
+
export const MAX_EXPAND_FIELDS = QUERY_LIMITS.maxRelationExpansions;
|
|
6
7
|
const defaultSchemaCache = new SchemaCache();
|
|
7
8
|
|
|
8
9
|
function expansionError(code, message, path = null) {
|
|
@@ -18,16 +19,24 @@ function normalizeDelimited(value) {
|
|
|
18
19
|
return values.map((entry) => String(entry).trim()).filter(Boolean);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function assertFieldToken(field, path) {
|
|
23
|
+
try {
|
|
24
|
+
assertIdentifier(field, 'field');
|
|
25
|
+
} catch {
|
|
26
|
+
throw expansionError('INVALID_QUERY', `Invalid field: ${field}`, path);
|
|
27
|
+
}
|
|
28
|
+
return field;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export function parseExpandInput(value) {
|
|
22
32
|
const fields = [...new Set(normalizeDelimited(value))];
|
|
23
33
|
if (fields.length > MAX_EXPAND_FIELDS) {
|
|
24
34
|
throw expansionError(
|
|
25
35
|
'INVALID_QUERY',
|
|
26
|
-
`expand
|
|
36
|
+
`expand cannot contain more than ${MAX_EXPAND_FIELDS} entries`,
|
|
27
37
|
'expand',
|
|
28
38
|
);
|
|
29
39
|
}
|
|
30
|
-
|
|
31
40
|
for (const field of fields) {
|
|
32
41
|
try {
|
|
33
42
|
assertIdentifier(field, 'expand field');
|
|
@@ -47,73 +56,199 @@ function parseRelationMetadata(value) {
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
function withExpansionFields(rawFields, expandFields) {
|
|
51
|
-
if (rawFields == null || rawFields === '') return rawFields;
|
|
52
|
-
const selected = normalizeDelimited(rawFields);
|
|
53
|
-
if (selected.includes('*')) return selected;
|
|
54
|
-
return [...new Set([...selected, ...expandFields])];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function withoutExpand(query = {}, expandFields = []) {
|
|
58
|
-
const base = { ...query };
|
|
59
|
-
delete base.expand;
|
|
60
|
-
if (expandFields.length > 0 && Object.hasOwn(base, 'fields')) {
|
|
61
|
-
base.fields = withExpansionFields(base.fields, expandFields);
|
|
62
|
-
}
|
|
63
|
-
return base;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
59
|
async function schemaSnapshot(options) {
|
|
67
60
|
if (options.schema) return options.schema;
|
|
68
61
|
const cache = options.schemaCache ?? defaultSchemaCache;
|
|
69
62
|
return cache.get(options.database);
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
function
|
|
73
|
-
|
|
65
|
+
function relationFromSnapshot(snapshot, collection, field) {
|
|
66
|
+
return snapshot.relationByManyField?.get(`${collection}.${field}`) ?? null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isDirectRelation(relation) {
|
|
70
|
+
if (!relation) return false;
|
|
71
|
+
const metadata = parseRelationMetadata(relation.metadata);
|
|
72
|
+
return !relation.junction_collection && metadata.kind !== 'm2m';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function directRelation(snapshot, collection, field, path = `fields.${field}`) {
|
|
76
|
+
const relation = relationFromSnapshot(snapshot, collection, field);
|
|
74
77
|
if (!relation) {
|
|
75
78
|
throw expansionError(
|
|
76
79
|
'INVALID_QUERY',
|
|
77
80
|
`Field is not a direct relation and cannot be expanded: ${collection}.${field}`,
|
|
78
|
-
|
|
81
|
+
path,
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
if (relation.junction_collection || metadata.kind === 'm2m') {
|
|
85
|
+
if (!isDirectRelation(relation)) {
|
|
84
86
|
throw expansionError(
|
|
85
87
|
'UNSUPPORTED_RELATION_EXPANSION',
|
|
86
|
-
`Only direct M2O fields can be expanded
|
|
87
|
-
|
|
88
|
+
`Only direct M2O/O2O fields can be expanded: ${collection}.${field}`,
|
|
89
|
+
path,
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
return relation;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
function readableSourceField(sourceSchema, permission, field) {
|
|
96
|
+
return Boolean(sourceSchema.fields?.[field]
|
|
97
|
+
&& (!permission.fields || permission.fields.includes(field)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function directReadableRelationFields(snapshot, collection, sourceSchema, permission) {
|
|
101
|
+
const prefix = `${collection}.`;
|
|
102
|
+
const fields = [];
|
|
103
|
+
for (const [key, relation] of snapshot.relationByManyField?.entries?.() ?? []) {
|
|
104
|
+
if (!key.startsWith(prefix) || !isDirectRelation(relation)) continue;
|
|
105
|
+
const field = key.slice(prefix.length);
|
|
106
|
+
if (readableSourceField(sourceSchema, permission, field)) fields.push(field);
|
|
107
|
+
}
|
|
108
|
+
return [...new Set(fields)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mergeExpansionSelection(expansions, relationField, targetField) {
|
|
112
|
+
const current = expansions.get(relationField) ?? [];
|
|
113
|
+
if (current.includes('*')) return;
|
|
114
|
+
if (targetField === '*') {
|
|
115
|
+
expansions.set(relationField, ['*']);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
expansions.set(relationField, [...new Set([...current, targetField])]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function assertExpansionLimit(expansions, path = 'fields') {
|
|
122
|
+
if (expansions.size > MAX_EXPAND_FIELDS) {
|
|
123
|
+
throw expansionError(
|
|
124
|
+
'INVALID_QUERY',
|
|
125
|
+
`Relation expansion cannot contain more than ${MAX_EXPAND_FIELDS} fields`,
|
|
126
|
+
path,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseFieldsPlan({ value, snapshot, collection, sourceSchema, permission }) {
|
|
132
|
+
const tokens = normalizeDelimited(value);
|
|
133
|
+
if (tokens.length === 0) {
|
|
134
|
+
return { sourceFields: null, expansions: new Map() };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let sourceAll = false;
|
|
138
|
+
const sourceFields = [];
|
|
139
|
+
const expansions = new Map();
|
|
140
|
+
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
if (token === '*') {
|
|
143
|
+
sourceAll = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (token === '*.*') {
|
|
148
|
+
sourceAll = true;
|
|
149
|
+
for (const relationField of directReadableRelationFields(
|
|
150
|
+
snapshot,
|
|
151
|
+
collection,
|
|
152
|
+
sourceSchema,
|
|
153
|
+
permission,
|
|
154
|
+
)) {
|
|
155
|
+
mergeExpansionSelection(expansions, relationField, '*');
|
|
156
|
+
}
|
|
157
|
+
assertExpansionLimit(expansions);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!token.includes('.')) {
|
|
162
|
+
sourceFields.push(assertFieldToken(token, `fields.${token}`));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const parts = token.split('.');
|
|
167
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
168
|
+
throw expansionError(
|
|
169
|
+
'UNSUPPORTED_RELATION_EXPANSION',
|
|
170
|
+
`Only one relation depth is supported in fields: ${token}`,
|
|
171
|
+
`fields.${token}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const relationField = assertFieldToken(parts[0], `fields.${token}`);
|
|
176
|
+
const targetField = parts[1] === '*'
|
|
177
|
+
? '*'
|
|
178
|
+
: assertFieldToken(parts[1], `fields.${token}`);
|
|
179
|
+
|
|
180
|
+
if (!readableSourceField(sourceSchema, permission, relationField)) {
|
|
181
|
+
throw expansionError('INVALID_QUERY', `Unknown field: ${relationField}`, `fields.${token}`);
|
|
182
|
+
}
|
|
183
|
+
directRelation(snapshot, collection, relationField, `fields.${token}`);
|
|
184
|
+
sourceFields.push(relationField);
|
|
185
|
+
mergeExpansionSelection(expansions, relationField, targetField);
|
|
186
|
+
assertExpansionLimit(expansions);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
sourceFields: sourceAll ? ['*'] : [...new Set(sourceFields)],
|
|
191
|
+
expansions,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission }) {
|
|
196
|
+
for (const field of parseExpandInput(query.expand)) {
|
|
197
|
+
if (!readableSourceField(sourceSchema, permission, field)) {
|
|
198
|
+
throw expansionError('INVALID_QUERY', `Unknown field: ${field}`, `expand.${field}`);
|
|
199
|
+
}
|
|
200
|
+
directRelation(snapshot, collection, field, `expand.${field}`);
|
|
201
|
+
mergeExpansionSelection(plan.expansions, field, '*');
|
|
202
|
+
assertExpansionLimit(plan.expansions, 'expand');
|
|
203
|
+
if (plan.sourceFields && !plan.sourceFields.includes('*') && !plan.sourceFields.includes(field)) {
|
|
204
|
+
plan.sourceFields.push(field);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return plan;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function buildSelectionPlan({ collection, query, options, service }) {
|
|
94
211
|
const snapshot = await schemaSnapshot(options);
|
|
95
212
|
const sourceSchema = snapshot.collections?.[collection];
|
|
96
213
|
if (!sourceSchema) {
|
|
97
214
|
throw expansionError('COLLECTION_NOT_FOUND', `Unknown collection: ${collection}`);
|
|
98
215
|
}
|
|
99
|
-
if (expandFields.length === 0) return snapshot;
|
|
100
216
|
|
|
101
217
|
const permission = await service.resolvePermission('read');
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
218
|
+
const plan = parseFieldsPlan({
|
|
219
|
+
value: query.fields,
|
|
220
|
+
snapshot,
|
|
221
|
+
collection,
|
|
222
|
+
sourceSchema,
|
|
223
|
+
permission,
|
|
224
|
+
});
|
|
225
|
+
addLegacyExpansions({ query, plan, snapshot, collection, sourceSchema, permission });
|
|
226
|
+
return { ...plan, snapshot };
|
|
109
227
|
}
|
|
110
228
|
|
|
111
|
-
|
|
112
|
-
|
|
229
|
+
function baseQuery(query, sourceFields) {
|
|
230
|
+
const base = { ...query };
|
|
231
|
+
delete base.expand;
|
|
232
|
+
if (sourceFields == null) delete base.fields;
|
|
233
|
+
else base.fields = sourceFields;
|
|
234
|
+
return base;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function projectTarget(target, selection, visibleFields) {
|
|
238
|
+
const projectedFields = selection.includes('*') ? visibleFields : selection;
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
projectedFields
|
|
241
|
+
.filter((field) => Object.hasOwn(target, field))
|
|
242
|
+
.map((field) => [field, target[field]]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function expandRows({ collection, rows, expansions, options, ItemsServiceClass, snapshot }) {
|
|
247
|
+
if (expansions.size === 0 || rows.length === 0) return rows;
|
|
113
248
|
const effectiveSnapshot = snapshot ?? await schemaSnapshot(options);
|
|
114
249
|
let expandedRows = rows.map((row) => ({ ...row }));
|
|
115
250
|
|
|
116
|
-
for (const field of
|
|
251
|
+
for (const [field, selection] of expansions) {
|
|
117
252
|
const relation = directRelation(effectiveSnapshot, collection, field);
|
|
118
253
|
const targetCollection = relation.one_collection;
|
|
119
254
|
const targetKey = relation.one_field
|
|
@@ -132,21 +267,25 @@ async function expandRows({ collection, rows, expandFields, options, ItemsServic
|
|
|
132
267
|
}
|
|
133
268
|
|
|
134
269
|
const targetService = new ItemsServiceClass(targetCollection, options);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
270
|
+
const targetResult = await targetService.readManyForRelation({
|
|
271
|
+
fields: selection,
|
|
272
|
+
lookupField: targetKey,
|
|
273
|
+
values,
|
|
138
274
|
});
|
|
139
275
|
|
|
140
276
|
const byKey = new Map();
|
|
141
|
-
for (const target of
|
|
277
|
+
for (const target of targetResult.data) {
|
|
142
278
|
if (!Object.hasOwn(target, targetKey)) {
|
|
143
279
|
throw expansionError(
|
|
144
280
|
'FORBIDDEN_FIELD',
|
|
145
281
|
`Expanded relation key is not readable: ${targetCollection}.${targetKey}`,
|
|
146
|
-
`
|
|
282
|
+
`fields.${field}`,
|
|
147
283
|
);
|
|
148
284
|
}
|
|
149
|
-
byKey.set(
|
|
285
|
+
byKey.set(
|
|
286
|
+
String(target[targetKey]),
|
|
287
|
+
projectTarget(target, selection, targetResult.visibleFields),
|
|
288
|
+
);
|
|
150
289
|
}
|
|
151
290
|
|
|
152
291
|
expandedRows = expandedRows.map((row) => ({
|
|
@@ -167,17 +306,16 @@ export async function readManyWithRelations({
|
|
|
167
306
|
ItemsServiceClass = ItemsService,
|
|
168
307
|
} = {}) {
|
|
169
308
|
assertIdentifier(collection, 'collection name');
|
|
170
|
-
const expandFields = parseExpandInput(query.expand);
|
|
171
309
|
const service = new ItemsServiceClass(collection, options);
|
|
172
|
-
const
|
|
173
|
-
const result = await service.readManyWithMeta(
|
|
310
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
311
|
+
const result = await service.readManyWithMeta(baseQuery(query, plan.sourceFields));
|
|
174
312
|
const data = await expandRows({
|
|
175
313
|
collection,
|
|
176
314
|
rows: result.data,
|
|
177
|
-
|
|
315
|
+
expansions: plan.expansions,
|
|
178
316
|
options,
|
|
179
317
|
ItemsServiceClass,
|
|
180
|
-
snapshot,
|
|
318
|
+
snapshot: plan.snapshot,
|
|
181
319
|
});
|
|
182
320
|
return { ...result, data };
|
|
183
321
|
}
|
|
@@ -196,21 +334,17 @@ export async function readOneWithRelations({
|
|
|
196
334
|
}
|
|
197
335
|
}
|
|
198
336
|
|
|
199
|
-
const expandFields = parseExpandInput(query.expand);
|
|
200
337
|
const service = new ItemsServiceClass(collection, options);
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const record = await service.readOne(id, { fields });
|
|
338
|
+
const plan = await buildSelectionPlan({ collection, query, options, service });
|
|
339
|
+
const record = await service.readOne(id, { fields: plan.sourceFields });
|
|
204
340
|
if (!record) return null;
|
|
205
341
|
const [expanded] = await expandRows({
|
|
206
342
|
collection,
|
|
207
343
|
rows: [record],
|
|
208
|
-
|
|
344
|
+
expansions: plan.expansions,
|
|
209
345
|
options,
|
|
210
346
|
ItemsServiceClass,
|
|
211
|
-
snapshot,
|
|
347
|
+
snapshot: plan.snapshot,
|
|
212
348
|
});
|
|
213
349
|
return expanded;
|
|
214
350
|
}
|
|
215
|
-
|
|
216
|
-
export { MAX_EXPAND_FIELDS };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
+
import { compileFilter } from '../query.js';
|
|
3
4
|
import { BaseService } from './base-service.js';
|
|
4
5
|
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
5
6
|
|
|
@@ -27,6 +28,38 @@ function normalizeMimeType(value) {
|
|
|
27
28
|
return mimetype;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
function startsWithBytes(buffer, bytes, offset = 0) {
|
|
32
|
+
if (buffer.byteLength < offset + bytes.length) return false;
|
|
33
|
+
return bytes.every((byte, index) => buffer[offset + index] === byte);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasKnownMimeSignature(contents, mimetype) {
|
|
37
|
+
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
38
|
+
switch (mimetype) {
|
|
39
|
+
case 'application/pdf':
|
|
40
|
+
return startsWithBytes(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
41
|
+
case 'image/png':
|
|
42
|
+
return startsWithBytes(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
43
|
+
case 'image/jpeg':
|
|
44
|
+
return startsWithBytes(buffer, [0xff, 0xd8, 0xff]);
|
|
45
|
+
case 'image/gif':
|
|
46
|
+
return buffer.subarray(0, 6).toString('ascii') === 'GIF87a'
|
|
47
|
+
|| buffer.subarray(0, 6).toString('ascii') === 'GIF89a';
|
|
48
|
+
case 'image/webp':
|
|
49
|
+
return buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
50
|
+
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
51
|
+
default:
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function assertMimeSignature(contents, mimetype) {
|
|
57
|
+
const matches = hasKnownMimeSignature(contents, mimetype);
|
|
58
|
+
if (matches === false) {
|
|
59
|
+
throw fileError('FILE_MIME_MISMATCH', `File contents do not match declared MIME type: ${mimetype}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
30
63
|
function decodeJson(value) {
|
|
31
64
|
if (value == null || typeof value === 'object') return value ?? null;
|
|
32
65
|
try {
|
|
@@ -56,6 +89,31 @@ export class FilesService extends BaseService {
|
|
|
56
89
|
});
|
|
57
90
|
}
|
|
58
91
|
|
|
92
|
+
#compileReadScope(permission, id = null) {
|
|
93
|
+
let sql = '';
|
|
94
|
+
let params = [];
|
|
95
|
+
|
|
96
|
+
if (permission?.filter) {
|
|
97
|
+
const collectionSchema = this.schema?.collections?.yuncms_files;
|
|
98
|
+
if (!collectionSchema) {
|
|
99
|
+
throw fileError(
|
|
100
|
+
'SYSTEM_SCHEMA_REQUIRED',
|
|
101
|
+
'System schema is required to enforce a filtered Files permission',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const compiled = compileFilter(permission.filter, collectionSchema);
|
|
105
|
+
sql = compiled.sql;
|
|
106
|
+
params = [...compiled.params];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (id != null) {
|
|
110
|
+
sql = sql ? `${sql} AND id = ?` : ' WHERE id = ?';
|
|
111
|
+
params.push(id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { sql, params };
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
async #readOneUnsafe(id) {
|
|
60
118
|
const [rows] = await this.database.query(
|
|
61
119
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
@@ -68,20 +126,34 @@ export class FilesService extends BaseService {
|
|
|
68
126
|
return normalizeRow(rows[0]);
|
|
69
127
|
}
|
|
70
128
|
|
|
129
|
+
async #readOneAuthorized(id, permission) {
|
|
130
|
+
const scope = this.#compileReadScope(permission, id);
|
|
131
|
+
const [rows] = await this.database.query(
|
|
132
|
+
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
133
|
+
uploaded_by, uploaded_at, metadata
|
|
134
|
+
FROM yuncms_files${scope.sql}
|
|
135
|
+
LIMIT 1`,
|
|
136
|
+
scope.params,
|
|
137
|
+
);
|
|
138
|
+
return normalizeRow(rows[0]);
|
|
139
|
+
}
|
|
140
|
+
|
|
71
141
|
async readMany() {
|
|
72
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
142
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
143
|
+
const scope = this.#compileReadScope(permission);
|
|
73
144
|
const [rows] = await this.database.query(
|
|
74
145
|
`SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
|
|
75
146
|
uploaded_by, uploaded_at, metadata
|
|
76
|
-
FROM yuncms_files
|
|
147
|
+
FROM yuncms_files${scope.sql}
|
|
77
148
|
ORDER BY uploaded_at DESC, id DESC`,
|
|
149
|
+
scope.params,
|
|
78
150
|
);
|
|
79
151
|
return rows.map(normalizeRow);
|
|
80
152
|
}
|
|
81
153
|
|
|
82
154
|
async readOne(id) {
|
|
83
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
84
|
-
return this.#
|
|
155
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
156
|
+
return this.#readOneAuthorized(id, permission);
|
|
85
157
|
}
|
|
86
158
|
|
|
87
159
|
async createOne({
|
|
@@ -100,6 +172,7 @@ export class FilesService extends BaseService {
|
|
|
100
172
|
|
|
101
173
|
const filename = normalizeFilename(filenameDownload);
|
|
102
174
|
const normalizedMime = normalizeMimeType(mimetype);
|
|
175
|
+
assertMimeSignature(contents, normalizedMime);
|
|
103
176
|
const driver = this.storage.get(storage);
|
|
104
177
|
const id = randomUUID();
|
|
105
178
|
const filenameDisk = id;
|
|
@@ -144,8 +217,8 @@ export class FilesService extends BaseService {
|
|
|
144
217
|
}
|
|
145
218
|
|
|
146
219
|
async readContent(id) {
|
|
147
|
-
await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
148
|
-
const file = await this.#
|
|
220
|
+
const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
|
|
221
|
+
const file = await this.#readOneAuthorized(id, permission);
|
|
149
222
|
if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
|
|
150
223
|
const driver = this.storage.get(file.storage);
|
|
151
224
|
const contents = await driver.get(file.filename_disk);
|
|
@@ -225,4 +298,9 @@ export class FilesService extends BaseService {
|
|
|
225
298
|
}
|
|
226
299
|
}
|
|
227
300
|
|
|
228
|
-
export {
|
|
301
|
+
export {
|
|
302
|
+
assertMimeSignature,
|
|
303
|
+
hasKnownMimeSignature,
|
|
304
|
+
normalizeFilename,
|
|
305
|
+
normalizeMimeType,
|
|
306
|
+
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
compileSelectFields,
|
|
8
8
|
compileSort,
|
|
9
9
|
parseItemsQuery,
|
|
10
|
+
QUERY_LIMITS,
|
|
10
11
|
} from '../query.js';
|
|
11
12
|
import { SchemaCache } from '../schema.js';
|
|
12
13
|
import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
|
|
@@ -215,6 +216,58 @@ export class ItemsService extends BaseService {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
async readManyForRelation({ fields = null, lookupField, values = [] } = {}) {
|
|
220
|
+
const schema = await this.getCollectionSchema();
|
|
221
|
+
const trustedLookupField = assertIdentifier(lookupField, 'relation lookup field');
|
|
222
|
+
if (!schema.fields[trustedLookupField]) {
|
|
223
|
+
throw serviceError(
|
|
224
|
+
'INVALID_QUERY',
|
|
225
|
+
`Unknown relation lookup field: ${trustedLookupField}`,
|
|
226
|
+
trustedLookupField,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxLimit) {
|
|
230
|
+
throw serviceError(
|
|
231
|
+
'INVALID_QUERY',
|
|
232
|
+
`Relation lookup values must contain between 1 and ${QUERY_LIMITS.maxLimit} entries`,
|
|
233
|
+
trustedLookupField,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const permission = await this.resolvePermission('read');
|
|
238
|
+
const accessSchema = schemaForFields(schema, permission.fields);
|
|
239
|
+
const visibleSelection = compileSelectFields(normalizeFields(fields), accessSchema);
|
|
240
|
+
const internalSchema = {
|
|
241
|
+
...accessSchema,
|
|
242
|
+
fields: {
|
|
243
|
+
...accessSchema.fields,
|
|
244
|
+
[trustedLookupField]: schema.fields[trustedLookupField],
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
const internalSelection = compileSelectFields(
|
|
248
|
+
[...visibleSelection.fields, trustedLookupField],
|
|
249
|
+
internalSchema,
|
|
250
|
+
);
|
|
251
|
+
const permissionFilter = compileFilter(permission.filter, schema);
|
|
252
|
+
const table = quoteIdentifier(this.collection, 'collection name');
|
|
253
|
+
const data = [];
|
|
254
|
+
|
|
255
|
+
for (let offset = 0; offset < values.length; offset += QUERY_LIMITS.maxInValues) {
|
|
256
|
+
const chunk = values.slice(offset, offset + QUERY_LIMITS.maxInValues);
|
|
257
|
+
const filter = combineCompiledFilters(
|
|
258
|
+
permissionFilter,
|
|
259
|
+
compileFilter({ [trustedLookupField]: { _in: chunk } }, schema),
|
|
260
|
+
);
|
|
261
|
+
const [rows] = await this.database.query(
|
|
262
|
+
`SELECT ${internalSelection.sql} FROM ${table}${filter.sql} LIMIT ?`,
|
|
263
|
+
[...filter.params, chunk.length],
|
|
264
|
+
);
|
|
265
|
+
data.push(...rows);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { data, visibleFields: visibleSelection.fields };
|
|
269
|
+
}
|
|
270
|
+
|
|
218
271
|
async readOne(id, { fields = null } = {}) {
|
|
219
272
|
const schema = await this.getCollectionSchema();
|
|
220
273
|
const permission = await this.resolvePermission('read');
|
|
@@ -464,4 +517,4 @@ export class ItemsService extends BaseService {
|
|
|
464
517
|
}
|
|
465
518
|
}
|
|
466
519
|
|
|
467
|
-
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
520
|
+
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
@@ -4,7 +4,7 @@ import { assertPermissionValidationRule } from '../permission-validation.js';
|
|
|
4
4
|
import { compileFilter } from '../query.js';
|
|
5
5
|
import { SchemaCache } from '../schema.js';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
assertSystemPermissionPayload,
|
|
8
8
|
assertSystemResourceAction,
|
|
9
9
|
isPermissionManagedSystemResource,
|
|
10
10
|
} from '../system-permissions.js';
|
|
@@ -86,6 +86,15 @@ export class PermissionsService extends BaseService {
|
|
|
86
86
|
this.schemaCache = options.schemaCache ?? defaultSchemaCache;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async action(event, payload) {
|
|
90
|
+
if (!this.emitter) return;
|
|
91
|
+
await this.emitter.action(event, payload, {
|
|
92
|
+
accountability: this.accountability,
|
|
93
|
+
requestId: this.requestId,
|
|
94
|
+
collection: 'yuncms_permissions',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
89
98
|
async #collectionSchema(collection) {
|
|
90
99
|
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
91
100
|
const collectionSchema = snapshot.collections?.[collection];
|
|
@@ -100,7 +109,10 @@ export class PermissionsService extends BaseService {
|
|
|
100
109
|
async resolve(action, collection) {
|
|
101
110
|
assertAction(action);
|
|
102
111
|
const key = cacheKey(this.accountability, action, collection);
|
|
103
|
-
if (this.permissionCache
|
|
112
|
+
if (this.permissionCache) {
|
|
113
|
+
const cached = await this.permissionCache.get(key);
|
|
114
|
+
if (cached !== undefined) return cached;
|
|
115
|
+
}
|
|
104
116
|
|
|
105
117
|
let permission;
|
|
106
118
|
if (this.accountability.admin === true || this.accountability.system === true) {
|
|
@@ -159,7 +171,7 @@ export class PermissionsService extends BaseService {
|
|
|
159
171
|
};
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
this.permissionCache
|
|
174
|
+
if (this.permissionCache) await this.permissionCache.set(key, permission);
|
|
163
175
|
return permission;
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -205,7 +217,7 @@ export class PermissionsService extends BaseService {
|
|
|
205
217
|
throw error;
|
|
206
218
|
}
|
|
207
219
|
assertSystemResourceAction(collectionSchema, action);
|
|
208
|
-
|
|
220
|
+
assertSystemPermissionPayload(collectionSchema, action, input);
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
|
|
@@ -224,11 +236,6 @@ export class PermissionsService extends BaseService {
|
|
|
224
236
|
error.code = 'ROLE_NOT_FOUND';
|
|
225
237
|
throw error;
|
|
226
238
|
}
|
|
227
|
-
if (collectionSchema.system && role.public) {
|
|
228
|
-
const error = new Error('Public role cannot be granted access to protected system resources');
|
|
229
|
-
error.code = 'PUBLIC_SYSTEM_ACCESS_FORBIDDEN';
|
|
230
|
-
throw error;
|
|
231
|
-
}
|
|
232
239
|
|
|
233
240
|
const id = randomUUID();
|
|
234
241
|
await this.database.query(
|
|
@@ -244,8 +251,10 @@ export class PermissionsService extends BaseService {
|
|
|
244
251
|
validation == null ? null : JSON.stringify(validation),
|
|
245
252
|
],
|
|
246
253
|
);
|
|
247
|
-
this.permissionCache
|
|
248
|
-
|
|
254
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
255
|
+
const permission = await this.readOne(id);
|
|
256
|
+
await this.action('permissions.create', { key: id, item: permission });
|
|
257
|
+
return permission;
|
|
249
258
|
}
|
|
250
259
|
|
|
251
260
|
async updateOne(id, patch = {}) {
|
|
@@ -269,7 +278,9 @@ export class PermissionsService extends BaseService {
|
|
|
269
278
|
throw error;
|
|
270
279
|
}
|
|
271
280
|
const collectionSchema = await this.#collectionSchema(existing.collection);
|
|
272
|
-
if (collectionSchema.system)
|
|
281
|
+
if (collectionSchema.system) {
|
|
282
|
+
assertSystemPermissionPayload(collectionSchema, existing.action, patch);
|
|
283
|
+
}
|
|
273
284
|
|
|
274
285
|
const assignments = [];
|
|
275
286
|
const params = [];
|
|
@@ -294,12 +305,20 @@ export class PermissionsService extends BaseService {
|
|
|
294
305
|
`UPDATE yuncms_permissions SET ${assignments.join(', ')} WHERE id = ?`,
|
|
295
306
|
params,
|
|
296
307
|
);
|
|
297
|
-
this.permissionCache
|
|
298
|
-
|
|
308
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
309
|
+
const permission = await this.readOne(id);
|
|
310
|
+
await this.action('permissions.update', {
|
|
311
|
+
key: id,
|
|
312
|
+
before: existing,
|
|
313
|
+
item: permission,
|
|
314
|
+
changes: patch,
|
|
315
|
+
});
|
|
316
|
+
return permission;
|
|
299
317
|
}
|
|
300
318
|
|
|
301
319
|
async deleteOne(id) {
|
|
302
320
|
assertPermissionManager(this.accountability);
|
|
321
|
+
const before = this.emitter ? await this.readOne(id) : null;
|
|
303
322
|
const [result] = await this.database.query(
|
|
304
323
|
'DELETE FROM yuncms_permissions WHERE id = ?',
|
|
305
324
|
[id],
|
|
@@ -309,7 +328,8 @@ export class PermissionsService extends BaseService {
|
|
|
309
328
|
error.code = 'PERMISSION_NOT_FOUND';
|
|
310
329
|
throw error;
|
|
311
330
|
}
|
|
312
|
-
this.permissionCache
|
|
331
|
+
if (this.permissionCache) await this.permissionCache.clear();
|
|
332
|
+
await this.action('permissions.delete', { key: id, before });
|
|
313
333
|
return true;
|
|
314
334
|
}
|
|
315
335
|
}
|