@yunsoft/yuncms-api 0.1.26 → 0.1.27
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 +3 -2
- package/src/byte-range.js +204 -0
- package/src/extensions/discovery.js +14 -5
- package/src/routes/files.js +133 -5
- package/src/routes/roles.js +1 -1
- package/src/routes/users.js +1 -1
- package/studio-dist/assets/index-BgonJo12.js +11 -0
- package/studio-dist/index.html +1 -1
- package/studio-dist/assets/index-NAtDOkZk.js +0 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunsoft/yuncms-api",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Express API runtime and bundled Studio server for YunCMS.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@modelcontextprotocol/node": "2.0.0",
|
|
36
36
|
"@modelcontextprotocol/server": "2.0.0",
|
|
37
37
|
"@node-saml/node-saml": "5.1.0",
|
|
38
|
-
"@yunsoft/yuncms-core": "0.1.
|
|
38
|
+
"@yunsoft/yuncms-core": "0.1.27",
|
|
39
39
|
"express": "5.2.1",
|
|
40
40
|
"ldapts": "9.0.0",
|
|
41
41
|
"openid-client": "6.8.7",
|
package/src/app.js
CHANGED
|
@@ -50,8 +50,9 @@ function studioCors(config) {
|
|
|
50
50
|
if (origin && allowedOrigin && origin === allowedOrigin) {
|
|
51
51
|
res.set('access-control-allow-origin', origin);
|
|
52
52
|
res.set('vary', 'Origin');
|
|
53
|
-
res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype, mcp-protocol-version, mcp-method, mcp-name');
|
|
54
|
-
res.set('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
53
|
+
res.set('access-control-allow-headers', 'content-type, authorization, x-request-id, x-filename, x-title, x-mimetype, mcp-protocol-version, mcp-method, mcp-name, range, if-range, if-none-match, if-modified-since');
|
|
54
|
+
res.set('access-control-allow-methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS');
|
|
55
|
+
res.set('access-control-expose-headers', 'etag, content-range, accept-ranges, last-modified');
|
|
55
56
|
}
|
|
56
57
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
57
58
|
return next();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure HTTP byte-range helper compliant with RFC 9110 Sections 13.1.5 and 14.1.2.
|
|
3
|
+
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-if-range
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function parseByteRange(rangeHeader, size) {
|
|
7
|
+
if (rangeHeader == null || rangeHeader === '') {
|
|
8
|
+
return { type: 'none' };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof rangeHeader !== 'string') {
|
|
12
|
+
return { type: 'malformed' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const trimmed = rangeHeader.trim();
|
|
16
|
+
if (!trimmed.startsWith('bytes=')) {
|
|
17
|
+
return { type: 'malformed' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const spec = trimmed.slice(6).trim();
|
|
21
|
+
if (!spec) {
|
|
22
|
+
return { type: 'malformed' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Multi-range is ignored according to YunCMS specification
|
|
26
|
+
if (spec.includes(',')) {
|
|
27
|
+
return { type: 'multi' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const match = spec.match(/^(?:(\d+)-(\d*)|-(\d+))$/);
|
|
31
|
+
if (!match) {
|
|
32
|
+
return { type: 'malformed' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const safeSize = Number(size);
|
|
36
|
+
|
|
37
|
+
if (match[1] !== undefined) {
|
|
38
|
+
const startStr = match[1];
|
|
39
|
+
const endStr = match[2];
|
|
40
|
+
|
|
41
|
+
if (endStr !== '') {
|
|
42
|
+
// Form: start-end
|
|
43
|
+
// Guard against huge numbers exceeding MAX_SAFE_INTEGER
|
|
44
|
+
if (startStr.length > 16 || Number(startStr) > Number.MAX_SAFE_INTEGER) {
|
|
45
|
+
return { type: 'unsatisfiable', size: safeSize };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const start = Number(startStr);
|
|
49
|
+
let end;
|
|
50
|
+
if (endStr.length > 16 || Number(endStr) > Number.MAX_SAFE_INTEGER) {
|
|
51
|
+
end = safeSize > 0 ? safeSize - 1 : 0;
|
|
52
|
+
} else {
|
|
53
|
+
end = Number(endStr);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// RFC 9110 14.1.2: invalid if last-byte-pos is less than first-byte-pos
|
|
57
|
+
if (start > end) {
|
|
58
|
+
return { type: 'malformed' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (safeSize === 0 || start >= safeSize) {
|
|
62
|
+
return { type: 'unsatisfiable', size: safeSize };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const clampedEnd = Math.min(end, safeSize - 1);
|
|
66
|
+
return {
|
|
67
|
+
type: 'range',
|
|
68
|
+
start,
|
|
69
|
+
end: clampedEnd,
|
|
70
|
+
length: clampedEnd - start + 1,
|
|
71
|
+
size: safeSize,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Form: start- (open-ended)
|
|
76
|
+
if (startStr.length > 16 || Number(startStr) > Number.MAX_SAFE_INTEGER) {
|
|
77
|
+
return { type: 'unsatisfiable', size: safeSize };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const start = Number(startStr);
|
|
81
|
+
if (safeSize === 0 || start >= safeSize) {
|
|
82
|
+
return { type: 'unsatisfiable', size: safeSize };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const end = safeSize - 1;
|
|
86
|
+
return {
|
|
87
|
+
type: 'range',
|
|
88
|
+
start,
|
|
89
|
+
end,
|
|
90
|
+
length: end - start + 1,
|
|
91
|
+
size: safeSize,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Form: -suffix
|
|
96
|
+
const suffixStr = match[3];
|
|
97
|
+
if (suffixStr.length > 16 || Number(suffixStr) > Number.MAX_SAFE_INTEGER) {
|
|
98
|
+
if (safeSize === 0) return { type: 'unsatisfiable', size: safeSize };
|
|
99
|
+
return {
|
|
100
|
+
type: 'range',
|
|
101
|
+
start: 0,
|
|
102
|
+
end: safeSize - 1,
|
|
103
|
+
length: safeSize,
|
|
104
|
+
size: safeSize,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const suffix = Number(suffixStr);
|
|
109
|
+
if (suffix === 0 || safeSize === 0) {
|
|
110
|
+
return { type: 'unsatisfiable', size: safeSize };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const start = Math.max(0, safeSize - suffix);
|
|
114
|
+
const end = safeSize - 1;
|
|
115
|
+
return {
|
|
116
|
+
type: 'range',
|
|
117
|
+
start,
|
|
118
|
+
end,
|
|
119
|
+
length: end - start + 1,
|
|
120
|
+
size: safeSize,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const HTTP_DATE_REGEX = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4})$/;
|
|
125
|
+
|
|
126
|
+
export function checkIfRange(ifRangeHeader, etag, modifiedAt) {
|
|
127
|
+
if (ifRangeHeader == null || ifRangeHeader === '') {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof ifRangeHeader !== 'string') {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const token = ifRangeHeader.trim();
|
|
136
|
+
// RFC 9110 section 13.1.5: weak entity tags MUST NOT be used for subrange requests
|
|
137
|
+
if (token.startsWith('W/') || token.startsWith('w/')) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Strong entity tag
|
|
142
|
+
if (token.startsWith('"')) {
|
|
143
|
+
return token === etag;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// HTTP-date validator: exact second match per RFC 9110 section 13.1.5 (not <=)
|
|
147
|
+
// Non-HTTP dates (ISO strings, numeric timestamps, date garbage) are rejected per RFC 9110 section 5.6.7
|
|
148
|
+
if (!modifiedAt || !HTTP_DATE_REGEX.test(token)) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parsed = Date.parse(token);
|
|
153
|
+
if (Number.isNaN(parsed)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const modDate = modifiedAt instanceof Date ? modifiedAt : new Date(modifiedAt);
|
|
158
|
+
if (Number.isNaN(modDate.getTime())) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return Math.floor(parsed / 1000) === Math.floor(modDate.getTime() / 1000);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function evaluateRangeRequest({
|
|
166
|
+
rangeHeader,
|
|
167
|
+
ifRangeHeader,
|
|
168
|
+
size,
|
|
169
|
+
etag,
|
|
170
|
+
modifiedAt,
|
|
171
|
+
}) {
|
|
172
|
+
const safeSize = Number(size);
|
|
173
|
+
|
|
174
|
+
if (ifRangeHeader && !checkIfRange(ifRangeHeader, etag, modifiedAt)) {
|
|
175
|
+
return {
|
|
176
|
+
status: 200,
|
|
177
|
+
contentLength: safeSize,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const parsed = parseByteRange(rangeHeader, safeSize);
|
|
182
|
+
if (parsed.type === 'range') {
|
|
183
|
+
return {
|
|
184
|
+
status: 206,
|
|
185
|
+
start: parsed.start,
|
|
186
|
+
end: parsed.end,
|
|
187
|
+
contentLength: parsed.length,
|
|
188
|
+
contentRange: `bytes ${parsed.start}-${parsed.end}/${safeSize}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (parsed.type === 'unsatisfiable') {
|
|
193
|
+
return {
|
|
194
|
+
status: 416,
|
|
195
|
+
contentLength: 0,
|
|
196
|
+
contentRange: `bytes */${safeSize}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
status: 200,
|
|
202
|
+
contentLength: safeSize,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -55,11 +55,20 @@ async function resolveDependencyPackageRoot(rootDir, packageName) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
if (/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(packageName)) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
58
|
+
const lookupPaths = requireFromProject.resolve.paths(packageName) || [];
|
|
59
|
+
for (const lookupDir of lookupPaths) {
|
|
60
|
+
const candidateRoot = join(lookupDir, ...packageName.split('/'));
|
|
61
|
+
const candidatePackagePath = join(candidateRoot, 'package.json');
|
|
62
|
+
if (await exists(candidatePackagePath)) {
|
|
63
|
+
try {
|
|
64
|
+
const candidatePackage = await readJson(candidatePackagePath);
|
|
65
|
+
if (candidatePackage?.name === packageName) {
|
|
66
|
+
return candidateRoot;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Keep searching if candidate package.json is unreadable or malformed
|
|
70
|
+
}
|
|
71
|
+
}
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|
package/src/routes/files.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { pipeline } from 'node:stream/promises';
|
|
1
2
|
import express from 'express';
|
|
2
3
|
|
|
4
|
+
import { evaluateRangeRequest } from '../byte-range.js';
|
|
3
5
|
import { serviceOptionsFromRequest } from '../service-options.js';
|
|
4
6
|
|
|
5
7
|
function filesService(req) {
|
|
@@ -36,6 +38,52 @@ function attachmentHeader(filename) {
|
|
|
36
38
|
return `attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(safe)}`;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
async function pipeStreamToResponse(stream, res) {
|
|
42
|
+
let upstreamDestroyed = false;
|
|
43
|
+
const destroyUpstream = () => {
|
|
44
|
+
if (upstreamDestroyed) return;
|
|
45
|
+
upstreamDestroyed = true;
|
|
46
|
+
if (stream && typeof stream.destroy === 'function' && !stream.destroyed) {
|
|
47
|
+
try {
|
|
48
|
+
stream.destroy();
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const onClose = () => {
|
|
54
|
+
if (!res.writableEnded) {
|
|
55
|
+
destroyUpstream();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
res.on('close', onClose);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await pipeline(stream, res);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
destroyUpstream();
|
|
65
|
+
if (res.headersSent || res.destroyed) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
} finally {
|
|
70
|
+
res.off('close', onClose);
|
|
71
|
+
if (!res.writableEnded) {
|
|
72
|
+
destroyUpstream();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clearFileHeaders(res) {
|
|
78
|
+
res.removeHeader('accept-ranges');
|
|
79
|
+
res.removeHeader('content-type');
|
|
80
|
+
res.removeHeader('content-disposition');
|
|
81
|
+
res.removeHeader('content-length');
|
|
82
|
+
res.removeHeader('content-range');
|
|
83
|
+
res.removeHeader('etag');
|
|
84
|
+
res.removeHeader('last-modified');
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
export function createFilesRouter({ maxUploadBytes = 25 * 1024 * 1024 } = {}) {
|
|
40
88
|
const router = express.Router();
|
|
41
89
|
const rawUpload = express.raw({ type: 'application/octet-stream', limit: maxUploadBytes });
|
|
@@ -74,12 +122,92 @@ export function createFilesRouter({ maxUploadBytes = 25 * 1024 * 1024 } = {}) {
|
|
|
74
122
|
res.json({ data });
|
|
75
123
|
});
|
|
76
124
|
|
|
125
|
+
router.head('/:id/content', async (req, res) => {
|
|
126
|
+
const info = await filesService(req).readContentInfo(req.params.id);
|
|
127
|
+
res.set('etag', info.etag);
|
|
128
|
+
if (info.modifiedAt) {
|
|
129
|
+
res.set('last-modified', info.modifiedAt.toUTCString());
|
|
130
|
+
}
|
|
131
|
+
res.set('accept-ranges', 'bytes');
|
|
132
|
+
res.set('content-type', info.file.mimetype || 'application/octet-stream');
|
|
133
|
+
res.set('content-disposition', attachmentHeader(info.file.filename_download));
|
|
134
|
+
if (req.fresh) {
|
|
135
|
+
return res.status(304).end();
|
|
136
|
+
}
|
|
137
|
+
res.set('content-length', String(info.size));
|
|
138
|
+
res.status(200).end();
|
|
139
|
+
});
|
|
140
|
+
|
|
77
141
|
router.get('/:id/content', async (req, res) => {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
res.set('
|
|
82
|
-
|
|
142
|
+
const service = filesService(req);
|
|
143
|
+
const info = await service.readContentInfo(req.params.id);
|
|
144
|
+
|
|
145
|
+
res.set('etag', info.etag);
|
|
146
|
+
if (info.modifiedAt) {
|
|
147
|
+
res.set('last-modified', info.modifiedAt.toUTCString());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (req.fresh) {
|
|
151
|
+
res.set('accept-ranges', 'bytes');
|
|
152
|
+
res.set('content-type', info.file.mimetype || 'application/octet-stream');
|
|
153
|
+
res.set('content-disposition', attachmentHeader(info.file.filename_download));
|
|
154
|
+
return res.status(304).end();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (req.method === 'HEAD') {
|
|
158
|
+
res.set('accept-ranges', 'bytes');
|
|
159
|
+
res.set('content-type', info.file.mimetype || 'application/octet-stream');
|
|
160
|
+
res.set('content-disposition', attachmentHeader(info.file.filename_download));
|
|
161
|
+
res.set('content-length', String(info.size));
|
|
162
|
+
return res.status(200).end();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const evaluation = evaluateRangeRequest({
|
|
166
|
+
rangeHeader: req.get('range'),
|
|
167
|
+
ifRangeHeader: req.get('if-range'),
|
|
168
|
+
size: info.size,
|
|
169
|
+
etag: info.etag,
|
|
170
|
+
modifiedAt: info.modifiedAt,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (evaluation.status === 416) {
|
|
174
|
+
res.set('accept-ranges', 'bytes');
|
|
175
|
+
res.set('content-type', info.file.mimetype || 'application/octet-stream');
|
|
176
|
+
res.set('content-disposition', attachmentHeader(info.file.filename_download));
|
|
177
|
+
res.set('content-range', evaluation.contentRange);
|
|
178
|
+
res.set('content-length', '0');
|
|
179
|
+
return res.status(416).end();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let streamResult;
|
|
183
|
+
try {
|
|
184
|
+
if (evaluation.status === 206) {
|
|
185
|
+
streamResult = await service.readContentStream(req.params.id, {
|
|
186
|
+
start: evaluation.start,
|
|
187
|
+
end: evaluation.end,
|
|
188
|
+
});
|
|
189
|
+
} else {
|
|
190
|
+
streamResult = await service.readContentStream(req.params.id);
|
|
191
|
+
}
|
|
192
|
+
} catch (streamError) {
|
|
193
|
+
clearFileHeaders(res);
|
|
194
|
+
throw streamError;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
res.set('accept-ranges', 'bytes');
|
|
198
|
+
res.set('content-type', info.file.mimetype || 'application/octet-stream');
|
|
199
|
+
res.set('content-disposition', attachmentHeader(info.file.filename_download));
|
|
200
|
+
|
|
201
|
+
if (evaluation.status === 206) {
|
|
202
|
+
res.status(206);
|
|
203
|
+
res.set('content-range', evaluation.contentRange);
|
|
204
|
+
res.set('content-length', String(evaluation.contentLength));
|
|
205
|
+
return pipeStreamToResponse(streamResult.stream, res);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
res.status(200);
|
|
209
|
+
res.set('content-length', String(info.size));
|
|
210
|
+
return pipeStreamToResponse(streamResult.stream, res);
|
|
83
211
|
});
|
|
84
212
|
|
|
85
213
|
router.patch('/:id', async (req, res) => {
|
package/src/routes/roles.js
CHANGED
|
@@ -17,7 +17,7 @@ export function createRolesRouter() {
|
|
|
17
17
|
const router = express.Router();
|
|
18
18
|
|
|
19
19
|
router.get('/', async (req, res) => {
|
|
20
|
-
res.json({ data: await rolesService(req).readMany() });
|
|
20
|
+
res.json({ data: await rolesService(req).readMany(req.query) });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
router.post('/', async (req, res) => {
|
package/src/routes/users.js
CHANGED
|
@@ -17,7 +17,7 @@ export function createUsersRouter() {
|
|
|
17
17
|
const router = express.Router();
|
|
18
18
|
|
|
19
19
|
router.get('/', async (req, res) => {
|
|
20
|
-
res.json({ data: await usersService(req).readMany() });
|
|
20
|
+
res.json({ data: await usersService(req).readMany(req.query) });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
router.post('/', async (req, res) => {
|