@yunsoft/yuncms-core 0.1.25 → 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 +1 -1
- package/src/bootstrap.js +2 -0
- package/src/database.js +13 -1
- package/src/index.js +3 -0
- package/src/maintenance-state.js +217 -15
- package/src/migrations/0022-utc-auth-deadlines.js +9 -0
- package/src/services/auth-service.js +2 -2
- package/src/services/auth-tokens-service.js +5 -5
- package/src/services/external-auth-service.js +5 -5
- package/src/services/files-service.js +106 -0
- package/src/services/items-service.js +68 -9
- package/src/services/roles-service.js +13 -6
- package/src/services/sessions-service.js +5 -5
- package/src/services/singleton-services.js +2 -0
- package/src/services/users-service.js +13 -6
- package/src/storage/local-storage-driver.js +12 -0
- package/src/storage/s3-storage-driver.js +16 -0
- package/src/storage/storage-registry.js +3 -0
- package/src/system-list-query.js +124 -0
package/package.json
CHANGED
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
|
-
|
|
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';
|
package/src/maintenance-state.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
165
|
+
export async function probeLegacyMaintenanceLock({
|
|
67
166
|
cwd = process.cwd(),
|
|
68
|
-
|
|
167
|
+
tempRoot,
|
|
69
168
|
readFileFn = readFile,
|
|
169
|
+
lstatFn = lstat,
|
|
170
|
+
getuidFn = typeof process.getuid === 'function' ? () => process.getuid() : null,
|
|
70
171
|
} = {}) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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: ${
|
|
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 =
|
|
86
|
-
error.startedAt = typeof state
|
|
87
|
-
error.pid = Number.isInteger(state
|
|
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 >
|
|
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 =
|
|
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 >
|
|
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 =
|
|
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 >
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 <
|
|
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',
|
|
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 (?, ?, ?, ?, ?, ?,
|
|
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 =
|
|
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)) {
|
|
@@ -231,10 +231,10 @@ export class ItemsService extends BaseService {
|
|
|
231
231
|
return combineCompiledFilters(permissionSql, userSql, searchSql);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
async normalizeReadQuery(rawQuery) {
|
|
234
|
+
async normalizeReadQuery(rawQuery, context = {}) {
|
|
235
235
|
const parsed = parseItemsQuery(rawQuery);
|
|
236
236
|
const filtered = this.emitter
|
|
237
|
-
? await this.emitter.filter('items.query', parsed, this.hookContext({ operation: 'read' }))
|
|
237
|
+
? await this.emitter.filter('items.query', parsed, this.hookContext({ ...context, operation: 'read' }))
|
|
238
238
|
: parsed;
|
|
239
239
|
const query = parseItemsQuery(filtered);
|
|
240
240
|
assertQueryCost(query);
|
|
@@ -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
|
|
302
|
-
|
|
303
|
-
|
|
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,
|
|
@@ -363,14 +418,18 @@ export class ItemsService extends BaseService {
|
|
|
363
418
|
|
|
364
419
|
async readOne(id, { fields = null } = {}) {
|
|
365
420
|
const schema = await this.getCollectionSchema();
|
|
421
|
+
const query = await this.normalizeReadQuery({ fields, limit: 1 }, { single: true, key: id });
|
|
422
|
+
if (query.aggregate) {
|
|
423
|
+
throw serviceError('INVALID_QUERY', 'Single-item reads do not support aggregate queries', 'aggregate');
|
|
424
|
+
}
|
|
366
425
|
const permission = await this.resolvePermission('read');
|
|
367
426
|
const accessSchema = schemaForFields(schema, permission.fields);
|
|
368
|
-
const selected = compileSelectFields(
|
|
427
|
+
const selected = compileSelectFields(query.fields, accessSchema);
|
|
369
428
|
const table = quoteIdentifier(this.collection, 'collection name');
|
|
370
429
|
const primaryKey = schema.primary_key;
|
|
371
430
|
const dynamicVariables = this.dynamicVariables();
|
|
372
431
|
const filter = combineCompiledFilters(
|
|
373
|
-
|
|
432
|
+
this.compileActionFilters(query.filter, permission.filter, accessSchema, schema, query.search, dynamicVariables),
|
|
374
433
|
compileFilter({ [primaryKey]: { _eq: id } }, schema),
|
|
375
434
|
);
|
|
376
435
|
const [rows] = await this.database.query(
|
|
@@ -378,7 +437,7 @@ export class ItemsService extends BaseService {
|
|
|
378
437
|
filter.params,
|
|
379
438
|
);
|
|
380
439
|
const record = rows[0] ?? null;
|
|
381
|
-
if (record) await this.emitReadAction(
|
|
440
|
+
if (record) await this.emitReadAction(query, [record], schema, { single: true });
|
|
382
441
|
return record;
|
|
383
442
|
}
|
|
384
443
|
|
|
@@ -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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 >
|
|
81
|
-
AND s.expires_at >
|
|
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 =
|
|
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 >
|
|
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 =
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|