@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunsoft/yuncms-api",
3
- "version": "0.1.26",
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.26",
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 installedRoot = join(resolve(rootDir), 'node_modules', ...packageName.split('/'));
59
- const installedPackagePath = join(installedRoot, 'package.json');
60
- if (await exists(installedPackagePath)) {
61
- const installedPackage = await readJson(installedPackagePath);
62
- if (installedPackage.name === packageName) return installedRoot;
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
 
@@ -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 result = await filesService(req).readContent(req.params.id);
79
- res.set('content-type', result.file.mimetype || 'application/octet-stream');
80
- res.set('content-length', String(result.contents.byteLength));
81
- res.set('content-disposition', attachmentHeader(result.file.filename_download));
82
- res.send(result.contents);
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) => {
@@ -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) => {
@@ -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) => {