@yunsoft/yuncms-api 0.1.3 → 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
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';
|
|
@@ -20,6 +22,21 @@ import { createUsersRouter } from './routes/users.js';
|
|
|
20
22
|
import { createStudioMiddleware } from './studio.js';
|
|
21
23
|
|
|
22
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('; ');
|
|
23
40
|
|
|
24
41
|
function securityHeaders(req, res, next) {
|
|
25
42
|
res.set('x-content-type-options', 'nosniff');
|
|
@@ -27,6 +44,12 @@ function securityHeaders(req, res, next) {
|
|
|
27
44
|
res.set('referrer-policy', 'no-referrer');
|
|
28
45
|
res.set('permissions-policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()');
|
|
29
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
|
+
}
|
|
30
53
|
next();
|
|
31
54
|
}
|
|
32
55
|
|
|
@@ -54,12 +77,23 @@ function requestIdentity(req, res, next) {
|
|
|
54
77
|
next();
|
|
55
78
|
}
|
|
56
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
|
+
|
|
57
90
|
export function createApp({
|
|
58
91
|
pool,
|
|
59
92
|
config,
|
|
60
93
|
logger = console,
|
|
61
94
|
serviceRegistry = createCoreServiceRegistry(),
|
|
62
95
|
schemaCache = null,
|
|
96
|
+
permissionCache = null,
|
|
63
97
|
emitter = null,
|
|
64
98
|
storage = null,
|
|
65
99
|
mailer = null,
|
|
@@ -100,12 +134,19 @@ export function createApp({
|
|
|
100
134
|
|
|
101
135
|
app.use(createStudioMiddleware({ root: studioRoot }));
|
|
102
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
|
+
|
|
103
143
|
app.use(createAuthenticationMiddleware({
|
|
104
144
|
pool,
|
|
105
145
|
config,
|
|
106
146
|
logger,
|
|
107
147
|
services,
|
|
108
148
|
schemaCache,
|
|
149
|
+
permissionCache,
|
|
109
150
|
emitter,
|
|
110
151
|
storage,
|
|
111
152
|
}));
|
|
@@ -137,4 +178,10 @@ export function createApp({
|
|
|
137
178
|
return app;
|
|
138
179
|
}
|
|
139
180
|
|
|
140
|
-
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
|
+
}
|
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,
|