@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.
@@ -2,6 +2,8 @@ function encodeJson(value) {
2
2
  return value == null ? null : JSON.stringify(value);
3
3
  }
4
4
 
5
+ const FILE_INTERFACES = new Set(['file', 'image']);
6
+
5
7
  export class SchemaMetadataRepository {
6
8
  constructor(database) {
7
9
  if (!database) throw new Error('Database handle is required');
@@ -10,7 +12,7 @@ export class SchemaMetadataRepository {
10
12
 
11
13
  async listCollections() {
12
14
  const [rows] = await this.database.query(
13
- `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
15
+ `SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
14
16
  FROM yuncms_collections
15
17
  ORDER BY collection ASC`,
16
18
  );
@@ -19,7 +21,7 @@ export class SchemaMetadataRepository {
19
21
 
20
22
  async readCollection(collection) {
21
23
  const [rows] = await this.database.query(
22
- `SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
24
+ `SELECT collection, name, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
23
25
  FROM yuncms_collections
24
26
  WHERE collection = ?
25
27
  LIMIT 1`,
@@ -30,6 +32,7 @@ export class SchemaMetadataRepository {
30
32
 
31
33
  async createCollection({
32
34
  collection,
35
+ name = collection,
33
36
  primaryKey = 'id',
34
37
  note = null,
35
38
  singleton = false,
@@ -39,10 +42,11 @@ export class SchemaMetadataRepository {
39
42
  }) {
40
43
  await this.database.query(
41
44
  `INSERT INTO yuncms_collections
42
- (collection, primary_key, note, singleton, hidden, \`system\`, metadata)
43
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
45
+ (collection, name, primary_key, note, singleton, hidden, \`system\`, metadata)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
44
47
  [
45
48
  collection,
49
+ name,
46
50
  primaryKey,
47
51
  note,
48
52
  singleton ? 1 : 0,
@@ -58,6 +62,10 @@ export class SchemaMetadataRepository {
58
62
  const assignments = [];
59
63
  const params = [];
60
64
 
65
+ if (Object.hasOwn(patch, 'name')) {
66
+ assignments.push('name = ?');
67
+ params.push(patch.name);
68
+ }
61
69
  if (Object.hasOwn(patch, 'note')) {
62
70
  assignments.push('note = ?');
63
71
  params.push(patch.note ?? null);
@@ -94,7 +102,7 @@ export class SchemaMetadataRepository {
94
102
 
95
103
  async listFields(collection) {
96
104
  const [rows] = await this.database.query(
97
- `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
105
+ `SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
98
106
  created_at, updated_at
99
107
  FROM yuncms_fields
100
108
  WHERE collection = ?
@@ -106,7 +114,7 @@ export class SchemaMetadataRepository {
106
114
 
107
115
  async readField(collection, field) {
108
116
  const [rows] = await this.database.query(
109
- `SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
117
+ `SELECT id, collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata,
110
118
  created_at, updated_at
111
119
  FROM yuncms_fields
112
120
  WHERE collection = ? AND field = ?
@@ -119,6 +127,7 @@ export class SchemaMetadataRepository {
119
127
  async createField({
120
128
  collection,
121
129
  field,
130
+ name = field,
122
131
  type,
123
132
  required = false,
124
133
  readonly = false,
@@ -128,13 +137,19 @@ export class SchemaMetadataRepository {
128
137
  options = null,
129
138
  schemaMetadata = null,
130
139
  }) {
140
+ if (FILE_INTERFACES.has(fieldInterface) && type !== 'uuid') {
141
+ const error = new Error(`${fieldInterface} interface requires uuid storage`);
142
+ error.code = 'INVALID_FIELD_INTERFACE';
143
+ throw error;
144
+ }
131
145
  await this.database.query(
132
146
  `INSERT INTO yuncms_fields
133
- (collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
134
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
147
+ (collection, field, name, type, required, readonly, hidden, sort, interface, options, schema_metadata)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
135
149
  [
136
150
  collection,
137
151
  field,
152
+ name,
138
153
  type,
139
154
  required ? 1 : 0,
140
155
  readonly ? 1 : 0,
@@ -152,6 +167,20 @@ export class SchemaMetadataRepository {
152
167
  const assignments = [];
153
168
  const params = [];
154
169
 
170
+ if (Object.hasOwn(patch, 'interface') && FILE_INTERFACES.has(patch.interface)) {
171
+ const existing = await this.readField(collection, field);
172
+ if (!existing) return null;
173
+ if (existing.type !== 'uuid') {
174
+ const error = new Error(`${patch.interface} interface requires uuid storage`);
175
+ error.code = 'INVALID_FIELD_INTERFACE';
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ if (Object.hasOwn(patch, 'name')) {
181
+ assignments.push('name = ?');
182
+ params.push(patch.name);
183
+ }
155
184
  if (Object.hasOwn(patch, 'readonly')) {
156
185
  assignments.push('readonly = ?');
157
186
  params.push(patch.readonly ? 1 : 0);
@@ -31,6 +31,7 @@ function publicUser(user) {
31
31
  id: user.id,
32
32
  email: user.email,
33
33
  role: user.role ?? null,
34
+ role_name: user.role_name ?? null,
34
35
  status: user.status,
35
36
  email_verified_at: user.email_verified_at ?? null,
36
37
  };
@@ -95,7 +96,7 @@ export class AuthService extends BaseService {
95
96
  if (tokenType(token) !== 'api') throw invalidToken();
96
97
  const [rows] = await this.database.query(
97
98
  `SELECT t.id AS api_token_id, t.user, u.email, u.role, u.status,
98
- r.admin AS role_admin
99
+ r.name AS role_name, r.admin AS role_admin
99
100
  FROM yuncms_api_tokens t
100
101
  INNER JOIN yuncms_users u ON u.id = t.user
101
102
  LEFT JOIN yuncms_roles r ON r.id = u.role
@@ -119,6 +120,7 @@ export class AuthService extends BaseService {
119
120
  return {
120
121
  user: row.user,
121
122
  role: row.role ?? null,
123
+ role_name: row.role_name ?? null,
122
124
  admin: Boolean(row.role_admin),
123
125
  email: row.email,
124
126
  session: null,
@@ -141,6 +143,7 @@ export class AuthService extends BaseService {
141
143
  id: result.user,
142
144
  email: result.email,
143
145
  role: result.role,
146
+ role_name: result.role_name ?? null,
144
147
  },
145
148
  access_token: result.access_token,
146
149
  access_expires_at: result.access_expires_at,
@@ -3,12 +3,14 @@ import { randomUUID } from 'node:crypto';
3
3
  import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
5
5
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
6
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
6
7
  import { incrementSchemaVersion } from '../schema-version.js';
8
+ import { compileCollectionSystemFields, normalizeCollectionSystemFields } from '../system-fields.js';
7
9
  import { withConnectionTransaction } from '../transaction.js';
8
10
  import { BaseService } from './base-service.js';
9
11
  import { assertSchemaManager } from './schema-access.js';
10
12
 
11
- const COLLECTION_METADATA_KEYS = new Set(['note', 'singleton', 'hidden', 'metadata']);
13
+ const COLLECTION_METADATA_KEYS = new Set(['name', 'note', 'singleton', 'hidden', 'metadata']);
12
14
 
13
15
  function assertUserCollectionName(collection) {
14
16
  assertIdentifier(collection, 'collection name');
@@ -46,6 +48,9 @@ function assertCollectionMetadataPatch(patch) {
46
48
  throw invalidSchemaPayload(`Collection ${key} must be a boolean`);
47
49
  }
48
50
  }
51
+ if (Object.hasOwn(patch, 'name')) {
52
+ patch.name = normalizeDisplayName(patch.name);
53
+ }
49
54
  if (Object.hasOwn(patch, 'note') && patch.note != null && typeof patch.note !== 'string') {
50
55
  throw invalidSchemaPayload('Collection note must be a string or null');
51
56
  }
@@ -70,6 +75,7 @@ function assertCollectionCreateMetadata(input) {
70
75
  )) {
71
76
  throw invalidSchemaPayload('Collection metadata must be an object or null');
72
77
  }
78
+ normalizeCollectionSystemFields(input.systemFields);
73
79
  }
74
80
 
75
81
  function temporaryDropName() {
@@ -91,7 +97,13 @@ export class CollectionsService extends BaseService {
91
97
  async createOne(input = {}) {
92
98
  assertSchemaManager(this.accountability);
93
99
  assertCollectionCreateMetadata(input);
94
- const collection = assertUserCollectionName(input.collection);
100
+ const resolvedName = resolveSchemaName({
101
+ displayName: input.name ?? input.collection,
102
+ key: input.collection,
103
+ prefix: 'collection',
104
+ });
105
+ const collection = assertUserCollectionName(resolvedName.key);
106
+ const name = resolvedName.name;
95
107
  const primaryKey = input.primaryKey ?? 'id';
96
108
 
97
109
  if (primaryKey !== 'id') {
@@ -100,6 +112,8 @@ export class CollectionsService extends BaseService {
100
112
  throw error;
101
113
  }
102
114
 
115
+ const systemFields = compileCollectionSystemFields(collection, input.systemFields);
116
+
103
117
  return withAdvisoryLock(this.database, 'yuncms:schema', async (connection) => {
104
118
  const metadata = new SchemaMetadataRepository(connection);
105
119
  const existing = await metadata.readCollection(collection);
@@ -111,11 +125,16 @@ export class CollectionsService extends BaseService {
111
125
 
112
126
  const table = quoteIdentifier(collection, 'collection name');
113
127
  let tableCreated = false;
128
+ const physicalDefinitions = [
129
+ 'id CHAR(36) NOT NULL PRIMARY KEY',
130
+ ...systemFields.columns,
131
+ ...systemFields.constraints,
132
+ ];
114
133
 
115
134
  try {
116
135
  await connection.query(
117
136
  `CREATE TABLE ${table} (
118
- id CHAR(36) NOT NULL PRIMARY KEY
137
+ ${physicalDefinitions.join(',\n ')}
119
138
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
120
139
  );
121
140
  tableCreated = true;
@@ -123,16 +142,21 @@ export class CollectionsService extends BaseService {
123
142
  return await withConnectionTransaction(connection, async () => {
124
143
  const created = await metadata.createCollection({
125
144
  collection,
145
+ name,
126
146
  primaryKey,
127
147
  note: input.note ?? null,
128
148
  singleton: input.singleton === true,
129
149
  hidden: input.hidden === true,
130
- metadata: input.metadata ?? null,
150
+ metadata: {
151
+ ...(input.metadata ?? {}),
152
+ systemFields: systemFields.fields,
153
+ },
131
154
  });
132
155
 
133
156
  await metadata.createField({
134
157
  collection,
135
158
  field: 'id',
159
+ name: 'ID',
136
160
  type: 'uuid',
137
161
  required: true,
138
162
  readonly: true,
@@ -140,6 +164,10 @@ export class CollectionsService extends BaseService {
140
164
  schemaMetadata: { primaryKey: true, length: 36 },
141
165
  });
142
166
 
167
+ for (const systemField of systemFields.metadata) {
168
+ await metadata.createField(systemField);
169
+ }
170
+
143
171
  const schemaVersion = await incrementSchemaVersion(connection);
144
172
  return { ...created, schemaVersion };
145
173
  });
@@ -11,6 +11,7 @@ import { PermissionsService } from './permissions-service.js';
11
11
  import { RelationsService } from './relations-service.js';
12
12
  import { RolesService } from './roles-service.js';
13
13
  import { StudioSettingsService } from './studio-settings-service.js';
14
+ import { SystemCollectionFieldsService } from './system-collection-fields-service.js';
14
15
  import { UsersService } from './users-service.js';
15
16
  import { createServiceRegistry } from './service-registry.js';
16
17
 
@@ -23,6 +24,7 @@ export function createCoreServiceRegistry() {
23
24
  ItemsService,
24
25
  CollectionsService,
25
26
  FieldsService,
27
+ SystemCollectionFieldsService,
26
28
  RelationsService,
27
29
  UsersService,
28
30
  RolesService,
@@ -4,13 +4,22 @@ import { withAdvisoryLock } from '../advisory-lock.js';
4
4
  import { compileFieldColumn } from '../field-types.js';
5
5
  import { assertIdentifier, quoteIdentifier } from '../identifier.js';
6
6
  import { SchemaMetadataRepository } from '../schema-metadata-repository.js';
7
+ import { normalizeDisplayName, resolveSchemaName } from '../schema-key.js';
7
8
  import { incrementSchemaVersion } from '../schema-version.js';
8
9
  import { withConnectionTransaction } from '../transaction.js';
9
10
  import { BaseService } from './base-service.js';
10
11
  import { assertSchemaManager } from './schema-access.js';
11
12
 
12
- const FIELD_METADATA_KEYS = new Set(['readonly', 'hidden', 'sort', 'interface', 'options']);
13
- const FIELD_PHYSICAL_KEYS = new Set(['required', 'defaultValue', 'removeDefault', 'indexed']);
13
+ const FIELD_METADATA_KEYS = new Set(['name', 'readonly', 'hidden', 'sort', 'interface', 'options']);
14
+ const FIELD_PHYSICAL_KEYS = new Set([
15
+ 'required',
16
+ 'defaultValue',
17
+ 'removeDefault',
18
+ 'defaultPreset',
19
+ 'removeDefaultPreset',
20
+ 'autoUpdate',
21
+ 'indexed',
22
+ ]);
14
23
 
15
24
  function assertFieldName(field) {
16
25
  assertIdentifier(field, 'field name');
@@ -18,11 +27,15 @@ function assertFieldName(field) {
18
27
  return field;
19
28
  }
20
29
 
30
+ function invalidSchemaPayload(message) {
31
+ const error = new Error(message);
32
+ error.code = 'INVALID_SCHEMA_PAYLOAD';
33
+ return error;
34
+ }
35
+
21
36
  function assertFieldMetadataPatch(patch) {
22
37
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
23
- const error = new Error('Field metadata patch must be an object');
24
- error.code = 'INVALID_SCHEMA_PAYLOAD';
25
- throw error;
38
+ throw invalidSchemaPayload('Field metadata patch must be an object');
26
39
  }
27
40
  for (const key of Object.keys(patch)) {
28
41
  if (!FIELD_METADATA_KEYS.has(key)) {
@@ -32,23 +45,18 @@ function assertFieldMetadataPatch(patch) {
32
45
  }
33
46
  }
34
47
  if (Object.keys(patch).length === 0) {
35
- const error = new Error('Field metadata patch cannot be empty');
36
- error.code = 'INVALID_SCHEMA_PAYLOAD';
37
- throw error;
48
+ throw invalidSchemaPayload('Field metadata patch cannot be empty');
38
49
  }
50
+ if (Object.hasOwn(patch, 'name')) patch.name = normalizeDisplayName(patch.name);
39
51
  }
40
52
 
41
53
  function assertPhysicalPatch(patch) {
42
54
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
43
- const error = new Error('Physical field patch must be an object');
44
- error.code = 'INVALID_SCHEMA_PAYLOAD';
45
- throw error;
55
+ throw invalidSchemaPayload('Physical field patch must be an object');
46
56
  }
47
57
  const keys = Object.keys(patch);
48
58
  if (keys.length === 0) {
49
- const error = new Error('Physical field patch cannot be empty');
50
- error.code = 'INVALID_SCHEMA_PAYLOAD';
51
- throw error;
59
+ throw invalidSchemaPayload('Physical field patch cannot be empty');
52
60
  }
53
61
  for (const key of keys) {
54
62
  if (!FIELD_PHYSICAL_KEYS.has(key)) {
@@ -58,24 +66,30 @@ function assertPhysicalPatch(patch) {
58
66
  }
59
67
  }
60
68
  if (Object.hasOwn(patch, 'required') && typeof patch.required !== 'boolean') {
61
- const error = new Error('required must be boolean');
62
- error.code = 'INVALID_SCHEMA_PAYLOAD';
63
- throw error;
69
+ throw invalidSchemaPayload('required must be boolean');
64
70
  }
65
71
  if (Object.hasOwn(patch, 'indexed') && typeof patch.indexed !== 'boolean') {
66
- const error = new Error('indexed must be boolean');
67
- error.code = 'INVALID_SCHEMA_PAYLOAD';
68
- throw error;
72
+ throw invalidSchemaPayload('indexed must be boolean');
69
73
  }
70
- if (Object.hasOwn(patch, 'removeDefault') && patch.removeDefault !== true) {
71
- const error = new Error('removeDefault must be true when provided');
72
- error.code = 'INVALID_SCHEMA_PAYLOAD';
73
- throw error;
74
+ if (Object.hasOwn(patch, 'autoUpdate') && typeof patch.autoUpdate !== 'boolean') {
75
+ throw invalidSchemaPayload('autoUpdate must be boolean');
76
+ }
77
+ for (const key of ['removeDefault', 'removeDefaultPreset']) {
78
+ if (Object.hasOwn(patch, key) && patch[key] !== true) {
79
+ throw invalidSchemaPayload(`${key} must be true when provided`);
80
+ }
81
+ }
82
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.defaultPreset !== 'now') {
83
+ throw invalidSchemaPayload('defaultPreset currently supports only now');
84
+ }
85
+ if (Object.hasOwn(patch, 'defaultValue') && Object.hasOwn(patch, 'defaultPreset')) {
86
+ throw invalidSchemaPayload('defaultValue and defaultPreset cannot be used together');
74
87
  }
75
88
  if (Object.hasOwn(patch, 'defaultValue') && patch.removeDefault === true) {
76
- const error = new Error('defaultValue and removeDefault cannot be used together');
77
- error.code = 'INVALID_SCHEMA_PAYLOAD';
78
- throw error;
89
+ throw invalidSchemaPayload('defaultValue and removeDefault cannot be used together');
90
+ }
91
+ if (Object.hasOwn(patch, 'defaultPreset') && patch.removeDefaultPreset === true) {
92
+ throw invalidSchemaPayload('defaultPreset and removeDefaultPreset cannot be used together');
79
93
  }
80
94
  }
81
95
 
@@ -89,15 +103,26 @@ function parseSchemaMetadata(value) {
89
103
  }
90
104
  }
91
105
 
106
+ function assertFieldNotSystemManaged(field) {
107
+ if (parseSchemaMetadata(field?.schema_metadata).systemManaged === true) {
108
+ const error = new Error(`System-managed field cannot be changed directly: ${field.collection}.${field.field}`);
109
+ error.code = 'SYSTEM_SCHEMA_READ_ONLY';
110
+ throw error;
111
+ }
112
+ }
113
+
92
114
  function fieldInputFromMetadata(field, schemaMetadata) {
93
115
  const input = {
94
116
  type: field.type,
95
117
  required: Boolean(field.required),
118
+ interface: field.interface ?? null,
96
119
  };
97
120
  if (schemaMetadata.length !== undefined && field.type === 'string') input.length = schemaMetadata.length;
98
121
  if (schemaMetadata.precision !== undefined && field.type === 'decimal') input.precision = schemaMetadata.precision;
99
122
  if (schemaMetadata.scale !== undefined && field.type === 'decimal') input.scale = schemaMetadata.scale;
100
123
  if (Object.hasOwn(schemaMetadata, 'defaultValue')) input.defaultValue = schemaMetadata.defaultValue;
124
+ if (schemaMetadata.defaultPreset !== undefined) input.defaultPreset = schemaMetadata.defaultPreset;
125
+ if (schemaMetadata.autoUpdate === true) input.autoUpdate = true;
101
126
  return input;
102
127
  }
103
128
 
@@ -127,7 +152,13 @@ export class FieldsService extends BaseService {
127
152
  async createOne(collection, input = {}) {
128
153
  assertSchemaManager(this.accountability);
129
154
  assertIdentifier(collection, 'collection name');
130
- const field = assertFieldName(input.field);
155
+ const resolvedName = resolveSchemaName({
156
+ displayName: input.name ?? input.field,
157
+ key: input.field,
158
+ prefix: 'field',
159
+ });
160
+ const field = assertFieldName(resolvedName.key);
161
+ const name = resolvedName.name;
131
162
 
132
163
  if (field === 'id') {
133
164
  const error = new Error('The id field is created with the collection and cannot be added again');
@@ -173,6 +204,7 @@ export class FieldsService extends BaseService {
173
204
  const created = await metadata.createField({
174
205
  collection,
175
206
  field,
207
+ name,
176
208
  type: input.type,
177
209
  required: input.required === true,
178
210
  readonly: input.readonly === true,
@@ -246,6 +278,7 @@ export class FieldsService extends BaseService {
246
278
  error.code = 'FIELD_NOT_FOUND';
247
279
  throw error;
248
280
  }
281
+ assertFieldNotSystemManaged(existing);
249
282
 
250
283
  return withConnectionTransaction(connection, async () => {
251
284
  const updated = await metadata.updateFieldMetadata(collection, field, patch);
@@ -288,6 +321,7 @@ export class FieldsService extends BaseService {
288
321
  error.code = 'FIELD_NOT_FOUND';
289
322
  throw error;
290
323
  }
324
+ assertFieldNotSystemManaged(existing);
291
325
 
292
326
  const relation = relations.find((candidate) =>
293
327
  candidate.many_collection === collection && candidate.many_field === field);
@@ -301,8 +335,18 @@ export class FieldsService extends BaseService {
301
335
  const currentMetadata = parseSchemaMetadata(existing.schema_metadata);
302
336
  const currentInput = fieldInputFromMetadata(existing, currentMetadata);
303
337
  const nextInput = { ...currentInput, required: nextRequired };
338
+
304
339
  if (patch.removeDefault === true) delete nextInput.defaultValue;
305
- else if (Object.hasOwn(patch, 'defaultValue')) nextInput.defaultValue = patch.defaultValue;
340
+ if (patch.removeDefaultPreset === true) delete nextInput.defaultPreset;
341
+ if (Object.hasOwn(patch, 'defaultValue')) {
342
+ nextInput.defaultValue = patch.defaultValue;
343
+ delete nextInput.defaultPreset;
344
+ }
345
+ if (Object.hasOwn(patch, 'defaultPreset')) {
346
+ nextInput.defaultPreset = patch.defaultPreset;
347
+ delete nextInput.defaultValue;
348
+ }
349
+ if (Object.hasOwn(patch, 'autoUpdate')) nextInput.autoUpdate = patch.autoUpdate;
306
350
 
307
351
  const currentCompiled = compileFieldColumn(currentInput);
308
352
  const nextCompiled = compileFieldColumn(nextInput);
@@ -410,6 +454,7 @@ export class FieldsService extends BaseService {
410
454
  error.code = 'FIELD_NOT_FOUND';
411
455
  throw error;
412
456
  }
457
+ assertFieldNotSystemManaged(existing);
413
458
 
414
459
  const blockingRelation = relations.find((relation) =>
415
460
  (relation.many_collection === collection && relation.many_field === field) ||
@@ -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 fileError(code, message) {
6
7
  const error = new Error(message);
@@ -8,11 +9,6 @@ function fileError(code, message) {
8
9
  return error;
9
10
  }
10
11
 
11
- function assertFileManager(accountability) {
12
- if (accountability.admin === true || accountability.system === true) return;
13
- throw fileError('FORBIDDEN', 'File management requires administrator accountability in V1');
14
- }
15
-
16
12
  function normalizeFilename(value) {
17
13
  if (typeof value !== 'string') throw fileError('INVALID_PAYLOAD', 'Download filename is required');
18
14
  const filename = value.trim();
@@ -60,28 +56,32 @@ export class FilesService extends BaseService {
60
56
  });
61
57
  }
62
58
 
63
- async readMany() {
64
- assertFileManager(this.accountability);
59
+ async #readOneUnsafe(id) {
65
60
  const [rows] = await this.database.query(
66
61
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
67
62
  uploaded_by, uploaded_at, metadata
68
63
  FROM yuncms_files
69
- ORDER BY uploaded_at DESC, id DESC`,
64
+ WHERE id = ?
65
+ LIMIT 1`,
66
+ [id],
70
67
  );
71
- return rows.map(normalizeRow);
68
+ return normalizeRow(rows[0]);
72
69
  }
73
70
 
74
- async readOne(id) {
75
- assertFileManager(this.accountability);
71
+ async readMany() {
72
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
76
73
  const [rows] = await this.database.query(
77
74
  `SELECT id, storage, filename_disk, filename_download, title, mimetype, filesize,
78
75
  uploaded_by, uploaded_at, metadata
79
76
  FROM yuncms_files
80
- WHERE id = ?
81
- LIMIT 1`,
82
- [id],
77
+ ORDER BY uploaded_at DESC, id DESC`,
83
78
  );
84
- return normalizeRow(rows[0]);
79
+ return rows.map(normalizeRow);
80
+ }
81
+
82
+ async readOne(id) {
83
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
84
+ return this.#readOneUnsafe(id);
85
85
  }
86
86
 
87
87
  async createOne({
@@ -92,7 +92,7 @@ export class FilesService extends BaseService {
92
92
  storage = 'local',
93
93
  metadata = null,
94
94
  } = {}) {
95
- assertFileManager(this.accountability);
95
+ await resolveSystemResourceAccess(this, 'create', 'yuncms_files');
96
96
  if (!Buffer.isBuffer(contents) && !(contents instanceof Uint8Array)) {
97
97
  throw fileError('INVALID_FILE_CONTENT', 'File contents must be Buffer or Uint8Array');
98
98
  }
@@ -124,7 +124,7 @@ export class FilesService extends BaseService {
124
124
  metadata == null ? null : JSON.stringify(metadata),
125
125
  ],
126
126
  );
127
- const file = await this.readOne(id);
127
+ const file = await this.#readOneUnsafe(id);
128
128
  await this.action('files.create', {
129
129
  key: id,
130
130
  item: file,
@@ -144,7 +144,8 @@ export class FilesService extends BaseService {
144
144
  }
145
145
 
146
146
  async readContent(id) {
147
- const file = await this.readOne(id);
147
+ await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
148
+ const file = await this.#readOneUnsafe(id);
148
149
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
149
150
  const driver = this.storage.get(file.storage);
150
151
  const contents = await driver.get(file.filename_disk);
@@ -152,7 +153,7 @@ export class FilesService extends BaseService {
152
153
  }
153
154
 
154
155
  async updateOne(id, patch = {}) {
155
- assertFileManager(this.accountability);
156
+ await resolveSystemResourceAccess(this, 'update', 'yuncms_files');
156
157
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
157
158
  throw fileError('INVALID_PAYLOAD', 'File metadata patch must be an object');
158
159
  }
@@ -161,7 +162,7 @@ export class FilesService extends BaseService {
161
162
  throw fileError('INVALID_PAYLOAD', 'File update supports filenameDownload, title and metadata only');
162
163
  }
163
164
 
164
- const before = await this.readOne(id);
165
+ const before = await this.#readOneUnsafe(id);
165
166
  if (!before) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
166
167
 
167
168
  const assignments = [];
@@ -185,7 +186,7 @@ export class FilesService extends BaseService {
185
186
  params,
186
187
  );
187
188
  if (result.affectedRows !== 1) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
188
- const file = await this.readOne(id);
189
+ const file = await this.#readOneUnsafe(id);
189
190
  await this.action('files.update', {
190
191
  key: id,
191
192
  before,
@@ -196,8 +197,8 @@ export class FilesService extends BaseService {
196
197
  }
197
198
 
198
199
  async deleteOne(id) {
199
- assertFileManager(this.accountability);
200
- const file = await this.readOne(id);
200
+ await resolveSystemResourceAccess(this, 'delete', 'yuncms_files');
201
+ const file = await this.#readOneUnsafe(id);
201
202
  if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
202
203
 
203
204
  const [result] = await this.database.query('DELETE FROM yuncms_files WHERE id = ?', [id]);