@json-to-office/jto 0.20.0 → 0.22.0
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/README.md +2 -2
- package/dist/cli.js +700 -129
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{HomePage-DePp4KBa.js → HomePage-BW2AGCC2.js} +3 -3
- package/dist/client/assets/{HomePage-DePp4KBa.js.map → HomePage-BW2AGCC2.js.map} +1 -1
- package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js → JsonEditorPage-Bclb98Ti.js} +3 -3
- package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js.map → JsonEditorPage-Bclb98Ti.js.map} +1 -1
- package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js → MonacoPluginProvider-BbjmAGrJ.js} +3 -3
- package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js.map → MonacoPluginProvider-BbjmAGrJ.js.map} +1 -1
- package/dist/client/assets/{button-BxE8yzur.js → button-CSrOcD5F.js} +2 -2
- package/dist/client/assets/{button-BxE8yzur.js.map → button-CSrOcD5F.js.map} +1 -1
- package/dist/client/assets/{editor-BpCI3VcW.js → editor-CGjdiUtd.js} +2 -2
- package/dist/client/assets/{editor-BpCI3VcW.js.map → editor-CGjdiUtd.js.map} +1 -1
- package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js → editor-monaco-json-Bw2M2Xcf.js} +2 -2
- package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js.map → editor-monaco-json-Bw2M2Xcf.js.map} +1 -1
- package/dist/client/assets/index-Bn9d6cEh.js +5 -0
- package/dist/client/assets/index-Bn9d6cEh.js.map +1 -0
- package/dist/client/assets/{preview-WFz8KjYy.js → preview-C51sEB99.js} +2 -2
- package/dist/client/assets/{preview-WFz8KjYy.js.map → preview-C51sEB99.js.map} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/render-server.d.ts +39 -1
- package/dist/render-server.js +848 -75
- package/dist/render-server.js.map +1 -1
- package/package.json +10 -14
- package/dist/client/assets/index-DTfptUZM.js +0 -5
- package/dist/client/assets/index-DTfptUZM.js.map +0 -1
package/dist/render-server.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// src/render-server.ts
|
|
2
|
-
import {
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { resolve } from "path";
|
|
3
4
|
import { Hono } from "hono";
|
|
5
|
+
import { bodyLimit as bodyLimit2 } from "hono/body-limit";
|
|
6
|
+
import { HTTPException as HTTPException4 } from "hono/http-exception";
|
|
7
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
4
8
|
|
|
5
9
|
// src/server/rasterize-route.ts
|
|
6
10
|
import { Type } from "@sinclair/typebox";
|
|
@@ -63,6 +67,319 @@ function getValidated(c, target) {
|
|
|
63
67
|
return validatedData[target];
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
// src/server/security/outbound-source-policy.ts
|
|
71
|
+
var UnsafeOutboundSourceError = class extends Error {
|
|
72
|
+
constructor(path, reason) {
|
|
73
|
+
super(`Unsafe outbound source at ${path}: ${reason}`);
|
|
74
|
+
this.path = path;
|
|
75
|
+
this.name = "UnsafeOutboundSourceError";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
79
|
+
var COMPONENT_SOURCE_NAMES = /* @__PURE__ */ new Set(["image"]);
|
|
80
|
+
function isRecord(value) {
|
|
81
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
function hostMatches(host, pattern) {
|
|
84
|
+
const normalized = pattern.toLowerCase().replace(/\.$/, "");
|
|
85
|
+
if (normalized.startsWith("*.")) {
|
|
86
|
+
const suffix = normalized.slice(1);
|
|
87
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
88
|
+
}
|
|
89
|
+
return host === normalized;
|
|
90
|
+
}
|
|
91
|
+
function isPrivateIpv4(host) {
|
|
92
|
+
const parts = host.split(".");
|
|
93
|
+
if (parts.length !== 4) return false;
|
|
94
|
+
const octets = parts.map(Number);
|
|
95
|
+
if (octets.some(
|
|
96
|
+
(part, index) => !Number.isInteger(part) || part < 0 || part > 255 || String(part) !== parts[index]
|
|
97
|
+
)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const [a, b] = octets;
|
|
101
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168) || a === 198 && (b === 18 || b === 19) || a >= 224;
|
|
102
|
+
}
|
|
103
|
+
function isPrivateHost(host) {
|
|
104
|
+
const normalized = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
105
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::" || normalized === "::1") {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (isPrivateIpv4(normalized)) return true;
|
|
109
|
+
if (normalized.startsWith("::ffff:")) {
|
|
110
|
+
const mapped = normalized.slice("::ffff:".length);
|
|
111
|
+
if (isPrivateIpv4(mapped)) return true;
|
|
112
|
+
const groups = mapped.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
113
|
+
if (groups) {
|
|
114
|
+
const value = Number.parseInt(groups[1], 16) * 65536 + Number.parseInt(groups[2], 16);
|
|
115
|
+
return isPrivateIpv4(
|
|
116
|
+
[
|
|
117
|
+
value >>> 24,
|
|
118
|
+
value >>> 16 & 255,
|
|
119
|
+
value >>> 8 & 255,
|
|
120
|
+
value & 255
|
|
121
|
+
].join(".")
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const firstIpv6Group = Number.parseInt(normalized.split(":")[0] || "0", 16);
|
|
126
|
+
return firstIpv6Group >= 64512 && firstIpv6Group <= 65023 || firstIpv6Group >= 65152 && firstIpv6Group <= 65215;
|
|
127
|
+
}
|
|
128
|
+
function assertAllowedUrl(source, path, allowedHosts) {
|
|
129
|
+
if (source.startsWith("data:")) return;
|
|
130
|
+
let url;
|
|
131
|
+
try {
|
|
132
|
+
url = new URL(source);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new UnsafeOutboundSourceError(
|
|
135
|
+
path,
|
|
136
|
+
"local and relative file paths are disabled for HTTP requests"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol !== "https:") {
|
|
140
|
+
throw new UnsafeOutboundSourceError(path, "only HTTPS URLs are allowed");
|
|
141
|
+
}
|
|
142
|
+
if (url.username || url.password) {
|
|
143
|
+
throw new UnsafeOutboundSourceError(
|
|
144
|
+
path,
|
|
145
|
+
"URLs containing credentials are not allowed"
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
149
|
+
if (isPrivateHost(host)) {
|
|
150
|
+
throw new UnsafeOutboundSourceError(
|
|
151
|
+
path,
|
|
152
|
+
`private or local host "${host}" is not allowed`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!allowedHosts.some((pattern) => hostMatches(host, pattern))) {
|
|
156
|
+
throw new UnsafeOutboundSourceError(
|
|
157
|
+
path,
|
|
158
|
+
`host "${host}" is not in OUTBOUND_HOST_ALLOWLIST`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function extractCssReferences(input) {
|
|
163
|
+
const references = [];
|
|
164
|
+
const urlPattern = /url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/gi;
|
|
165
|
+
const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1/gi;
|
|
166
|
+
for (const pattern of [urlPattern, importPattern]) {
|
|
167
|
+
let match;
|
|
168
|
+
while (match = pattern.exec(input)) references.push(match[2]);
|
|
169
|
+
}
|
|
170
|
+
return references;
|
|
171
|
+
}
|
|
172
|
+
function extractAbsoluteUrls(input) {
|
|
173
|
+
return input.match(/(?:https?|file):\/\/[^\s'"<>)}\]]+/gi) ?? [];
|
|
174
|
+
}
|
|
175
|
+
function assertSafeSvg(svg, path, allowedHosts) {
|
|
176
|
+
if (/<\s*script\b/i.test(svg) || /<\s*(?:foreignObject|iframe|object|embed)\b/i.test(svg) || /\bon[a-z]+\s*=/i.test(svg)) {
|
|
177
|
+
throw new UnsafeOutboundSourceError(path, "active SVG content is disabled");
|
|
178
|
+
}
|
|
179
|
+
if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svg)) {
|
|
180
|
+
throw new UnsafeOutboundSourceError(
|
|
181
|
+
path,
|
|
182
|
+
"SVG document types and entities are disabled"
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const refPattern = /\b(?:href|src)\s*=\s*(['"])(.*?)\1/gi;
|
|
186
|
+
let match;
|
|
187
|
+
while (match = refPattern.exec(svg)) {
|
|
188
|
+
const ref = match[2].trim();
|
|
189
|
+
if (!ref || ref.startsWith("#") || ref.startsWith("data:")) continue;
|
|
190
|
+
assertAllowedUrl(ref, path, allowedHosts);
|
|
191
|
+
}
|
|
192
|
+
for (const ref of extractCssReferences(svg)) {
|
|
193
|
+
if (ref.startsWith("#") || ref.startsWith("data:")) continue;
|
|
194
|
+
assertAllowedUrl(ref, path, allowedHosts);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function assertSafeEncodedImage(source, path, allowedHosts) {
|
|
198
|
+
let candidate = source.trim();
|
|
199
|
+
let declaredSvg = false;
|
|
200
|
+
if (candidate.startsWith("data:")) {
|
|
201
|
+
const comma = candidate.indexOf(",");
|
|
202
|
+
if (comma < 0) {
|
|
203
|
+
throw new UnsafeOutboundSourceError(path, "malformed image data URL");
|
|
204
|
+
}
|
|
205
|
+
const metadata = candidate.slice(5, comma);
|
|
206
|
+
declaredSvg = metadata.split(";")[0].toLowerCase() === "image/svg+xml";
|
|
207
|
+
if (!declaredSvg) return;
|
|
208
|
+
const payload = candidate.slice(comma + 1);
|
|
209
|
+
try {
|
|
210
|
+
candidate = /(?:^|;)base64(?:;|$)/i.test(metadata) ? Buffer.from(payload, "base64").toString("utf8") : decodeURIComponent(payload);
|
|
211
|
+
} catch {
|
|
212
|
+
throw new UnsafeOutboundSourceError(path, "malformed SVG data URL");
|
|
213
|
+
}
|
|
214
|
+
} else if (!/<\s*svg\b/i.test(candidate)) {
|
|
215
|
+
candidate = Buffer.from(candidate, "base64").toString("utf8");
|
|
216
|
+
}
|
|
217
|
+
const svgStart = candidate.search(/<\s*svg\b/i);
|
|
218
|
+
if (svgStart < 0) {
|
|
219
|
+
if (declaredSvg) {
|
|
220
|
+
throw new UnsafeOutboundSourceError(path, "malformed SVG image data");
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
assertSafeSvg(candidate, path, allowedHosts);
|
|
225
|
+
}
|
|
226
|
+
function assertSafeResources(resources, path, allowedHosts) {
|
|
227
|
+
if (typeof resources.js === "string" && resources.js.trim()) {
|
|
228
|
+
throw new UnsafeOutboundSourceError(
|
|
229
|
+
`${path}.js`,
|
|
230
|
+
"remote renderer JavaScript resources are disabled"
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (typeof resources.css === "string") {
|
|
234
|
+
for (const ref of extractCssReferences(resources.css)) {
|
|
235
|
+
if (ref.startsWith("data:")) continue;
|
|
236
|
+
assertAllowedUrl(ref, `${path}.css`, allowedHosts);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(resources.files) && resources.files.length > 0) {
|
|
240
|
+
throw new UnsafeOutboundSourceError(
|
|
241
|
+
`${path}.files`,
|
|
242
|
+
"remote renderer JavaScript resources are disabled"
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function assertNoRemoteReferences(value, path, allowedHosts, seen) {
|
|
247
|
+
if (typeof value === "string") {
|
|
248
|
+
if (/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/i.test(value) || /\b(?:javascript|file):/i.test(value)) {
|
|
249
|
+
throw new UnsafeOutboundSourceError(
|
|
250
|
+
path,
|
|
251
|
+
"network-capable JavaScript and file URLs are disabled"
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
for (const url of [
|
|
255
|
+
...extractAbsoluteUrls(value),
|
|
256
|
+
...extractCssReferences(value)
|
|
257
|
+
]) {
|
|
258
|
+
if (url.startsWith("data:")) continue;
|
|
259
|
+
assertAllowedUrl(url, path, allowedHosts);
|
|
260
|
+
}
|
|
261
|
+
if (/^\/\//.test(value.trim())) {
|
|
262
|
+
throw new UnsafeOutboundSourceError(
|
|
263
|
+
path,
|
|
264
|
+
"protocol-relative URLs are disabled"
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
270
|
+
seen.add(value);
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
value.forEach(
|
|
273
|
+
(entry, index) => assertNoRemoteReferences(entry, `${path}[${index}]`, allowedHosts, seen)
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const [key, child] of Object.entries(value)) {
|
|
278
|
+
assertNoRemoteReferences(child, `${path}.${key}`, allowedHosts, seen);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function visit(value, path, containerKey, allowedHosts, seen) {
|
|
282
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
283
|
+
seen.add(value);
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
value.forEach(
|
|
286
|
+
(entry, index) => visit(entry, `${path}[${index}]`, containerKey, allowedHosts, seen)
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const record = value;
|
|
291
|
+
for (const key of Object.keys(record)) {
|
|
292
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
293
|
+
throw new UnsafeOutboundSourceError(
|
|
294
|
+
`${path}.${key}`,
|
|
295
|
+
"prototype mutation keys are disabled"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const kind = typeof record.kind === "string" ? record.kind : void 0;
|
|
300
|
+
if (kind === "file" && typeof record.path === "string") {
|
|
301
|
+
throw new UnsafeOutboundSourceError(
|
|
302
|
+
`${path}.path`,
|
|
303
|
+
"local file sources are disabled for HTTP requests"
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if ((kind === "url" || kind === "variable") && typeof record.url === "string") {
|
|
307
|
+
assertAllowedUrl(record.url, `${path}.url`, allowedHosts);
|
|
308
|
+
}
|
|
309
|
+
const componentName = typeof record.name === "string" ? record.name.toLowerCase() : void 0;
|
|
310
|
+
const props = isRecord(record.props) ? record.props : void 0;
|
|
311
|
+
if (componentName && props) {
|
|
312
|
+
if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.path === "string") {
|
|
313
|
+
assertAllowedUrl(props.path, `${path}.props.path`, allowedHosts);
|
|
314
|
+
assertSafeEncodedImage(props.path, `${path}.props.path`, allowedHosts);
|
|
315
|
+
}
|
|
316
|
+
if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.base64 === "string") {
|
|
317
|
+
assertSafeEncodedImage(
|
|
318
|
+
props.base64,
|
|
319
|
+
`${path}.props.base64`,
|
|
320
|
+
allowedHosts
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if ((componentName === "highcharts" || componentName === "visual") && typeof props.serverUrl === "string") {
|
|
324
|
+
assertAllowedUrl(
|
|
325
|
+
props.serverUrl,
|
|
326
|
+
`${path}.props.serverUrl`,
|
|
327
|
+
allowedHosts
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (componentName === "highcharts") {
|
|
331
|
+
if (isRecord(props.resources)) {
|
|
332
|
+
assertSafeResources(
|
|
333
|
+
props.resources,
|
|
334
|
+
`${path}.props.resources`,
|
|
335
|
+
allowedHosts
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
assertNoRemoteReferences(
|
|
339
|
+
props.options,
|
|
340
|
+
`${path}.props.options`,
|
|
341
|
+
allowedHosts,
|
|
342
|
+
/* @__PURE__ */ new WeakSet()
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (containerKey === "image" && typeof record.path === "string") {
|
|
347
|
+
assertAllowedUrl(record.path, `${path}.path`, allowedHosts);
|
|
348
|
+
assertSafeEncodedImage(record.path, `${path}.path`, allowedHosts);
|
|
349
|
+
}
|
|
350
|
+
if (typeof record.svg === "string") {
|
|
351
|
+
assertSafeSvg(record.svg, `${path}.svg`, allowedHosts);
|
|
352
|
+
}
|
|
353
|
+
if (containerKey === "resources") {
|
|
354
|
+
assertSafeResources(record, path, allowedHosts);
|
|
355
|
+
}
|
|
356
|
+
for (const [key, child] of Object.entries(record)) {
|
|
357
|
+
visit(child, `${path}.${key}`, key, allowedHosts, seen);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function assertSafeOutboundSources(value, policy, rootPath = "request") {
|
|
361
|
+
if (policy.mode === "development") return;
|
|
362
|
+
let parsed = value;
|
|
363
|
+
if (typeof value === "string") {
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(value);
|
|
366
|
+
} catch {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
visit(parsed, rootPath, void 0, policy.allowedHosts, /* @__PURE__ */ new WeakSet());
|
|
371
|
+
}
|
|
372
|
+
function assertSafeRendererPayload(value, policy) {
|
|
373
|
+
assertSafeOutboundSources(value, policy, "export");
|
|
374
|
+
if (policy.mode === "development" || !isRecord(value)) return;
|
|
375
|
+
assertNoRemoteReferences(
|
|
376
|
+
value.infile,
|
|
377
|
+
"export.infile",
|
|
378
|
+
policy.allowedHosts,
|
|
379
|
+
/* @__PURE__ */ new WeakSet()
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
66
383
|
// src/server/rasterize-route.ts
|
|
67
384
|
var RasterizeRequestSchema = Type.Object(
|
|
68
385
|
{
|
|
@@ -105,6 +422,13 @@ function registerRasterizeRoute(router, options = {}) {
|
|
|
105
422
|
async (c) => {
|
|
106
423
|
const { presentation, dpi } = getValidated(c, "json");
|
|
107
424
|
try {
|
|
425
|
+
if (options.sourcePolicy) {
|
|
426
|
+
assertSafeOutboundSources(
|
|
427
|
+
presentation,
|
|
428
|
+
options.sourcePolicy,
|
|
429
|
+
"presentation"
|
|
430
|
+
);
|
|
431
|
+
}
|
|
108
432
|
const result = await getRasterizer()({
|
|
109
433
|
presentation,
|
|
110
434
|
dpi: clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI)
|
|
@@ -113,6 +437,9 @@ function registerRasterizeRoute(router, options = {}) {
|
|
|
113
437
|
} catch (error) {
|
|
114
438
|
options.onError?.(error);
|
|
115
439
|
if (error instanceof HTTPException2) throw error;
|
|
440
|
+
if (error instanceof UnsafeOutboundSourceError) {
|
|
441
|
+
throw new HTTPException2(400, { message: error.message });
|
|
442
|
+
}
|
|
116
443
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
|
|
117
444
|
if (msg.includes("not found") || msg.includes("rasterization needs")) {
|
|
118
445
|
throw new HTTPException2(503, { message: error.message });
|
|
@@ -130,24 +457,41 @@ function registerRasterizeRoute(router, options = {}) {
|
|
|
130
457
|
|
|
131
458
|
// src/server/middleware/hono/rate-limit.ts
|
|
132
459
|
import { HTTPException as HTTPException3 } from "hono/http-exception";
|
|
133
|
-
|
|
460
|
+
function clientAddress(c, trustProxy) {
|
|
461
|
+
if (trustProxy) {
|
|
462
|
+
const forwarded = c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",")[0]?.trim();
|
|
463
|
+
if (forwarded) return forwarded;
|
|
464
|
+
}
|
|
465
|
+
const incoming = c.env?.incoming || c.env?.req;
|
|
466
|
+
return incoming?.socket?.remoteAddress || "anonymous";
|
|
467
|
+
}
|
|
134
468
|
var rateLimiter = (options) => {
|
|
135
469
|
const { limit, window, keyGenerator } = options;
|
|
470
|
+
const maxEntries = options.maxEntries ?? 1e4;
|
|
471
|
+
const rateLimitStore = /* @__PURE__ */ new Map();
|
|
472
|
+
let lastCleanup = 0;
|
|
136
473
|
return async (c, next) => {
|
|
137
|
-
const
|
|
474
|
+
const clientKey = keyGenerator ? keyGenerator(c) : clientAddress(c, options.trustProxy === true);
|
|
475
|
+
const namespace = typeof options.namespace === "function" ? options.namespace(c) : options.namespace || `${c.req.method}:${c.req.path}`;
|
|
476
|
+
const key = `${namespace}:${String(clientKey).slice(0, 256)}`;
|
|
138
477
|
const now = Date.now();
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
rateLimitStore.delete(
|
|
478
|
+
if (now - lastCleanup >= Math.min(window, 6e4)) {
|
|
479
|
+
for (const [storedKey, value] of rateLimitStore.entries()) {
|
|
480
|
+
if (value.resetTime <= now) rateLimitStore.delete(storedKey);
|
|
142
481
|
}
|
|
482
|
+
lastCleanup = now;
|
|
143
483
|
}
|
|
144
484
|
const record = rateLimitStore.get(key);
|
|
145
485
|
if (!record) {
|
|
486
|
+
if (rateLimitStore.size >= maxEntries) {
|
|
487
|
+
const oldestKey = rateLimitStore.keys().next().value;
|
|
488
|
+
if (oldestKey) rateLimitStore.delete(oldestKey);
|
|
489
|
+
}
|
|
146
490
|
rateLimitStore.set(key, {
|
|
147
491
|
count: 1,
|
|
148
492
|
resetTime: now + window
|
|
149
493
|
});
|
|
150
|
-
} else if (record.resetTime
|
|
494
|
+
} else if (record.resetTime <= now) {
|
|
151
495
|
record.count = 1;
|
|
152
496
|
record.resetTime = now + window;
|
|
153
497
|
} else if (record.count >= limit) {
|
|
@@ -173,83 +517,512 @@ var rateLimiter = (options) => {
|
|
|
173
517
|
};
|
|
174
518
|
};
|
|
175
519
|
|
|
520
|
+
// src/server/middleware/hono/concurrency-limit.ts
|
|
521
|
+
function concurrencyLimiter(options) {
|
|
522
|
+
const limit = Math.max(1, Math.floor(options.limit));
|
|
523
|
+
const retryAfterSeconds = options.retryAfterSeconds ?? 1;
|
|
524
|
+
let active = 0;
|
|
525
|
+
return async (c, next) => {
|
|
526
|
+
c.header("X-Concurrency-Limit", String(limit));
|
|
527
|
+
if (active >= limit) {
|
|
528
|
+
c.header("Retry-After", String(retryAfterSeconds));
|
|
529
|
+
return c.json(
|
|
530
|
+
{
|
|
531
|
+
success: false,
|
|
532
|
+
error: "Server is at capacity",
|
|
533
|
+
code: "CONCURRENCY_LIMIT_EXCEEDED"
|
|
534
|
+
},
|
|
535
|
+
503
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
active += 1;
|
|
539
|
+
c.header("X-Concurrency-Remaining", String(Math.max(0, limit - active)));
|
|
540
|
+
try {
|
|
541
|
+
await next();
|
|
542
|
+
} finally {
|
|
543
|
+
active -= 1;
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/server/middleware/hono/auth.ts
|
|
549
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
550
|
+
|
|
551
|
+
// src/server/config/index.ts
|
|
552
|
+
import dotenv from "dotenv";
|
|
553
|
+
dotenv.config();
|
|
554
|
+
function positiveInteger(value, fallback) {
|
|
555
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
556
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
557
|
+
}
|
|
558
|
+
function parseAuthMode(value, nodeEnv) {
|
|
559
|
+
if (value === "auto" || value === "required" || value === "disabled") {
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
return nodeEnv === "production" ? "required" : "auto";
|
|
563
|
+
}
|
|
564
|
+
function parseOutboundSourceMode(value, nodeEnv) {
|
|
565
|
+
if (value === "development" || value === "safe") return value;
|
|
566
|
+
return nodeEnv === "production" ? "safe" : "development";
|
|
567
|
+
}
|
|
568
|
+
function normalizeNodeEnv(value) {
|
|
569
|
+
return value === "development" || value === "test" ? value : "production";
|
|
570
|
+
}
|
|
571
|
+
function parseEnv(env) {
|
|
572
|
+
const nodeEnv = normalizeNodeEnv(env.NODE_ENV || "development");
|
|
573
|
+
return {
|
|
574
|
+
NODE_ENV: nodeEnv,
|
|
575
|
+
// No PORT here: the listener is opened by the dev server from the CLI
|
|
576
|
+
// config (`-p` > `server.port` > `PORT` env > format default), so a second
|
|
577
|
+
// copy of it in this module would be silently ignored.
|
|
578
|
+
CORS_ORIGIN: env.CORS_ORIGIN || "*",
|
|
579
|
+
API_KEY: env.API_KEY,
|
|
580
|
+
API_KEY_HEADER: env.API_KEY_HEADER || "x-api-key",
|
|
581
|
+
API_AUTH_MODE: parseAuthMode(env.API_AUTH_MODE, nodeEnv),
|
|
582
|
+
RATE_LIMIT_WINDOW_MS: positiveInteger(env.RATE_LIMIT_WINDOW_MS, 9e5),
|
|
583
|
+
RATE_LIMIT_MAX: positiveInteger(
|
|
584
|
+
env.RATE_LIMIT_MAX,
|
|
585
|
+
nodeEnv === "production" ? 100 : 1e3
|
|
586
|
+
),
|
|
587
|
+
TRUST_PROXY_HEADERS: env.TRUST_PROXY_HEADERS === "true",
|
|
588
|
+
MAX_FILE_SIZE: positiveInteger(env.MAX_FILE_SIZE, 10 * 1024 * 1024),
|
|
589
|
+
MAX_REQUEST_BODY_SIZE: positiveInteger(
|
|
590
|
+
env.MAX_REQUEST_BODY_SIZE,
|
|
591
|
+
32 * 1024 * 1024
|
|
592
|
+
),
|
|
593
|
+
MAX_CONCURRENT_REQUESTS: positiveInteger(
|
|
594
|
+
env.MAX_CONCURRENT_REQUESTS,
|
|
595
|
+
nodeEnv === "production" ? 8 : 64
|
|
596
|
+
),
|
|
597
|
+
OUTBOUND_SOURCE_MODE: parseOutboundSourceMode(
|
|
598
|
+
env.OUTBOUND_SOURCE_MODE,
|
|
599
|
+
nodeEnv
|
|
600
|
+
),
|
|
601
|
+
OUTBOUND_HOST_ALLOWLIST: (env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean),
|
|
602
|
+
LIBREOFFICE_PATH: env.LIBREOFFICE_PATH,
|
|
603
|
+
LIBREOFFICE_TIMEOUT_MS: env.LIBREOFFICE_TIMEOUT_MS ? positiveInteger(env.LIBREOFFICE_TIMEOUT_MS, 3e4) : 3e4,
|
|
604
|
+
LOG_LEVEL: env.LOG_LEVEL || "info",
|
|
605
|
+
CACHE_ENABLED: env.CACHE_ENABLED !== "false",
|
|
606
|
+
CACHE_MAX_SIZE_MB: positiveInteger(env.CACHE_MAX_SIZE_MB, 100),
|
|
607
|
+
CACHE_TTL_SECONDS: positiveInteger(env.CACHE_TTL_SECONDS, 3600),
|
|
608
|
+
CACHE_MAX_ITEMS: positiveInteger(env.CACHE_MAX_ITEMS, 1e3)
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
var parsedEnv = parseEnv(process.env);
|
|
612
|
+
var config = {
|
|
613
|
+
...parsedEnv,
|
|
614
|
+
isDevelopment: parsedEnv.NODE_ENV === "development",
|
|
615
|
+
isProduction: parsedEnv.NODE_ENV === "production",
|
|
616
|
+
isTest: parsedEnv.NODE_ENV === "test",
|
|
617
|
+
features: {
|
|
618
|
+
apiKey: parsedEnv.API_AUTH_MODE !== "disabled",
|
|
619
|
+
cache: parsedEnv.CACHE_ENABLED
|
|
620
|
+
},
|
|
621
|
+
cors: {
|
|
622
|
+
origin: parsedEnv.CORS_ORIGIN === "*" ? parsedEnv.CORS_ORIGIN : parsedEnv.CORS_ORIGIN.split(",").map((o) => o.trim()),
|
|
623
|
+
credentials: true
|
|
624
|
+
},
|
|
625
|
+
rateLimit: {
|
|
626
|
+
windowMs: parsedEnv.RATE_LIMIT_WINDOW_MS,
|
|
627
|
+
max: parsedEnv.RATE_LIMIT_MAX,
|
|
628
|
+
trustProxy: parsedEnv.TRUST_PROXY_HEADERS
|
|
629
|
+
},
|
|
630
|
+
requestLimits: {
|
|
631
|
+
maxBodySize: parsedEnv.MAX_REQUEST_BODY_SIZE,
|
|
632
|
+
maxConcurrent: parsedEnv.MAX_CONCURRENT_REQUESTS,
|
|
633
|
+
maxFileSize: parsedEnv.MAX_FILE_SIZE
|
|
634
|
+
},
|
|
635
|
+
outboundSources: {
|
|
636
|
+
mode: parsedEnv.OUTBOUND_SOURCE_MODE,
|
|
637
|
+
allowedHosts: parsedEnv.OUTBOUND_HOST_ALLOWLIST
|
|
638
|
+
},
|
|
639
|
+
cache: {
|
|
640
|
+
enabled: parsedEnv.CACHE_ENABLED,
|
|
641
|
+
maxSizeMB: parsedEnv.CACHE_MAX_SIZE_MB,
|
|
642
|
+
ttlSeconds: parsedEnv.CACHE_TTL_SECONDS,
|
|
643
|
+
maxItems: parsedEnv.CACHE_MAX_ITEMS
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// src/server/middleware/hono/auth.ts
|
|
648
|
+
function keysEqual(received, expected) {
|
|
649
|
+
const receivedDigest = createHash("sha256").update(received).digest();
|
|
650
|
+
const expectedDigest = createHash("sha256").update(expected).digest();
|
|
651
|
+
return timingSafeEqual(receivedDigest, expectedDigest);
|
|
652
|
+
}
|
|
653
|
+
function readCredential(headers, headerName) {
|
|
654
|
+
const direct = headers.get(headerName)?.trim();
|
|
655
|
+
if (direct) {
|
|
656
|
+
if (headerName.toLowerCase() === "authorization" && direct.toLowerCase().startsWith("bearer ")) {
|
|
657
|
+
return direct.slice(7).trim() || void 0;
|
|
658
|
+
}
|
|
659
|
+
return direct;
|
|
660
|
+
}
|
|
661
|
+
const authorization = headers.get("authorization")?.trim();
|
|
662
|
+
if (authorization?.toLowerCase().startsWith("bearer ")) {
|
|
663
|
+
return authorization.slice(7).trim() || void 0;
|
|
664
|
+
}
|
|
665
|
+
return void 0;
|
|
666
|
+
}
|
|
667
|
+
function createApiKeyAuthMiddleware(options) {
|
|
668
|
+
const headerName = options.headerName || "x-api-key";
|
|
669
|
+
return async (c, next) => {
|
|
670
|
+
if (c.req.method === "OPTIONS" || options.mode === "disabled") {
|
|
671
|
+
return next();
|
|
672
|
+
}
|
|
673
|
+
if (!options.apiKey) {
|
|
674
|
+
if (options.mode === "auto") return next();
|
|
675
|
+
return c.json(
|
|
676
|
+
{
|
|
677
|
+
success: false,
|
|
678
|
+
error: "API authentication is not configured",
|
|
679
|
+
code: "AUTH_CONFIGURATION_ERROR"
|
|
680
|
+
},
|
|
681
|
+
503
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
const apiKey = readCredential(c.req.raw.headers, headerName);
|
|
685
|
+
if (!apiKey) {
|
|
686
|
+
return c.json(
|
|
687
|
+
{
|
|
688
|
+
success: false,
|
|
689
|
+
error: "API key required",
|
|
690
|
+
code: "UNAUTHORIZED"
|
|
691
|
+
},
|
|
692
|
+
401
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (!keysEqual(apiKey, options.apiKey)) {
|
|
696
|
+
return c.json(
|
|
697
|
+
{
|
|
698
|
+
success: false,
|
|
699
|
+
error: "Invalid API key",
|
|
700
|
+
code: "UNAUTHORIZED"
|
|
701
|
+
},
|
|
702
|
+
401
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
await next();
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
var apiKeyAuthMiddleware = createApiKeyAuthMiddleware({
|
|
709
|
+
mode: config.API_AUTH_MODE,
|
|
710
|
+
apiKey: config.API_KEY,
|
|
711
|
+
headerName: config.API_KEY_HEADER
|
|
712
|
+
});
|
|
713
|
+
|
|
176
714
|
// src/render-server.ts
|
|
177
|
-
var
|
|
178
|
-
var
|
|
179
|
-
var
|
|
180
|
-
var
|
|
181
|
-
var
|
|
715
|
+
var DEFAULT_UPSTREAM = "http://127.0.0.1:7801";
|
|
716
|
+
var DEFAULT_PORT = 1e4;
|
|
717
|
+
var DEFAULT_PROXY_TIMEOUT_MS = 3e4;
|
|
718
|
+
var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
|
|
719
|
+
var DEFAULT_EXPORT_BODY_BYTES = 4 * 1024 * 1024;
|
|
720
|
+
var DEFAULT_RASTERIZE_BODY_BYTES = 32 * 1024 * 1024;
|
|
721
|
+
var DEFAULT_RESPONSE_BYTES = 24 * 1024 * 1024;
|
|
722
|
+
var MAX_CHART_DIMENSION = 4096;
|
|
723
|
+
var MAX_CHART_PIXELS = 16e6;
|
|
724
|
+
var MAX_CHART_SCALE = 4;
|
|
725
|
+
var UpstreamResponseTooLargeError = class extends Error {
|
|
726
|
+
};
|
|
727
|
+
function positiveInteger2(value, fallback) {
|
|
728
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
729
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
730
|
+
}
|
|
731
|
+
function isHardenedEnv() {
|
|
732
|
+
const nodeEnv = process.env.NODE_ENV;
|
|
733
|
+
return nodeEnv !== void 0 && nodeEnv !== "development" && nodeEnv !== "test";
|
|
734
|
+
}
|
|
735
|
+
function envAuthOptions() {
|
|
736
|
+
const production = isHardenedEnv();
|
|
737
|
+
const requestedMode = process.env.RENDER_AUTH_MODE;
|
|
738
|
+
const mode = requestedMode === "auto" || requestedMode === "required" || requestedMode === "disabled" ? requestedMode : production ? "required" : "auto";
|
|
739
|
+
return {
|
|
740
|
+
mode,
|
|
741
|
+
apiKey: process.env.RENDER_API_KEY || process.env.API_KEY,
|
|
742
|
+
headerName: process.env.RENDER_API_KEY_HEADER || "x-api-key"
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
function envSourcePolicy() {
|
|
746
|
+
const requestedMode = process.env.OUTBOUND_SOURCE_MODE;
|
|
747
|
+
const mode = requestedMode === "safe" || requestedMode === "development" ? requestedMode : isHardenedEnv() ? "safe" : "development";
|
|
748
|
+
return {
|
|
749
|
+
mode,
|
|
750
|
+
allowedHosts: (process.env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean)
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
function isRecord2(value) {
|
|
754
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
755
|
+
}
|
|
756
|
+
function resolveDimension(exporting, chart, sourceKey, chartKey, fallback) {
|
|
757
|
+
for (const candidate of [exporting?.[sourceKey], chart?.[chartKey]]) {
|
|
758
|
+
if (candidate === void 0) continue;
|
|
759
|
+
if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0 || candidate > MAX_CHART_DIMENSION) {
|
|
760
|
+
throw new HTTPException4(400, {
|
|
761
|
+
message: "Requested chart dimensions are too large"
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
return candidate;
|
|
765
|
+
}
|
|
766
|
+
return fallback;
|
|
767
|
+
}
|
|
768
|
+
function resolveScale(value, fallback) {
|
|
769
|
+
if (value === void 0) return fallback;
|
|
770
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_CHART_SCALE) {
|
|
771
|
+
throw new HTTPException4(400, {
|
|
772
|
+
message: `scale must be between 0 and ${MAX_CHART_SCALE}`
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return value;
|
|
776
|
+
}
|
|
777
|
+
function validateExportRequest(value) {
|
|
778
|
+
if (!isRecord2(value)) {
|
|
779
|
+
throw new HTTPException4(400, { message: "Export body must be an object" });
|
|
780
|
+
}
|
|
781
|
+
const allowedKeys = /* @__PURE__ */ new Set(["infile", "type", "b64", "scale", "resources"]);
|
|
782
|
+
const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
783
|
+
if (unknown.length > 0) {
|
|
784
|
+
throw new HTTPException4(400, {
|
|
785
|
+
message: `Unsupported export field: ${unknown[0]}`
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (!isRecord2(value.infile)) {
|
|
789
|
+
throw new HTTPException4(400, {
|
|
790
|
+
message: "Export infile must be a chart options object"
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (value.type !== "png" || value.b64 !== true) {
|
|
794
|
+
throw new HTTPException4(400, {
|
|
795
|
+
message: "Only base64 PNG exports are allowed"
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const chart = isRecord2(value.infile.chart) ? value.infile.chart : void 0;
|
|
799
|
+
const exporting = isRecord2(value.infile.exporting) ? value.infile.exporting : void 0;
|
|
800
|
+
const scale = resolveScale(value.scale, resolveScale(exporting?.scale, 1));
|
|
801
|
+
const width = resolveDimension(exporting, chart, "sourceWidth", "width", 600);
|
|
802
|
+
const height = resolveDimension(
|
|
803
|
+
exporting,
|
|
804
|
+
chart,
|
|
805
|
+
"sourceHeight",
|
|
806
|
+
"height",
|
|
807
|
+
400
|
|
808
|
+
);
|
|
809
|
+
if (width * height * scale * scale > MAX_CHART_PIXELS) {
|
|
810
|
+
throw new HTTPException4(400, {
|
|
811
|
+
message: "Requested chart dimensions are too large"
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
182
815
|
function isTimeout(error) {
|
|
183
816
|
const name = error?.name;
|
|
184
817
|
return name === "TimeoutError" || name === "AbortError";
|
|
185
818
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (res.ok) return c.text("ok");
|
|
192
|
-
return c.json({ status: "degraded", upstream: res.status }, 503);
|
|
193
|
-
} catch {
|
|
194
|
-
return c.json({ status: "degraded", upstream: "unreachable" }, 503);
|
|
819
|
+
async function readLimitedBody(response, maxBytes) {
|
|
820
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
821
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
822
|
+
await response.body?.cancel();
|
|
823
|
+
throw new UpstreamResponseTooLargeError();
|
|
195
824
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
)
|
|
210
|
-
)
|
|
211
|
-
});
|
|
212
|
-
app.all("*", async (c) => {
|
|
213
|
-
const url = new URL(c.req.url);
|
|
214
|
-
const target = `${UPSTREAM}${url.pathname}${url.search}`;
|
|
215
|
-
const reqHeaders = new Headers(c.req.raw.headers);
|
|
216
|
-
reqHeaders.delete("host");
|
|
217
|
-
reqHeaders.delete("connection");
|
|
218
|
-
reqHeaders.delete("content-length");
|
|
219
|
-
const init = {
|
|
220
|
-
method: c.req.method,
|
|
221
|
-
headers: reqHeaders,
|
|
222
|
-
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS)
|
|
223
|
-
};
|
|
224
|
-
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
225
|
-
init.body = await c.req.arrayBuffer();
|
|
825
|
+
if (!response.body) return new Uint8Array();
|
|
826
|
+
const reader = response.body.getReader();
|
|
827
|
+
const chunks = [];
|
|
828
|
+
let total = 0;
|
|
829
|
+
for (; ; ) {
|
|
830
|
+
const { value, done } = await reader.read();
|
|
831
|
+
if (done) break;
|
|
832
|
+
total += value.byteLength;
|
|
833
|
+
if (total > maxBytes) {
|
|
834
|
+
await reader.cancel();
|
|
835
|
+
throw new UpstreamResponseTooLargeError();
|
|
836
|
+
}
|
|
837
|
+
chunks.push(value);
|
|
226
838
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
{
|
|
233
|
-
error: `Highcharts upstream timed out after ${PROXY_TIMEOUT_MS}ms`
|
|
234
|
-
},
|
|
235
|
-
504
|
|
236
|
-
) : c.json(
|
|
237
|
-
{ error: `Highcharts upstream unreachable at ${UPSTREAM}` },
|
|
238
|
-
502
|
|
239
|
-
);
|
|
839
|
+
const output = new Uint8Array(total);
|
|
840
|
+
let offset = 0;
|
|
841
|
+
for (const chunk of chunks) {
|
|
842
|
+
output.set(chunk, offset);
|
|
843
|
+
offset += chunk.byteLength;
|
|
240
844
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
845
|
+
return output;
|
|
846
|
+
}
|
|
847
|
+
function createRenderServerApp(options = {}) {
|
|
848
|
+
const production = isHardenedEnv();
|
|
849
|
+
const upstream = (options.upstreamUrl || process.env.HIGHCHARTS_UPSTREAM_URL || DEFAULT_UPSTREAM).replace(/\/$/, "");
|
|
850
|
+
const proxyTimeoutMs = options.proxyTimeoutMs ?? positiveInteger2(process.env.PROXY_TIMEOUT_MS, DEFAULT_PROXY_TIMEOUT_MS);
|
|
851
|
+
const healthTimeoutMs = options.healthTimeoutMs ?? positiveInteger2(process.env.HEALTH_TIMEOUT_MS, DEFAULT_HEALTH_TIMEOUT_MS);
|
|
852
|
+
const maxExportBodyBytes = options.maxExportBodyBytes ?? positiveInteger2(
|
|
853
|
+
process.env.MAX_EXPORT_BODY_SIZE,
|
|
854
|
+
DEFAULT_EXPORT_BODY_BYTES
|
|
855
|
+
);
|
|
856
|
+
const maxRasterizeBodyBytes = options.maxRasterizeBodyBytes ?? positiveInteger2(
|
|
857
|
+
process.env.MAX_RASTERIZE_BODY_SIZE,
|
|
858
|
+
DEFAULT_RASTERIZE_BODY_BYTES
|
|
859
|
+
);
|
|
860
|
+
const maxResponseBytes = options.maxResponseBytes ?? positiveInteger2(
|
|
861
|
+
process.env.MAX_RENDER_RESPONSE_SIZE,
|
|
862
|
+
DEFAULT_RESPONSE_BYTES
|
|
863
|
+
);
|
|
864
|
+
const maxConcurrent = options.maxConcurrent ?? positiveInteger2(process.env.MAX_CONCURRENT_RENDERS, production ? 4 : 16);
|
|
865
|
+
const windowMs = options.rateLimitWindowMs ?? positiveInteger2(process.env.RENDER_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1e3);
|
|
866
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
867
|
+
const sourcePolicy = options.sourcePolicy ?? envSourcePolicy();
|
|
868
|
+
const trustProxyHeaders = options.trustProxyHeaders ?? process.env.TRUST_PROXY_HEADERS === "true";
|
|
869
|
+
const app = new Hono();
|
|
870
|
+
const auth = createApiKeyAuthMiddleware(options.auth ?? envAuthOptions());
|
|
871
|
+
const capacity = concurrencyLimiter({ limit: maxConcurrent });
|
|
872
|
+
app.use("*", secureHeaders());
|
|
873
|
+
app.onError((error, c) => {
|
|
874
|
+
if (error instanceof HTTPException4) {
|
|
875
|
+
return c.json({ success: false, error: error.message }, error.status);
|
|
246
876
|
}
|
|
877
|
+
return c.json({ success: false, error: "Internal server error" }, 500);
|
|
247
878
|
});
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
879
|
+
app.get("/health", async (c) => {
|
|
880
|
+
try {
|
|
881
|
+
const response = await fetchImpl(`${upstream}/health`, {
|
|
882
|
+
signal: AbortSignal.timeout(healthTimeoutMs)
|
|
883
|
+
});
|
|
884
|
+
if (response.ok) return c.text("ok");
|
|
885
|
+
return c.json({ status: "degraded", upstream: response.status }, 503);
|
|
886
|
+
} catch {
|
|
887
|
+
return c.json({ status: "degraded", upstream: "unreachable" }, 503);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
registerRasterizeRoute(app, {
|
|
891
|
+
getRasterizer: options.getRasterizer,
|
|
892
|
+
preMiddleware: [
|
|
893
|
+
rateLimiter({
|
|
894
|
+
limit: options.rasterizeRateLimit ?? positiveInteger2(
|
|
895
|
+
process.env.RASTERIZE_RATE_LIMIT,
|
|
896
|
+
production ? 30 : 1e3
|
|
897
|
+
),
|
|
898
|
+
window: windowMs,
|
|
899
|
+
namespace: "rasterize",
|
|
900
|
+
trustProxy: trustProxyHeaders
|
|
901
|
+
}),
|
|
902
|
+
capacity,
|
|
903
|
+
bodyLimit2({
|
|
904
|
+
maxSize: maxRasterizeBodyBytes,
|
|
905
|
+
onError: () => {
|
|
906
|
+
throw new HTTPException4(413, { message: "Request body too large" });
|
|
907
|
+
}
|
|
908
|
+
}),
|
|
909
|
+
auth
|
|
910
|
+
],
|
|
911
|
+
sourcePolicy
|
|
912
|
+
});
|
|
913
|
+
app.post(
|
|
914
|
+
"/export",
|
|
915
|
+
rateLimiter({
|
|
916
|
+
limit: options.exportRateLimit ?? positiveInteger2(process.env.EXPORT_RATE_LIMIT, production ? 60 : 1e3),
|
|
917
|
+
window: windowMs,
|
|
918
|
+
namespace: "export",
|
|
919
|
+
trustProxy: trustProxyHeaders
|
|
920
|
+
}),
|
|
921
|
+
capacity,
|
|
922
|
+
bodyLimit2({
|
|
923
|
+
maxSize: maxExportBodyBytes,
|
|
924
|
+
onError: () => {
|
|
925
|
+
throw new HTTPException4(413, { message: "Request body too large" });
|
|
926
|
+
}
|
|
927
|
+
}),
|
|
928
|
+
auth,
|
|
929
|
+
async (c) => {
|
|
930
|
+
const contentType = c.req.header("content-type");
|
|
931
|
+
if (!contentType?.toLowerCase().includes("application/json")) {
|
|
932
|
+
throw new HTTPException4(415, {
|
|
933
|
+
message: "Content-Type must be application/json"
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
let body;
|
|
937
|
+
try {
|
|
938
|
+
body = await c.req.json();
|
|
939
|
+
} catch {
|
|
940
|
+
throw new HTTPException4(400, { message: "Invalid JSON body" });
|
|
941
|
+
}
|
|
942
|
+
validateExportRequest(body);
|
|
943
|
+
try {
|
|
944
|
+
assertSafeRendererPayload(body, sourcePolicy);
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (error instanceof UnsafeOutboundSourceError) {
|
|
947
|
+
throw new HTTPException4(400, { message: error.message });
|
|
948
|
+
}
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
let response;
|
|
952
|
+
try {
|
|
953
|
+
response = await fetchImpl(`${upstream}/export`, {
|
|
954
|
+
method: "POST",
|
|
955
|
+
headers: {
|
|
956
|
+
"Content-Type": "application/json",
|
|
957
|
+
Accept: "text/plain, image/png, application/json"
|
|
958
|
+
},
|
|
959
|
+
body: JSON.stringify(body),
|
|
960
|
+
signal: AbortSignal.timeout(proxyTimeoutMs)
|
|
961
|
+
});
|
|
962
|
+
} catch (error) {
|
|
963
|
+
return isTimeout(error) ? c.json(
|
|
964
|
+
{
|
|
965
|
+
error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
|
|
966
|
+
},
|
|
967
|
+
504
|
|
968
|
+
) : c.json({ error: "Highcharts upstream unreachable" }, 502);
|
|
969
|
+
}
|
|
970
|
+
let payload;
|
|
971
|
+
try {
|
|
972
|
+
payload = await readLimitedBody(response, maxResponseBytes);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (error instanceof UpstreamResponseTooLargeError) {
|
|
975
|
+
return c.json({ error: "Highcharts response too large" }, 502);
|
|
976
|
+
}
|
|
977
|
+
if (isTimeout(error)) {
|
|
978
|
+
return c.json(
|
|
979
|
+
{
|
|
980
|
+
error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
|
|
981
|
+
},
|
|
982
|
+
504
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
return c.json({ error: "Failed to read Highcharts response" }, 502);
|
|
986
|
+
}
|
|
987
|
+
const headers = {};
|
|
988
|
+
for (const name of [
|
|
989
|
+
"content-type",
|
|
990
|
+
"content-disposition",
|
|
991
|
+
"cache-control"
|
|
992
|
+
]) {
|
|
993
|
+
const value = response.headers.get(name);
|
|
994
|
+
if (value) headers[name] = value;
|
|
995
|
+
}
|
|
996
|
+
return c.body(payload, response.status, headers);
|
|
997
|
+
}
|
|
253
998
|
);
|
|
254
|
-
|
|
999
|
+
for (const route of ["/export", "/rasterize"]) {
|
|
1000
|
+
app.all(route, (c) => {
|
|
1001
|
+
c.header("Allow", "POST");
|
|
1002
|
+
return c.json({ success: false, error: "Method not allowed" }, 405);
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
app.notFound((c) => c.json({ success: false, error: "Not found" }, 404));
|
|
1006
|
+
return app;
|
|
1007
|
+
}
|
|
1008
|
+
function isMainModule() {
|
|
1009
|
+
if (!process.argv[1]) return false;
|
|
1010
|
+
try {
|
|
1011
|
+
return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
|
1012
|
+
} catch {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
if (isMainModule()) {
|
|
1017
|
+
const port = positiveInteger2(process.env.PORT, DEFAULT_PORT);
|
|
1018
|
+
const app = createRenderServerApp();
|
|
1019
|
+
void import("@hono/node-server").then(({ serve }) => {
|
|
1020
|
+
serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
|
|
1021
|
+
console.log(`[jto-render-server] listening on :${info.port}`);
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
export {
|
|
1026
|
+
createRenderServerApp
|
|
1027
|
+
};
|
|
255
1028
|
//# sourceMappingURL=render-server.js.map
|