@yunsoft/yuncms-api 0.1.0 → 0.1.2
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/README.md +6 -0
- package/package.json +2 -2
- package/src/app.js +15 -7
- package/src/error-response.js +6 -0
- package/src/rate-limit.js +9 -4
- package/src/routes/schema.js +56 -2
- package/src/routes/studio-settings.js +22 -0
- package/studio-dist/assets/index-Db-PpxwL.js +9 -0
- package/studio-dist/assets/index-I8WeDHKd.css +1 -0
- package/studio-dist/index.html +2 -2
- package/studio-dist/assets/index-B-brC-Im.css +0 -1
- package/studio-dist/assets/index-D3C1Fv1L.js +0 -9
package/README.md
CHANGED
|
@@ -3,3 +3,9 @@
|
|
|
3
3
|
Express API runtime and bundled React Studio server for YunCMS.
|
|
4
4
|
|
|
5
5
|
The normal entry point is the `yuncms` executable provided by `@yunsoft/yuncms`. See the [project repository](https://github.com/Yunsoft-Software/yuncms) for configuration and deployment documentation.
|
|
6
|
+
|
|
7
|
+
## Project status
|
|
8
|
+
|
|
9
|
+
YunCMS is developed and maintained by [Yunsoft Software](https://yunsoft.com). It is under active development, so interfaces and behavior may change between releases. Test upgrades and keep verified backups before production use.
|
|
10
|
+
|
|
11
|
+
Use YunCMS at your own risk. This package is provided under the [MIT License](https://github.com/Yunsoft-Software/yuncms/blob/16-08-2026/LICENSE) without warranty.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Express API runtime and bundled Studio server for YunCMS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test": "node --test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@yunsoft/yuncms-core": "0.1.
|
|
35
|
+
"@yunsoft/yuncms-core": "0.1.2",
|
|
36
36
|
"express": "5.2.1"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/app.js
CHANGED
|
@@ -14,9 +14,12 @@ import { createItemsRouter } from './routes/items.js';
|
|
|
14
14
|
import { createPermissionsRouter } from './routes/permissions.js';
|
|
15
15
|
import { createRolesRouter } from './routes/roles.js';
|
|
16
16
|
import { createSchemaRouter } from './routes/schema.js';
|
|
17
|
+
import { createStudioSettingsRouter } from './routes/studio-settings.js';
|
|
17
18
|
import { createUsersRouter } from './routes/users.js';
|
|
18
19
|
import { createStudioMiddleware } from './studio.js';
|
|
19
20
|
|
|
21
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
22
|
+
|
|
20
23
|
function securityHeaders(req, res, next) {
|
|
21
24
|
res.set('x-content-type-options', 'nosniff');
|
|
22
25
|
res.set('x-frame-options', 'DENY');
|
|
@@ -43,6 +46,13 @@ function studioCors(config) {
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function requestIdentity(req, res, next) {
|
|
50
|
+
const supplied = req.get('x-request-id');
|
|
51
|
+
req.id = supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
|
|
52
|
+
res.set('x-request-id', req.id);
|
|
53
|
+
next();
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
export function createApp({
|
|
47
57
|
pool,
|
|
48
58
|
config,
|
|
@@ -62,14 +72,11 @@ export function createApp({
|
|
|
62
72
|
const services = serviceRegistry.toObject();
|
|
63
73
|
const app = express();
|
|
64
74
|
app.disable('x-powered-by');
|
|
75
|
+
if (config.server?.trustProxyHops > 0) app.set('trust proxy', config.server.trustProxyHops);
|
|
65
76
|
app.use(securityHeaders);
|
|
66
77
|
app.use(studioCors(config));
|
|
78
|
+
app.use(requestIdentity);
|
|
67
79
|
app.use(express.json({ limit: '1mb' }));
|
|
68
|
-
app.use((req, res, next) => {
|
|
69
|
-
req.id = req.get('x-request-id') || randomUUID();
|
|
70
|
-
res.set('x-request-id', req.id);
|
|
71
|
-
next();
|
|
72
|
-
});
|
|
73
80
|
|
|
74
81
|
app.get('/health', (req, res) => {
|
|
75
82
|
res.json({ status: 'ok', request_id: req.id });
|
|
@@ -101,9 +108,10 @@ export function createApp({
|
|
|
101
108
|
emitter,
|
|
102
109
|
storage,
|
|
103
110
|
}));
|
|
111
|
+
app.use('/studio-settings', createStudioSettingsRouter());
|
|
104
112
|
app.use('/auth', createAuthRouter({ mailer, config, logger }));
|
|
105
113
|
app.use('/items', createItemsRouter());
|
|
106
|
-
app.use('/schema', createSchemaRouter());
|
|
114
|
+
app.use('/schema', createSchemaRouter({ schemaCache }));
|
|
107
115
|
app.use('/users', createUsersRouter());
|
|
108
116
|
app.use('/roles', createRolesRouter());
|
|
109
117
|
app.use('/permissions', createPermissionsRouter());
|
|
@@ -127,4 +135,4 @@ export function createApp({
|
|
|
127
135
|
return app;
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
export { securityHeaders, studioCors };
|
|
138
|
+
export { requestIdentity, securityHeaders, studioCors };
|
package/src/error-response.js
CHANGED
|
@@ -87,6 +87,12 @@ const SAFE_DATABASE_MESSAGES = new Map([
|
|
|
87
87
|
]);
|
|
88
88
|
|
|
89
89
|
function normalizeApiError(error) {
|
|
90
|
+
if (error?.type === 'entity.parse.failed') {
|
|
91
|
+
const normalized = new Error('Request body contains invalid JSON');
|
|
92
|
+
normalized.code = 'INVALID_PAYLOAD';
|
|
93
|
+
normalized.cause = error;
|
|
94
|
+
return normalized;
|
|
95
|
+
}
|
|
90
96
|
if (error?.type === 'entity.too.large') {
|
|
91
97
|
const normalized = new Error('Request body exceeds the configured upload limit');
|
|
92
98
|
normalized.code = 'PAYLOAD_TOO_LARGE';
|
package/src/rate-limit.js
CHANGED
|
@@ -14,24 +14,29 @@ export function createFixedWindowRateLimit({
|
|
|
14
14
|
} = {}) {
|
|
15
15
|
if (!Number.isInteger(windowMs) || windowMs < 1000) throw new Error('Rate-limit windowMs must be at least 1000');
|
|
16
16
|
if (!Number.isInteger(max) || max < 1) throw new Error('Rate-limit max must be a positive integer');
|
|
17
|
+
if (!Number.isInteger(maxBuckets) || maxBuckets < 1) throw new Error('Rate-limit maxBuckets must be a positive integer');
|
|
17
18
|
|
|
18
19
|
const buckets = new Map();
|
|
19
20
|
|
|
20
|
-
function
|
|
21
|
-
if (buckets.size < maxBuckets) return;
|
|
21
|
+
function ensureCapacity(timestamp) {
|
|
22
22
|
for (const [bucketKey, bucket] of buckets) {
|
|
23
23
|
if (bucket.resetAt <= timestamp) buckets.delete(bucketKey);
|
|
24
|
-
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
while (buckets.size >= maxBuckets) {
|
|
27
|
+
const oldestKey = buckets.keys().next().value;
|
|
28
|
+
if (oldestKey === undefined) break;
|
|
29
|
+
buckets.delete(oldestKey);
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
return (req, res, next) => {
|
|
29
34
|
const timestamp = now();
|
|
30
|
-
prune(timestamp);
|
|
31
35
|
const bucketKey = String(key(req));
|
|
32
36
|
let bucket = buckets.get(bucketKey);
|
|
33
37
|
|
|
34
38
|
if (!bucket || bucket.resetAt <= timestamp) {
|
|
39
|
+
if (!bucket) ensureCapacity(timestamp);
|
|
35
40
|
bucket = { count: 0, resetAt: timestamp + windowMs };
|
|
36
41
|
buckets.set(bucketKey, bucket);
|
|
37
42
|
}
|
package/src/routes/schema.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createO2ORelation,
|
|
4
|
+
deleteM2MJunction,
|
|
5
|
+
deleteO2ORelation,
|
|
6
|
+
} from '@yunsoft/yuncms-core';
|
|
3
7
|
|
|
4
8
|
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
5
9
|
|
|
@@ -34,15 +38,20 @@ async function auditSchema(req, { action, collection = null, itemKey = null, pay
|
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
export function createSchemaRouter() {
|
|
41
|
+
export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
38
42
|
const router = express.Router();
|
|
39
43
|
|
|
44
|
+
function clearSchemaCache() {
|
|
45
|
+
schemaCache?.clear();
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
router.get('/collections', async (req, res) => {
|
|
41
49
|
res.json({ data: await service(req, 'CollectionsService').readMany() });
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
router.post('/collections', async (req, res) => {
|
|
45
53
|
const data = await service(req, 'CollectionsService').createOne(req.body ?? {});
|
|
54
|
+
clearSchemaCache();
|
|
46
55
|
await auditSchema(req, {
|
|
47
56
|
action: 'schema.collection.create',
|
|
48
57
|
collection: data.collection,
|
|
@@ -66,6 +75,7 @@ export function createSchemaRouter() {
|
|
|
66
75
|
const collections = service(req, 'CollectionsService');
|
|
67
76
|
const before = await collections.readOne(req.params.collection);
|
|
68
77
|
const data = await collections.updateOne(req.params.collection, req.body ?? {});
|
|
78
|
+
clearSchemaCache();
|
|
69
79
|
await auditSchema(req, {
|
|
70
80
|
action: 'schema.collection.update',
|
|
71
81
|
collection: req.params.collection,
|
|
@@ -81,6 +91,7 @@ export function createSchemaRouter() {
|
|
|
81
91
|
await collections.deleteOne(req.params.collection, {
|
|
82
92
|
destructive: destructiveRequested(req),
|
|
83
93
|
});
|
|
94
|
+
clearSchemaCache();
|
|
84
95
|
await auditSchema(req, {
|
|
85
96
|
action: 'schema.collection.delete',
|
|
86
97
|
collection: req.params.collection,
|
|
@@ -99,6 +110,7 @@ export function createSchemaRouter() {
|
|
|
99
110
|
req.params.collection,
|
|
100
111
|
req.body ?? {},
|
|
101
112
|
);
|
|
113
|
+
clearSchemaCache();
|
|
102
114
|
await auditSchema(req, {
|
|
103
115
|
action: 'schema.field.create',
|
|
104
116
|
collection: req.params.collection,
|
|
@@ -129,6 +141,7 @@ export function createSchemaRouter() {
|
|
|
129
141
|
req.params.field,
|
|
130
142
|
req.body ?? {},
|
|
131
143
|
);
|
|
144
|
+
clearSchemaCache();
|
|
132
145
|
await auditSchema(req, {
|
|
133
146
|
action: 'schema.field.update',
|
|
134
147
|
collection: req.params.collection,
|
|
@@ -146,6 +159,7 @@ export function createSchemaRouter() {
|
|
|
146
159
|
req.params.field,
|
|
147
160
|
req.body ?? {},
|
|
148
161
|
);
|
|
162
|
+
clearSchemaCache();
|
|
149
163
|
await auditSchema(req, {
|
|
150
164
|
action: 'schema.field.alter',
|
|
151
165
|
collection: req.params.collection,
|
|
@@ -163,6 +177,7 @@ export function createSchemaRouter() {
|
|
|
163
177
|
req.params.field,
|
|
164
178
|
{ destructive: destructiveRequested(req) },
|
|
165
179
|
);
|
|
180
|
+
clearSchemaCache();
|
|
166
181
|
await auditSchema(req, {
|
|
167
182
|
action: 'schema.field.delete',
|
|
168
183
|
collection: req.params.collection,
|
|
@@ -197,6 +212,7 @@ export function createSchemaRouter() {
|
|
|
197
212
|
|
|
198
213
|
router.post('/relations/m2o', async (req, res) => {
|
|
199
214
|
const data = await service(req, 'RelationsService').createM2O(req.body ?? {});
|
|
215
|
+
clearSchemaCache();
|
|
200
216
|
await auditSchema(req, {
|
|
201
217
|
action: 'schema.relation.create',
|
|
202
218
|
collection: data.many_collection,
|
|
@@ -213,6 +229,7 @@ export function createSchemaRouter() {
|
|
|
213
229
|
req.params.manyCollection,
|
|
214
230
|
req.params.manyField,
|
|
215
231
|
);
|
|
232
|
+
clearSchemaCache();
|
|
216
233
|
await auditSchema(req, {
|
|
217
234
|
action: 'schema.relation.delete',
|
|
218
235
|
collection: req.params.manyCollection,
|
|
@@ -222,8 +239,44 @@ export function createSchemaRouter() {
|
|
|
222
239
|
res.status(204).end();
|
|
223
240
|
});
|
|
224
241
|
|
|
242
|
+
router.post('/relations/o2o', async (req, res) => {
|
|
243
|
+
const data = await createO2ORelation({
|
|
244
|
+
database: req.context.database,
|
|
245
|
+
accountability: req.accountability,
|
|
246
|
+
input: req.body ?? {},
|
|
247
|
+
});
|
|
248
|
+
clearSchemaCache();
|
|
249
|
+
await auditSchema(req, {
|
|
250
|
+
action: 'schema.relation.o2o.create',
|
|
251
|
+
collection: data.many_collection,
|
|
252
|
+
itemKey: data.many_field,
|
|
253
|
+
payload: { after: data },
|
|
254
|
+
});
|
|
255
|
+
res.status(201).json({ data });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
router.delete('/relations/o2o/:manyCollection/:manyField', async (req, res) => {
|
|
259
|
+
const relations = service(req, 'RelationsService');
|
|
260
|
+
const before = await relations.readOne(req.params.manyCollection, req.params.manyField);
|
|
261
|
+
await deleteO2ORelation({
|
|
262
|
+
database: req.context.database,
|
|
263
|
+
accountability: req.accountability,
|
|
264
|
+
manyCollection: req.params.manyCollection,
|
|
265
|
+
manyField: req.params.manyField,
|
|
266
|
+
});
|
|
267
|
+
clearSchemaCache();
|
|
268
|
+
await auditSchema(req, {
|
|
269
|
+
action: 'schema.relation.o2o.delete',
|
|
270
|
+
collection: req.params.manyCollection,
|
|
271
|
+
itemKey: req.params.manyField,
|
|
272
|
+
payload: { before },
|
|
273
|
+
});
|
|
274
|
+
res.status(204).end();
|
|
275
|
+
});
|
|
276
|
+
|
|
225
277
|
router.post('/relations/m2m', async (req, res) => {
|
|
226
278
|
const data = await service(req, 'RelationsService').createM2M(req.body ?? {});
|
|
279
|
+
clearSchemaCache();
|
|
227
280
|
await auditSchema(req, {
|
|
228
281
|
action: 'schema.relation.m2m.create',
|
|
229
282
|
collection: data.junctionCollection,
|
|
@@ -244,6 +297,7 @@ export function createSchemaRouter() {
|
|
|
244
297
|
junctionCollection: req.params.junctionCollection,
|
|
245
298
|
destructive: destructiveRequested(req),
|
|
246
299
|
});
|
|
300
|
+
clearSchemaCache();
|
|
247
301
|
await auditSchema(req, {
|
|
248
302
|
action: 'schema.relation.m2m.delete',
|
|
249
303
|
collection: req.params.junctionCollection,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
4
|
+
|
|
5
|
+
function settingsService(req) {
|
|
6
|
+
const Service = req.context.services.StudioSettingsService;
|
|
7
|
+
return new Service(serviceOptionsFromRequest(req));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createStudioSettingsRouter() {
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
router.get('/', async (req, res) => {
|
|
14
|
+
res.json({ data: await settingsService(req).readPublic() });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.patch('/', async (req, res) => {
|
|
18
|
+
res.json({ data: await settingsService(req).updateOne(req.body ?? {}) });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return router;
|
|
22
|
+
}
|