github-issue-tower-defence-management 1.94.2 → 1.94.4
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/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/cli/index.js +1 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleImageProxy.js +79 -0
- package/bin/adapter/entry-points/console/consoleImageProxy.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js +29 -1
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +101 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +1 -0
- package/src/adapter/entry-points/console/consoleImageProxy.test.ts +140 -0
- package/src/adapter/entry-points/console/consoleImageProxy.ts +111 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +182 -0
- package/src/adapter/entry-points/console/consoleServer.ts +45 -0
- package/src/adapter/entry-points/console/consoleUiDistSync.test.ts +66 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +10 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +23 -3
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +7 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +9 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +7 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.test.ts +78 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.ts +41 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +9 -0
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +11 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/types/adapter/entry-points/console/consoleImageProxy.d.ts +19 -0
- package/types/adapter/entry-points/console/consoleImageProxy.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts +4 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-eSyo3r3C.js +0 -101
- 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-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-fKvnL036.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-CO3Vvzqr.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
package/package.json
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const repoRoot = path.resolve(__dirname, '..', '..', '..', '..');
|
|
5
|
+
const srcUiDistDir = path.join(
|
|
6
|
+
repoRoot,
|
|
7
|
+
'src',
|
|
8
|
+
'adapter',
|
|
9
|
+
'entry-points',
|
|
10
|
+
'console',
|
|
11
|
+
'ui-dist',
|
|
12
|
+
);
|
|
13
|
+
const binUiDistDir = path.join(
|
|
14
|
+
repoRoot,
|
|
15
|
+
'bin',
|
|
16
|
+
'adapter',
|
|
17
|
+
'entry-points',
|
|
18
|
+
'console',
|
|
19
|
+
'ui-dist',
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const listFilesRecursively = (dir: string): string[] => {
|
|
23
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
return entries
|
|
25
|
+
.flatMap((entry) => {
|
|
26
|
+
const fullPath = path.join(dir, entry.name);
|
|
27
|
+
return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath];
|
|
28
|
+
})
|
|
29
|
+
.sort();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const relativePaths = (dir: string): string[] =>
|
|
33
|
+
listFilesRecursively(dir).map((file) => path.relative(dir, file));
|
|
34
|
+
|
|
35
|
+
describe('console ui-dist served bundle synchronization', () => {
|
|
36
|
+
it('has the same file set in bin/ui-dist as in src/ui-dist', () => {
|
|
37
|
+
expect(relativePaths(binUiDistDir)).toEqual(relativePaths(srcUiDistDir));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('has byte-identical files in bin/ui-dist and src/ui-dist', () => {
|
|
41
|
+
for (const relativePath of relativePaths(srcUiDistDir)) {
|
|
42
|
+
const srcContent = fs
|
|
43
|
+
.readFileSync(path.join(srcUiDistDir, relativePath))
|
|
44
|
+
.toString('base64');
|
|
45
|
+
const binContent = fs
|
|
46
|
+
.readFileSync(path.join(binUiDistDir, relativePath))
|
|
47
|
+
.toString('base64');
|
|
48
|
+
expect(binContent).toBe(srcContent);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('serves bundle assets that read the API from the server root, never via a relative path', () => {
|
|
53
|
+
const assetsDir = path.join(binUiDistDir, 'assets');
|
|
54
|
+
const scriptFiles = fs
|
|
55
|
+
.readdirSync(assetsDir)
|
|
56
|
+
.filter((name) => name.endsWith('.js'));
|
|
57
|
+
expect(scriptFiles.length).toBeGreaterThan(0);
|
|
58
|
+
for (const scriptFile of scriptFiles) {
|
|
59
|
+
const content = fs.readFileSync(
|
|
60
|
+
path.join(assetsDir, scriptFile),
|
|
61
|
+
'utf-8',
|
|
62
|
+
);
|
|
63
|
+
expect(content).not.toMatch(/\.\/api\//);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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(() =>
|
|
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 = ({
|
|
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
|
|
61
|
+
<ConsoleMarkdownHtmlBlock
|
|
62
|
+
key={segment.key}
|
|
63
|
+
source={segment.source}
|
|
64
|
+
buildImageProxyUrl={buildImageProxyUrl}
|
|
65
|
+
/>
|
|
46
66
|
),
|
|
47
67
|
)}
|
|
48
68
|
</div>
|