github-issue-tower-defence-management 1.94.3 → 1.95.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/index.js +1 -0
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleImageProxy.js +79 -0
  5. package/bin/adapter/entry-points/console/consoleImageProxy.js.map +1 -0
  6. package/bin/adapter/entry-points/console/consoleServer.js +29 -1
  7. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/index-BeJzGnfH.js +101 -0
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  10. package/package.json +1 -1
  11. package/src/adapter/entry-points/cli/index.ts +1 -0
  12. package/src/adapter/entry-points/console/consoleImageProxy.test.ts +140 -0
  13. package/src/adapter/entry-points/console/consoleImageProxy.ts +111 -0
  14. package/src/adapter/entry-points/console/consoleServer.test.ts +182 -0
  15. package/src/adapter/entry-points/console/consoleServer.ts +45 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +10 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +23 -3
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +7 -1
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +9 -1
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +7 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +14 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.test.ts +78 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.ts +41 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +48 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.ts +19 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +9 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +39 -4
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +29 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +11 -0
  30. package/src/adapter/entry-points/console/ui-dist/assets/index-BeJzGnfH.js +101 -0
  31. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  32. package/types/adapter/entry-points/console/consoleImageProxy.d.ts +19 -0
  33. package/types/adapter/entry-points/console/consoleImageProxy.d.ts.map +1 -0
  34. package/types/adapter/entry-points/console/consoleServer.d.ts +4 -0
  35. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  36. package/bin/adapter/entry-points/console/ui-dist/assets/index-eSyo3r3C.js +0 -101
  37. package/src/adapter/entry-points/console/ui-dist/assets/index-eSyo3r3C.js +0 -101
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-eSyo3r3C.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-BeJzGnfH.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-CO3Vvzqr.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.94.3",
3
+ "version": "1.95.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -716,6 +716,7 @@ program
716
716
  consoleDataOutputDir,
717
717
  inTmuxDataDir,
718
718
  dashboardDir,
719
+ githubToken: token,
719
720
  issueRepository,
720
721
  resolveProject,
721
722
  issueTitleStateCache: new IssueTitleStateCache(),
@@ -0,0 +1,140 @@
1
+ import {
2
+ type ImageFetcher,
3
+ fetchProxiedImage,
4
+ isAllowedImageUrl,
5
+ } from './consoleImageProxy';
6
+
7
+ describe('isAllowedImageUrl', () => {
8
+ it('allows github user-attachments URLs', () => {
9
+ expect(
10
+ isAllowedImageUrl(
11
+ 'https://github.com/user-attachments/assets/0a1b2c3d-4e5f',
12
+ ),
13
+ ).toBe(true);
14
+ });
15
+
16
+ it('allows githubusercontent subdomain URLs', () => {
17
+ expect(
18
+ isAllowedImageUrl(
19
+ 'https://private-user-images.githubusercontent.com/1/2.png',
20
+ ),
21
+ ).toBe(true);
22
+ });
23
+
24
+ it('rejects arbitrary external hosts', () => {
25
+ expect(isAllowedImageUrl('https://example.com/avatar.png')).toBe(false);
26
+ });
27
+
28
+ it('rejects non-https github paths outside user-attachments', () => {
29
+ expect(isAllowedImageUrl('https://github.com/HiromiShikata/repo')).toBe(
30
+ false,
31
+ );
32
+ });
33
+
34
+ it('rejects a host that merely contains githubusercontent.com as a suffix trick', () => {
35
+ expect(isAllowedImageUrl('https://evil-githubusercontent.com/x.png')).toBe(
36
+ false,
37
+ );
38
+ });
39
+ });
40
+
41
+ describe('fetchProxiedImage', () => {
42
+ const successFetcher: ImageFetcher = async (_url, headers) => {
43
+ expect(headers.Authorization).toBe('token gh-token-value');
44
+ return {
45
+ status: 200,
46
+ contentType: 'image/png',
47
+ body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
48
+ };
49
+ };
50
+
51
+ it('returns image bytes for an allow-listed url with a valid token', async () => {
52
+ const result = await fetchProxiedImage(
53
+ 'https://github.com/user-attachments/assets/abc',
54
+ 'gh-token-value',
55
+ successFetcher,
56
+ );
57
+ expect(result.ok).toBe(true);
58
+ if (result.ok) {
59
+ expect(result.contentType).toBe('image/png');
60
+ expect(Array.from(result.body)).toEqual([0x89, 0x50, 0x4e, 0x47]);
61
+ }
62
+ });
63
+
64
+ it('rejects a missing url with status 400', async () => {
65
+ const result = await fetchProxiedImage(
66
+ '',
67
+ 'gh-token-value',
68
+ successFetcher,
69
+ );
70
+ expect(result).toEqual({
71
+ ok: false,
72
+ statusCode: 400,
73
+ error: 'missing url parameter',
74
+ });
75
+ });
76
+
77
+ it('rejects a non-allow-listed url with status 400', async () => {
78
+ const result = await fetchProxiedImage(
79
+ 'https://example.com/avatar.png',
80
+ 'gh-token-value',
81
+ successFetcher,
82
+ );
83
+ expect(result).toEqual({
84
+ ok: false,
85
+ statusCode: 400,
86
+ error: 'url not in allowed domain',
87
+ });
88
+ });
89
+
90
+ it('returns 502 when the upstream responds with a non-2xx status', async () => {
91
+ const failingFetcher: ImageFetcher = async () => ({
92
+ status: 404,
93
+ contentType: null,
94
+ body: Buffer.alloc(0),
95
+ });
96
+ const result = await fetchProxiedImage(
97
+ 'https://github.com/user-attachments/assets/abc',
98
+ 'gh-token-value',
99
+ failingFetcher,
100
+ );
101
+ expect(result).toEqual({
102
+ ok: false,
103
+ statusCode: 502,
104
+ error: 'upstream 404',
105
+ });
106
+ });
107
+
108
+ it('returns 502 when the fetcher throws', async () => {
109
+ const throwingFetcher: ImageFetcher = async () => {
110
+ throw new Error('network down');
111
+ };
112
+ const result = await fetchProxiedImage(
113
+ 'https://github.com/user-attachments/assets/abc',
114
+ 'gh-token-value',
115
+ throwingFetcher,
116
+ );
117
+ expect(result).toEqual({
118
+ ok: false,
119
+ statusCode: 502,
120
+ error: 'proxy error: network down',
121
+ });
122
+ });
123
+
124
+ it('falls back to octet-stream content type when upstream omits it', async () => {
125
+ const noContentTypeFetcher: ImageFetcher = async () => ({
126
+ status: 200,
127
+ contentType: null,
128
+ body: Buffer.from([0x01]),
129
+ });
130
+ const result = await fetchProxiedImage(
131
+ 'https://private-user-images.githubusercontent.com/1/2',
132
+ 'gh-token-value',
133
+ noContentTypeFetcher,
134
+ );
135
+ expect(result.ok).toBe(true);
136
+ if (result.ok) {
137
+ expect(result.contentType).toBe('application/octet-stream');
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,111 @@
1
+ const GITHUBUSERCONTENT_HOST_SUFFIX = '.githubusercontent.com';
2
+
3
+ const isAllowedHost = (hostname: string): boolean => {
4
+ if (hostname === 'github.com') {
5
+ return true;
6
+ }
7
+ if (!hostname.endsWith(GITHUBUSERCONTENT_HOST_SUFFIX)) {
8
+ return false;
9
+ }
10
+ const subdomain = hostname.slice(
11
+ 0,
12
+ hostname.length - GITHUBUSERCONTENT_HOST_SUFFIX.length,
13
+ );
14
+ return subdomain.length > 0 && /^[a-z0-9][a-z0-9.-]*$/.test(subdomain);
15
+ };
16
+
17
+ const parseAllowedImageUrl = (url: string): URL | null => {
18
+ let parsed: URL;
19
+ try {
20
+ parsed = new URL(url);
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (parsed.protocol !== 'https:') {
25
+ return null;
26
+ }
27
+ if (parsed.hostname === 'github.com') {
28
+ return parsed.pathname.startsWith('/user-attachments/') ? parsed : null;
29
+ }
30
+ return isAllowedHost(parsed.hostname) ? parsed : null;
31
+ };
32
+
33
+ export const isAllowedImageUrl = (url: string): boolean =>
34
+ parseAllowedImageUrl(url) !== null;
35
+
36
+ export type ProxiedImageSuccess = {
37
+ ok: true;
38
+ contentType: string;
39
+ body: Buffer;
40
+ };
41
+
42
+ export type ProxiedImageFailure = {
43
+ ok: false;
44
+ statusCode: number;
45
+ error: string;
46
+ };
47
+
48
+ export type ProxiedImageResult = ProxiedImageSuccess | ProxiedImageFailure;
49
+
50
+ export type ImageFetcher = (
51
+ url: string,
52
+ headers: Record<string, string>,
53
+ ) => Promise<{
54
+ status: number;
55
+ contentType: string | null;
56
+ body: Buffer;
57
+ }>;
58
+
59
+ const defaultImageFetcher: ImageFetcher = async (url, headers) => {
60
+ const validated = parseAllowedImageUrl(url);
61
+ if (validated === null) {
62
+ throw new Error('url not in allowed domain');
63
+ }
64
+ const safeUrl = `https://${validated.host}${validated.pathname}${validated.search}`;
65
+ const response = await fetch(safeUrl, { headers, redirect: 'follow' });
66
+ const arrayBuffer = await response.arrayBuffer();
67
+ return {
68
+ status: response.status,
69
+ contentType: response.headers.get('content-type'),
70
+ body: Buffer.from(arrayBuffer),
71
+ };
72
+ };
73
+
74
+ export const fetchProxiedImage = async (
75
+ url: string,
76
+ githubToken: string,
77
+ fetcher: ImageFetcher = defaultImageFetcher,
78
+ ): Promise<ProxiedImageResult> => {
79
+ if (url.length === 0) {
80
+ return { ok: false, statusCode: 400, error: 'missing url parameter' };
81
+ }
82
+ const allowedUrl = parseAllowedImageUrl(url);
83
+ if (allowedUrl === null) {
84
+ return { ok: false, statusCode: 400, error: 'url not in allowed domain' };
85
+ }
86
+ let result: {
87
+ status: number;
88
+ contentType: string | null;
89
+ body: Buffer;
90
+ };
91
+ try {
92
+ result = await fetcher(allowedUrl.toString(), {
93
+ Authorization: `token ${githubToken}`,
94
+ });
95
+ } catch (error) {
96
+ const detail = error instanceof Error ? error.message : String(error);
97
+ return { ok: false, statusCode: 502, error: `proxy error: ${detail}` };
98
+ }
99
+ if (result.status < 200 || result.status >= 300) {
100
+ return {
101
+ ok: false,
102
+ statusCode: 502,
103
+ error: `upstream ${result.status}`,
104
+ };
105
+ }
106
+ return {
107
+ ok: true,
108
+ contentType: result.contentType ?? 'application/octet-stream',
109
+ body: result.body,
110
+ };
111
+ };
@@ -15,6 +15,7 @@ import {
15
15
  resolveDashboardFilePath,
16
16
  startConsoleServer,
17
17
  } from './consoleServer';
18
+ import type { ImageFetcher } from './consoleImageProxy';
18
19
  import { IssueTitleStateCache } from './consoleReadApi';
19
20
  import { readDoneProjectItemIds } from './consoleDoneStore';
20
21
  import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
@@ -1131,3 +1132,184 @@ describe('consoleServer dashboard /tdpm.txt route integration', () => {
1131
1132
  }
1132
1133
  });
1133
1134
  });
1135
+
1136
+ describe('consoleServer image proxy', () => {
1137
+ const testToken = 'image-proxy-token-value';
1138
+ const githubToken = 'gh-token-value';
1139
+ const allowedUrl = 'https://github.com/user-attachments/assets/abc-123';
1140
+
1141
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
1142
+
1143
+ const stubFetcher: ImageFetcher = async () => ({
1144
+ status: 200,
1145
+ contentType: 'image/png',
1146
+ body: pngBytes,
1147
+ });
1148
+
1149
+ const closeServer = (server: http.Server): Promise<void> =>
1150
+ new Promise((resolve, reject) => {
1151
+ server.close((error) => {
1152
+ if (error) {
1153
+ reject(error);
1154
+ return;
1155
+ }
1156
+ resolve();
1157
+ });
1158
+ });
1159
+
1160
+ const requestImage = (
1161
+ server: http.Server,
1162
+ requestPath: string,
1163
+ ): Promise<{
1164
+ statusCode: number;
1165
+ contentType: string | undefined;
1166
+ contentLength: string | undefined;
1167
+ cacheControl: string | undefined;
1168
+ body: Buffer;
1169
+ }> => {
1170
+ const address = server.address();
1171
+ if (address === null || typeof address === 'string') {
1172
+ throw new Error('server is not listening on a TCP port');
1173
+ }
1174
+ const port = address.port;
1175
+ return new Promise((resolve, reject) => {
1176
+ const request = http.request(
1177
+ { host: '127.0.0.1', port, path: requestPath, method: 'GET' },
1178
+ (response) => {
1179
+ const chunks: Uint8Array[] = [];
1180
+ response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
1181
+ response.on('end', () => {
1182
+ resolve({
1183
+ statusCode: response.statusCode ?? 0,
1184
+ contentType: response.headers['content-type'],
1185
+ contentLength: response.headers['content-length'],
1186
+ cacheControl: response.headers['cache-control'],
1187
+ body: Buffer.concat(chunks),
1188
+ });
1189
+ });
1190
+ },
1191
+ );
1192
+ request.on('error', reject);
1193
+ request.end();
1194
+ });
1195
+ };
1196
+
1197
+ const startProxyServer = (
1198
+ fetcher: ImageFetcher | null = stubFetcher,
1199
+ token: string | null = githubToken,
1200
+ ): Promise<{ server: http.Server; tmpDir: string }> =>
1201
+ (async () => {
1202
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
1203
+ const server = await startConsoleServer({
1204
+ accessToken: testToken,
1205
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
1206
+ consoleDataOutputDir: null,
1207
+ inTmuxDataDir: null,
1208
+ dashboardDir: null,
1209
+ githubToken: token,
1210
+ imageFetcher: fetcher,
1211
+ port: 0,
1212
+ });
1213
+ return { server, tmpDir };
1214
+ })();
1215
+
1216
+ it('returns image bytes for an allow-listed url with a valid token', async () => {
1217
+ const { server, tmpDir } = await startProxyServer();
1218
+ try {
1219
+ const response = await requestImage(
1220
+ server,
1221
+ `/api/img?url=${encodeURIComponent(allowedUrl)}&k=${testToken}`,
1222
+ );
1223
+ expect(response.statusCode).toBe(200);
1224
+ expect(response.contentType).toBe('image/png');
1225
+ expect(response.contentLength).toBe(String(pngBytes.length));
1226
+ expect(response.cacheControl).toBe('private, max-age=300');
1227
+ expect(Array.from(response.body)).toEqual(Array.from(pngBytes));
1228
+ } finally {
1229
+ await closeServer(server);
1230
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1231
+ }
1232
+ });
1233
+
1234
+ it('rejects a non-allow-listed url with 400', async () => {
1235
+ const { server, tmpDir } = await startProxyServer();
1236
+ try {
1237
+ const response = await requestImage(
1238
+ server,
1239
+ `/api/img?url=${encodeURIComponent('https://example.com/x.png')}&k=${testToken}`,
1240
+ );
1241
+ expect(response.statusCode).toBe(400);
1242
+ expect(response.body.toString('utf-8')).toContain(
1243
+ 'not in allowed domain',
1244
+ );
1245
+ } finally {
1246
+ await closeServer(server);
1247
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1248
+ }
1249
+ });
1250
+
1251
+ it('rejects a request without a token with 401', async () => {
1252
+ const { server, tmpDir } = await startProxyServer();
1253
+ try {
1254
+ const response = await requestImage(
1255
+ server,
1256
+ `/api/img?url=${encodeURIComponent(allowedUrl)}`,
1257
+ );
1258
+ expect(response.statusCode).toBe(401);
1259
+ } finally {
1260
+ await closeServer(server);
1261
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1262
+ }
1263
+ });
1264
+
1265
+ it('rejects a request with an invalid token with 401', async () => {
1266
+ const { server, tmpDir } = await startProxyServer();
1267
+ try {
1268
+ const response = await requestImage(
1269
+ server,
1270
+ `/api/img?url=${encodeURIComponent(allowedUrl)}&k=wrong-token`,
1271
+ );
1272
+ expect(response.statusCode).toBe(401);
1273
+ } finally {
1274
+ await closeServer(server);
1275
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1276
+ }
1277
+ });
1278
+
1279
+ it('returns 502 when the github token is not configured', async () => {
1280
+ const { server, tmpDir } = await startProxyServer(stubFetcher, null);
1281
+ try {
1282
+ const response = await requestImage(
1283
+ server,
1284
+ `/api/img?url=${encodeURIComponent(allowedUrl)}&k=${testToken}`,
1285
+ );
1286
+ expect(response.statusCode).toBe(502);
1287
+ expect(response.body.toString('utf-8')).toContain(
1288
+ 'github token is not configured',
1289
+ );
1290
+ } finally {
1291
+ await closeServer(server);
1292
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1293
+ }
1294
+ });
1295
+
1296
+ it('returns 502 when the upstream image fetch fails', async () => {
1297
+ const failingFetcher: ImageFetcher = async () => ({
1298
+ status: 404,
1299
+ contentType: null,
1300
+ body: Buffer.alloc(0),
1301
+ });
1302
+ const { server, tmpDir } = await startProxyServer(failingFetcher);
1303
+ try {
1304
+ const response = await requestImage(
1305
+ server,
1306
+ `/api/img?url=${encodeURIComponent(allowedUrl)}&k=${testToken}`,
1307
+ );
1308
+ expect(response.statusCode).toBe(502);
1309
+ expect(response.body.toString('utf-8')).toContain('upstream 404');
1310
+ } finally {
1311
+ await closeServer(server);
1312
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1313
+ }
1314
+ });
1315
+ });
@@ -24,6 +24,7 @@ import {
24
24
  handleReview,
25
25
  handleTriage,
26
26
  } from './consoleOperationApi';
27
+ import { ImageFetcher, fetchProxiedImage } from './consoleImageProxy';
27
28
 
28
29
  export const DEFAULT_CONSOLE_PORT = 9981;
29
30
 
@@ -156,6 +157,8 @@ export type ConsoleServerOptions = {
156
157
  consoleDataOutputDir: string | null;
157
158
  inTmuxDataDir: string | null;
158
159
  dashboardDir: string | null;
160
+ githubToken?: string | null;
161
+ imageFetcher?: ImageFetcher | null;
159
162
  issueRepository?: IssueRepository | null;
160
163
  resolveProject?: ConsoleProjectResolver | null;
161
164
  issueTitleStateCache?: IssueTitleStateCache | null;
@@ -167,6 +170,8 @@ const FLAT_IN_TMUX_FILE = /^[A-Za-z0-9._-]+\.json$/;
167
170
 
168
171
  export const DASHBOARD_REQUEST_PATH = '/tdpm.txt';
169
172
 
173
+ export const IMAGE_PROXY_REQUEST_PATH = '/api/img';
174
+
170
175
  const DASHBOARD_FILE_NAME = 'tdpm.txt';
171
176
 
172
177
  export const resolveDashboardFilePath = (
@@ -259,6 +264,42 @@ const sendJson = (
259
264
  response.end(JSON.stringify(body));
260
265
  };
261
266
 
267
+ const sendImage = (
268
+ response: http.ServerResponse,
269
+ contentType: string,
270
+ body: Buffer,
271
+ ): void => {
272
+ response.writeHead(200, {
273
+ 'Content-Type': contentType,
274
+ 'Content-Length': String(body.length),
275
+ 'Cache-Control': 'private, max-age=300',
276
+ });
277
+ response.end(body);
278
+ };
279
+
280
+ const handleImageProxy = async (
281
+ options: ConsoleServerOptions,
282
+ response: http.ServerResponse,
283
+ searchParams: URLSearchParams,
284
+ ): Promise<void> => {
285
+ const githubToken = options.githubToken ?? null;
286
+ if (githubToken === null || githubToken.length === 0) {
287
+ sendJson(response, 502, { error: 'github token is not configured' });
288
+ return;
289
+ }
290
+ const url = searchParams.get('url') ?? '';
291
+ const result = await fetchProxiedImage(
292
+ url,
293
+ githubToken,
294
+ options.imageFetcher ?? undefined,
295
+ );
296
+ if (!result.ok) {
297
+ sendJson(response, result.statusCode, { error: result.error });
298
+ return;
299
+ }
300
+ sendImage(response, result.contentType, result.body);
301
+ };
302
+
262
303
  const sendDataResponse = (
263
304
  response: http.ServerResponse,
264
305
  statusCode: number,
@@ -371,6 +412,10 @@ const handleTokenedRequest = async (
371
412
 
372
413
  if (requestPath.startsWith('/api/')) {
373
414
  if (method === 'GET') {
415
+ if (requestPath === IMAGE_PROXY_REQUEST_PATH) {
416
+ await handleImageProxy(options, response, searchParams);
417
+ return;
418
+ }
374
419
  const readResult = await handleReadApi(
375
420
  options,
376
421
  requestPath,
@@ -1,6 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { buildImageProxyUrl } from '../../lib/imageProxy';
2
3
  import {
3
4
  consoleMarkdownBodyFixture,
5
+ consoleMarkdownImageBodyFixture,
4
6
  consoleMermaidBodyFixture,
5
7
  } from '../../testing/fixtures';
6
8
  import { ConsoleMarkdownContent } from './ConsoleMarkdownContent';
@@ -22,6 +24,14 @@ export const WithMermaidFence: Story = {
22
24
  args: { body: consoleMermaidBodyFixture },
23
25
  };
24
26
 
27
+ export const WithProxiedGitHubImages: Story = {
28
+ args: {
29
+ body: consoleMarkdownImageBodyFixture,
30
+ buildImageProxyUrl: (src) =>
31
+ buildImageProxyUrl(src, 'console-token-fixture'),
32
+ },
33
+ };
34
+
25
35
  export const Empty: Story = {
26
36
  args: { body: '' },
27
37
  };
@@ -1,4 +1,8 @@
1
1
  import { useEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ type ImageProxyUrlBuilder,
4
+ rewriteGitHubImageSources,
5
+ } from '../../lib/imageProxy';
2
6
  import {
3
7
  renderMarkdownToSafeHtml,
4
8
  splitMarkdownSegments,
@@ -7,16 +11,25 @@ import { ConsoleMermaidDiagram } from './ConsoleMermaidDiagram';
7
11
 
8
12
  export type ConsoleMarkdownViewProps = {
9
13
  body: string;
14
+ buildImageProxyUrl?: ImageProxyUrlBuilder;
10
15
  };
11
16
 
12
17
  type ConsoleMarkdownHtmlBlockProps = {
13
18
  source: string;
19
+ buildImageProxyUrl?: ImageProxyUrlBuilder;
14
20
  };
15
21
 
16
22
  const ConsoleMarkdownHtmlBlock = ({
17
23
  source,
24
+ buildImageProxyUrl,
18
25
  }: ConsoleMarkdownHtmlBlockProps) => {
19
- const html = useMemo(() => renderMarkdownToSafeHtml(source), [source]);
26
+ const html = useMemo(() => {
27
+ const safeHtml = renderMarkdownToSafeHtml(source);
28
+ if (buildImageProxyUrl === undefined) {
29
+ return safeHtml;
30
+ }
31
+ return rewriteGitHubImageSources(safeHtml, buildImageProxyUrl);
32
+ }, [source, buildImageProxyUrl]);
20
33
  const containerRef = useRef<HTMLDivElement>(null);
21
34
 
22
35
  useEffect(() => {
@@ -29,7 +42,10 @@ const ConsoleMarkdownHtmlBlock = ({
29
42
  return <div ref={containerRef} className="console-markdown" />;
30
43
  };
31
44
 
32
- export const ConsoleMarkdownContent = ({ body }: ConsoleMarkdownViewProps) => {
45
+ export const ConsoleMarkdownContent = ({
46
+ body,
47
+ buildImageProxyUrl,
48
+ }: ConsoleMarkdownViewProps) => {
33
49
  const segments = useMemo(() => splitMarkdownSegments(body), [body]);
34
50
 
35
51
  if (body.trim() === '') {
@@ -42,7 +58,11 @@ export const ConsoleMarkdownContent = ({ body }: ConsoleMarkdownViewProps) => {
42
58
  segment.kind === 'mermaid' ? (
43
59
  <ConsoleMermaidDiagram key={segment.key} code={segment.code} />
44
60
  ) : (
45
- <ConsoleMarkdownHtmlBlock key={segment.key} source={segment.source} />
61
+ <ConsoleMarkdownHtmlBlock
62
+ key={segment.key}
63
+ source={segment.source}
64
+ buildImageProxyUrl={buildImageProxyUrl}
65
+ />
46
66
  ),
47
67
  )}
48
68
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react';
2
+ import type { ImageProxyUrlBuilder } from '../../lib/imageProxy';
2
3
  import { formatRelativeTime } from '../../logic/relativeTime';
3
4
  import type { ConsoleComment } from '../../logic/types';
4
5
  import { ConsoleMarkdownContent } from '../content/ConsoleMarkdownContent';
@@ -8,6 +9,7 @@ export type ConsoleCommentListProps = {
8
9
  isLoading: boolean;
9
10
  error: string | null;
10
11
  now: number;
12
+ buildImageProxyUrl?: ImageProxyUrlBuilder;
11
13
  };
12
14
 
13
15
  export const ConsoleCommentList = ({
@@ -15,6 +17,7 @@ export const ConsoleCommentList = ({
15
17
  isLoading,
16
18
  error,
17
19
  now,
20
+ buildImageProxyUrl,
18
21
  }: ConsoleCommentListProps) => {
19
22
  const [showAll, setShowAll] = useState<boolean>(false);
20
23
 
@@ -58,7 +61,10 @@ export const ConsoleCommentList = ({
58
61
  {formatRelativeTime(comment.createdAt, now)}
59
62
  </span>
60
63
  </header>
61
- <ConsoleMarkdownContent body={comment.body} />
64
+ <ConsoleMarkdownContent
65
+ body={comment.body}
66
+ buildImageProxyUrl={buildImageProxyUrl}
67
+ />
62
68
  </article>
63
69
  ))}
64
70
  </div>