@json-to-office/jto 0.20.0 → 0.21.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.
Files changed (26) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +691 -128
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{HomePage-DePp4KBa.js → HomePage-CpxJ_KFq.js} +3 -3
  5. package/dist/client/assets/{HomePage-DePp4KBa.js.map → HomePage-CpxJ_KFq.js.map} +1 -1
  6. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js → JsonEditorPage-BMH043CA.js} +3 -3
  7. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js.map → JsonEditorPage-BMH043CA.js.map} +1 -1
  8. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js → MonacoPluginProvider-Doo7BbuJ.js} +3 -3
  9. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js.map → MonacoPluginProvider-Doo7BbuJ.js.map} +1 -1
  10. package/dist/client/assets/{button-BxE8yzur.js → button--7gFS6Cb.js} +2 -2
  11. package/dist/client/assets/{button-BxE8yzur.js.map → button--7gFS6Cb.js.map} +1 -1
  12. package/dist/client/assets/{editor-BpCI3VcW.js → editor-CJxA-PmD.js} +2 -2
  13. package/dist/client/assets/{editor-BpCI3VcW.js.map → editor-CJxA-PmD.js.map} +1 -1
  14. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js → editor-monaco-json-CTqecbMp.js} +2 -2
  15. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js.map → editor-monaco-json-CTqecbMp.js.map} +1 -1
  16. package/dist/client/assets/index-C-7wxCe7.js +5 -0
  17. package/dist/client/assets/index-C-7wxCe7.js.map +1 -0
  18. package/dist/client/assets/{preview-WFz8KjYy.js → preview-DRq49DTu.js} +2 -2
  19. package/dist/client/assets/{preview-WFz8KjYy.js.map → preview-DRq49DTu.js.map} +1 -1
  20. package/dist/client/index.html +1 -1
  21. package/dist/render-server.d.ts +39 -1
  22. package/dist/render-server.js +847 -75
  23. package/dist/render-server.js.map +1 -1
  24. package/package.json +10 -14
  25. package/dist/client/assets/index-DTfptUZM.js +0 -5
  26. package/dist/client/assets/index-DTfptUZM.js.map +0 -1
@@ -1,6 +1,10 @@
1
1
  // src/render-server.ts
2
- import { serve } from "@hono/node-server";
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
- var rateLimitStore = /* @__PURE__ */ new Map();
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 key = keyGenerator ? keyGenerator(c) : c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous";
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
- for (const [k, v] of rateLimitStore.entries()) {
140
- if (v.resetTime < now) {
141
- rateLimitStore.delete(k);
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 < now) {
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,511 @@ 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
+ PORT: positiveInteger(env.PORT, 3003),
576
+ CORS_ORIGIN: env.CORS_ORIGIN || "*",
577
+ API_KEY: env.API_KEY,
578
+ API_KEY_HEADER: env.API_KEY_HEADER || "x-api-key",
579
+ API_AUTH_MODE: parseAuthMode(env.API_AUTH_MODE, nodeEnv),
580
+ RATE_LIMIT_WINDOW_MS: positiveInteger(env.RATE_LIMIT_WINDOW_MS, 9e5),
581
+ RATE_LIMIT_MAX: positiveInteger(
582
+ env.RATE_LIMIT_MAX,
583
+ nodeEnv === "production" ? 100 : 1e3
584
+ ),
585
+ TRUST_PROXY_HEADERS: env.TRUST_PROXY_HEADERS === "true",
586
+ MAX_FILE_SIZE: positiveInteger(env.MAX_FILE_SIZE, 10 * 1024 * 1024),
587
+ MAX_REQUEST_BODY_SIZE: positiveInteger(
588
+ env.MAX_REQUEST_BODY_SIZE,
589
+ 32 * 1024 * 1024
590
+ ),
591
+ MAX_CONCURRENT_REQUESTS: positiveInteger(
592
+ env.MAX_CONCURRENT_REQUESTS,
593
+ nodeEnv === "production" ? 8 : 64
594
+ ),
595
+ OUTBOUND_SOURCE_MODE: parseOutboundSourceMode(
596
+ env.OUTBOUND_SOURCE_MODE,
597
+ nodeEnv
598
+ ),
599
+ OUTBOUND_HOST_ALLOWLIST: (env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean),
600
+ UPLOAD_DIR: env.UPLOAD_DIR || "uploads",
601
+ LIBREOFFICE_PATH: env.LIBREOFFICE_PATH,
602
+ LIBREOFFICE_TIMEOUT_MS: env.LIBREOFFICE_TIMEOUT_MS ? positiveInteger(env.LIBREOFFICE_TIMEOUT_MS, 3e4) : 3e4,
603
+ LOG_LEVEL: env.LOG_LEVEL || "info",
604
+ CACHE_ENABLED: env.CACHE_ENABLED !== "false",
605
+ CACHE_MAX_SIZE_MB: positiveInteger(env.CACHE_MAX_SIZE_MB, 100),
606
+ CACHE_TTL_SECONDS: positiveInteger(env.CACHE_TTL_SECONDS, 3600),
607
+ CACHE_MAX_ITEMS: positiveInteger(env.CACHE_MAX_ITEMS, 1e3)
608
+ };
609
+ }
610
+ var parsedEnv = parseEnv(process.env);
611
+ var config = {
612
+ ...parsedEnv,
613
+ isDevelopment: parsedEnv.NODE_ENV === "development",
614
+ isProduction: parsedEnv.NODE_ENV === "production",
615
+ isTest: parsedEnv.NODE_ENV === "test",
616
+ features: {
617
+ apiKey: parsedEnv.API_AUTH_MODE !== "disabled",
618
+ cache: parsedEnv.CACHE_ENABLED
619
+ },
620
+ cors: {
621
+ origin: parsedEnv.CORS_ORIGIN === "*" ? parsedEnv.CORS_ORIGIN : parsedEnv.CORS_ORIGIN.split(",").map((o) => o.trim()),
622
+ credentials: true
623
+ },
624
+ rateLimit: {
625
+ windowMs: parsedEnv.RATE_LIMIT_WINDOW_MS,
626
+ max: parsedEnv.RATE_LIMIT_MAX,
627
+ trustProxy: parsedEnv.TRUST_PROXY_HEADERS
628
+ },
629
+ requestLimits: {
630
+ maxBodySize: parsedEnv.MAX_REQUEST_BODY_SIZE,
631
+ maxConcurrent: parsedEnv.MAX_CONCURRENT_REQUESTS,
632
+ maxFileSize: parsedEnv.MAX_FILE_SIZE
633
+ },
634
+ outboundSources: {
635
+ mode: parsedEnv.OUTBOUND_SOURCE_MODE,
636
+ allowedHosts: parsedEnv.OUTBOUND_HOST_ALLOWLIST
637
+ },
638
+ cache: {
639
+ enabled: parsedEnv.CACHE_ENABLED,
640
+ maxSizeMB: parsedEnv.CACHE_MAX_SIZE_MB,
641
+ ttlSeconds: parsedEnv.CACHE_TTL_SECONDS,
642
+ maxItems: parsedEnv.CACHE_MAX_ITEMS
643
+ }
644
+ };
645
+
646
+ // src/server/middleware/hono/auth.ts
647
+ function keysEqual(received, expected) {
648
+ const receivedDigest = createHash("sha256").update(received).digest();
649
+ const expectedDigest = createHash("sha256").update(expected).digest();
650
+ return timingSafeEqual(receivedDigest, expectedDigest);
651
+ }
652
+ function readCredential(headers, headerName) {
653
+ const direct = headers.get(headerName)?.trim();
654
+ if (direct) {
655
+ if (headerName.toLowerCase() === "authorization" && direct.toLowerCase().startsWith("bearer ")) {
656
+ return direct.slice(7).trim() || void 0;
657
+ }
658
+ return direct;
659
+ }
660
+ const authorization = headers.get("authorization")?.trim();
661
+ if (authorization?.toLowerCase().startsWith("bearer ")) {
662
+ return authorization.slice(7).trim() || void 0;
663
+ }
664
+ return void 0;
665
+ }
666
+ function createApiKeyAuthMiddleware(options) {
667
+ const headerName = options.headerName || "x-api-key";
668
+ return async (c, next) => {
669
+ if (c.req.method === "OPTIONS" || options.mode === "disabled") {
670
+ return next();
671
+ }
672
+ if (!options.apiKey) {
673
+ if (options.mode === "auto") return next();
674
+ return c.json(
675
+ {
676
+ success: false,
677
+ error: "API authentication is not configured",
678
+ code: "AUTH_CONFIGURATION_ERROR"
679
+ },
680
+ 503
681
+ );
682
+ }
683
+ const apiKey = readCredential(c.req.raw.headers, headerName);
684
+ if (!apiKey) {
685
+ return c.json(
686
+ {
687
+ success: false,
688
+ error: "API key required",
689
+ code: "UNAUTHORIZED"
690
+ },
691
+ 401
692
+ );
693
+ }
694
+ if (!keysEqual(apiKey, options.apiKey)) {
695
+ return c.json(
696
+ {
697
+ success: false,
698
+ error: "Invalid API key",
699
+ code: "UNAUTHORIZED"
700
+ },
701
+ 401
702
+ );
703
+ }
704
+ await next();
705
+ };
706
+ }
707
+ var apiKeyAuthMiddleware = createApiKeyAuthMiddleware({
708
+ mode: config.API_AUTH_MODE,
709
+ apiKey: config.API_KEY,
710
+ headerName: config.API_KEY_HEADER
711
+ });
712
+
176
713
  // src/render-server.ts
177
- var UPSTREAM = (process.env.HIGHCHARTS_UPSTREAM_URL || "http://127.0.0.1:7801").replace(/\/$/, "");
178
- var PORT = Number(process.env.PORT || 1e4);
179
- var PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS || 3e4);
180
- var HEALTH_TIMEOUT_MS = 2e3;
181
- var app = new Hono();
714
+ var DEFAULT_UPSTREAM = "http://127.0.0.1:7801";
715
+ var DEFAULT_PORT = 1e4;
716
+ var DEFAULT_PROXY_TIMEOUT_MS = 3e4;
717
+ var DEFAULT_HEALTH_TIMEOUT_MS = 2e3;
718
+ var DEFAULT_EXPORT_BODY_BYTES = 4 * 1024 * 1024;
719
+ var DEFAULT_RASTERIZE_BODY_BYTES = 32 * 1024 * 1024;
720
+ var DEFAULT_RESPONSE_BYTES = 24 * 1024 * 1024;
721
+ var MAX_CHART_DIMENSION = 4096;
722
+ var MAX_CHART_PIXELS = 16e6;
723
+ var MAX_CHART_SCALE = 4;
724
+ var UpstreamResponseTooLargeError = class extends Error {
725
+ };
726
+ function positiveInteger2(value, fallback) {
727
+ const parsed = Number.parseInt(value ?? "", 10);
728
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
729
+ }
730
+ function isHardenedEnv() {
731
+ const nodeEnv = process.env.NODE_ENV;
732
+ return nodeEnv !== void 0 && nodeEnv !== "development" && nodeEnv !== "test";
733
+ }
734
+ function envAuthOptions() {
735
+ const production = isHardenedEnv();
736
+ const requestedMode = process.env.RENDER_AUTH_MODE;
737
+ const mode = requestedMode === "auto" || requestedMode === "required" || requestedMode === "disabled" ? requestedMode : production ? "required" : "auto";
738
+ return {
739
+ mode,
740
+ apiKey: process.env.RENDER_API_KEY || process.env.API_KEY,
741
+ headerName: process.env.RENDER_API_KEY_HEADER || "x-api-key"
742
+ };
743
+ }
744
+ function envSourcePolicy() {
745
+ const requestedMode = process.env.OUTBOUND_SOURCE_MODE;
746
+ const mode = requestedMode === "safe" || requestedMode === "development" ? requestedMode : isHardenedEnv() ? "safe" : "development";
747
+ return {
748
+ mode,
749
+ allowedHosts: (process.env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean)
750
+ };
751
+ }
752
+ function isRecord2(value) {
753
+ return typeof value === "object" && value !== null && !Array.isArray(value);
754
+ }
755
+ function resolveDimension(exporting, chart, sourceKey, chartKey, fallback) {
756
+ for (const candidate of [exporting?.[sourceKey], chart?.[chartKey]]) {
757
+ if (candidate === void 0) continue;
758
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate <= 0 || candidate > MAX_CHART_DIMENSION) {
759
+ throw new HTTPException4(400, {
760
+ message: "Requested chart dimensions are too large"
761
+ });
762
+ }
763
+ return candidate;
764
+ }
765
+ return fallback;
766
+ }
767
+ function resolveScale(value, fallback) {
768
+ if (value === void 0) return fallback;
769
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > MAX_CHART_SCALE) {
770
+ throw new HTTPException4(400, {
771
+ message: `scale must be between 0 and ${MAX_CHART_SCALE}`
772
+ });
773
+ }
774
+ return value;
775
+ }
776
+ function validateExportRequest(value) {
777
+ if (!isRecord2(value)) {
778
+ throw new HTTPException4(400, { message: "Export body must be an object" });
779
+ }
780
+ const allowedKeys = /* @__PURE__ */ new Set(["infile", "type", "b64", "scale", "resources"]);
781
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
782
+ if (unknown.length > 0) {
783
+ throw new HTTPException4(400, {
784
+ message: `Unsupported export field: ${unknown[0]}`
785
+ });
786
+ }
787
+ if (!isRecord2(value.infile)) {
788
+ throw new HTTPException4(400, {
789
+ message: "Export infile must be a chart options object"
790
+ });
791
+ }
792
+ if (value.type !== "png" || value.b64 !== true) {
793
+ throw new HTTPException4(400, {
794
+ message: "Only base64 PNG exports are allowed"
795
+ });
796
+ }
797
+ const chart = isRecord2(value.infile.chart) ? value.infile.chart : void 0;
798
+ const exporting = isRecord2(value.infile.exporting) ? value.infile.exporting : void 0;
799
+ const scale = resolveScale(value.scale, resolveScale(exporting?.scale, 1));
800
+ const width = resolveDimension(exporting, chart, "sourceWidth", "width", 600);
801
+ const height = resolveDimension(
802
+ exporting,
803
+ chart,
804
+ "sourceHeight",
805
+ "height",
806
+ 400
807
+ );
808
+ if (width * height * scale * scale > MAX_CHART_PIXELS) {
809
+ throw new HTTPException4(400, {
810
+ message: "Requested chart dimensions are too large"
811
+ });
812
+ }
813
+ }
182
814
  function isTimeout(error) {
183
815
  const name = error?.name;
184
816
  return name === "TimeoutError" || name === "AbortError";
185
817
  }
186
- app.get("/health", async (c) => {
187
- try {
188
- const res = await fetch(`${UPSTREAM}/health`, {
189
- signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
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);
818
+ async function readLimitedBody(response, maxBytes) {
819
+ const declaredLength = Number(response.headers.get("content-length"));
820
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
821
+ await response.body?.cancel();
822
+ throw new UpstreamResponseTooLargeError();
195
823
  }
196
- });
197
- registerRasterizeRoute(app, {
198
- preMiddleware: [
199
- rateLimiter({
200
- limit: process.env.NODE_ENV === "production" ? 30 : 1e3,
201
- window: 15 * 60 * 1e3
202
- })
203
- ],
204
- onError: (error) => (
205
- // eslint-disable-next-line no-console
206
- console.error(
207
- "[jto-render-server] rasterize failed:",
208
- error instanceof Error ? error.message : error
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();
824
+ if (!response.body) return new Uint8Array();
825
+ const reader = response.body.getReader();
826
+ const chunks = [];
827
+ let total = 0;
828
+ for (; ; ) {
829
+ const { value, done } = await reader.read();
830
+ if (done) break;
831
+ total += value.byteLength;
832
+ if (total > maxBytes) {
833
+ await reader.cancel();
834
+ throw new UpstreamResponseTooLargeError();
835
+ }
836
+ chunks.push(value);
226
837
  }
227
- let res;
228
- try {
229
- res = await fetch(target, init);
230
- } catch (error) {
231
- return isTimeout(error) ? c.json(
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
- );
838
+ const output = new Uint8Array(total);
839
+ let offset = 0;
840
+ for (const chunk of chunks) {
841
+ output.set(chunk, offset);
842
+ offset += chunk.byteLength;
240
843
  }
241
- const payload = await res.arrayBuffer();
242
- const headers = {};
243
- res.headers.forEach((value, key) => {
244
- if (key !== "content-encoding" && key !== "content-length" && key !== "transfer-encoding") {
245
- headers[key] = value;
844
+ return output;
845
+ }
846
+ function createRenderServerApp(options = {}) {
847
+ const production = isHardenedEnv();
848
+ const upstream = (options.upstreamUrl || process.env.HIGHCHARTS_UPSTREAM_URL || DEFAULT_UPSTREAM).replace(/\/$/, "");
849
+ const proxyTimeoutMs = options.proxyTimeoutMs ?? positiveInteger2(process.env.PROXY_TIMEOUT_MS, DEFAULT_PROXY_TIMEOUT_MS);
850
+ const healthTimeoutMs = options.healthTimeoutMs ?? positiveInteger2(process.env.HEALTH_TIMEOUT_MS, DEFAULT_HEALTH_TIMEOUT_MS);
851
+ const maxExportBodyBytes = options.maxExportBodyBytes ?? positiveInteger2(
852
+ process.env.MAX_EXPORT_BODY_SIZE,
853
+ DEFAULT_EXPORT_BODY_BYTES
854
+ );
855
+ const maxRasterizeBodyBytes = options.maxRasterizeBodyBytes ?? positiveInteger2(
856
+ process.env.MAX_RASTERIZE_BODY_SIZE,
857
+ DEFAULT_RASTERIZE_BODY_BYTES
858
+ );
859
+ const maxResponseBytes = options.maxResponseBytes ?? positiveInteger2(
860
+ process.env.MAX_RENDER_RESPONSE_SIZE,
861
+ DEFAULT_RESPONSE_BYTES
862
+ );
863
+ const maxConcurrent = options.maxConcurrent ?? positiveInteger2(process.env.MAX_CONCURRENT_RENDERS, production ? 4 : 16);
864
+ const windowMs = options.rateLimitWindowMs ?? positiveInteger2(process.env.RENDER_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1e3);
865
+ const fetchImpl = options.fetch ?? fetch;
866
+ const sourcePolicy = options.sourcePolicy ?? envSourcePolicy();
867
+ const trustProxyHeaders = options.trustProxyHeaders ?? process.env.TRUST_PROXY_HEADERS === "true";
868
+ const app = new Hono();
869
+ const auth = createApiKeyAuthMiddleware(options.auth ?? envAuthOptions());
870
+ const capacity = concurrencyLimiter({ limit: maxConcurrent });
871
+ app.use("*", secureHeaders());
872
+ app.onError((error, c) => {
873
+ if (error instanceof HTTPException4) {
874
+ return c.json({ success: false, error: error.message }, error.status);
246
875
  }
876
+ return c.json({ success: false, error: "Internal server error" }, 500);
247
877
  });
248
- return c.body(payload, res.status, headers);
249
- });
250
- serve({ fetch: app.fetch, port: PORT, hostname: "0.0.0.0" }, (info) => {
251
- console.log(
252
- `[jto-render-server] listening on :${info.port} \u2014 POST /rasterize (local), proxy \u2192 ${UPSTREAM}`
878
+ app.get("/health", async (c) => {
879
+ try {
880
+ const response = await fetchImpl(`${upstream}/health`, {
881
+ signal: AbortSignal.timeout(healthTimeoutMs)
882
+ });
883
+ if (response.ok) return c.text("ok");
884
+ return c.json({ status: "degraded", upstream: response.status }, 503);
885
+ } catch {
886
+ return c.json({ status: "degraded", upstream: "unreachable" }, 503);
887
+ }
888
+ });
889
+ registerRasterizeRoute(app, {
890
+ getRasterizer: options.getRasterizer,
891
+ preMiddleware: [
892
+ rateLimiter({
893
+ limit: options.rasterizeRateLimit ?? positiveInteger2(
894
+ process.env.RASTERIZE_RATE_LIMIT,
895
+ production ? 30 : 1e3
896
+ ),
897
+ window: windowMs,
898
+ namespace: "rasterize",
899
+ trustProxy: trustProxyHeaders
900
+ }),
901
+ capacity,
902
+ bodyLimit2({
903
+ maxSize: maxRasterizeBodyBytes,
904
+ onError: () => {
905
+ throw new HTTPException4(413, { message: "Request body too large" });
906
+ }
907
+ }),
908
+ auth
909
+ ],
910
+ sourcePolicy
911
+ });
912
+ app.post(
913
+ "/export",
914
+ rateLimiter({
915
+ limit: options.exportRateLimit ?? positiveInteger2(process.env.EXPORT_RATE_LIMIT, production ? 60 : 1e3),
916
+ window: windowMs,
917
+ namespace: "export",
918
+ trustProxy: trustProxyHeaders
919
+ }),
920
+ capacity,
921
+ bodyLimit2({
922
+ maxSize: maxExportBodyBytes,
923
+ onError: () => {
924
+ throw new HTTPException4(413, { message: "Request body too large" });
925
+ }
926
+ }),
927
+ auth,
928
+ async (c) => {
929
+ const contentType = c.req.header("content-type");
930
+ if (!contentType?.toLowerCase().includes("application/json")) {
931
+ throw new HTTPException4(415, {
932
+ message: "Content-Type must be application/json"
933
+ });
934
+ }
935
+ let body;
936
+ try {
937
+ body = await c.req.json();
938
+ } catch {
939
+ throw new HTTPException4(400, { message: "Invalid JSON body" });
940
+ }
941
+ validateExportRequest(body);
942
+ try {
943
+ assertSafeRendererPayload(body, sourcePolicy);
944
+ } catch (error) {
945
+ if (error instanceof UnsafeOutboundSourceError) {
946
+ throw new HTTPException4(400, { message: error.message });
947
+ }
948
+ throw error;
949
+ }
950
+ let response;
951
+ try {
952
+ response = await fetchImpl(`${upstream}/export`, {
953
+ method: "POST",
954
+ headers: {
955
+ "Content-Type": "application/json",
956
+ Accept: "text/plain, image/png, application/json"
957
+ },
958
+ body: JSON.stringify(body),
959
+ signal: AbortSignal.timeout(proxyTimeoutMs)
960
+ });
961
+ } catch (error) {
962
+ return isTimeout(error) ? c.json(
963
+ {
964
+ error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
965
+ },
966
+ 504
967
+ ) : c.json({ error: "Highcharts upstream unreachable" }, 502);
968
+ }
969
+ let payload;
970
+ try {
971
+ payload = await readLimitedBody(response, maxResponseBytes);
972
+ } catch (error) {
973
+ if (error instanceof UpstreamResponseTooLargeError) {
974
+ return c.json({ error: "Highcharts response too large" }, 502);
975
+ }
976
+ if (isTimeout(error)) {
977
+ return c.json(
978
+ {
979
+ error: `Highcharts upstream timed out after ${proxyTimeoutMs}ms`
980
+ },
981
+ 504
982
+ );
983
+ }
984
+ return c.json({ error: "Failed to read Highcharts response" }, 502);
985
+ }
986
+ const headers = {};
987
+ for (const name of [
988
+ "content-type",
989
+ "content-disposition",
990
+ "cache-control"
991
+ ]) {
992
+ const value = response.headers.get(name);
993
+ if (value) headers[name] = value;
994
+ }
995
+ return c.body(payload, response.status, headers);
996
+ }
253
997
  );
254
- });
998
+ for (const route of ["/export", "/rasterize"]) {
999
+ app.all(route, (c) => {
1000
+ c.header("Allow", "POST");
1001
+ return c.json({ success: false, error: "Method not allowed" }, 405);
1002
+ });
1003
+ }
1004
+ app.notFound((c) => c.json({ success: false, error: "Not found" }, 404));
1005
+ return app;
1006
+ }
1007
+ function isMainModule() {
1008
+ if (!process.argv[1]) return false;
1009
+ try {
1010
+ return resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
1011
+ } catch {
1012
+ return false;
1013
+ }
1014
+ }
1015
+ if (isMainModule()) {
1016
+ const port = positiveInteger2(process.env.PORT, DEFAULT_PORT);
1017
+ const app = createRenderServerApp();
1018
+ void import("@hono/node-server").then(({ serve }) => {
1019
+ serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => {
1020
+ console.log(`[jto-render-server] listening on :${info.port}`);
1021
+ });
1022
+ });
1023
+ }
1024
+ export {
1025
+ createRenderServerApp
1026
+ };
255
1027
  //# sourceMappingURL=render-server.js.map