@yunsoft/yuncms-core 0.1.3 → 0.1.6

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.
@@ -38,6 +38,16 @@ function publicUser(user) {
38
38
  }
39
39
 
40
40
  export class AuthService extends BaseService {
41
+ async action(event, payload, context = {}) {
42
+ if (!this.emitter) return;
43
+ await this.emitter.action(event, payload, {
44
+ accountability: this.accountability,
45
+ requestId: this.requestId,
46
+ collection: 'yuncms_users',
47
+ ...context,
48
+ });
49
+ }
50
+
41
51
  createSessionsService() {
42
52
  return new SessionsService({
43
53
  accountability: this.accountability,
@@ -78,10 +88,19 @@ export class AuthService extends BaseService {
78
88
  );
79
89
 
80
90
  if (!user || !passwordMatches || user.status !== 'active') {
91
+ await this.action('auth.login.failed', {
92
+ method: 'local',
93
+ reason: 'invalid_credentials',
94
+ }, { ip });
81
95
  throw invalidLogin();
82
96
  }
83
97
 
84
98
  const tokens = await this.createSessionsService().createForUser(user, { ip, userAgent });
99
+ await this.action('auth.login.success', {
100
+ method: 'local',
101
+ user: user.id,
102
+ role: user.role ?? null,
103
+ }, { ip });
85
104
  return {
86
105
  user: publicUser(user),
87
106
  ...tokens,
@@ -138,6 +157,11 @@ export class AuthService extends BaseService {
138
157
 
139
158
  async refresh(refreshToken) {
140
159
  const result = await this.createSessionsService().rotateRefreshToken(refreshToken);
160
+ await this.action('auth.refresh.success', {
161
+ method: 'session',
162
+ user: result.user,
163
+ role: result.role ?? null,
164
+ });
141
165
  return {
142
166
  user: {
143
167
  id: result.user,
@@ -153,7 +177,14 @@ export class AuthService extends BaseService {
153
177
  }
154
178
 
155
179
  async logout(accessToken) {
156
- return this.createSessionsService().revokeByAccessToken(accessToken);
180
+ const revoked = await this.createSessionsService().revokeByAccessToken(accessToken);
181
+ await this.action('auth.logout', {
182
+ method: 'session',
183
+ user: this.accountability.user ?? null,
184
+ all: false,
185
+ revoked: Boolean(revoked),
186
+ });
187
+ return revoked;
157
188
  }
158
189
 
159
190
  async logoutAll() {
@@ -162,6 +193,13 @@ export class AuthService extends BaseService {
162
193
  error.code = 'UNAUTHORIZED';
163
194
  throw error;
164
195
  }
165
- return this.createSessionsService().revokeAllForUser(this.accountability.user);
196
+ const revoked = await this.createSessionsService().revokeAllForUser(this.accountability.user);
197
+ await this.action('auth.logout', {
198
+ method: 'session',
199
+ user: this.accountability.user,
200
+ all: true,
201
+ revoked: Number(revoked ?? 0),
202
+ });
203
+ return revoked;
166
204
  }
167
205
  }
@@ -3,6 +3,7 @@ import { AuditService } from './audit-service.js';
3
3
  import { AuthService } from './auth-service.js';
4
4
  import { AuthTokensService } from './auth-tokens-service.js';
5
5
  import { CollectionsService } from './collections-service.js';
6
+ import { ExternalAuthService } from './external-auth-service.js';
6
7
  import { FieldsService } from './fields-service.js';
7
8
  import { FileReconciliationService } from './file-reconciliation-service.js';
8
9
  import { FilesService } from './files-service.js';
@@ -19,6 +20,7 @@ export function createCoreServiceRegistry() {
19
20
  return createServiceRegistry({
20
21
  AuthService,
21
22
  AuthTokensService,
23
+ ExternalAuthService,
22
24
  ApiTokensService,
23
25
  AuditService,
24
26
  ItemsService,
@@ -0,0 +1,323 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import {
4
+ assertLocalRedirectTarget,
5
+ decryptExternalAuthSecret,
6
+ encryptExternalAuthSecret,
7
+ hashExternalAuthState,
8
+ } from '../auth/external-state.js';
9
+ import { withTransaction } from '../transaction.js';
10
+ import { BaseService } from './base-service.js';
11
+ import { SessionsService } from './sessions-service.js';
12
+
13
+ const AUTH_TRANSACTION_TTL_MS = 5 * 60 * 1000;
14
+
15
+ function externalAuthError(code, message) {
16
+ const error = new Error(message);
17
+ error.code = code;
18
+ return error;
19
+ }
20
+
21
+ function normalizeProvider(value) {
22
+ const provider = String(value ?? '').trim();
23
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(provider)) {
24
+ throw externalAuthError('INVALID_AUTH_PROVIDER', 'External auth provider id is invalid');
25
+ }
26
+ return provider;
27
+ }
28
+
29
+ function normalizeSubject(value) {
30
+ const subject = String(value ?? '').trim();
31
+ if (!subject || subject.length > 255 || /[\r\n\0]/.test(subject)) {
32
+ throw externalAuthError('INVALID_EXTERNAL_IDENTITY', 'External identity subject is invalid');
33
+ }
34
+ return subject;
35
+ }
36
+
37
+ function normalizeEmail(value) {
38
+ if (value == null || value === '') return null;
39
+ const email = String(value).trim().toLowerCase();
40
+ if (!email || email.length > 191 || !email.includes('@') || /[\r\n\0]/.test(email)) return null;
41
+ return email;
42
+ }
43
+
44
+ function safeProfile(profile) {
45
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null;
46
+ const safe = {};
47
+ for (const key of ['name', 'given_name', 'family_name', 'preferred_username', 'picture', 'groups']) {
48
+ if (!Object.hasOwn(profile, key)) continue;
49
+ const value = profile[key];
50
+ if (typeof value === 'string' && value.length <= 1024) safe[key] = value;
51
+ else if (Array.isArray(value) && value.length <= 100) safe[key] = value.filter((entry) => typeof entry === 'string').slice(0, 100);
52
+ }
53
+ return Object.keys(safe).length ? safe : null;
54
+ }
55
+
56
+ function publicUser(user) {
57
+ return {
58
+ id: user.id,
59
+ email: user.email,
60
+ role: user.role ?? null,
61
+ role_name: user.role_name ?? null,
62
+ status: user.status,
63
+ email_verified_at: user.email_verified_at ?? null,
64
+ };
65
+ }
66
+
67
+ function isDuplicateEntry(error) {
68
+ return error?.code === 'ER_DUP_ENTRY' || error?.errno === 1062;
69
+ }
70
+
71
+ export class ExternalAuthService extends BaseService {
72
+ constructor(options = {}) {
73
+ super(options);
74
+ this.stateSecret = options.stateSecret;
75
+ }
76
+
77
+ async action(event, payload, context = {}) {
78
+ if (!this.emitter) return;
79
+ await this.emitter.action(event, payload, {
80
+ accountability: this.accountability,
81
+ requestId: this.requestId,
82
+ ...context,
83
+ });
84
+ }
85
+
86
+ async beginTransaction({ provider, state, secret = null, redirectTarget = '/', metadata = null, ttlMs = AUTH_TRANSACTION_TTL_MS } = {}) {
87
+ const providerId = normalizeProvider(provider);
88
+ const stateHash = hashExternalAuthState(state);
89
+ const redirect = assertLocalRedirectTarget(redirectTarget);
90
+ if (!Number.isInteger(ttlMs) || ttlMs < 30_000 || ttlMs > 15 * 60_000) {
91
+ throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External auth transaction TTL must be between 30 seconds and 15 minutes');
92
+ }
93
+ const expiresAt = new Date(Date.now() + ttlMs);
94
+ const id = randomUUID();
95
+ const encrypted = secret == null ? null : encryptExternalAuthSecret(this.stateSecret, secret);
96
+ const safeMetadata = metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? metadata : null;
97
+
98
+ await this.database.query(
99
+ `INSERT INTO yuncms_auth_transactions
100
+ (id, provider, state_hash, secret_ciphertext, redirect_target, metadata, expires_at)
101
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
102
+ [id, providerId, stateHash, encrypted, redirect, safeMetadata == null ? null : JSON.stringify(safeMetadata), expiresAt],
103
+ );
104
+ return { id, provider: providerId, redirectTarget: redirect, expiresAt };
105
+ }
106
+
107
+ async consumeTransaction({ provider, state } = {}) {
108
+ const providerId = normalizeProvider(provider);
109
+ const stateHash = hashExternalAuthState(state);
110
+
111
+ return withTransaction(this.database, async (connection) => {
112
+ const [rows] = await connection.query(
113
+ `SELECT id, provider, secret_ciphertext, redirect_target, metadata, expires_at, used_at
114
+ FROM yuncms_auth_transactions
115
+ WHERE provider = ? AND state_hash = ?
116
+ LIMIT 1 FOR UPDATE`,
117
+ [providerId, stateHash],
118
+ );
119
+ const transaction = rows[0];
120
+ if (!transaction || transaction.used_at || new Date(transaction.expires_at).getTime() <= Date.now()) {
121
+ throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External authentication transaction is invalid or expired');
122
+ }
123
+ const [result] = await connection.query(
124
+ `UPDATE yuncms_auth_transactions
125
+ SET used_at = CURRENT_TIMESTAMP(3)
126
+ WHERE id = ? AND used_at IS NULL`,
127
+ [transaction.id],
128
+ );
129
+ if (result.affectedRows !== 1) throw externalAuthError('INVALID_AUTH_TRANSACTION', 'External authentication transaction was already consumed');
130
+
131
+ let metadata = transaction.metadata;
132
+ if (typeof metadata === 'string') {
133
+ try { metadata = JSON.parse(metadata); } catch { metadata = null; }
134
+ }
135
+ return {
136
+ id: transaction.id,
137
+ provider: transaction.provider,
138
+ redirectTarget: assertLocalRedirectTarget(transaction.redirect_target),
139
+ metadata: metadata && typeof metadata === 'object' ? metadata : null,
140
+ secret: transaction.secret_ciphertext == null ? null : decryptExternalAuthSecret(this.stateSecret, transaction.secret_ciphertext),
141
+ };
142
+ });
143
+ }
144
+
145
+ async cleanupTransactions({ batchSize = 1000 } = {}) {
146
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 5000) throw new Error('Invalid auth transaction cleanup batch size');
147
+ const [rows] = await this.database.query(
148
+ `SELECT id FROM yuncms_auth_transactions
149
+ WHERE expires_at < CURRENT_TIMESTAMP(3) OR used_at IS NOT NULL
150
+ ORDER BY created_at ASC
151
+ LIMIT ?`,
152
+ [batchSize],
153
+ );
154
+ if (rows.length === 0) return 0;
155
+ const placeholders = rows.map(() => '?').join(', ');
156
+ const [result] = await this.database.query(
157
+ `DELETE FROM yuncms_auth_transactions WHERE id IN (${placeholders})`,
158
+ rows.map((row) => row.id),
159
+ );
160
+ return result.affectedRows;
161
+ }
162
+
163
+ async readIdentity(provider, subject, database = this.database) {
164
+ const [rows] = await database.query(
165
+ `SELECT i.id AS identity_id, i.provider, i.subject, i.user, i.email AS identity_email,
166
+ u.id, u.email, u.role, u.status, u.email_verified_at,
167
+ r.name AS role_name, r.admin AS role_admin, r.public AS role_public
168
+ FROM yuncms_auth_identities i
169
+ INNER JOIN yuncms_users u ON u.id = i.user
170
+ LEFT JOIN yuncms_roles r ON r.id = u.role
171
+ WHERE i.provider = ? AND i.subject = ?
172
+ LIMIT 1`,
173
+ [normalizeProvider(provider), normalizeSubject(subject)],
174
+ );
175
+ return rows[0] ?? null;
176
+ }
177
+
178
+ async resolveJitRole(roleId, database) {
179
+ if (!roleId) throw externalAuthError('EXTERNAL_JIT_ROLE_REQUIRED', 'JIT external authentication requires a default role');
180
+ const [rows] = await database.query(
181
+ 'SELECT id, name, admin, public FROM yuncms_roles WHERE id = ? LIMIT 1',
182
+ [String(roleId)],
183
+ );
184
+ const role = rows[0];
185
+ if (!role || role.admin || role.public) {
186
+ throw externalAuthError('INVALID_EXTERNAL_JIT_ROLE', 'External JIT role must be an existing non-admin, non-public role');
187
+ }
188
+ return role;
189
+ }
190
+
191
+ async #createOrLinkIdentity({ providerId, subjectId, normalizedEmail, emailVerified, sanitizedProfile, policy }) {
192
+ if (!normalizedEmail || emailVerified !== true) {
193
+ throw externalAuthError('VERIFIED_EXTERNAL_EMAIL_REQUIRED', 'A verified external email is required to create or link a YunCMS user');
194
+ }
195
+
196
+ try {
197
+ return await withTransaction(this.database, async (connection) => {
198
+ const existingIdentity = await this.readIdentity(providerId, subjectId, connection);
199
+ if (existingIdentity) return existingIdentity.user;
200
+
201
+ const [emailRows] = await connection.query(
202
+ `SELECT u.id, u.email, u.role, u.status, u.email_verified_at,
203
+ r.name AS role_name, r.admin AS role_admin, r.public AS role_public
204
+ FROM yuncms_users u
205
+ LEFT JOIN yuncms_roles r ON r.id = u.role
206
+ WHERE u.email = ? LIMIT 1 FOR UPDATE`,
207
+ [normalizedEmail],
208
+ );
209
+ const existingUser = emailRows[0] ?? null;
210
+ let localUserId;
211
+
212
+ if (existingUser) {
213
+ if (policy.linkByVerifiedEmail !== true) {
214
+ throw externalAuthError('EXTERNAL_EMAIL_CONFLICT', 'A YunCMS user already exists for this email and automatic linking is disabled');
215
+ }
216
+ if (existingUser.status !== 'active') throw externalAuthError('EXTERNAL_USER_INACTIVE', 'Linked YunCMS user is not active');
217
+ if (existingUser.role_admin && policy.allowAdminLink !== true) {
218
+ throw externalAuthError('EXTERNAL_ADMIN_LINK_FORBIDDEN', 'Automatic external identity linking to administrator users is disabled');
219
+ }
220
+ localUserId = existingUser.id;
221
+ } else {
222
+ if (policy.jit !== true) throw externalAuthError('EXTERNAL_IDENTITY_NOT_LINKED', 'External identity is not linked to a YunCMS user');
223
+ const role = await this.resolveJitRole(policy.defaultRole, connection);
224
+ localUserId = randomUUID();
225
+ await connection.query(
226
+ `INSERT INTO yuncms_users
227
+ (id, email, password_hash, role, status, email_verified_at)
228
+ VALUES (?, ?, NULL, ?, 'active', CURRENT_TIMESTAMP(3))`,
229
+ [localUserId, normalizedEmail, role.id],
230
+ );
231
+ }
232
+
233
+ await connection.query(
234
+ `INSERT INTO yuncms_auth_identities
235
+ (id, provider, subject, user, email, profile, last_login_at)
236
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP(3))`,
237
+ [randomUUID(), providerId, subjectId, localUserId, normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile)],
238
+ );
239
+ return localUserId;
240
+ });
241
+ } catch (error) {
242
+ if (!isDuplicateEntry(error)) throw error;
243
+ const identity = await this.readIdentity(providerId, subjectId);
244
+ if (!identity) throw error;
245
+ return identity.user;
246
+ }
247
+ }
248
+
249
+ async completeLogin({
250
+ provider,
251
+ subject,
252
+ email = null,
253
+ emailVerified = false,
254
+ profile = null,
255
+ policy = {},
256
+ ip = null,
257
+ userAgent = null,
258
+ } = {}) {
259
+ const providerId = normalizeProvider(provider);
260
+ const subjectId = normalizeSubject(subject);
261
+ const normalizedEmail = normalizeEmail(email);
262
+ const sanitizedProfile = safeProfile(profile);
263
+ let user = await this.readIdentity(providerId, subjectId);
264
+
265
+ if (!user) {
266
+ if (policy.jit !== true && policy.linkByVerifiedEmail !== true) {
267
+ throw externalAuthError('EXTERNAL_IDENTITY_NOT_LINKED', 'External identity is not linked to a YunCMS user');
268
+ }
269
+ const userId = await this.#createOrLinkIdentity({
270
+ providerId,
271
+ subjectId,
272
+ normalizedEmail,
273
+ emailVerified,
274
+ sanitizedProfile,
275
+ policy,
276
+ });
277
+ const [userRows] = await this.database.query(
278
+ `SELECT u.id, u.email, u.role, u.status, u.email_verified_at,
279
+ r.name AS role_name, r.admin AS role_admin, r.public AS role_public
280
+ FROM yuncms_users u
281
+ LEFT JOIN yuncms_roles r ON r.id = u.role
282
+ WHERE u.id = ? LIMIT 1`,
283
+ [userId],
284
+ );
285
+ user = userRows[0] ?? null;
286
+ } else {
287
+ await this.database.query(
288
+ `UPDATE yuncms_auth_identities
289
+ SET email = COALESCE(?, email), profile = COALESCE(?, profile), last_login_at = CURRENT_TIMESTAMP(3)
290
+ WHERE id = ?`,
291
+ [normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile), user.identity_id],
292
+ );
293
+ }
294
+
295
+ if (!user || user.status !== 'active') throw externalAuthError('EXTERNAL_USER_INACTIVE', 'Linked YunCMS user is not active');
296
+ const sessions = new SessionsService({
297
+ accountability: this.accountability,
298
+ database: this.database,
299
+ schema: this.schema,
300
+ emitter: this.emitter,
301
+ logger: this.logger,
302
+ requestId: this.requestId,
303
+ });
304
+ const tokens = await sessions.createForUser(user, { ip, userAgent });
305
+ await this.action('auth.login.success', {
306
+ method: 'external',
307
+ provider: providerId,
308
+ user: user.id,
309
+ role: user.role ?? null,
310
+ });
311
+ return { user: publicUser(user), ...tokens };
312
+ }
313
+
314
+ async loginFailed(provider, reason = 'external_auth_failed') {
315
+ await this.action('auth.login.failed', {
316
+ method: 'external',
317
+ provider: normalizeProvider(provider),
318
+ reason: String(reason).slice(0, 100),
319
+ });
320
+ }
321
+ }
322
+
323
+ export { AUTH_TRANSACTION_TTL_MS };
@@ -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.#readOneUnsafe(id);
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.#readOneUnsafe(id);
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 { normalizeFilename, normalizeMimeType };
301
+ export {
302
+ assertMimeSignature,
303
+ hasKnownMimeSignature,
304
+ normalizeFilename,
305
+ normalizeMimeType,
306
+ };