@yunsoft/yuncms-core 0.1.26 → 0.1.27

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-core",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Core services, schema, authentication, permissions and storage primitives for YunCMS.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/bootstrap.js CHANGED
@@ -21,6 +21,7 @@ import { publicRegistrationSettingsMigration } from './migrations/0018-public-re
21
21
  import { publicRegistrationEmailVerificationMigration } from './migrations/0019-public-registration-email-verification.js';
22
22
  import { studioLocalesMigration } from './migrations/0020-studio-locales.js';
23
23
  import { aiAutomationsMigration } from './migrations/0021-ai-automations.js';
24
+ import { utcAuthDeadlinesMigration } from './migrations/0022-utc-auth-deadlines.js';
24
25
  import { readSchemaVersion } from './schema-version.js';
25
26
  import { ensurePublicRole } from './setup.js';
26
27
 
@@ -46,6 +47,7 @@ export const CORE_MIGRATIONS = Object.freeze([
46
47
  publicRegistrationEmailVerificationMigration,
47
48
  studioLocalesMigration,
48
49
  aiAutomationsMigration,
50
+ utcAuthDeadlinesMigration,
49
51
  ]);
50
52
 
51
53
  export const REQUIRED_CORE_MIGRATION_IDS = Object.freeze(
package/src/database.js CHANGED
@@ -3,12 +3,14 @@ import mysql from 'mysql2/promise';
3
3
  export function createDatabasePool(config) {
4
4
  if (!config) throw new Error('Database config is required');
5
5
 
6
- return mysql.createPool({
6
+ const pool = mysql.createPool({
7
7
  host: config.host,
8
8
  port: config.port,
9
9
  database: config.database,
10
10
  user: config.user,
11
11
  password: config.password,
12
+ timezone: 'Z',
13
+ dateStrings: ['DATE'],
12
14
  waitForConnections: true,
13
15
  connectionLimit: config.connectionLimit ?? 10,
14
16
  queueLimit: 0,
@@ -19,6 +21,16 @@ export function createDatabasePool(config) {
19
21
  multipleStatements: false,
20
22
  ssl: config.ssl ? { minVersion: 'TLSv1.2' } : undefined,
21
23
  });
24
+
25
+ pool.on('connection', (connection) => {
26
+ connection.query("SET SESSION time_zone = '+00:00'", (err) => {
27
+ if (err) {
28
+ connection.destroy(err);
29
+ }
30
+ });
31
+ });
32
+
33
+ return pool;
22
34
  }
23
35
 
24
36
  export async function pingDatabase(pool) {
package/src/index.js CHANGED
@@ -23,6 +23,9 @@ export { withDatabaseRetry } from './retry.js';
23
23
  export {
24
24
  MAINTENANCE_BYPASS_ENV,
25
25
  maintenanceLockPath,
26
+ legacyMaintenanceLockPath,
27
+ probeLegacyMaintenanceLock,
28
+ assertSecureUserParentDir,
26
29
  hashMaintenanceBypassToken,
27
30
  assertMaintenanceStartupAllowed,
28
31
  } from './maintenance-state.js';
@@ -1,8 +1,8 @@
1
1
  import { createHash, timingSafeEqual } from 'node:crypto';
2
2
  import { realpathSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { join, resolve } from 'node:path';
3
+ import { lstat, readFile } from 'node:fs/promises';
4
+ import { tmpdir, userInfo } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
 
7
7
  export const MAINTENANCE_BYPASS_ENV = 'YUNCMS_MAINTENANCE_BYPASS_TOKEN';
8
8
 
@@ -19,9 +19,48 @@ export function canonicalProjectPath(cwd = process.cwd()) {
19
19
  }
20
20
  }
21
21
 
22
- export function maintenanceLockPath(cwd = process.cwd()) {
22
+ export function resolveUserIdentity({
23
+ userInfoFn = userInfo,
24
+ getuidFn = typeof process.getuid === 'function' ? () => process.getuid() : null,
25
+ } = {}) {
26
+ if (typeof getuidFn === 'function') {
27
+ const rawUid = getuidFn();
28
+ if (Number.isSafeInteger(rawUid) && rawUid >= 0) {
29
+ return String(rawUid);
30
+ }
31
+ const err = new Error(`Operating system user identity is unavailable or invalid: ${rawUid}`);
32
+ err.code = 'OS_USER_IDENTITY_UNAVAILABLE';
33
+ throw err;
34
+ }
35
+ let info;
36
+ try {
37
+ info = userInfoFn();
38
+ } catch (error) {
39
+ const err = new Error('Operating system user identity is unavailable');
40
+ err.code = 'OS_USER_IDENTITY_UNAVAILABLE';
41
+ err.cause = error;
42
+ throw err;
43
+ }
44
+ const name = info?.username;
45
+ if (!name || typeof name !== 'string') {
46
+ const err = new Error('Operating system user identity is unavailable');
47
+ err.code = 'OS_USER_IDENTITY_UNAVAILABLE';
48
+ throw err;
49
+ }
50
+ return sha256(name).slice(0, 32);
51
+ }
52
+
53
+ export function maintenanceLockPath(cwd = process.cwd(), options = {}) {
54
+ const projectKey = sha256(canonicalProjectPath(cwd)).slice(0, 32);
55
+ const tempRoot = options?.tempRoot ?? tmpdir();
56
+ const identity = resolveUserIdentity(options);
57
+ return join(tempRoot, `yuncms-update-locks-${identity}`, `${projectKey}.lock`);
58
+ }
59
+
60
+ export function legacyMaintenanceLockPath(cwd = process.cwd(), options = {}) {
23
61
  const projectKey = sha256(canonicalProjectPath(cwd)).slice(0, 32);
24
- return join(tmpdir(), 'yuncms-update-locks', `${projectKey}.lock`);
62
+ const tempRoot = options?.tempRoot ?? tmpdir();
63
+ return join(tempRoot, 'yuncms-update-locks', `${projectKey}.lock`);
25
64
  }
26
65
 
27
66
  export function hashMaintenanceBypassToken(token) {
@@ -41,6 +80,66 @@ function hashesEqual(left, right) {
41
80
  return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
42
81
  }
43
82
 
83
+ export async function assertSecureUserParentDir(parentDir, {
84
+ lstatFn = lstat,
85
+ getuidFn = typeof process.getuid === 'function' ? () => process.getuid() : null,
86
+ } = {}) {
87
+ let st;
88
+ try {
89
+ st = await lstatFn(parentDir);
90
+ } catch (error) {
91
+ if (error?.code === 'ENOENT') return;
92
+ throw error;
93
+ }
94
+
95
+ const isSymlink = typeof st.isSymbolicLink === 'function' ? st.isSymbolicLink() : false;
96
+ if (isSymlink) {
97
+ const error = new Error(`YunCMS maintenance directory is an insecure symbolic link: ${parentDir}`);
98
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
99
+ error.parentDir = parentDir;
100
+ throw error;
101
+ }
102
+
103
+ const isDir = typeof st.isDirectory === 'function' ? st.isDirectory() : false;
104
+ if (!isDir) {
105
+ const error = new Error(`YunCMS maintenance path is not a directory: ${parentDir}`);
106
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
107
+ error.parentDir = parentDir;
108
+ throw error;
109
+ }
110
+
111
+ if (typeof getuidFn === 'function') {
112
+ const raw = getuidFn();
113
+ if (!Number.isSafeInteger(raw) || raw < 0) {
114
+ const error = new Error(`Operating system user identity is invalid: ${raw}`);
115
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
116
+ error.parentDir = parentDir;
117
+ throw error;
118
+ }
119
+ const currentUid = raw;
120
+
121
+ if (typeof st.uid === 'number' && st.uid !== currentUid) {
122
+ const error = new Error(
123
+ `YunCMS maintenance directory is not owned by current user (owner UID ${st.uid}, current UID ${currentUid}): ${parentDir}`,
124
+ );
125
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
126
+ error.parentDir = parentDir;
127
+ throw error;
128
+ }
129
+
130
+ const mode = typeof st.mode === 'number' ? st.mode : 0;
131
+ const isPrivate = (mode & 0o077) === 0;
132
+ if (!isPrivate) {
133
+ const error = new Error(
134
+ `YunCMS maintenance directory has insecure permissions (expected 0700): ${parentDir}`,
135
+ );
136
+ error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
137
+ error.parentDir = parentDir;
138
+ throw error;
139
+ }
140
+ }
141
+ }
142
+
44
143
  async function readMaintenanceState(path, readFileFn) {
45
144
  let text;
46
145
  try {
@@ -63,27 +162,130 @@ async function readMaintenanceState(path, readFileFn) {
63
162
  }
64
163
  }
65
164
 
66
- export async function assertMaintenanceStartupAllowed({
165
+ export async function probeLegacyMaintenanceLock({
67
166
  cwd = process.cwd(),
68
- env = process.env,
167
+ tempRoot,
69
168
  readFileFn = readFile,
169
+ lstatFn = lstat,
170
+ getuidFn = typeof process.getuid === 'function' ? () => process.getuid() : null,
70
171
  } = {}) {
71
- const path = maintenanceLockPath(cwd);
72
- const state = await readMaintenanceState(path, readFileFn);
73
- if (!state) return true;
172
+ const legacyPath = legacyMaintenanceLockPath(cwd, { tempRoot });
173
+ let state;
174
+ try {
175
+ state = await readMaintenanceState(legacyPath, readFileFn);
176
+ } catch (error) {
177
+ const causeCode = error?.cause?.code || error?.code;
178
+ if (causeCode === 'EACCES' || causeCode === 'EPERM') {
179
+ const legacyParent = dirname(legacyPath);
180
+ let st;
181
+ try {
182
+ st = await lstatFn(legacyParent);
183
+ } catch (statError) {
184
+ const failClosedError = new Error(
185
+ `YunCMS legacy maintenance directory is inaccessible: ${legacyParent}`,
186
+ );
187
+ failClosedError.code = 'YUNCMS_MAINTENANCE_ACTIVE';
188
+ failClosedError.lockPath = legacyPath;
189
+ failClosedError.cause = error;
190
+ throw failClosedError;
191
+ }
192
+
193
+ let currentUid = null;
194
+ if (typeof getuidFn === 'function') {
195
+ const raw = getuidFn();
196
+ if (Number.isSafeInteger(raw) && raw >= 0) {
197
+ currentUid = raw;
198
+ } else {
199
+ const failClosedError = new Error(
200
+ `YunCMS legacy maintenance directory has invalid user identity: ${raw}`,
201
+ );
202
+ failClosedError.code = 'YUNCMS_MAINTENANCE_ACTIVE';
203
+ failClosedError.lockPath = legacyPath;
204
+ throw failClosedError;
205
+ }
206
+ }
207
+
208
+ const isSymlink = typeof st.isSymbolicLink === 'function' ? st.isSymbolicLink() : false;
209
+ const isDir = typeof st.isDirectory === 'function' ? st.isDirectory() : false;
210
+ const parentUid = st.uid;
211
+ const mode = typeof st.mode === 'number' ? st.mode : 0;
212
+ const hasGroupOrWorldTraversal = (mode & 0o011) !== 0;
213
+ const isPrivateToOwner = (mode & 0o077) === 0;
214
+ const isForeignUid = typeof parentUid === 'number' && currentUid !== null && parentUid !== currentUid;
215
+
216
+ if (!isSymlink && isDir && isForeignUid && isPrivateToOwner && !hasGroupOrWorldTraversal) {
217
+ // Non-symlink private directory owned by another UID with no group/world traversal. Safe to ignore.
218
+ return null;
219
+ }
220
+
221
+ // Own, unknown owner, symlink, or insecure permissions fail-closed
222
+ const failClosedError = new Error(
223
+ `YunCMS legacy maintenance lock is inaccessible or insecure: ${legacyPath}`,
224
+ );
225
+ failClosedError.code = 'YUNCMS_MAINTENANCE_ACTIVE';
226
+ failClosedError.lockPath = legacyPath;
227
+ failClosedError.cause = error;
228
+ throw failClosedError;
229
+ }
230
+ throw error;
231
+ }
232
+
233
+ if (!state) return null;
234
+ return { path: legacyPath, state };
235
+ }
74
236
 
237
+ function assertBypassTokenAllowed(state, lockPath, env) {
75
238
  const suppliedToken = env?.[MAINTENANCE_BYPASS_ENV];
76
239
  if (typeof suppliedToken === 'string' && suppliedToken.length >= 32) {
77
240
  const suppliedHash = sha256(suppliedToken);
78
- if (hashesEqual(suppliedHash, state.bypassTokenHash)) return true;
241
+ if (hashesEqual(suppliedHash, state?.bypassTokenHash)) return true;
79
242
  }
80
243
 
81
244
  const error = new Error(
82
- `YunCMS maintenance is active for this project. Do not start the application until the maintenance operation finishes: ${path}`,
245
+ `YunCMS maintenance is active for this project. Do not start the application until the maintenance operation finishes: ${lockPath}`,
83
246
  );
84
247
  error.code = 'YUNCMS_MAINTENANCE_ACTIVE';
85
- error.lockPath = path;
86
- error.startedAt = typeof state.startedAt === 'string' ? state.startedAt : null;
87
- error.pid = Number.isInteger(state.pid) ? state.pid : null;
248
+ error.lockPath = lockPath;
249
+ error.startedAt = typeof state?.startedAt === 'string' ? state.startedAt : null;
250
+ error.pid = Number.isInteger(state?.pid) ? state.pid : null;
88
251
  throw error;
89
252
  }
253
+
254
+ export async function assertMaintenanceStartupAllowed({
255
+ cwd = process.cwd(),
256
+ env = process.env,
257
+ readFileFn = readFile,
258
+ lstatFn = lstat,
259
+ tempRoot,
260
+ getuidFn = typeof process.getuid === 'function' ? () => process.getuid() : null,
261
+ userInfoFn = userInfo,
262
+ } = {}) {
263
+ const options = {
264
+ cwd,
265
+ env,
266
+ readFileFn,
267
+ lstatFn,
268
+ tempRoot,
269
+ getuidFn,
270
+ userInfoFn,
271
+ };
272
+
273
+ // 1. Check legacy lock path (0.1.26 compatibility)
274
+ const legacy = await probeLegacyMaintenanceLock(options);
275
+ if (legacy) {
276
+ assertBypassTokenAllowed(legacy.state, legacy.path, env);
277
+ }
278
+
279
+ // 2. Validate current per-user parent directory security if it exists
280
+ const currentPath = maintenanceLockPath(cwd, options);
281
+ const currentParent = dirname(currentPath);
282
+ await assertSecureUserParentDir(currentParent, { lstatFn, getuidFn });
283
+
284
+ // 3. Check current per-user lock
285
+ const currentState = await readMaintenanceState(currentPath, readFileFn);
286
+ if (currentState) {
287
+ assertBypassTokenAllowed(currentState, currentPath, env);
288
+ }
289
+
290
+ return true;
291
+ }
@@ -0,0 +1,9 @@
1
+ export const utcAuthDeadlinesMigration = Object.freeze({
2
+ id: '0022-utc-auth-deadlines',
3
+ statements: [
4
+ 'DELETE FROM yuncms_sessions',
5
+ 'DELETE FROM yuncms_auth_tokens',
6
+ 'DELETE FROM yuncms_auth_transactions',
7
+ 'DELETE FROM yuncms_api_tokens WHERE expires_at IS NOT NULL',
8
+ ],
9
+ });
@@ -144,7 +144,7 @@ export class AuthService extends BaseService {
144
144
  INNER JOIN yuncms_users u ON u.id = t.user
145
145
  LEFT JOIN yuncms_roles r ON r.id = u.role
146
146
  WHERE t.token_hash = ?
147
- AND (t.expires_at IS NULL OR t.expires_at > CURRENT_TIMESTAMP(3))
147
+ AND (t.expires_at IS NULL OR t.expires_at > UTC_TIMESTAMP(3))
148
148
  AND u.status = 'active'
149
149
  LIMIT 1`,
150
150
  [hashToken(token)],
@@ -155,7 +155,7 @@ export class AuthService extends BaseService {
155
155
  await this.database.query(
156
156
  `UPDATE yuncms_api_tokens t
157
157
  INNER JOIN yuncms_users u ON u.id = t.user
158
- SET t.last_used_at = CURRENT_TIMESTAMP(3), u.last_access = CURRENT_TIMESTAMP(3)
158
+ SET t.last_used_at = UTC_TIMESTAMP(3), u.last_access = UTC_TIMESTAMP(3)
159
159
  WHERE t.id = ?`,
160
160
  [row.api_token_id],
161
161
  );
@@ -116,7 +116,7 @@ export class AuthTokensService extends BaseService {
116
116
  WHERE token_hash = ?
117
117
  AND type = ?
118
118
  AND used_at IS NULL
119
- AND expires_at > CURRENT_TIMESTAMP(3)
119
+ AND expires_at > UTC_TIMESTAMP(3)
120
120
  LIMIT 1
121
121
  FOR UPDATE`,
122
122
  [hashToken(token), TOKEN_TYPES.reset],
@@ -133,7 +133,7 @@ export class AuthTokensService extends BaseService {
133
133
  if (result.affectedRows !== 1) throw invalidActionToken();
134
134
 
135
135
  await connection.query(
136
- 'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
136
+ 'UPDATE yuncms_auth_tokens SET used_at = UTC_TIMESTAMP(3) WHERE id = ?',
137
137
  [actionToken.id],
138
138
  );
139
139
  await connection.query('DELETE FROM yuncms_sessions WHERE user = ?', [actionToken.user]);
@@ -203,7 +203,7 @@ export class AuthTokensService extends BaseService {
203
203
  WHERE token_hash = ?
204
204
  AND type = ?
205
205
  AND used_at IS NULL
206
- AND expires_at > CURRENT_TIMESTAMP(3)
206
+ AND expires_at > UTC_TIMESTAMP(3)
207
207
  LIMIT 1
208
208
  FOR UPDATE`,
209
209
  [hashToken(token), TOKEN_TYPES.verify],
@@ -213,14 +213,14 @@ export class AuthTokensService extends BaseService {
213
213
 
214
214
  const [result] = await connection.query(
215
215
  `UPDATE yuncms_users
216
- SET email_verified_at = COALESCE(email_verified_at, CURRENT_TIMESTAMP(3))
216
+ SET email_verified_at = COALESCE(email_verified_at, UTC_TIMESTAMP(3))
217
217
  WHERE id = ? AND status = 'active'`,
218
218
  [actionToken.user],
219
219
  );
220
220
  if (result.affectedRows !== 1) throw invalidActionToken();
221
221
 
222
222
  await connection.query(
223
- 'UPDATE yuncms_auth_tokens SET used_at = CURRENT_TIMESTAMP(3) WHERE id = ?',
223
+ 'UPDATE yuncms_auth_tokens SET used_at = UTC_TIMESTAMP(3) WHERE id = ?',
224
224
  [actionToken.id],
225
225
  );
226
226
  await connection.query(
@@ -123,7 +123,7 @@ export class ExternalAuthService extends BaseService {
123
123
  }
124
124
  const [result] = await connection.query(
125
125
  `UPDATE yuncms_auth_transactions
126
- SET used_at = CURRENT_TIMESTAMP(3)
126
+ SET used_at = UTC_TIMESTAMP(3)
127
127
  WHERE id = ? AND used_at IS NULL`,
128
128
  [transaction.id],
129
129
  );
@@ -147,7 +147,7 @@ export class ExternalAuthService extends BaseService {
147
147
  if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 5000) throw new Error('Invalid auth transaction cleanup batch size');
148
148
  const [rows] = await this.database.query(
149
149
  `SELECT id FROM yuncms_auth_transactions
150
- WHERE expires_at < CURRENT_TIMESTAMP(3) OR used_at IS NOT NULL
150
+ WHERE expires_at < UTC_TIMESTAMP(3) OR used_at IS NOT NULL
151
151
  ORDER BY created_at ASC
152
152
  LIMIT ?`,
153
153
  [batchSize],
@@ -226,7 +226,7 @@ export class ExternalAuthService extends BaseService {
226
226
  await connection.query(
227
227
  `INSERT INTO yuncms_users
228
228
  (id, email, password_hash, role, status, email_verified_at)
229
- VALUES (?, ?, NULL, ?, 'active', CURRENT_TIMESTAMP(3))`,
229
+ VALUES (?, ?, NULL, ?, 'active', UTC_TIMESTAMP(3))`,
230
230
  [localUserId, normalizedEmail, role.id],
231
231
  );
232
232
  }
@@ -234,7 +234,7 @@ export class ExternalAuthService extends BaseService {
234
234
  await connection.query(
235
235
  `INSERT INTO yuncms_auth_identities
236
236
  (id, provider, subject, user, email, profile, last_login_at)
237
- VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP(3))`,
237
+ VALUES (?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(3))`,
238
238
  [randomUUID(), providerId, subjectId, localUserId, normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile)],
239
239
  );
240
240
  return localUserId;
@@ -287,7 +287,7 @@ export class ExternalAuthService extends BaseService {
287
287
  } else {
288
288
  await this.database.query(
289
289
  `UPDATE yuncms_auth_identities
290
- SET email = COALESCE(?, email), profile = COALESCE(?, profile), last_login_at = CURRENT_TIMESTAMP(3)
290
+ SET email = COALESCE(?, email), profile = COALESCE(?, profile), last_login_at = UTC_TIMESTAMP(3)
291
291
  WHERE id = ?`,
292
292
  [normalizedEmail, sanitizedProfile == null ? null : JSON.stringify(sanitizedProfile), user.identity_id],
293
293
  );
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { Readable } from 'node:stream';
2
3
 
3
4
  import { compileFilter } from '../query.js';
4
5
  import { BaseService } from './base-service.js';
@@ -73,6 +74,26 @@ function normalizeRow(row) {
73
74
  return row ? { ...row, metadata: decodeJson(row.metadata) } : null;
74
75
  }
75
76
 
77
+ function normalizeModifiedAt(rawDate) {
78
+ if (!rawDate) return null;
79
+ const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
80
+ return Number.isNaN(date.getTime()) ? null : date;
81
+ }
82
+
83
+ const STRONG_ETAG_PATTERN = /^"[!\x23-\x7e\x80-\xff]*"$/;
84
+
85
+ function isValidStrongEtag(value) {
86
+ return typeof value === 'string' && STRONG_ETAG_PATTERN.test(value);
87
+ }
88
+
89
+ function resolveContentEtag(file, size, modifiedAt, providerEtag) {
90
+ if (isValidStrongEtag(providerEtag)) {
91
+ return providerEtag;
92
+ }
93
+ const mtimeMs = modifiedAt ? modifiedAt.getTime() : 0;
94
+ return `"${file.id}-${size.toString(16)}-${mtimeMs.toString(16)}"`;
95
+ }
96
+
76
97
  export class FilesService extends BaseService {
77
98
  constructor(options = {}) {
78
99
  super(options);
@@ -231,6 +252,91 @@ export class FilesService extends BaseService {
231
252
  return { file, contents };
232
253
  }
233
254
 
255
+ async readContentInfo(id) {
256
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
257
+ const file = await this.#readOneAuthorized(id, permission);
258
+ if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
259
+ const driver = this.storage.get(file.storage);
260
+ const stat = await driver.stat(file.filename_disk);
261
+ if (!stat) throw fileError('FILE_NOT_FOUND', `File content missing for file: ${id}`);
262
+ const size = Number(stat.size);
263
+ if (!Number.isSafeInteger(size) || size < 0) {
264
+ throw fileError('INVALID_FILE_SIZE', `Physical file size is invalid: ${stat.size}`);
265
+ }
266
+ const modifiedAt = normalizeModifiedAt(stat.modifiedAt ?? file.uploaded_at);
267
+ const etag = resolveContentEtag(file, size, modifiedAt, stat.etag);
268
+ return {
269
+ file,
270
+ size,
271
+ modifiedAt,
272
+ etag,
273
+ };
274
+ }
275
+
276
+ async readContentStream(id, { start, end } = {}) {
277
+ const permission = await resolveSystemResourceAccess(this, 'read', 'yuncms_files');
278
+ const file = await this.#readOneAuthorized(id, permission);
279
+ if (!file) throw fileError('FILE_NOT_FOUND', `Unknown file: ${id}`);
280
+ const driver = this.storage.get(file.storage);
281
+ const stat = await driver.stat(file.filename_disk);
282
+ if (!stat) throw fileError('FILE_NOT_FOUND', `File content missing for file: ${id}`);
283
+ const size = Number(stat.size);
284
+ if (!Number.isSafeInteger(size) || size < 0) {
285
+ throw fileError('INVALID_FILE_SIZE', `Physical file size is invalid: ${stat.size}`);
286
+ }
287
+
288
+ const rangeStart = typeof start === 'number' ? start : 0;
289
+ if (start !== undefined) {
290
+ if (!Number.isSafeInteger(start) || start < 0) {
291
+ throw fileError('INVALID_RANGE', 'Range start must be a non-negative integer');
292
+ }
293
+ if (size === 0 || start >= size) {
294
+ throw fileError('INVALID_RANGE', 'Range start is out of bounds');
295
+ }
296
+ }
297
+
298
+ if (end !== undefined) {
299
+ if (!Number.isSafeInteger(end) || end < rangeStart) {
300
+ throw fileError('INVALID_RANGE', 'Range end must be greater than or equal to start');
301
+ }
302
+ if (size === 0 || end >= size) {
303
+ throw fileError('INVALID_RANGE', 'Range end is out of bounds');
304
+ }
305
+ }
306
+
307
+ let stream;
308
+ if (typeof driver.getStream === 'function') {
309
+ stream = await driver.getStream(file.filename_disk, { start, end });
310
+ if (!stream) throw fileError('FILE_NOT_FOUND', `File content missing for file: ${id}`);
311
+ if (!(stream instanceof Readable)) {
312
+ throw fileError('INVALID_STREAM', 'Storage getStream must return a Node Readable stream');
313
+ }
314
+ } else {
315
+ const raw = await driver.get(file.filename_disk);
316
+ if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
317
+ throw fileError('INVALID_FILE_CONTENT', 'Storage get must return Buffer or Uint8Array');
318
+ }
319
+ const buffer = Buffer.isBuffer(raw)
320
+ ? raw
321
+ : Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
322
+ const slice = typeof end === 'number'
323
+ ? buffer.subarray(rangeStart, end + 1)
324
+ : (typeof start === 'number' ? buffer.subarray(start) : buffer);
325
+ stream = Readable.from([slice]);
326
+ }
327
+
328
+ const modifiedAt = normalizeModifiedAt(stat.modifiedAt ?? file.uploaded_at);
329
+ const etag = resolveContentEtag(file, size, modifiedAt, stat.etag);
330
+
331
+ return {
332
+ file,
333
+ stream,
334
+ size,
335
+ modifiedAt,
336
+ etag,
337
+ };
338
+ }
339
+
234
340
  async updateOne(id, patch = {}) {
235
341
  await resolveSystemResourceAccess(this, 'update', 'yuncms_files');
236
342
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
@@ -298,10 +298,65 @@ export class ItemsService extends BaseService {
298
298
  const requestedFields = normalizeFields(query.fields);
299
299
  const selected = compileSelectFields(requestedFields, accessSchema);
300
300
  const sortSql = compileSort(query.sort, accessSchema);
301
- const [rows] = await this.database.query(
302
- `SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
303
- [...filter.params, query.limit, query.offset],
304
- );
301
+ const hasExplicitSort = Boolean(query.sort && query.sort.length > 0);
302
+ const hasLargeField = selected.fields.some((fieldName) => {
303
+ const fieldType = accessSchema.fields[fieldName]?.type ?? schema.fields[fieldName]?.type;
304
+ return fieldType === 'json' || fieldType === 'text';
305
+ });
306
+
307
+ let rows;
308
+
309
+ if (hasExplicitSort && hasLargeField) {
310
+ const primaryKey = schema.primary_key;
311
+ const pkSql = quoteIdentifier(primaryKey, 'primary key');
312
+ const [idRows] = await this.database.query(
313
+ `SELECT ${pkSql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
314
+ [...filter.params, query.limit, query.offset],
315
+ );
316
+
317
+ const pageIds = idRows.map((row) => row[primaryKey]).filter((id) => id != null);
318
+ rows = [];
319
+
320
+ if (pageIds.length > 0) {
321
+ const primaryKeyRequestedAndAllowed = selected.fields.includes(primaryKey);
322
+ const materializationSql = primaryKeyRequestedAndAllowed
323
+ ? selected.sql
324
+ : `${selected.sql}, ${pkSql}`;
325
+ const idFilter = {
326
+ sql: ` WHERE ${pkSql} IN (${pageIds.map(() => '?').join(', ')})`,
327
+ params: pageIds,
328
+ };
329
+ const secondFilter = combineCompiledFilters(filter, idFilter);
330
+ const [materializedRows] = await this.database.query(
331
+ `SELECT ${materializationSql} FROM ${table}${secondFilter.sql}`,
332
+ secondFilter.params,
333
+ );
334
+
335
+ const rowMap = new Map();
336
+ for (const row of materializedRows) {
337
+ rowMap.set(row[primaryKey], row);
338
+ }
339
+
340
+ for (const id of pageIds) {
341
+ const row = rowMap.get(id);
342
+ if (row) {
343
+ if (!primaryKeyRequestedAndAllowed) {
344
+ const { [primaryKey]: _ignored, ...rest } = row;
345
+ rows.push(rest);
346
+ } else {
347
+ rows.push(row);
348
+ }
349
+ }
350
+ }
351
+ }
352
+ } else {
353
+ const [resultRows] = await this.database.query(
354
+ `SELECT ${selected.sql} FROM ${table}${filter.sql}${sortSql} LIMIT ? OFFSET ?`,
355
+ [...filter.params, query.limit, query.offset],
356
+ );
357
+ rows = resultRows;
358
+ }
359
+
305
360
  const [countRows] = await this.database.query(
306
361
  `SELECT COUNT(*) AS total_count FROM ${table}${filter.sql}`,
307
362
  filter.params,
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
+ import {
4
+ buildSystemListQuery,
5
+ ROLE_SAFE_SELECT_COLUMNS,
6
+ ROLE_SYSTEM_SEARCH_SCHEMA,
7
+ } from '../system-list-query.js';
3
8
  import { BaseService } from './base-service.js';
4
9
  import { resolveSystemResourceAccess } from './system-resource-access.js';
5
10
 
@@ -48,13 +53,15 @@ export class RolesService extends BaseService {
48
53
  return rows[0] ?? null;
49
54
  }
50
55
 
51
- async readMany() {
56
+ async readMany(query = {}) {
52
57
  await resolveSystemResourceAccess(this, 'read', 'yuncms_roles');
53
- const [rows] = await this.database.query(
54
- `SELECT id, name, description, admin, public, created_at, updated_at
55
- FROM yuncms_roles
56
- ORDER BY name ASC`,
57
- );
58
+ const { sql, params } = buildSystemListQuery(query, {
59
+ table: 'yuncms_roles',
60
+ columns: ROLE_SAFE_SELECT_COLUMNS,
61
+ orderBy: 'name ASC, id ASC',
62
+ searchSchema: ROLE_SYSTEM_SEARCH_SCHEMA,
63
+ });
64
+ const [rows] = await this.database.query(sql, params);
58
65
  return rows;
59
66
  }
60
67
 
@@ -77,8 +77,8 @@ export class SessionsService extends BaseService {
77
77
  INNER JOIN yuncms_users u ON u.id = s.user
78
78
  LEFT JOIN yuncms_roles r ON r.id = u.role
79
79
  WHERE s.access_token_hash = ?
80
- AND s.access_expires_at > CURRENT_TIMESTAMP(3)
81
- AND s.expires_at > CURRENT_TIMESTAMP(3)
80
+ AND s.access_expires_at > UTC_TIMESTAMP(3)
81
+ AND s.expires_at > UTC_TIMESTAMP(3)
82
82
  AND u.status = 'active'
83
83
  LIMIT 1`,
84
84
  [hash],
@@ -89,7 +89,7 @@ export class SessionsService extends BaseService {
89
89
  await this.database.query(
90
90
  `UPDATE yuncms_sessions s
91
91
  INNER JOIN yuncms_users u ON u.id = s.user
92
- SET s.last_used_at = CURRENT_TIMESTAMP(3), u.last_access = CURRENT_TIMESTAMP(3)
92
+ SET s.last_used_at = UTC_TIMESTAMP(3), u.last_access = UTC_TIMESTAMP(3)
93
93
  WHERE s.id = ?`,
94
94
  [row.session_id],
95
95
  );
@@ -111,7 +111,7 @@ export class SessionsService extends BaseService {
111
111
  INNER JOIN yuncms_users u ON u.id = s.user
112
112
  LEFT JOIN yuncms_roles r ON r.id = u.role
113
113
  WHERE s.token_hash = ?
114
- AND s.expires_at > CURRENT_TIMESTAMP(3)
114
+ AND s.expires_at > UTC_TIMESTAMP(3)
115
115
  AND u.status = 'active'
116
116
  LIMIT 1`,
117
117
  [oldHash],
@@ -126,7 +126,7 @@ export class SessionsService extends BaseService {
126
126
  const [result] = await this.database.query(
127
127
  `UPDATE yuncms_sessions
128
128
  SET token_hash = ?, access_token_hash = ?, access_expires_at = ?, expires_at = ?,
129
- last_used_at = CURRENT_TIMESTAMP(3)
129
+ last_used_at = UTC_TIMESTAMP(3)
130
130
  WHERE id = ? AND token_hash = ?`,
131
131
  [
132
132
  refresh.hash,
@@ -29,6 +29,7 @@ export class SingletonItemsService extends ItemsService {
29
29
  async createOne(payload = {}) {
30
30
  const schema = await this.getCollectionSchema();
31
31
  if (!schema.singleton) return super.createOne(payload);
32
+ await this.resolvePermission('create');
32
33
  return withAdvisoryLock(
33
34
  this.database,
34
35
  singletonLockName(this.collection),
@@ -43,6 +44,7 @@ export class SingletonItemsService extends ItemsService {
43
44
  async createMany(payloads = []) {
44
45
  const schema = await this.getCollectionSchema();
45
46
  if (!schema.singleton) return super.createMany(payloads);
47
+ await this.resolvePermission('create');
46
48
  if (!Array.isArray(payloads) || payloads.length !== 1) {
47
49
  throw singletonError('SINGLETON_BULK_CREATE_FORBIDDEN', 'Singleton collections accept exactly one create payload');
48
50
  }
@@ -1,6 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { hashPassword } from '../auth/password.js';
4
+ import {
5
+ buildSystemListQuery,
6
+ USER_SAFE_SELECT_COLUMNS,
7
+ USER_SYSTEM_SEARCH_SCHEMA,
8
+ } from '../system-list-query.js';
4
9
  import { withTransaction } from '../transaction.js';
5
10
  import { BaseService } from './base-service.js';
6
11
  import { resolveSystemResourceAccess } from './system-resource-access.js';
@@ -118,13 +123,15 @@ export class UsersService extends BaseService {
118
123
  return rows[0] ?? null;
119
124
  }
120
125
 
121
- async readMany() {
126
+ async readMany(query = {}) {
122
127
  await resolveSystemResourceAccess(this, 'read', 'yuncms_users');
123
- const [rows] = await this.database.query(
124
- `SELECT id, email, role, status, email_verified_at, last_access, created_at, updated_at
125
- FROM yuncms_users
126
- ORDER BY email ASC`,
127
- );
128
+ const { sql, params } = buildSystemListQuery(query, {
129
+ table: 'yuncms_users',
130
+ columns: USER_SAFE_SELECT_COLUMNS,
131
+ orderBy: 'email ASC, id ASC',
132
+ searchSchema: USER_SYSTEM_SEARCH_SCHEMA,
133
+ });
134
+ const [rows] = await this.database.query(sql, params);
128
135
  return rows;
129
136
  }
130
137
 
@@ -1,3 +1,4 @@
1
+ import { createReadStream } from 'node:fs';
1
2
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises';
2
3
  import { isAbsolute, relative, resolve } from 'node:path';
3
4
 
@@ -46,6 +47,17 @@ export class LocalStorageDriver {
46
47
  return readFile(this.pathFor(key));
47
48
  }
48
49
 
50
+ async getStream(key, { start, end } = {}) {
51
+ const path = this.pathFor(key);
52
+ const options = { highWaterMark: 64 * 1024 };
53
+ if (typeof start === 'number') options.start = start;
54
+ if (typeof end === 'number') {
55
+ options.end = end;
56
+ if (typeof start !== 'number') options.start = 0;
57
+ }
58
+ return createReadStream(path, options);
59
+ }
60
+
49
61
  async stat(key) {
50
62
  try {
51
63
  const info = await stat(this.pathFor(key));
@@ -76,6 +76,22 @@ export class S3StorageDriver {
76
76
  return bodyToBuffer(response.Body);
77
77
  }
78
78
 
79
+ async getStream(key, { start, end } = {}) {
80
+ const safeKey = assertStorageKey(key);
81
+ const commandInput = {
82
+ Bucket: this.bucket,
83
+ Key: safeKey,
84
+ };
85
+ if (typeof end === 'number') {
86
+ const rangeStart = typeof start === 'number' ? start : 0;
87
+ commandInput.Range = `bytes=${rangeStart}-${end}`;
88
+ } else if (typeof start === 'number') {
89
+ commandInput.Range = `bytes=${start}-`;
90
+ }
91
+ const response = await this.client.send(new GetObjectCommand(commandInput));
92
+ return response.Body;
93
+ }
94
+
79
95
  async stat(key) {
80
96
  const safeKey = assertStorageKey(key);
81
97
  try {
@@ -4,6 +4,9 @@ function assertDriver(name, driver) {
4
4
  throw new Error(`Storage driver ${name} must implement ${method}()`);
5
5
  }
6
6
  }
7
+ if (driver.getStream != null && typeof driver.getStream !== 'function') {
8
+ throw new Error(`Storage driver ${name} getStream must be a function when provided`);
9
+ }
7
10
  return driver;
8
11
  }
9
12
 
@@ -0,0 +1,124 @@
1
+ import { assertIdentifier } from './identifier.js';
2
+ import { compileSearch, parseItemsQuery, QUERY_LIMITS } from './query.js';
3
+
4
+ export const SYSTEM_LIST_LIMITS = Object.freeze({
5
+ defaultLimit: QUERY_LIMITS.defaultLimit,
6
+ minLimit: 1,
7
+ maxLimit: QUERY_LIMITS.maxLimit,
8
+ defaultOffset: 0,
9
+ minOffset: 0,
10
+ maxOffset: QUERY_LIMITS.maxOffset,
11
+ maxSearchLength: QUERY_LIMITS.maxSearchLength,
12
+ });
13
+
14
+ const ALLOWED_QUERY_KEYS = new Set(['limit', 'offset', 'search']);
15
+
16
+ export const USER_SAFE_SELECT_COLUMNS = Object.freeze([
17
+ 'id',
18
+ 'email',
19
+ 'role',
20
+ 'status',
21
+ 'email_verified_at',
22
+ 'last_access',
23
+ 'created_at',
24
+ 'updated_at',
25
+ ]);
26
+
27
+ export const ROLE_SAFE_SELECT_COLUMNS = Object.freeze([
28
+ 'id',
29
+ 'name',
30
+ 'description',
31
+ 'admin',
32
+ 'public',
33
+ 'created_at',
34
+ 'updated_at',
35
+ ]);
36
+
37
+ export const USER_SYSTEM_SEARCH_SCHEMA = Object.freeze({
38
+ fields: {
39
+ email: { field: 'email', type: 'string' },
40
+ },
41
+ });
42
+
43
+ export const ROLE_SYSTEM_SEARCH_SCHEMA = Object.freeze({
44
+ fields: {
45
+ name: { field: 'name', type: 'string' },
46
+ description: { field: 'description', type: 'string' },
47
+ },
48
+ });
49
+
50
+ function queryError(message, path = null) {
51
+ const error = new Error(message);
52
+ error.code = 'INVALID_QUERY';
53
+ if (path) error.path = path;
54
+ return error;
55
+ }
56
+
57
+ export function parseSystemListQuery(raw = {}) {
58
+ if (raw == null) {
59
+ return Object.freeze({
60
+ isBounded: false,
61
+ limit: null,
62
+ offset: null,
63
+ search: null,
64
+ });
65
+ }
66
+
67
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
68
+ throw queryError('Query must be an object');
69
+ }
70
+
71
+ const keys = Object.keys(raw);
72
+ if (keys.length === 0) {
73
+ return Object.freeze({
74
+ isBounded: false,
75
+ limit: null,
76
+ offset: null,
77
+ search: null,
78
+ });
79
+ }
80
+
81
+ for (const key of keys) {
82
+ if (!ALLOWED_QUERY_KEYS.has(key)) {
83
+ throw queryError(`Unknown query parameter: ${key}`, key);
84
+ }
85
+ }
86
+
87
+ if (raw.search !== undefined && raw.search !== null && raw.search !== '') {
88
+ if (typeof raw.search !== 'string') {
89
+ throw queryError('search must be a string', 'search');
90
+ }
91
+ }
92
+
93
+ const parsed = parseItemsQuery(raw);
94
+
95
+ return Object.freeze({
96
+ isBounded: true,
97
+ limit: parsed.limit,
98
+ offset: parsed.offset,
99
+ search: parsed.search,
100
+ });
101
+ }
102
+
103
+ export function buildSystemListQuery(rawQuery, { table, columns, orderBy, searchSchema }) {
104
+ const parsed = parseSystemListQuery(rawQuery);
105
+ const validatedTable = assertIdentifier(table, 'table');
106
+ const validatedColumns = columns.map((col) => assertIdentifier(col, 'column')).join(', ');
107
+
108
+ if (!parsed.isBounded) {
109
+ return {
110
+ parsed,
111
+ sql: `SELECT ${validatedColumns}\nFROM ${validatedTable}\nORDER BY ${orderBy}`,
112
+ params: [],
113
+ };
114
+ }
115
+
116
+ const searchResult = compileSearch(parsed.search, searchSchema);
117
+ const whereSql = searchResult.sql ? `\n${searchResult.sql.trim()}` : '';
118
+
119
+ return {
120
+ parsed,
121
+ sql: `SELECT ${validatedColumns}\nFROM ${validatedTable}${whereSql}\nORDER BY ${orderBy}\nLIMIT ? OFFSET ?`,
122
+ params: [...searchResult.params, parsed.limit, parsed.offset],
123
+ };
124
+ }