@yunsoft/yuncms-core 0.1.0
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/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +36 -0
- package/src/accountability.js +39 -0
- package/src/advisory-lock.js +30 -0
- package/src/auth/password.js +100 -0
- package/src/auth/tokens.js +38 -0
- package/src/auth/users-repository.js +25 -0
- package/src/bootstrap.js +41 -0
- package/src/config.js +106 -0
- package/src/context.js +33 -0
- package/src/database.js +31 -0
- package/src/errors.js +35 -0
- package/src/field-types.js +110 -0
- package/src/hooks.js +121 -0
- package/src/identifier.js +13 -0
- package/src/index.js +67 -0
- package/src/logger.js +56 -0
- package/src/m2m-lifecycle.js +139 -0
- package/src/mail/smtp-mailer.js +65 -0
- package/src/migrations/0001-system-schema.js +164 -0
- package/src/migrations/0002-session-access-tokens.js +10 -0
- package/src/migrations/0003-public-role-constraints.js +10 -0
- package/src/migrations/0004-auth-action-tokens.js +19 -0
- package/src/migrations.js +81 -0
- package/src/permission-validation.js +63 -0
- package/src/query.js +193 -0
- package/src/relation-expansion.js +216 -0
- package/src/retry.js +29 -0
- package/src/schema-metadata-repository.js +284 -0
- package/src/schema-version.js +21 -0
- package/src/schema.js +82 -0
- package/src/services/api-tokens-service.js +117 -0
- package/src/services/audit-service.js +182 -0
- package/src/services/auth-service.js +164 -0
- package/src/services/auth-tokens-service.js +215 -0
- package/src/services/base-service.js +26 -0
- package/src/services/collections-service.js +249 -0
- package/src/services/core-services.js +32 -0
- package/src/services/fields-service.js +471 -0
- package/src/services/file-reconciliation-service.js +127 -0
- package/src/services/files-service.js +227 -0
- package/src/services/items-service.js +445 -0
- package/src/services/permissions-service.js +282 -0
- package/src/services/relations-service.js +455 -0
- package/src/services/roles-service.js +160 -0
- package/src/services/schema-access.js +7 -0
- package/src/services/service-registry.js +32 -0
- package/src/services/sessions-service.js +179 -0
- package/src/services/users-service.js +215 -0
- package/src/setup.js +66 -0
- package/src/storage/local-storage-driver.js +105 -0
- package/src/storage/s3-storage-driver.js +150 -0
- package/src/storage/storage-registry.js +39 -0
- package/src/transaction.js +46 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
function encodeJson(value) {
|
|
2
|
+
return value == null ? null : JSON.stringify(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class SchemaMetadataRepository {
|
|
6
|
+
constructor(database) {
|
|
7
|
+
if (!database) throw new Error('Database handle is required');
|
|
8
|
+
this.database = database;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async listCollections() {
|
|
12
|
+
const [rows] = await this.database.query(
|
|
13
|
+
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
14
|
+
FROM yuncms_collections
|
|
15
|
+
ORDER BY collection ASC`,
|
|
16
|
+
);
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async readCollection(collection) {
|
|
21
|
+
const [rows] = await this.database.query(
|
|
22
|
+
`SELECT collection, primary_key, note, singleton, hidden, \`system\`, metadata, created_at, updated_at
|
|
23
|
+
FROM yuncms_collections
|
|
24
|
+
WHERE collection = ?
|
|
25
|
+
LIMIT 1`,
|
|
26
|
+
[collection],
|
|
27
|
+
);
|
|
28
|
+
return rows[0] ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async createCollection({
|
|
32
|
+
collection,
|
|
33
|
+
primaryKey = 'id',
|
|
34
|
+
note = null,
|
|
35
|
+
singleton = false,
|
|
36
|
+
hidden = false,
|
|
37
|
+
system = false,
|
|
38
|
+
metadata = null,
|
|
39
|
+
}) {
|
|
40
|
+
await this.database.query(
|
|
41
|
+
`INSERT INTO yuncms_collections
|
|
42
|
+
(collection, primary_key, note, singleton, hidden, \`system\`, metadata)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
44
|
+
[
|
|
45
|
+
collection,
|
|
46
|
+
primaryKey,
|
|
47
|
+
note,
|
|
48
|
+
singleton ? 1 : 0,
|
|
49
|
+
hidden ? 1 : 0,
|
|
50
|
+
system ? 1 : 0,
|
|
51
|
+
encodeJson(metadata),
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
return this.readCollection(collection);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async updateCollectionMetadata(collection, patch = {}) {
|
|
58
|
+
const assignments = [];
|
|
59
|
+
const params = [];
|
|
60
|
+
|
|
61
|
+
if (Object.hasOwn(patch, 'note')) {
|
|
62
|
+
assignments.push('note = ?');
|
|
63
|
+
params.push(patch.note ?? null);
|
|
64
|
+
}
|
|
65
|
+
if (Object.hasOwn(patch, 'singleton')) {
|
|
66
|
+
assignments.push('singleton = ?');
|
|
67
|
+
params.push(patch.singleton ? 1 : 0);
|
|
68
|
+
}
|
|
69
|
+
if (Object.hasOwn(patch, 'hidden')) {
|
|
70
|
+
assignments.push('hidden = ?');
|
|
71
|
+
params.push(patch.hidden ? 1 : 0);
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(patch, 'metadata')) {
|
|
74
|
+
assignments.push('metadata = ?');
|
|
75
|
+
params.push(encodeJson(patch.metadata));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (assignments.length === 0) return this.readCollection(collection);
|
|
79
|
+
params.push(collection);
|
|
80
|
+
await this.database.query(
|
|
81
|
+
`UPDATE yuncms_collections SET ${assignments.join(', ')} WHERE collection = ?`,
|
|
82
|
+
params,
|
|
83
|
+
);
|
|
84
|
+
return this.readCollection(collection);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async deleteCollection(collection) {
|
|
88
|
+
const [result] = await this.database.query(
|
|
89
|
+
'DELETE FROM yuncms_collections WHERE collection = ?',
|
|
90
|
+
[collection],
|
|
91
|
+
);
|
|
92
|
+
return result.affectedRows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async listFields(collection) {
|
|
96
|
+
const [rows] = await this.database.query(
|
|
97
|
+
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
98
|
+
created_at, updated_at
|
|
99
|
+
FROM yuncms_fields
|
|
100
|
+
WHERE collection = ?
|
|
101
|
+
ORDER BY COALESCE(sort, 2147483647), id ASC`,
|
|
102
|
+
[collection],
|
|
103
|
+
);
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async readField(collection, field) {
|
|
108
|
+
const [rows] = await this.database.query(
|
|
109
|
+
`SELECT id, collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata,
|
|
110
|
+
created_at, updated_at
|
|
111
|
+
FROM yuncms_fields
|
|
112
|
+
WHERE collection = ? AND field = ?
|
|
113
|
+
LIMIT 1`,
|
|
114
|
+
[collection, field],
|
|
115
|
+
);
|
|
116
|
+
return rows[0] ?? null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async createField({
|
|
120
|
+
collection,
|
|
121
|
+
field,
|
|
122
|
+
type,
|
|
123
|
+
required = false,
|
|
124
|
+
readonly = false,
|
|
125
|
+
hidden = false,
|
|
126
|
+
sort = null,
|
|
127
|
+
interface: fieldInterface = null,
|
|
128
|
+
options = null,
|
|
129
|
+
schemaMetadata = null,
|
|
130
|
+
}) {
|
|
131
|
+
await this.database.query(
|
|
132
|
+
`INSERT INTO yuncms_fields
|
|
133
|
+
(collection, field, type, required, readonly, hidden, sort, interface, options, schema_metadata)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
135
|
+
[
|
|
136
|
+
collection,
|
|
137
|
+
field,
|
|
138
|
+
type,
|
|
139
|
+
required ? 1 : 0,
|
|
140
|
+
readonly ? 1 : 0,
|
|
141
|
+
hidden ? 1 : 0,
|
|
142
|
+
sort,
|
|
143
|
+
fieldInterface,
|
|
144
|
+
encodeJson(options),
|
|
145
|
+
encodeJson(schemaMetadata),
|
|
146
|
+
],
|
|
147
|
+
);
|
|
148
|
+
return this.readField(collection, field);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async updateFieldMetadata(collection, field, patch = {}) {
|
|
152
|
+
const assignments = [];
|
|
153
|
+
const params = [];
|
|
154
|
+
|
|
155
|
+
if (Object.hasOwn(patch, 'readonly')) {
|
|
156
|
+
assignments.push('readonly = ?');
|
|
157
|
+
params.push(patch.readonly ? 1 : 0);
|
|
158
|
+
}
|
|
159
|
+
if (Object.hasOwn(patch, 'hidden')) {
|
|
160
|
+
assignments.push('hidden = ?');
|
|
161
|
+
params.push(patch.hidden ? 1 : 0);
|
|
162
|
+
}
|
|
163
|
+
if (Object.hasOwn(patch, 'sort')) {
|
|
164
|
+
assignments.push('sort = ?');
|
|
165
|
+
params.push(patch.sort ?? null);
|
|
166
|
+
}
|
|
167
|
+
if (Object.hasOwn(patch, 'interface')) {
|
|
168
|
+
assignments.push('interface = ?');
|
|
169
|
+
params.push(patch.interface ?? null);
|
|
170
|
+
}
|
|
171
|
+
if (Object.hasOwn(patch, 'options')) {
|
|
172
|
+
assignments.push('options = ?');
|
|
173
|
+
params.push(encodeJson(patch.options));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (assignments.length === 0) return this.readField(collection, field);
|
|
177
|
+
params.push(collection, field);
|
|
178
|
+
await this.database.query(
|
|
179
|
+
`UPDATE yuncms_fields SET ${assignments.join(', ')} WHERE collection = ? AND field = ?`,
|
|
180
|
+
params,
|
|
181
|
+
);
|
|
182
|
+
return this.readField(collection, field);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async updateFieldPhysicalMetadata(collection, field, { required, schemaMetadata } = {}) {
|
|
186
|
+
const assignments = [];
|
|
187
|
+
const params = [];
|
|
188
|
+
|
|
189
|
+
if (required !== undefined) {
|
|
190
|
+
assignments.push('required = ?');
|
|
191
|
+
params.push(required ? 1 : 0);
|
|
192
|
+
}
|
|
193
|
+
if (schemaMetadata !== undefined) {
|
|
194
|
+
assignments.push('schema_metadata = ?');
|
|
195
|
+
params.push(encodeJson(schemaMetadata));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (assignments.length === 0) return this.readField(collection, field);
|
|
199
|
+
params.push(collection, field);
|
|
200
|
+
await this.database.query(
|
|
201
|
+
`UPDATE yuncms_fields SET ${assignments.join(', ')} WHERE collection = ? AND field = ?`,
|
|
202
|
+
params,
|
|
203
|
+
);
|
|
204
|
+
return this.readField(collection, field);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async deleteField(collection, field) {
|
|
208
|
+
const [result] = await this.database.query(
|
|
209
|
+
'DELETE FROM yuncms_fields WHERE collection = ? AND field = ?',
|
|
210
|
+
[collection, field],
|
|
211
|
+
);
|
|
212
|
+
return result.affectedRows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async listRelations() {
|
|
216
|
+
const [rows] = await this.database.query(
|
|
217
|
+
`SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
|
|
218
|
+
junction_field, on_delete, metadata, created_at
|
|
219
|
+
FROM yuncms_relations
|
|
220
|
+
ORDER BY many_collection, many_field`,
|
|
221
|
+
);
|
|
222
|
+
return rows;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async listRelationsForOne(oneCollection) {
|
|
226
|
+
const [rows] = await this.database.query(
|
|
227
|
+
`SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
|
|
228
|
+
junction_field, on_delete, metadata, created_at
|
|
229
|
+
FROM yuncms_relations
|
|
230
|
+
WHERE one_collection = ?
|
|
231
|
+
ORDER BY many_collection, many_field`,
|
|
232
|
+
[oneCollection],
|
|
233
|
+
);
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async readRelation(manyCollection, manyField) {
|
|
238
|
+
const [rows] = await this.database.query(
|
|
239
|
+
`SELECT id, many_collection, many_field, one_collection, one_field, junction_collection,
|
|
240
|
+
junction_field, on_delete, metadata, created_at
|
|
241
|
+
FROM yuncms_relations
|
|
242
|
+
WHERE many_collection = ? AND many_field = ?
|
|
243
|
+
LIMIT 1`,
|
|
244
|
+
[manyCollection, manyField],
|
|
245
|
+
);
|
|
246
|
+
return rows[0] ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async createRelation({
|
|
250
|
+
manyCollection,
|
|
251
|
+
manyField,
|
|
252
|
+
oneCollection,
|
|
253
|
+
oneField = null,
|
|
254
|
+
junctionCollection = null,
|
|
255
|
+
junctionField = null,
|
|
256
|
+
onDelete = 'RESTRICT',
|
|
257
|
+
metadata = null,
|
|
258
|
+
}) {
|
|
259
|
+
await this.database.query(
|
|
260
|
+
`INSERT INTO yuncms_relations
|
|
261
|
+
(many_collection, many_field, one_collection, one_field, junction_collection, junction_field, on_delete, metadata)
|
|
262
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
263
|
+
[
|
|
264
|
+
manyCollection,
|
|
265
|
+
manyField,
|
|
266
|
+
oneCollection,
|
|
267
|
+
oneField,
|
|
268
|
+
junctionCollection,
|
|
269
|
+
junctionField,
|
|
270
|
+
onDelete,
|
|
271
|
+
encodeJson(metadata),
|
|
272
|
+
],
|
|
273
|
+
);
|
|
274
|
+
return this.readRelation(manyCollection, manyField);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async deleteRelation(manyCollection, manyField) {
|
|
278
|
+
const [result] = await this.database.query(
|
|
279
|
+
'DELETE FROM yuncms_relations WHERE many_collection = ? AND many_field = ?',
|
|
280
|
+
[manyCollection, manyField],
|
|
281
|
+
);
|
|
282
|
+
return result.affectedRows;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function readSchemaVersion(database) {
|
|
2
|
+
const [rows] = await database.query('SELECT version FROM yuncms_schema_state WHERE id = 1 LIMIT 1');
|
|
3
|
+
if (!rows?.[0]) {
|
|
4
|
+
const error = new Error('YunCMS schema state is missing');
|
|
5
|
+
error.code = 'SCHEMA_STATE_MISSING';
|
|
6
|
+
throw error;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return Number(rows[0].version);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function incrementSchemaVersion(database) {
|
|
13
|
+
const [result] = await database.query('UPDATE yuncms_schema_state SET version = version + 1 WHERE id = 1');
|
|
14
|
+
if (result.affectedRows !== 1) {
|
|
15
|
+
const error = new Error('Could not update YunCMS schema version');
|
|
16
|
+
error.code = 'SCHEMA_STATE_MISSING';
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return readSchemaVersion(database);
|
|
21
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { SchemaMetadataRepository } from './schema-metadata-repository.js';
|
|
2
|
+
import { readSchemaVersion } from './schema-version.js';
|
|
3
|
+
|
|
4
|
+
export async function loadSchemaSnapshot(database) {
|
|
5
|
+
const version = await readSchemaVersion(database);
|
|
6
|
+
const repository = new SchemaMetadataRepository(database);
|
|
7
|
+
const [collections, relations] = await Promise.all([
|
|
8
|
+
repository.listCollections(),
|
|
9
|
+
repository.listRelations(),
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const fieldsByCollection = new Map();
|
|
13
|
+
await Promise.all(
|
|
14
|
+
collections.map(async (collection) => {
|
|
15
|
+
fieldsByCollection.set(
|
|
16
|
+
collection.collection,
|
|
17
|
+
await repository.listFields(collection.collection),
|
|
18
|
+
);
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const relationByManyField = new Map(
|
|
23
|
+
relations.map((relation) => [
|
|
24
|
+
`${relation.many_collection}.${relation.many_field}`,
|
|
25
|
+
relation,
|
|
26
|
+
]),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
version,
|
|
31
|
+
collections: Object.freeze(
|
|
32
|
+
Object.fromEntries(
|
|
33
|
+
collections.map((collection) => [
|
|
34
|
+
collection.collection,
|
|
35
|
+
Object.freeze({
|
|
36
|
+
...collection,
|
|
37
|
+
fields: Object.freeze(
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
(fieldsByCollection.get(collection.collection) ?? []).map((field) => [field.field, field]),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
}),
|
|
43
|
+
]),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
relations: Object.freeze(relations),
|
|
47
|
+
relationByManyField,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SchemaCache {
|
|
52
|
+
constructor({ versionCheckTtlMs = 250 } = {}) {
|
|
53
|
+
this.versionCheckTtlMs = versionCheckTtlMs;
|
|
54
|
+
this.snapshot = null;
|
|
55
|
+
this.lastVersionCheckAt = 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async get(database, { force = false } = {}) {
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
if (!force && this.snapshot && now - this.lastVersionCheckAt < this.versionCheckTtlMs) {
|
|
62
|
+
return this.snapshot;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const currentVersion = await readSchemaVersion(database);
|
|
66
|
+
this.lastVersionCheckAt = now;
|
|
67
|
+
|
|
68
|
+
if (!force && this.snapshot?.version === currentVersion) {
|
|
69
|
+
return this.snapshot;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const snapshot = await loadSchemaSnapshot(database);
|
|
73
|
+
this.snapshot = snapshot;
|
|
74
|
+
this.lastVersionCheckAt = Date.now();
|
|
75
|
+
return snapshot;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clear() {
|
|
79
|
+
this.snapshot = null;
|
|
80
|
+
this.lastVersionCheckAt = 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { createOpaqueToken } from '../auth/tokens.js';
|
|
4
|
+
import { BaseService } from './base-service.js';
|
|
5
|
+
|
|
6
|
+
function forbidden(message) {
|
|
7
|
+
const error = new Error(message);
|
|
8
|
+
error.code = 'FORBIDDEN';
|
|
9
|
+
return error;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function targetUser(accountability, requestedUser = null) {
|
|
13
|
+
if (accountability.admin === true || accountability.system === true) {
|
|
14
|
+
const user = requestedUser ?? accountability.user;
|
|
15
|
+
if (!user) {
|
|
16
|
+
const error = new Error('API token user is required');
|
|
17
|
+
error.code = 'INVALID_PAYLOAD';
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
return user;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!accountability.user) throw forbidden('Authenticated user is required');
|
|
24
|
+
if (requestedUser && requestedUser !== accountability.user) {
|
|
25
|
+
throw forbidden('API tokens can only be managed for the authenticated user');
|
|
26
|
+
}
|
|
27
|
+
return accountability.user;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseExpiry(value) {
|
|
31
|
+
if (value == null || value === '') return null;
|
|
32
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
33
|
+
if (Number.isNaN(date.getTime())) {
|
|
34
|
+
const error = new Error('API token expiry is invalid');
|
|
35
|
+
error.code = 'INVALID_PAYLOAD';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (date.getTime() <= Date.now()) {
|
|
39
|
+
const error = new Error('API token expiry must be in the future');
|
|
40
|
+
error.code = 'INVALID_PAYLOAD';
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
return date;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ApiTokensService extends BaseService {
|
|
47
|
+
async readMany(userId = null) {
|
|
48
|
+
const user = targetUser(this.accountability, userId);
|
|
49
|
+
const [rows] = await this.database.query(
|
|
50
|
+
`SELECT id, user, name, expires_at, last_used_at, created_at
|
|
51
|
+
FROM yuncms_api_tokens
|
|
52
|
+
WHERE user = ?
|
|
53
|
+
ORDER BY created_at DESC`,
|
|
54
|
+
[user],
|
|
55
|
+
);
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async createOne(input = {}) {
|
|
60
|
+
const user = targetUser(this.accountability, input.user ?? null);
|
|
61
|
+
if (!input.name || typeof input.name !== 'string' || input.name.trim().length === 0) {
|
|
62
|
+
const error = new Error('API token name is required');
|
|
63
|
+
error.code = 'INVALID_PAYLOAD';
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
const name = input.name.trim();
|
|
67
|
+
if (name.length > 100) {
|
|
68
|
+
const error = new Error('API token name cannot exceed 100 characters');
|
|
69
|
+
error.code = 'INVALID_PAYLOAD';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const expiresAt = parseExpiry(input.expires_at ?? input.expiresAt ?? null);
|
|
73
|
+
|
|
74
|
+
const [userRows] = await this.database.query(
|
|
75
|
+
`SELECT id FROM yuncms_users WHERE id = ? AND status = 'active' LIMIT 1`,
|
|
76
|
+
[user],
|
|
77
|
+
);
|
|
78
|
+
if (!userRows[0]) {
|
|
79
|
+
const error = new Error(`Unknown or inactive user: ${user}`);
|
|
80
|
+
error.code = 'USER_NOT_FOUND';
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const generated = createOpaqueToken('api', { bytes: 40 });
|
|
86
|
+
await this.database.query(
|
|
87
|
+
`INSERT INTO yuncms_api_tokens (id, user, name, token_hash, expires_at)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
89
|
+
[id, user, name, generated.hash, expiresAt],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
user,
|
|
95
|
+
name,
|
|
96
|
+
token: generated.token,
|
|
97
|
+
expires_at: expiresAt,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async deleteOne(id, userId = null) {
|
|
102
|
+
const user = targetUser(this.accountability, userId);
|
|
103
|
+
let sql = 'DELETE FROM yuncms_api_tokens WHERE id = ?';
|
|
104
|
+
const params = [id];
|
|
105
|
+
|
|
106
|
+
if (this.accountability.admin !== true && this.accountability.system !== true) {
|
|
107
|
+
sql += ' AND user = ?';
|
|
108
|
+
params.push(user);
|
|
109
|
+
} else if (userId) {
|
|
110
|
+
sql += ' AND user = ?';
|
|
111
|
+
params.push(user);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [result] = await this.database.query(sql, params);
|
|
115
|
+
return result.affectedRows > 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { BaseService } from './base-service.js';
|
|
2
|
+
|
|
3
|
+
const SENSITIVE_KEY = /(password|passwd|token|secret|authorization|cookie|api[_-]?key|credential)/i;
|
|
4
|
+
const MAX_DEPTH = 12;
|
|
5
|
+
const DEFAULT_RETENTION_DAYS = 90;
|
|
6
|
+
const DEFAULT_CLEANUP_BATCH_SIZE = 1000;
|
|
7
|
+
|
|
8
|
+
function auditError(code, message) {
|
|
9
|
+
const error = new Error(message);
|
|
10
|
+
error.code = code;
|
|
11
|
+
return error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertAuditReader(accountability) {
|
|
15
|
+
if (accountability.admin === true || accountability.system === true) return;
|
|
16
|
+
throw auditError('FORBIDDEN', 'Audit log access requires administrator accountability');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function boundedInteger(value, fallback, { min, max, label }) {
|
|
20
|
+
const normalized = value ?? fallback;
|
|
21
|
+
if (!Number.isInteger(normalized) || normalized < min || normalized > max) {
|
|
22
|
+
throw auditError('INVALID_PAYLOAD', `${label} must be an integer between ${min} and ${max}`);
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function redactAuditValue(value, depth = 0, seen = new WeakSet()) {
|
|
28
|
+
if (value == null || typeof value !== 'object') return value;
|
|
29
|
+
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
|
|
30
|
+
if (seen.has(value)) return '[CIRCULAR]';
|
|
31
|
+
seen.add(value);
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
const result = value.map((entry) => redactAuditValue(entry, depth + 1, seen));
|
|
35
|
+
seen.delete(value);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = {};
|
|
40
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
41
|
+
result[key] = SENSITIVE_KEY.test(key)
|
|
42
|
+
? '[REDACTED]'
|
|
43
|
+
: redactAuditValue(entry, depth + 1, seen);
|
|
44
|
+
}
|
|
45
|
+
seen.delete(value);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodePayload(value) {
|
|
50
|
+
if (value == null || typeof value === 'object') return value ?? null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(value);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class AuditService extends BaseService {
|
|
59
|
+
async record({
|
|
60
|
+
user = this.accountability.user ?? null,
|
|
61
|
+
action,
|
|
62
|
+
collection = null,
|
|
63
|
+
itemKey = null,
|
|
64
|
+
requestId = this.requestId ?? null,
|
|
65
|
+
ip = null,
|
|
66
|
+
payload = null,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (typeof action !== 'string' || !action.trim() || action.length > 32) {
|
|
69
|
+
throw auditError('INVALID_AUDIT_EVENT', 'Audit action is required and must not exceed 32 characters');
|
|
70
|
+
}
|
|
71
|
+
if (collection != null && (typeof collection !== 'string' || collection.length > 64)) {
|
|
72
|
+
throw auditError('INVALID_AUDIT_EVENT', 'Audit collection is invalid');
|
|
73
|
+
}
|
|
74
|
+
if (itemKey != null && String(itemKey).length > 191) {
|
|
75
|
+
throw auditError('INVALID_AUDIT_EVENT', 'Audit item key is too long');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const redacted = payload == null ? null : redactAuditValue(payload);
|
|
79
|
+
const [result] = await this.database.query(
|
|
80
|
+
`INSERT INTO yuncms_audit_log
|
|
81
|
+
(user, action, collection, item_key, request_id, ip, payload)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
83
|
+
[
|
|
84
|
+
user,
|
|
85
|
+
action.trim(),
|
|
86
|
+
collection,
|
|
87
|
+
itemKey == null ? null : String(itemKey),
|
|
88
|
+
requestId == null ? null : String(requestId).slice(0, 64),
|
|
89
|
+
ip == null ? null : String(ip).slice(0, 45),
|
|
90
|
+
redacted == null ? null : JSON.stringify(redacted),
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
return result.insertId ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async readMany({ limit = 100, offset = 0, collection = null, user = null } = {}) {
|
|
97
|
+
assertAuditReader(this.accountability);
|
|
98
|
+
const safeLimit = Number.isInteger(limit) ? Math.min(Math.max(limit, 1), 500) : 100;
|
|
99
|
+
const safeOffset = Number.isInteger(offset) ? Math.max(offset, 0) : 0;
|
|
100
|
+
const where = [];
|
|
101
|
+
const params = [];
|
|
102
|
+
|
|
103
|
+
if (collection) {
|
|
104
|
+
where.push('collection = ?');
|
|
105
|
+
params.push(collection);
|
|
106
|
+
}
|
|
107
|
+
if (user) {
|
|
108
|
+
where.push('user = ?');
|
|
109
|
+
params.push(user);
|
|
110
|
+
}
|
|
111
|
+
const whereSql = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
|
|
112
|
+
|
|
113
|
+
const [rows] = await this.database.query(
|
|
114
|
+
`SELECT id, user, action, collection, item_key, request_id, ip, payload, created_at
|
|
115
|
+
FROM yuncms_audit_log${whereSql}
|
|
116
|
+
ORDER BY id DESC
|
|
117
|
+
LIMIT ? OFFSET ?`,
|
|
118
|
+
[...params, safeLimit, safeOffset],
|
|
119
|
+
);
|
|
120
|
+
return rows.map((row) => ({ ...row, payload: decodePayload(row.payload) }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async cleanup({
|
|
124
|
+
retentionDays = DEFAULT_RETENTION_DAYS,
|
|
125
|
+
batchSize = DEFAULT_CLEANUP_BATCH_SIZE,
|
|
126
|
+
maxBatches = 100,
|
|
127
|
+
} = {}) {
|
|
128
|
+
assertAuditReader(this.accountability);
|
|
129
|
+
const days = boundedInteger(retentionDays, DEFAULT_RETENTION_DAYS, {
|
|
130
|
+
min: 1,
|
|
131
|
+
max: 3650,
|
|
132
|
+
label: 'Audit retention days',
|
|
133
|
+
});
|
|
134
|
+
const size = boundedInteger(batchSize, DEFAULT_CLEANUP_BATCH_SIZE, {
|
|
135
|
+
min: 1,
|
|
136
|
+
max: 5000,
|
|
137
|
+
label: 'Audit cleanup batch size',
|
|
138
|
+
});
|
|
139
|
+
const batchesLimit = boundedInteger(maxBatches, 100, {
|
|
140
|
+
min: 1,
|
|
141
|
+
max: 1000,
|
|
142
|
+
label: 'Audit cleanup max batches',
|
|
143
|
+
});
|
|
144
|
+
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
145
|
+
|
|
146
|
+
let deleted = 0;
|
|
147
|
+
let batches = 0;
|
|
148
|
+
let complete = false;
|
|
149
|
+
|
|
150
|
+
while (batches < batchesLimit) {
|
|
151
|
+
const [result] = await this.database.query(
|
|
152
|
+
`DELETE FROM yuncms_audit_log
|
|
153
|
+
WHERE created_at < ?
|
|
154
|
+
ORDER BY id ASC
|
|
155
|
+
LIMIT ?`,
|
|
156
|
+
[cutoff, size],
|
|
157
|
+
);
|
|
158
|
+
const affected = Number(result.affectedRows ?? 0);
|
|
159
|
+
deleted += affected;
|
|
160
|
+
batches += 1;
|
|
161
|
+
if (affected < size) {
|
|
162
|
+
complete = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
retentionDays: days,
|
|
169
|
+
batchSize: size,
|
|
170
|
+
maxBatches: batchesLimit,
|
|
171
|
+
cutoff,
|
|
172
|
+
deleted,
|
|
173
|
+
batches,
|
|
174
|
+
complete,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export {
|
|
180
|
+
DEFAULT_CLEANUP_BATCH_SIZE,
|
|
181
|
+
DEFAULT_RETENTION_DAYS,
|
|
182
|
+
};
|