@yunsoft/yuncms-api 0.1.0 → 0.1.1
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 +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 +16 -1
- package/src/routes/studio-settings.js +22 -0
- package/studio-dist/assets/index-CeSr4SMH.js +9 -0
- package/studio-dist/assets/index-CozkIhar.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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.1",
|
|
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
|
@@ -34,15 +34,20 @@ async function auditSchema(req, { action, collection = null, itemKey = null, pay
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function createSchemaRouter() {
|
|
37
|
+
export function createSchemaRouter({ schemaCache = null } = {}) {
|
|
38
38
|
const router = express.Router();
|
|
39
39
|
|
|
40
|
+
function clearSchemaCache() {
|
|
41
|
+
schemaCache?.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
router.get('/collections', async (req, res) => {
|
|
41
45
|
res.json({ data: await service(req, 'CollectionsService').readMany() });
|
|
42
46
|
});
|
|
43
47
|
|
|
44
48
|
router.post('/collections', async (req, res) => {
|
|
45
49
|
const data = await service(req, 'CollectionsService').createOne(req.body ?? {});
|
|
50
|
+
clearSchemaCache();
|
|
46
51
|
await auditSchema(req, {
|
|
47
52
|
action: 'schema.collection.create',
|
|
48
53
|
collection: data.collection,
|
|
@@ -66,6 +71,7 @@ export function createSchemaRouter() {
|
|
|
66
71
|
const collections = service(req, 'CollectionsService');
|
|
67
72
|
const before = await collections.readOne(req.params.collection);
|
|
68
73
|
const data = await collections.updateOne(req.params.collection, req.body ?? {});
|
|
74
|
+
clearSchemaCache();
|
|
69
75
|
await auditSchema(req, {
|
|
70
76
|
action: 'schema.collection.update',
|
|
71
77
|
collection: req.params.collection,
|
|
@@ -81,6 +87,7 @@ export function createSchemaRouter() {
|
|
|
81
87
|
await collections.deleteOne(req.params.collection, {
|
|
82
88
|
destructive: destructiveRequested(req),
|
|
83
89
|
});
|
|
90
|
+
clearSchemaCache();
|
|
84
91
|
await auditSchema(req, {
|
|
85
92
|
action: 'schema.collection.delete',
|
|
86
93
|
collection: req.params.collection,
|
|
@@ -99,6 +106,7 @@ export function createSchemaRouter() {
|
|
|
99
106
|
req.params.collection,
|
|
100
107
|
req.body ?? {},
|
|
101
108
|
);
|
|
109
|
+
clearSchemaCache();
|
|
102
110
|
await auditSchema(req, {
|
|
103
111
|
action: 'schema.field.create',
|
|
104
112
|
collection: req.params.collection,
|
|
@@ -129,6 +137,7 @@ export function createSchemaRouter() {
|
|
|
129
137
|
req.params.field,
|
|
130
138
|
req.body ?? {},
|
|
131
139
|
);
|
|
140
|
+
clearSchemaCache();
|
|
132
141
|
await auditSchema(req, {
|
|
133
142
|
action: 'schema.field.update',
|
|
134
143
|
collection: req.params.collection,
|
|
@@ -146,6 +155,7 @@ export function createSchemaRouter() {
|
|
|
146
155
|
req.params.field,
|
|
147
156
|
req.body ?? {},
|
|
148
157
|
);
|
|
158
|
+
clearSchemaCache();
|
|
149
159
|
await auditSchema(req, {
|
|
150
160
|
action: 'schema.field.alter',
|
|
151
161
|
collection: req.params.collection,
|
|
@@ -163,6 +173,7 @@ export function createSchemaRouter() {
|
|
|
163
173
|
req.params.field,
|
|
164
174
|
{ destructive: destructiveRequested(req) },
|
|
165
175
|
);
|
|
176
|
+
clearSchemaCache();
|
|
166
177
|
await auditSchema(req, {
|
|
167
178
|
action: 'schema.field.delete',
|
|
168
179
|
collection: req.params.collection,
|
|
@@ -197,6 +208,7 @@ export function createSchemaRouter() {
|
|
|
197
208
|
|
|
198
209
|
router.post('/relations/m2o', async (req, res) => {
|
|
199
210
|
const data = await service(req, 'RelationsService').createM2O(req.body ?? {});
|
|
211
|
+
clearSchemaCache();
|
|
200
212
|
await auditSchema(req, {
|
|
201
213
|
action: 'schema.relation.create',
|
|
202
214
|
collection: data.many_collection,
|
|
@@ -213,6 +225,7 @@ export function createSchemaRouter() {
|
|
|
213
225
|
req.params.manyCollection,
|
|
214
226
|
req.params.manyField,
|
|
215
227
|
);
|
|
228
|
+
clearSchemaCache();
|
|
216
229
|
await auditSchema(req, {
|
|
217
230
|
action: 'schema.relation.delete',
|
|
218
231
|
collection: req.params.manyCollection,
|
|
@@ -224,6 +237,7 @@ export function createSchemaRouter() {
|
|
|
224
237
|
|
|
225
238
|
router.post('/relations/m2m', async (req, res) => {
|
|
226
239
|
const data = await service(req, 'RelationsService').createM2M(req.body ?? {});
|
|
240
|
+
clearSchemaCache();
|
|
227
241
|
await auditSchema(req, {
|
|
228
242
|
action: 'schema.relation.m2m.create',
|
|
229
243
|
collection: data.junctionCollection,
|
|
@@ -244,6 +258,7 @@ export function createSchemaRouter() {
|
|
|
244
258
|
junctionCollection: req.params.junctionCollection,
|
|
245
259
|
destructive: destructiveRequested(req),
|
|
246
260
|
});
|
|
261
|
+
clearSchemaCache();
|
|
247
262
|
await auditSchema(req, {
|
|
248
263
|
action: 'schema.relation.m2m.delete',
|
|
249
264
|
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
|
+
}
|