@yunsoft/yuncms-api 0.1.2 → 0.1.5
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 +50 -1
- package/src/audit-events.js +18 -0
- package/src/authentication.js +2 -0
- package/src/pressure-limit.js +51 -0
- package/src/routes/studio-settings.js +16 -0
- package/src/routes/system-schema.js +41 -0
- package/src/server.js +15 -9
- package/studio-dist/assets/index-CT3jNSGp.js +9 -0
- package/studio-dist/assets/{index-I8WeDHKd.css → index-Dd4flzSY.css} +1 -1
- package/studio-dist/index.html +3 -2
- package/studio-dist/assets/index-Db-PpxwL.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.5",
|
|
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.5",
|
|
36
36
|
"express": "5.2.1"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/app.js
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
|
|
8
8
|
import { createAuthenticationMiddleware } from './authentication.js';
|
|
9
9
|
import { apiErrorHandler } from './error-response.js';
|
|
10
|
+
import { createPressureLimit } from './pressure-limit.js';
|
|
11
|
+
import { createFixedWindowRateLimit } from './rate-limit.js';
|
|
10
12
|
import { createAuditRouter } from './routes/audit.js';
|
|
11
13
|
import { createAuthRouter } from './routes/auth.js';
|
|
12
14
|
import { createFilesRouter } from './routes/files.js';
|
|
@@ -15,10 +17,26 @@ import { createPermissionsRouter } from './routes/permissions.js';
|
|
|
15
17
|
import { createRolesRouter } from './routes/roles.js';
|
|
16
18
|
import { createSchemaRouter } from './routes/schema.js';
|
|
17
19
|
import { createStudioSettingsRouter } from './routes/studio-settings.js';
|
|
20
|
+
import { createSystemSchemaRouter } from './routes/system-schema.js';
|
|
18
21
|
import { createUsersRouter } from './routes/users.js';
|
|
19
22
|
import { createStudioMiddleware } from './studio.js';
|
|
20
23
|
|
|
21
24
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
25
|
+
const CONTENT_SECURITY_POLICY = [
|
|
26
|
+
"default-src 'self'",
|
|
27
|
+
"base-uri 'self'",
|
|
28
|
+
"object-src 'none'",
|
|
29
|
+
"frame-ancestors 'none'",
|
|
30
|
+
"script-src 'self'",
|
|
31
|
+
"style-src 'self' 'unsafe-inline'",
|
|
32
|
+
"img-src 'self' data: blob: https://yunsoft.com",
|
|
33
|
+
"font-src 'self' data:",
|
|
34
|
+
"connect-src 'self'",
|
|
35
|
+
"media-src 'self' blob:",
|
|
36
|
+
"frame-src 'self' blob:",
|
|
37
|
+
"worker-src 'self' blob:",
|
|
38
|
+
"form-action 'self'",
|
|
39
|
+
].join('; ');
|
|
22
40
|
|
|
23
41
|
function securityHeaders(req, res, next) {
|
|
24
42
|
res.set('x-content-type-options', 'nosniff');
|
|
@@ -26,6 +44,12 @@ function securityHeaders(req, res, next) {
|
|
|
26
44
|
res.set('referrer-policy', 'no-referrer');
|
|
27
45
|
res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
|
28
46
|
res.set('cross-origin-resource-policy', 'same-origin');
|
|
47
|
+
res.set('cross-origin-opener-policy', 'same-origin');
|
|
48
|
+
res.set('x-dns-prefetch-control', 'off');
|
|
49
|
+
res.set('content-security-policy', CONTENT_SECURITY_POLICY);
|
|
50
|
+
if (req.secure === true) {
|
|
51
|
+
res.set('strict-transport-security', 'max-age=15552000; includeSubDomains');
|
|
52
|
+
}
|
|
29
53
|
next();
|
|
30
54
|
}
|
|
31
55
|
|
|
@@ -53,12 +77,23 @@ function requestIdentity(req, res, next) {
|
|
|
53
77
|
next();
|
|
54
78
|
}
|
|
55
79
|
|
|
80
|
+
function createApiRateLimit(config) {
|
|
81
|
+
const limits = config?.server?.rateLimit;
|
|
82
|
+
if (!limits?.enabled) return null;
|
|
83
|
+
return createFixedWindowRateLimit({
|
|
84
|
+
windowMs: limits.windowMs,
|
|
85
|
+
max: limits.max,
|
|
86
|
+
maxBuckets: limits.maxBuckets,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
export function createApp({
|
|
57
91
|
pool,
|
|
58
92
|
config,
|
|
59
93
|
logger = console,
|
|
60
94
|
serviceRegistry = createCoreServiceRegistry(),
|
|
61
95
|
schemaCache = null,
|
|
96
|
+
permissionCache = null,
|
|
62
97
|
emitter = null,
|
|
63
98
|
storage = null,
|
|
64
99
|
mailer = null,
|
|
@@ -99,18 +134,26 @@ export function createApp({
|
|
|
99
134
|
|
|
100
135
|
app.use(createStudioMiddleware({ root: studioRoot }));
|
|
101
136
|
|
|
137
|
+
const pressureLimit = createPressureLimit(config.server?.pressure);
|
|
138
|
+
if (pressureLimit) app.use(pressureLimit);
|
|
139
|
+
|
|
140
|
+
const apiRateLimit = createApiRateLimit(config);
|
|
141
|
+
if (apiRateLimit) app.use(apiRateLimit);
|
|
142
|
+
|
|
102
143
|
app.use(createAuthenticationMiddleware({
|
|
103
144
|
pool,
|
|
104
145
|
config,
|
|
105
146
|
logger,
|
|
106
147
|
services,
|
|
107
148
|
schemaCache,
|
|
149
|
+
permissionCache,
|
|
108
150
|
emitter,
|
|
109
151
|
storage,
|
|
110
152
|
}));
|
|
111
153
|
app.use('/studio-settings', createStudioSettingsRouter());
|
|
112
154
|
app.use('/auth', createAuthRouter({ mailer, config, logger }));
|
|
113
155
|
app.use('/items', createItemsRouter());
|
|
156
|
+
app.use('/schema', createSystemSchemaRouter({ schemaCache }));
|
|
114
157
|
app.use('/schema', createSchemaRouter({ schemaCache }));
|
|
115
158
|
app.use('/users', createUsersRouter());
|
|
116
159
|
app.use('/roles', createRolesRouter());
|
|
@@ -135,4 +178,10 @@ export function createApp({
|
|
|
135
178
|
return app;
|
|
136
179
|
}
|
|
137
180
|
|
|
138
|
-
export {
|
|
181
|
+
export {
|
|
182
|
+
CONTENT_SECURITY_POLICY,
|
|
183
|
+
createApiRateLimit,
|
|
184
|
+
requestIdentity,
|
|
185
|
+
securityHeaders,
|
|
186
|
+
studioCors,
|
|
187
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const INTERNAL_AUDIT_EVENTS = Object.freeze([
|
|
2
|
+
'items.create',
|
|
3
|
+
'items.update',
|
|
4
|
+
'items.delete',
|
|
5
|
+
'files.create',
|
|
6
|
+
'files.update',
|
|
7
|
+
'files.delete',
|
|
8
|
+
'users.create',
|
|
9
|
+
'users.update',
|
|
10
|
+
'users.delete',
|
|
11
|
+
'users.password.update',
|
|
12
|
+
'roles.create',
|
|
13
|
+
'roles.update',
|
|
14
|
+
'roles.delete',
|
|
15
|
+
'permissions.create',
|
|
16
|
+
'permissions.update',
|
|
17
|
+
'permissions.delete',
|
|
18
|
+
]);
|
package/src/authentication.js
CHANGED
|
@@ -25,6 +25,7 @@ export function createAuthenticationMiddleware({
|
|
|
25
25
|
logger,
|
|
26
26
|
services,
|
|
27
27
|
schemaCache = null,
|
|
28
|
+
permissionCache = null,
|
|
28
29
|
emitter = null,
|
|
29
30
|
storage = null,
|
|
30
31
|
}) {
|
|
@@ -79,6 +80,7 @@ export function createAuthenticationMiddleware({
|
|
|
79
80
|
env: config,
|
|
80
81
|
emitter,
|
|
81
82
|
storage,
|
|
83
|
+
permissionCache,
|
|
82
84
|
requestId: req.id,
|
|
83
85
|
});
|
|
84
86
|
next();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function assertPositiveInteger(value, name) {
|
|
2
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createPressureLimit({
|
|
6
|
+
enabled = true,
|
|
7
|
+
maxConcurrent = 250,
|
|
8
|
+
maxHeapPercent = 95,
|
|
9
|
+
retryAfterSeconds = 1,
|
|
10
|
+
memoryUsage = () => process.memoryUsage(),
|
|
11
|
+
} = {}) {
|
|
12
|
+
if (!enabled) return null;
|
|
13
|
+
assertPositiveInteger(maxConcurrent, 'Pressure maxConcurrent');
|
|
14
|
+
assertPositiveInteger(maxHeapPercent, 'Pressure maxHeapPercent');
|
|
15
|
+
if (maxHeapPercent > 100) throw new Error('Pressure maxHeapPercent cannot exceed 100');
|
|
16
|
+
assertPositiveInteger(retryAfterSeconds, 'Pressure retryAfterSeconds');
|
|
17
|
+
if (typeof memoryUsage !== 'function') throw new Error('Pressure memoryUsage must be a function');
|
|
18
|
+
|
|
19
|
+
let inFlight = 0;
|
|
20
|
+
|
|
21
|
+
return (req, res, next) => {
|
|
22
|
+
const memory = memoryUsage();
|
|
23
|
+
const heapPercent = memory.heapTotal > 0
|
|
24
|
+
? (memory.heapUsed / memory.heapTotal) * 100
|
|
25
|
+
: 0;
|
|
26
|
+
const overloaded = inFlight >= maxConcurrent || heapPercent >= maxHeapPercent;
|
|
27
|
+
|
|
28
|
+
if (overloaded) {
|
|
29
|
+
res.set('retry-after', String(retryAfterSeconds));
|
|
30
|
+
res.status(503).json({
|
|
31
|
+
errors: [{
|
|
32
|
+
code: 'SERVER_PRESSURE',
|
|
33
|
+
message: 'Server is temporarily under pressure',
|
|
34
|
+
request_id: req.id ?? null,
|
|
35
|
+
}],
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
inFlight += 1;
|
|
41
|
+
let released = false;
|
|
42
|
+
const release = () => {
|
|
43
|
+
if (released) return;
|
|
44
|
+
released = true;
|
|
45
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
46
|
+
};
|
|
47
|
+
res.once('finish', release);
|
|
48
|
+
res.once('close', release);
|
|
49
|
+
next();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -7,9 +7,25 @@ function settingsService(req) {
|
|
|
7
7
|
return new Service(serviceOptionsFromRequest(req));
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function sendImageAsset(res, asset) {
|
|
11
|
+
const { file, contents } = asset;
|
|
12
|
+
res.set('cache-control', 'no-cache, must-revalidate');
|
|
13
|
+
res.set('content-security-policy', 'sandbox');
|
|
14
|
+
res.set('content-disposition', 'inline');
|
|
15
|
+
return res.type(file.mimetype || 'application/octet-stream').send(contents);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function createStudioSettingsRouter() {
|
|
11
19
|
const router = express.Router();
|
|
12
20
|
|
|
21
|
+
router.get('/logo', async (req, res) => {
|
|
22
|
+
sendImageAsset(res, await settingsService(req).readLogoContent());
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
router.get('/favicon', async (req, res) => {
|
|
26
|
+
sendImageAsset(res, await settingsService(req).readFaviconContent());
|
|
27
|
+
});
|
|
28
|
+
|
|
13
29
|
router.get('/', async (req, res) => {
|
|
14
30
|
res.json({ data: await settingsService(req).readPublic() });
|
|
15
31
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
4
|
+
|
|
5
|
+
function service(req, name) {
|
|
6
|
+
const Service = req.context.services[name];
|
|
7
|
+
if (!Service) throw new Error(`Service is not registered: ${name}`);
|
|
8
|
+
return new Service(serviceOptionsFromRequest(req));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSystemSchemaRouter({ schemaCache = null } = {}) {
|
|
12
|
+
const router = express.Router();
|
|
13
|
+
|
|
14
|
+
router.post('/system-collections/:collection/fields', async (req, res) => {
|
|
15
|
+
const data = await service(req, 'SystemCollectionFieldsService').createOne(
|
|
16
|
+
req.params.collection,
|
|
17
|
+
req.body ?? {},
|
|
18
|
+
);
|
|
19
|
+
schemaCache?.clear();
|
|
20
|
+
try {
|
|
21
|
+
await service(req, 'AuditService').record({
|
|
22
|
+
action: 'schema.system-field.create',
|
|
23
|
+
collection: req.params.collection,
|
|
24
|
+
itemKey: data.field,
|
|
25
|
+
requestId: req.id,
|
|
26
|
+
payload: { after: data },
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
req.context.logger?.error?.('YunCMS system field audit write failed after committed mutation', {
|
|
30
|
+
requestId: req.id,
|
|
31
|
+
collection: req.params.collection,
|
|
32
|
+
field: data.field,
|
|
33
|
+
code: error?.code,
|
|
34
|
+
message: error?.message,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
res.status(201).json({ data });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return router;
|
|
41
|
+
}
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertDatabaseCompatible,
|
|
3
|
+
assertMaintenanceStartupAllowed,
|
|
3
4
|
closeDatabasePool,
|
|
4
5
|
createCoreServiceRegistry,
|
|
5
6
|
createDatabasePool,
|
|
@@ -10,14 +11,20 @@ import {
|
|
|
10
11
|
loadConfig,
|
|
11
12
|
loadEnvFileIfPresent,
|
|
12
13
|
LocalStorageDriver,
|
|
14
|
+
MemoryCacheStore,
|
|
13
15
|
S3StorageDriver,
|
|
14
16
|
SchemaCache,
|
|
15
17
|
SmtpMailer,
|
|
16
18
|
} from '@yunsoft/yuncms-core';
|
|
17
19
|
import { createApp } from './app.js';
|
|
20
|
+
import { INTERNAL_AUDIT_EVENTS } from './audit-events.js';
|
|
18
21
|
import { loadExtensionRuntime } from './extensions/runtime.js';
|
|
19
22
|
|
|
20
23
|
loadEnvFileIfPresent();
|
|
24
|
+
await assertMaintenanceStartupAllowed({
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
env: process.env,
|
|
27
|
+
});
|
|
21
28
|
const config = loadConfig();
|
|
22
29
|
const logger = createJsonLogger({ level: config.logging.level });
|
|
23
30
|
const pool = createDatabasePool(config.database);
|
|
@@ -35,6 +42,12 @@ if (config.storage.s3.bucket) {
|
|
|
35
42
|
});
|
|
36
43
|
}
|
|
37
44
|
const storage = createStorageRegistry(storageDrivers);
|
|
45
|
+
const permissionCache = config.cache.enabled
|
|
46
|
+
? new MemoryCacheStore({
|
|
47
|
+
ttlMs: config.cache.ttlMs,
|
|
48
|
+
maxEntries: config.cache.maxEntries,
|
|
49
|
+
})
|
|
50
|
+
: null;
|
|
38
51
|
|
|
39
52
|
const hasAnyMailConfig = Boolean(
|
|
40
53
|
config.mail.host || config.mail.from || config.mail.user || config.mail.password,
|
|
@@ -59,16 +72,8 @@ let shuttingDown = false;
|
|
|
59
72
|
function registerInternalAudit({ emitter, services }) {
|
|
60
73
|
const AuditService = services.AuditService;
|
|
61
74
|
const systemAccountability = createSystemAccountability();
|
|
62
|
-
const events = [
|
|
63
|
-
'items.create',
|
|
64
|
-
'items.update',
|
|
65
|
-
'items.delete',
|
|
66
|
-
'files.create',
|
|
67
|
-
'files.update',
|
|
68
|
-
'files.delete',
|
|
69
|
-
];
|
|
70
75
|
|
|
71
|
-
for (const event of
|
|
76
|
+
for (const event of INTERNAL_AUDIT_EVENTS) {
|
|
72
77
|
emitter.registerAction(event, async (payload, context) => {
|
|
73
78
|
try {
|
|
74
79
|
const audit = new AuditService({
|
|
@@ -122,6 +127,7 @@ async function start() {
|
|
|
122
127
|
logger,
|
|
123
128
|
serviceRegistry,
|
|
124
129
|
schemaCache,
|
|
130
|
+
permissionCache,
|
|
125
131
|
emitter,
|
|
126
132
|
storage,
|
|
127
133
|
mailer,
|