radithgo commited on
Commit
bd02a11
·
1 Parent(s): f315513

fix: add image proxy diagnostics

Browse files
Files changed (1) hide show
  1. image/server.js +206 -32
image/server.js CHANGED
@@ -1,11 +1,20 @@
1
  const express = require("express");
2
  const https = require("https");
 
3
 
4
  const app = express();
5
  const PORT = process.env.PORT || 7860;
6
  const UPLOADS_ORIGIN = "https://uploads.mangadex.org";
 
 
 
 
 
 
 
7
 
8
  app.get("/*", (req, res) => {
 
9
  let imagePath = req.params[0];
10
 
11
  if (!imagePath && req.query.url) {
@@ -13,49 +22,38 @@ app.get("/*", (req, res) => {
13
  }
14
 
15
  if (!imagePath) {
 
16
  return res.status(400).send("Image path required");
17
  }
18
 
19
  imagePath = normalizeImagePath(imagePath);
20
 
21
  if (!imagePath.startsWith("covers/")) {
 
 
 
 
22
  return res.status(400).send("Only MangaDex cover paths are supported");
23
  }
24
 
25
- const targetUrl = `${UPLOADS_ORIGIN}/${imagePath}`;
26
- const requestOptions = {
27
- headers: {
28
- Referer: "https://mangadex.org/",
29
- "User-Agent":
30
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
31
- Accept: "image/avif,image/webp,image/apng,*/*;q=0.8",
32
- "Accept-Language": "en-US,en;q=0.9,id;q=0.8",
33
- DNT: "1",
34
- },
35
- };
36
 
37
- https
38
- .get(targetUrl, requestOptions, (proxyRes) => {
39
- if (proxyRes.statusCode !== 200) {
40
- proxyRes.resume();
41
- return res
42
- .status(proxyRes.statusCode || 502)
43
- .send(`Image not found or error from upstream: ${proxyRes.statusCode}`);
44
- }
45
 
46
- res.set("Access-Control-Allow-Origin", "*");
47
- res.set("Access-Control-Allow-Methods", "GET, OPTIONS");
48
- res.set("Access-Control-Allow-Headers", "*");
49
- res.set("Cross-Origin-Resource-Policy", "cross-origin");
50
- res.set("Content-Type", proxyRes.headers["content-type"] || "application/octet-stream");
51
- res.set("Cache-Control", "public, max-age=31536000, immutable");
52
-
53
- proxyRes.pipe(res);
54
- })
55
- .on("error", (err) => {
56
- console.error("Proxy error:", err.message);
57
- res.status(500).send("Internal Server Error while proxying");
58
- });
59
  });
60
 
61
  app.options("/*", (_req, res) => {
@@ -75,3 +73,179 @@ function normalizeImagePath(input) {
75
  imagePath = imagePath.replace(/^\/+/, "");
76
  return imagePath;
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  const express = require("express");
2
  const https = require("https");
3
+ const { randomUUID } = require("crypto");
4
 
5
  const app = express();
6
  const PORT = process.env.PORT || 7860;
7
  const UPLOADS_ORIGIN = "https://uploads.mangadex.org";
8
+ const SITE_ORIGIN = "https://mangadex.org";
9
+ const UPSTREAM_TIMEOUT_MS = Number.parseInt(process.env.UPSTREAM_TIMEOUT_MS || "30000", 10);
10
+ const ERROR_BODY_LIMIT = Number.parseInt(process.env.ERROR_BODY_LIMIT || "2048", 10);
11
+
12
+ app.get("/healthz", (_req, res) => {
13
+ res.status(200).json({ ok: true });
14
+ });
15
 
16
  app.get("/*", (req, res) => {
17
+ const requestId = req.get("x-request-id") || randomUUID().slice(0, 8);
18
  let imagePath = req.params[0];
19
 
20
  if (!imagePath && req.query.url) {
 
22
  }
23
 
24
  if (!imagePath) {
25
+ logEvent("warn", requestId, "missing image path", requestDetails(req));
26
  return res.status(400).send("Image path required");
27
  }
28
 
29
  imagePath = normalizeImagePath(imagePath);
30
 
31
  if (!imagePath.startsWith("covers/")) {
32
+ logEvent("warn", requestId, "rejected non-cover path", {
33
+ ...requestDetails(req),
34
+ imagePath,
35
+ });
36
  return res.status(400).send("Only MangaDex cover paths are supported");
37
  }
38
 
39
+ const candidates = [
40
+ { label: "uploads", url: `${UPLOADS_ORIGIN}/${imagePath}` },
41
+ { label: "site", url: `${SITE_ORIGIN}/${imagePath}` },
42
+ ];
 
 
 
 
 
 
 
43
 
44
+ logEvent("info", requestId, "proxy request", {
45
+ ...requestDetails(req),
46
+ imagePath,
47
+ candidates: candidates.map((candidate) => candidate.url),
48
+ });
 
 
 
49
 
50
+ proxyFromCandidates({
51
+ candidates,
52
+ requestId,
53
+ requestMethod: req.method,
54
+ response: res,
55
+ errors: [],
56
+ });
 
 
 
 
 
 
57
  });
58
 
59
  app.options("/*", (_req, res) => {
 
73
  imagePath = imagePath.replace(/^\/+/, "");
74
  return imagePath;
75
  }
76
+
77
+ function proxyFromCandidates({ candidates, requestId, requestMethod, response, errors }) {
78
+ const [candidate, ...remainingCandidates] = candidates;
79
+
80
+ if (!candidate) {
81
+ const status = errors.find((error) => Number.isInteger(error.status))?.status || 502;
82
+ response.set("Access-Control-Allow-Origin", "*");
83
+ response.set("X-Proxy-Request-Id", requestId);
84
+ response.status(status).send(
85
+ [
86
+ `Image not found or error from upstream. requestId=${requestId}`,
87
+ ...errors.map((error) => `${error.label}: ${error.status || "error"} ${error.message || ""}`),
88
+ ].join("\n")
89
+ );
90
+ return;
91
+ }
92
+
93
+ const upstreamRequest = https.get(
94
+ candidate.url,
95
+ {
96
+ method: requestMethod,
97
+ timeout: UPSTREAM_TIMEOUT_MS,
98
+ headers: upstreamHeaders(),
99
+ },
100
+ (upstreamResponse) => {
101
+ const statusCode = upstreamResponse.statusCode || 502;
102
+ const responseHeaders = selectedHeaders(upstreamResponse.headers);
103
+
104
+ logEvent("info", requestId, "upstream response", {
105
+ label: candidate.label,
106
+ url: candidate.url,
107
+ statusCode,
108
+ statusMessage: upstreamResponse.statusMessage,
109
+ headers: responseHeaders,
110
+ });
111
+
112
+ if (statusCode === 200) {
113
+ response.set("Access-Control-Allow-Origin", "*");
114
+ response.set("Access-Control-Allow-Methods", "GET, OPTIONS");
115
+ response.set("Access-Control-Allow-Headers", "*");
116
+ response.set("Cross-Origin-Resource-Policy", "cross-origin");
117
+ response.set("X-Proxy-Request-Id", requestId);
118
+ response.set("X-Proxy-Upstream", candidate.label);
119
+ response.set("Content-Type", upstreamResponse.headers["content-type"] || "application/octet-stream");
120
+ response.set("Cache-Control", "public, max-age=31536000, immutable");
121
+
122
+ upstreamResponse.pipe(response);
123
+ response.on("finish", () => {
124
+ logEvent("info", requestId, "proxy response complete", {
125
+ label: candidate.label,
126
+ statusCode: response.statusCode,
127
+ });
128
+ });
129
+ return;
130
+ }
131
+
132
+ collectErrorBody(upstreamResponse, (bodySnippet) => {
133
+ const upstreamError = {
134
+ label: candidate.label,
135
+ url: candidate.url,
136
+ status: statusCode,
137
+ message: upstreamResponse.statusMessage,
138
+ headers: responseHeaders,
139
+ bodySnippet,
140
+ };
141
+ logEvent("warn", requestId, "upstream non-200", upstreamError);
142
+ proxyFromCandidates({
143
+ candidates: remainingCandidates,
144
+ requestId,
145
+ requestMethod,
146
+ response,
147
+ errors: [...errors, upstreamError],
148
+ });
149
+ });
150
+ }
151
+ );
152
+
153
+ upstreamRequest.on("timeout", () => {
154
+ upstreamRequest.destroy(new Error(`Upstream timeout after ${UPSTREAM_TIMEOUT_MS}ms`));
155
+ });
156
+
157
+ upstreamRequest.on("error", (err) => {
158
+ const upstreamError = {
159
+ label: candidate.label,
160
+ url: candidate.url,
161
+ message: err.message,
162
+ };
163
+ logEvent("error", requestId, "upstream request failed", upstreamError);
164
+ proxyFromCandidates({
165
+ candidates: remainingCandidates,
166
+ requestId,
167
+ requestMethod,
168
+ response,
169
+ errors: [...errors, upstreamError],
170
+ });
171
+ });
172
+ }
173
+
174
+ function upstreamHeaders() {
175
+ return {
176
+ Referer: "https://mangadex.org/",
177
+ "User-Agent":
178
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
179
+ Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
180
+ "Accept-Language": "en-US,en;q=0.9,id;q=0.8",
181
+ DNT: "1",
182
+ };
183
+ }
184
+
185
+ function collectErrorBody(stream, onDone) {
186
+ const chunks = [];
187
+ let collected = 0;
188
+
189
+ stream.on("data", (chunk) => {
190
+ if (collected >= ERROR_BODY_LIMIT) {
191
+ return;
192
+ }
193
+
194
+ const remaining = ERROR_BODY_LIMIT - collected;
195
+ const piece = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
196
+ chunks.push(piece);
197
+ collected += piece.length;
198
+ });
199
+
200
+ stream.on("end", () => {
201
+ onDone(Buffer.concat(chunks).toString("utf8"));
202
+ });
203
+
204
+ stream.on("error", (err) => {
205
+ onDone(`Failed to read upstream error body: ${err.message}`);
206
+ });
207
+ }
208
+
209
+ function requestDetails(req) {
210
+ return {
211
+ method: req.method,
212
+ originalUrl: req.originalUrl,
213
+ ip: req.ip,
214
+ userAgent: req.get("user-agent"),
215
+ referer: req.get("referer"),
216
+ };
217
+ }
218
+
219
+ function selectedHeaders(headers) {
220
+ return {
221
+ "content-type": headers["content-type"],
222
+ "content-length": headers["content-length"],
223
+ server: headers.server,
224
+ via: headers.via,
225
+ "x-request-id": headers["x-request-id"],
226
+ "x-cache": headers["x-cache"],
227
+ };
228
+ }
229
+
230
+ function logEvent(level, requestId, message, details = {}) {
231
+ const entry = {
232
+ time: new Date().toISOString(),
233
+ level,
234
+ requestId,
235
+ message,
236
+ ...details,
237
+ };
238
+ const line = JSON.stringify(entry);
239
+
240
+ if (level === "error") {
241
+ console.error(line);
242
+ return;
243
+ }
244
+
245
+ if (level === "warn") {
246
+ console.warn(line);
247
+ return;
248
+ }
249
+
250
+ console.log(line);
251
+ }