@yunsoft/yuncms-core 0.1.1 → 0.1.3
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/README.md +6 -0
- package/package.json +1 -1
- package/src/auth/users-repository.js +2 -2
- package/src/bootstrap.js +8 -0
- package/src/config.js +3 -1
- package/src/field-types.js +49 -1
- package/src/index.js +18 -1
- package/src/migrations/0007-system-permission-resources.js +46 -0
- package/src/migrations/0008-studio-logo-file.js +10 -0
- package/src/migrations/0009-schema-display-names.js +19 -0
- package/src/migrations/0010-studio-favicon-file.js +12 -0
- package/src/o2o-relation.js +231 -0
- package/src/schema-key.js +54 -0
- package/src/schema-metadata-repository.js +37 -8
- package/src/services/auth-service.js +4 -1
- package/src/services/collections-service.js +32 -4
- package/src/services/core-services.js +2 -0
- package/src/services/fields-service.js +74 -29
- package/src/services/files-service.js +24 -23
- package/src/services/items-service.js +34 -12
- package/src/services/permissions-service.js +48 -13
- package/src/services/roles-service.js +4 -3
- package/src/services/sessions-service.js +3 -3
- package/src/services/studio-settings-service.js +83 -11
- package/src/services/system-collection-fields-service.js +122 -0
- package/src/services/system-resource-access.js +23 -0
- package/src/services/users-service.js +71 -28
- package/src/setup.js +1 -2
- package/src/system-fields.js +146 -0
- package/src/system-permissions.js +54 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
parseItemsQuery,
|
|
10
10
|
} from '../query.js';
|
|
11
11
|
import { SchemaCache } from '../schema.js';
|
|
12
|
+
import { isSystemManagedField, systemMutationEntries } from '../system-fields.js';
|
|
12
13
|
import { withTransaction } from '../transaction.js';
|
|
13
14
|
import { BaseService } from './base-service.js';
|
|
14
15
|
import { PermissionsService } from './permissions-service.js';
|
|
@@ -74,6 +75,10 @@ function createCandidateRecord(schema, id, entries) {
|
|
|
74
75
|
continue;
|
|
75
76
|
}
|
|
76
77
|
const metadata = parseMetadata(fieldSchema.schema_metadata);
|
|
78
|
+
if (metadata.defaultPreset === 'now') {
|
|
79
|
+
candidate[fieldSchema.field] = new Date();
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
77
82
|
candidate[fieldSchema.field] = Object.hasOwn(metadata, 'defaultValue')
|
|
78
83
|
? metadata.defaultValue
|
|
79
84
|
: null;
|
|
@@ -82,6 +87,10 @@ function createCandidateRecord(schema, id, entries) {
|
|
|
82
87
|
return candidate;
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
function mergeSystemEntries(entries, schema, accountability, operation, now = new Date()) {
|
|
91
|
+
return [...entries, ...systemMutationEntries(schema, accountability, operation, now)];
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
export class ItemsService extends BaseService {
|
|
86
95
|
constructor(collection, options = {}) {
|
|
87
96
|
super(options);
|
|
@@ -114,6 +123,12 @@ export class ItemsService extends BaseService {
|
|
|
114
123
|
if (!collectionSchema) {
|
|
115
124
|
throw serviceError('COLLECTION_NOT_FOUND', `Unknown collection: ${this.collection}`);
|
|
116
125
|
}
|
|
126
|
+
if (collectionSchema.system) {
|
|
127
|
+
throw serviceError(
|
|
128
|
+
'SYSTEM_COLLECTION_USE_DEDICATED_SERVICE',
|
|
129
|
+
`System collection ${this.collection} must be accessed through its dedicated service`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
117
132
|
return collectionSchema;
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -150,8 +165,9 @@ export class ItemsService extends BaseService {
|
|
|
150
165
|
for (const fieldSchema of Object.values(schema.fields)) {
|
|
151
166
|
if (!fieldSchema.required || fieldSchema.field === schema.primary_key) continue;
|
|
152
167
|
if (Object.hasOwn(payload, fieldSchema.field)) continue;
|
|
168
|
+
if (isSystemManagedField(fieldSchema)) continue;
|
|
153
169
|
const metadata = parseMetadata(fieldSchema.schema_metadata);
|
|
154
|
-
if (Object.hasOwn(metadata, 'defaultValue')) continue;
|
|
170
|
+
if (Object.hasOwn(metadata, 'defaultValue') || metadata.defaultPreset != null) continue;
|
|
155
171
|
throw serviceError('REQUIRED_FIELD_MISSING', `Required field is missing: ${fieldSchema.field}`, fieldSchema.field);
|
|
156
172
|
}
|
|
157
173
|
}
|
|
@@ -230,7 +246,8 @@ export class ItemsService extends BaseService {
|
|
|
230
246
|
const schema = await this.getCollectionSchema();
|
|
231
247
|
const permission = await this.resolvePermission('create');
|
|
232
248
|
const filteredPayload = await this.filterMutation('items.create', payload, { operation: 'create' });
|
|
233
|
-
const
|
|
249
|
+
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
250
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
|
|
234
251
|
const id = randomUUID();
|
|
235
252
|
const candidate = createCandidateRecord(schema, id, entries);
|
|
236
253
|
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
@@ -264,7 +281,8 @@ export class ItemsService extends BaseService {
|
|
|
264
281
|
operation: 'create',
|
|
265
282
|
bulk: true,
|
|
266
283
|
});
|
|
267
|
-
const
|
|
284
|
+
const callerEntries = this.validatePayload(filteredPayload, schema, permission, { creating: true });
|
|
285
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'create');
|
|
268
286
|
const id = randomUUID();
|
|
269
287
|
const candidate = createCandidateRecord(schema, id, entries);
|
|
270
288
|
enforcePermissionValidation(candidate, permission.validation, schema);
|
|
@@ -306,8 +324,10 @@ export class ItemsService extends BaseService {
|
|
|
306
324
|
operation: 'update',
|
|
307
325
|
key: id,
|
|
308
326
|
});
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
327
|
+
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
328
|
+
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
329
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
330
|
+
const effectiveChanges = Object.fromEntries(entries);
|
|
311
331
|
|
|
312
332
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
313
333
|
const filter = combineCompiledFilters(
|
|
@@ -322,7 +342,7 @@ export class ItemsService extends BaseService {
|
|
|
322
342
|
);
|
|
323
343
|
const current = currentRows[0];
|
|
324
344
|
if (!current) return null;
|
|
325
|
-
enforcePermissionValidation({ ...current, ...
|
|
345
|
+
enforcePermissionValidation({ ...current, ...effectiveChanges }, permission.validation, schema);
|
|
326
346
|
}
|
|
327
347
|
|
|
328
348
|
const setSql = entries.map(([field]) => `${quoteIdentifier(field, 'field name')} = ?`).join(', ');
|
|
@@ -336,7 +356,7 @@ export class ItemsService extends BaseService {
|
|
|
336
356
|
await this.actionMutation('items.update', {
|
|
337
357
|
key: id,
|
|
338
358
|
item: record,
|
|
339
|
-
changes:
|
|
359
|
+
changes: effectiveChanges,
|
|
340
360
|
}, { operation: 'update' });
|
|
341
361
|
return record;
|
|
342
362
|
}
|
|
@@ -354,8 +374,10 @@ export class ItemsService extends BaseService {
|
|
|
354
374
|
bulk: true,
|
|
355
375
|
filter: filterInput,
|
|
356
376
|
});
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
377
|
+
const callerEntries = this.validatePayload(filteredPayload, schema, permission);
|
|
378
|
+
if (callerEntries.length === 0) throw serviceError('INVALID_PAYLOAD', 'Update payload cannot be empty');
|
|
379
|
+
const entries = mergeSystemEntries(callerEntries, schema, this.accountability, 'update');
|
|
380
|
+
const effectiveChanges = Object.fromEntries(entries);
|
|
359
381
|
const filter = this.compileActionFilters(filterInput, permission.filter, accessSchema, schema);
|
|
360
382
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
361
383
|
|
|
@@ -371,7 +393,7 @@ export class ItemsService extends BaseService {
|
|
|
371
393
|
);
|
|
372
394
|
}
|
|
373
395
|
for (const row of rows) {
|
|
374
|
-
enforcePermissionValidation({ ...row, ...
|
|
396
|
+
enforcePermissionValidation({ ...row, ...effectiveChanges }, permission.validation, schema);
|
|
375
397
|
}
|
|
376
398
|
}
|
|
377
399
|
|
|
@@ -382,7 +404,7 @@ export class ItemsService extends BaseService {
|
|
|
382
404
|
);
|
|
383
405
|
await this.actionMutation('items.update', {
|
|
384
406
|
filter: filterInput,
|
|
385
|
-
changes:
|
|
407
|
+
changes: effectiveChanges,
|
|
386
408
|
affected: result.affectedRows,
|
|
387
409
|
}, { operation: 'update', bulk: true });
|
|
388
410
|
return result.affectedRows;
|
|
@@ -442,4 +464,4 @@ export class ItemsService extends BaseService {
|
|
|
442
464
|
}
|
|
443
465
|
}
|
|
444
466
|
|
|
445
|
-
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
467
|
+
export { MAX_BULK_VALIDATION_ROWS, createCandidateRecord };
|
|
@@ -3,6 +3,11 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { assertPermissionValidationRule } from '../permission-validation.js';
|
|
4
4
|
import { compileFilter } from '../query.js';
|
|
5
5
|
import { SchemaCache } from '../schema.js';
|
|
6
|
+
import {
|
|
7
|
+
assertActionOnlyPermissionPayload,
|
|
8
|
+
assertSystemResourceAction,
|
|
9
|
+
isPermissionManagedSystemResource,
|
|
10
|
+
} from '../system-permissions.js';
|
|
6
11
|
import { BaseService } from './base-service.js';
|
|
7
12
|
|
|
8
13
|
const ACTIONS = new Set(['create', 'read', 'update', 'delete']);
|
|
@@ -81,6 +86,17 @@ export class PermissionsService extends BaseService {
|
|
|
81
86
|
this.schemaCache = options.schemaCache ?? defaultSchemaCache;
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
async #collectionSchema(collection) {
|
|
90
|
+
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
91
|
+
const collectionSchema = snapshot.collections?.[collection];
|
|
92
|
+
if (!collectionSchema) {
|
|
93
|
+
const error = new Error(`Unknown permission collection: ${collection}`);
|
|
94
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
return collectionSchema;
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
async resolve(action, collection) {
|
|
85
101
|
assertAction(action);
|
|
86
102
|
const key = cacheKey(this.accountability, action, collection);
|
|
@@ -102,6 +118,19 @@ export class PermissionsService extends BaseService {
|
|
|
102
118
|
throw forbidden(`No role is available for ${action} access to ${collection}`);
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const collectionSchema = await this.#collectionSchema(collection);
|
|
122
|
+
if (collectionSchema.system) {
|
|
123
|
+
if (!isPermissionManagedSystemResource(collectionSchema)) {
|
|
124
|
+
throw forbidden(`System resource is not delegatable: ${collection}`);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
assertSystemResourceAction(collectionSchema, action);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code === 'SYSTEM_PERMISSION_ACTION_PROTECTED') throw forbidden(error.message);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
const [rows] = await this.database.query(
|
|
106
135
|
`SELECT id, role, collection, action, fields, filter, validation
|
|
107
136
|
FROM yuncms_permissions
|
|
@@ -154,17 +183,6 @@ export class PermissionsService extends BaseService {
|
|
|
154
183
|
return normalizePermissionRow(rows[0]);
|
|
155
184
|
}
|
|
156
185
|
|
|
157
|
-
async #collectionSchema(collection) {
|
|
158
|
-
const snapshot = this.schema ?? await this.schemaCache.get(this.database);
|
|
159
|
-
const collectionSchema = snapshot.collections?.[collection];
|
|
160
|
-
if (!collectionSchema) {
|
|
161
|
-
const error = new Error(`Unknown permission collection: ${collection}`);
|
|
162
|
-
error.code = 'COLLECTION_NOT_FOUND';
|
|
163
|
-
throw error;
|
|
164
|
-
}
|
|
165
|
-
return collectionSchema;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
186
|
async createOne(input = {}) {
|
|
169
187
|
assertPermissionManager(this.accountability);
|
|
170
188
|
const action = assertAction(input.action);
|
|
@@ -180,6 +198,16 @@ export class PermissionsService extends BaseService {
|
|
|
180
198
|
}
|
|
181
199
|
|
|
182
200
|
const collectionSchema = await this.#collectionSchema(input.collection);
|
|
201
|
+
if (collectionSchema.system) {
|
|
202
|
+
if (!isPermissionManagedSystemResource(collectionSchema)) {
|
|
203
|
+
const error = new Error(`System resource permissions are protected: ${input.collection}`);
|
|
204
|
+
error.code = 'SYSTEM_PERMISSION_RESOURCE_PROTECTED';
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
assertSystemResourceAction(collectionSchema, action);
|
|
208
|
+
assertActionOnlyPermissionPayload(collectionSchema, input);
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
const fields = normalizePermissionFields(input.fields ?? null, collectionSchema);
|
|
184
212
|
const filter = input.filter ?? null;
|
|
185
213
|
const validation = input.validation ?? null;
|
|
@@ -187,14 +215,20 @@ export class PermissionsService extends BaseService {
|
|
|
187
215
|
assertPermissionValidationRule(validation, collectionSchema);
|
|
188
216
|
|
|
189
217
|
const [roleRows] = await this.database.query(
|
|
190
|
-
'SELECT id FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
218
|
+
'SELECT id, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
|
|
191
219
|
[input.role],
|
|
192
220
|
);
|
|
193
|
-
|
|
221
|
+
const role = roleRows[0];
|
|
222
|
+
if (!role) {
|
|
194
223
|
const error = new Error(`Unknown permission role: ${input.role}`);
|
|
195
224
|
error.code = 'ROLE_NOT_FOUND';
|
|
196
225
|
throw error;
|
|
197
226
|
}
|
|
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
|
+
}
|
|
198
232
|
|
|
199
233
|
const id = randomUUID();
|
|
200
234
|
await this.database.query(
|
|
@@ -235,6 +269,7 @@ export class PermissionsService extends BaseService {
|
|
|
235
269
|
throw error;
|
|
236
270
|
}
|
|
237
271
|
const collectionSchema = await this.#collectionSchema(existing.collection);
|
|
272
|
+
if (collectionSchema.system) assertActionOnlyPermissionPayload(collectionSchema, patch);
|
|
238
273
|
|
|
239
274
|
const assignments = [];
|
|
240
275
|
const params = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { BaseService } from './base-service.js';
|
|
4
|
+
import { resolveSystemResourceAccess } from './system-resource-access.js';
|
|
4
5
|
|
|
5
6
|
function assertRoleManager(accountability) {
|
|
6
7
|
if (accountability.admin === true || accountability.system === true) return;
|
|
@@ -26,7 +27,7 @@ function normalizeRoleName(name) {
|
|
|
26
27
|
|
|
27
28
|
export class RolesService extends BaseService {
|
|
28
29
|
async readMany() {
|
|
29
|
-
|
|
30
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
30
31
|
const [rows] = await this.database.query(
|
|
31
32
|
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
32
33
|
FROM yuncms_roles
|
|
@@ -36,7 +37,7 @@ export class RolesService extends BaseService {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async readOne(id) {
|
|
39
|
-
|
|
40
|
+
await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
|
|
40
41
|
const [rows] = await this.database.query(
|
|
41
42
|
`SELECT id, name, description, admin, public, created_at, updated_at
|
|
42
43
|
FROM yuncms_roles
|
|
@@ -157,4 +158,4 @@ export class RolesService extends BaseService {
|
|
|
157
158
|
}
|
|
158
159
|
return true;
|
|
159
160
|
}
|
|
160
|
-
}
|
|
161
|
+
}
|
|
@@ -20,6 +20,7 @@ function identityFromRow(row) {
|
|
|
20
20
|
return {
|
|
21
21
|
user: row.user,
|
|
22
22
|
role: row.role ?? null,
|
|
23
|
+
role_name: row.role_name ?? null,
|
|
23
24
|
admin: Boolean(row.role_admin),
|
|
24
25
|
email: row.email,
|
|
25
26
|
session: row.session_id,
|
|
@@ -71,7 +72,7 @@ export class SessionsService extends BaseService {
|
|
|
71
72
|
const hash = hashToken(token);
|
|
72
73
|
const [rows] = await this.database.query(
|
|
73
74
|
`SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
|
|
74
|
-
r.admin AS role_admin
|
|
75
|
+
r.name AS role_name, r.admin AS role_admin
|
|
75
76
|
FROM yuncms_sessions s
|
|
76
77
|
INNER JOIN yuncms_users u ON u.id = s.user
|
|
77
78
|
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
@@ -105,7 +106,7 @@ export class SessionsService extends BaseService {
|
|
|
105
106
|
const oldHash = hashToken(token);
|
|
106
107
|
const [rows] = await this.database.query(
|
|
107
108
|
`SELECT s.id AS session_id, s.user, u.email, u.role, u.status,
|
|
108
|
-
r.admin AS role_admin
|
|
109
|
+
r.name AS role_name, r.admin AS role_admin
|
|
109
110
|
FROM yuncms_sessions s
|
|
110
111
|
INNER JOIN yuncms_users u ON u.id = s.user
|
|
111
112
|
LEFT JOIN yuncms_roles r ON r.id = u.role
|
|
@@ -164,7 +165,6 @@ export class SessionsService extends BaseService {
|
|
|
164
165
|
error.code = 'FORBIDDEN';
|
|
165
166
|
throw error;
|
|
166
167
|
}
|
|
167
|
-
|
|
168
168
|
const [result] = await this.database.query(
|
|
169
169
|
'DELETE FROM yuncms_sessions WHERE user = ?',
|
|
170
170
|
[userId],
|
|
@@ -3,6 +3,11 @@ import { BaseService } from './base-service.js';
|
|
|
3
3
|
const THEMES = new Set(['system', 'light', 'dark']);
|
|
4
4
|
const LOCALES = new Set(['en', 'tr']);
|
|
5
5
|
const ACCENT_PATTERN = /^#[0-9a-f]{6}$/i;
|
|
6
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
const YUNSOFT_LOGOS = new Set([
|
|
8
|
+
'https://yunsoft.com/light-logo.png',
|
|
9
|
+
'https://yunsoft.com/dark-logo.png',
|
|
10
|
+
]);
|
|
6
11
|
|
|
7
12
|
function invalid(message) {
|
|
8
13
|
const error = new Error(message);
|
|
@@ -10,6 +15,12 @@ function invalid(message) {
|
|
|
10
15
|
return error;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
function notFound(message) {
|
|
19
|
+
const error = new Error(message);
|
|
20
|
+
error.code = 'NOT_FOUND';
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
function assertManager(accountability) {
|
|
14
25
|
if (accountability.admin === true || accountability.system === true) return;
|
|
15
26
|
const error = new Error('Studio settings require administrator accountability');
|
|
@@ -25,16 +36,17 @@ function normalizeBrandName(value) {
|
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
function normalizeLogoUrl(value) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let url;
|
|
32
|
-
try {
|
|
33
|
-
url = new URL(normalized);
|
|
34
|
-
} catch {
|
|
35
|
-
throw invalid('Logo URL must be a valid URL');
|
|
39
|
+
const normalized = String(value ?? '').trim();
|
|
40
|
+
if (!YUNSOFT_LOGOS.has(normalized)) {
|
|
41
|
+
throw invalid('External logo URLs are not supported; choose an image from Files');
|
|
36
42
|
}
|
|
37
|
-
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeImageFile(value, label) {
|
|
47
|
+
if (value == null || value === '') return null;
|
|
48
|
+
const normalized = String(value).trim();
|
|
49
|
+
if (!UUID_PATTERN.test(normalized)) throw invalid(`${label} file id must be a UUID`);
|
|
38
50
|
return normalized;
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -60,6 +72,8 @@ function publicSettings(row) {
|
|
|
60
72
|
return {
|
|
61
73
|
brand_name: row.brand_name,
|
|
62
74
|
logo_url: row.logo_url,
|
|
75
|
+
logo_file: row.logo_file ?? null,
|
|
76
|
+
favicon_file: row.favicon_file ?? null,
|
|
63
77
|
accent_color: row.accent_color,
|
|
64
78
|
theme: row.theme,
|
|
65
79
|
default_locale: row.default_locale,
|
|
@@ -70,7 +84,7 @@ function publicSettings(row) {
|
|
|
70
84
|
export class StudioSettingsService extends BaseService {
|
|
71
85
|
async readPublic() {
|
|
72
86
|
const [rows] = await this.database.query(
|
|
73
|
-
`SELECT brand_name, logo_url, accent_color, theme, default_locale, updated_at
|
|
87
|
+
`SELECT brand_name, logo_url, logo_file, favicon_file, accent_color, theme, default_locale, updated_at
|
|
74
88
|
FROM yuncms_studio_settings
|
|
75
89
|
WHERE id = 1
|
|
76
90
|
LIMIT 1`,
|
|
@@ -89,12 +103,56 @@ export class StudioSettingsService extends BaseService {
|
|
|
89
103
|
return this.readPublic();
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
async readImageAssetContent(settingKey, label) {
|
|
107
|
+
const settings = await this.readPublic();
|
|
108
|
+
const fileId = settings[settingKey];
|
|
109
|
+
if (!fileId) throw notFound(`No file-backed Studio ${label.toLowerCase()} is configured`);
|
|
110
|
+
if (!this.storage) throw new Error(`StudioSettingsService requires storage to read a file-backed ${label.toLowerCase()}`);
|
|
111
|
+
|
|
112
|
+
const [rows] = await this.database.query(
|
|
113
|
+
`SELECT id, storage, filename_disk, mimetype, filesize
|
|
114
|
+
FROM yuncms_files
|
|
115
|
+
WHERE id = ?
|
|
116
|
+
LIMIT 1`,
|
|
117
|
+
[fileId],
|
|
118
|
+
);
|
|
119
|
+
const file = rows[0];
|
|
120
|
+
if (!file) throw notFound(`Configured Studio ${label.toLowerCase()} file does not exist`);
|
|
121
|
+
if (!String(file.mimetype || '').toLowerCase().startsWith('image/')) {
|
|
122
|
+
throw invalid(`Configured Studio ${label.toLowerCase()} must be an image`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const driver = this.storage.get(file.storage);
|
|
126
|
+
const contents = await driver.get(file.filename_disk);
|
|
127
|
+
return { file, contents };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async readLogoContent() {
|
|
131
|
+
return this.readImageAssetContent('logo_file', 'Logo');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async readFaviconContent() {
|
|
135
|
+
return this.readImageAssetContent('favicon_file', 'Favicon');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async validateSelectedImage(fileId, label) {
|
|
139
|
+
if (!fileId) return;
|
|
140
|
+
const [files] = await this.database.query(
|
|
141
|
+
'SELECT id, mimetype FROM yuncms_files WHERE id = ? LIMIT 1',
|
|
142
|
+
[fileId],
|
|
143
|
+
);
|
|
144
|
+
if (!files[0]) throw invalid(`Selected ${label.toLowerCase()} file does not exist`);
|
|
145
|
+
if (!String(files[0].mimetype || '').toLowerCase().startsWith('image/')) {
|
|
146
|
+
throw invalid(`Selected ${label.toLowerCase()} file must be an image`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
92
150
|
async updateOne(patch = {}) {
|
|
93
151
|
assertManager(this.accountability);
|
|
94
152
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid('Studio settings patch must be an object');
|
|
95
153
|
|
|
96
154
|
const keys = Object.keys(patch);
|
|
97
|
-
const allowed = new Set(['brand_name', 'logo_url', 'accent_color', 'theme', 'default_locale']);
|
|
155
|
+
const allowed = new Set(['brand_name', 'logo_url', 'logo_file', 'favicon_file', 'accent_color', 'theme', 'default_locale']);
|
|
98
156
|
if (keys.length === 0 || keys.some((key) => !allowed.has(key))) {
|
|
99
157
|
throw invalid('Studio settings patch contains unsupported properties');
|
|
100
158
|
}
|
|
@@ -109,6 +167,18 @@ export class StudioSettingsService extends BaseService {
|
|
|
109
167
|
assignments.push('logo_url = ?');
|
|
110
168
|
params.push(normalizeLogoUrl(patch.logo_url));
|
|
111
169
|
}
|
|
170
|
+
if (Object.hasOwn(patch, 'logo_file')) {
|
|
171
|
+
const fileId = normalizeImageFile(patch.logo_file, 'Logo');
|
|
172
|
+
await this.validateSelectedImage(fileId, 'Logo');
|
|
173
|
+
assignments.push('logo_file = ?');
|
|
174
|
+
params.push(fileId);
|
|
175
|
+
}
|
|
176
|
+
if (Object.hasOwn(patch, 'favicon_file')) {
|
|
177
|
+
const fileId = normalizeImageFile(patch.favicon_file, 'Favicon');
|
|
178
|
+
await this.validateSelectedImage(fileId, 'Favicon');
|
|
179
|
+
assignments.push('favicon_file = ?');
|
|
180
|
+
params.push(fileId);
|
|
181
|
+
}
|
|
112
182
|
if (Object.hasOwn(patch, 'accent_color')) {
|
|
113
183
|
assignments.push('accent_color = ?');
|
|
114
184
|
params.push(normalizeAccent(patch.accent_color));
|
|
@@ -134,6 +204,8 @@ export class StudioSettingsService extends BaseService {
|
|
|
134
204
|
export const STUDIO_SETTING_DEFAULTS = Object.freeze({
|
|
135
205
|
brand_name: 'YunCMS',
|
|
136
206
|
logo_url: 'https://yunsoft.com/light-logo.png',
|
|
207
|
+
logo_file: null,
|
|
208
|
+
favicon_file: null,
|
|
137
209
|
accent_color: '#2563eb',
|
|
138
210
|
theme: 'system',
|
|
139
211
|
default_locale: 'en',
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { withAdvisoryLock } from '../advisory-lock.js';
|
|
2
|
+
import { compileFieldColumn } from '../field-types.js';
|
|
3
|
+
import { assertIdentifier, quoteIdentifier } from '../identifier.js';
|
|
4
|
+
import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
|
|
5
|
+
import { resolveSchemaName } from '../schema-key.js';
|
|
6
|
+
import { incrementSchemaVersion } from '../schema-version.js';
|
|
7
|
+
import { isPermissionManagedSystemResource } from '../system-permissions.js';
|
|
8
|
+
import { withConnectionTransaction } from '../transaction.js';
|
|
9
|
+
import { BaseService } from './base-service.js';
|
|
10
|
+
import { assertSchemaManager } from './schema-access.js';
|
|
11
|
+
|
|
12
|
+
function fieldName(value) {
|
|
13
|
+
assertIdentifier(value, 'field name');
|
|
14
|
+
if (value.length > 64) {
|
|
15
|
+
const error = new Error('Field name cannot exceed 64 characters');
|
|
16
|
+
error.code = 'INVALID_SCHEMA_PAYLOAD';
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (value === 'id') {
|
|
20
|
+
const error = new Error('The id field cannot be replaced');
|
|
21
|
+
error.code = 'FIELD_EXISTS';
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertExtensibleSystemCollection(collection) {
|
|
28
|
+
if (!collection?.system || !isPermissionManagedSystemResource(collection)) {
|
|
29
|
+
const error = new Error(`System collection is not extensible: ${collection?.collection || 'unknown'}`);
|
|
30
|
+
error.code = 'SYSTEM_SCHEMA_READ_ONLY';
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertSystemExtensionInput(input = {}) {
|
|
36
|
+
if (input.required === true) {
|
|
37
|
+
const error = new Error('Custom system collection fields must be optional in V1');
|
|
38
|
+
error.code = 'SYSTEM_EXTENSION_REQUIRED_UNSUPPORTED';
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SystemCollectionFieldsService extends BaseService {
|
|
44
|
+
async createOne(collectionName, input = {}) {
|
|
45
|
+
assertSchemaManager(this.accountability);
|
|
46
|
+
assertIdentifier(collectionName, 'collection name');
|
|
47
|
+
assertSystemExtensionInput(input);
|
|
48
|
+
const resolvedName = resolveSchemaName({
|
|
49
|
+
displayName: input.name ?? input.field,
|
|
50
|
+
key: input.field,
|
|
51
|
+
prefix: 'field',
|
|
52
|
+
});
|
|
53
|
+
const field = fieldName(resolvedName.key);
|
|
54
|
+
const name = resolvedName.name;
|
|
55
|
+
const compiled = compileFieldColumn({ ...input, required: false });
|
|
56
|
+
|
|
57
|
+
return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
|
|
58
|
+
const metadata = new SchemaMetadataRepository(connection);
|
|
59
|
+
const collection = await metadata.readCollection(collectionName);
|
|
60
|
+
if (!collection) {
|
|
61
|
+
const error = new Error(`Unknown collection: ${collectionName}`);
|
|
62
|
+
error.code = 'COLLECTION_NOT_FOUND';
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
assertExtensibleSystemCollection(collection);
|
|
66
|
+
|
|
67
|
+
const existing = await metadata.readField(collectionName, field);
|
|
68
|
+
if (existing) {
|
|
69
|
+
const error = new Error(`Field already exists: ${collectionName}.${field}`);
|
|
70
|
+
error.code = 'FIELD_EXISTS';
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const table = quoteIdentifier(collectionName, 'collection name');
|
|
75
|
+
const column = quoteIdentifier(field, 'field name');
|
|
76
|
+
let physicalCreated = false;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await connection.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${compiled.sql}`, compiled.params);
|
|
80
|
+
physicalCreated = true;
|
|
81
|
+
|
|
82
|
+
return await withConnectionTransaction(connection, async () => {
|
|
83
|
+
const created = await metadata.createField({
|
|
84
|
+
collection: collectionName,
|
|
85
|
+
field,
|
|
86
|
+
name,
|
|
87
|
+
type: input.type,
|
|
88
|
+
required: false,
|
|
89
|
+
readonly: input.readonly === true,
|
|
90
|
+
hidden: input.hidden === true,
|
|
91
|
+
sort: input.sort ?? null,
|
|
92
|
+
interface: input.interface ?? null,
|
|
93
|
+
options: input.options ?? null,
|
|
94
|
+
schemaMetadata: {
|
|
95
|
+
...compiled.schemaMetadata,
|
|
96
|
+
systemExtension: true,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const schemaVersion = await incrementSchemaVersion(connection);
|
|
100
|
+
return { ...created, schemaVersion };
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
try {
|
|
104
|
+
await metadata.deleteField(collectionName, field);
|
|
105
|
+
} catch (cleanupError) {
|
|
106
|
+
error.cleanupMetadataError = cleanupError;
|
|
107
|
+
}
|
|
108
|
+
if (physicalCreated) {
|
|
109
|
+
try {
|
|
110
|
+
await connection.query(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
|
111
|
+
} catch (cleanupError) {
|
|
112
|
+
error.cleanupPhysicalError = cleanupError;
|
|
113
|
+
error.code ||= 'SCHEMA_PARTIAL_FAILURE';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { assertExtensibleSystemCollection, assertSystemExtensionInput };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PermissionsService } from './permissions-service.js';
|
|
2
|
+
|
|
3
|
+
export async function resolveSystemResourceAccess(service, action, collection) {
|
|
4
|
+
if (service.accountability.admin === true || service.accountability.system === true) {
|
|
5
|
+
return {
|
|
6
|
+
fullAccess: true,
|
|
7
|
+
action,
|
|
8
|
+
collection,
|
|
9
|
+
role: service.accountability.role ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const permissions = new PermissionsService({
|
|
14
|
+
accountability: service.accountability,
|
|
15
|
+
database: service.database,
|
|
16
|
+
schema: service.schema,
|
|
17
|
+
emitter: service.emitter,
|
|
18
|
+
logger: service.logger,
|
|
19
|
+
permissionCache: service.permissionCache,
|
|
20
|
+
requestId: service.requestId,
|
|
21
|
+
});
|
|
22
|
+
return permissions.resolve(action, collection);
|
|
23
|
+
}
|