comparison hg-web/main.c @ 231:09a96dcb2b4c hg-web

[merge] Join existing hg-web branch head
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:50:48 -0700
parents 3007ef5fc0ed
children c5129452493e
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
1 #include "seobeo/seobeo.h" 1 #include "seobeo/seobeo.h"
2 #include "dowa/dowa.h" 2 #include "dowa/dowa.h"
3
4 #include <ctype.h>
5 #include <errno.h>
3 #include <stdio.h> 6 #include <stdio.h>
4 #include <stdlib.h> 7 #include <stdlib.h>
5 #include <string.h> 8 #include <string.h>
6 #include <ctype.h> 9 #include <strings.h>
10 #include <time.h>
7 #include <unistd.h> 11 #include <unistd.h>
8 #include <sys/socket.h>
9 #include <netinet/in.h>
10 #include <arpa/inet.h>
11 #include <netdb.h>
12 12
13 #define HG_SERVE_HOST "127.0.0.1" 13 #define HG_SERVE_HOST "127.0.0.1"
14 #define HG_SERVE_PORT "4444" 14 #define HG_SERVE_PORT "4444"
15 15 #define HG_API_TIMEOUT_MS 15000
16 #define MAX_PATH 4096 16 #define HG_STREAM_IDLE_TIMEOUT_MS 60000
17 17 #define MAX_PATH_LENGTH 4096
18 static char* sanitize_path(const char *input_path, Dowa_Arena *arena) 18 #define MAX_WIRE_QUERY_LENGTH 8192
19 { 19 #define MAX_WIRE_HEADER_LENGTH 8192
20 if (!input_path || strlen(input_path) == 0) 20
21 { 21 static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key)
22 char *empty = Dowa_Arena_Allocate(arena, 1); 22 {
23 empty[0] = '\0'; 23 if (!map || !key)
24 return empty; 24 return NULL;
25 } 25
26 26 for (size_t i = 0; i < Dowa_Array_Length(map); i++)
27 size_t len = strlen(input_path); 27 {
28 char *result = Dowa_Arena_Allocate(arena, len + 1); 28 if (map[i].key && strcasecmp(map[i].key, key) == 0)
29 size_t j = 0; 29 return map[i].value;
30 30 }
31 for (size_t i = 0; i < len; i++) 31 return NULL;
32 { 32 }
33 if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) 33
34 static char *arena_string(Dowa_Arena *arena, const char *value)
35 {
36 size_t length = strlen(value);
37 char *copy = Dowa_Arena_Allocate(arena, length + 1);
38 memcpy(copy, value, length + 1);
39 return copy;
40 }
41
42 static Seobeo_Request_Entry *text_response(
43 Dowa_Arena *arena,
44 const char *status,
45 const char *content_type,
46 const char *body)
47 {
48 Seobeo_Request_Entry *response = NULL;
49 Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena);
50 Dowa_HashMap_Push_Arena(
51 response, "content-type", arena_string(arena, content_type), arena);
52 Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena);
53 return response;
54 }
55
56 static boolean decode_url_component(
57 const char *encoded,
58 Dowa_Arena *arena,
59 char **decoded_out,
60 size_t *decoded_length_out)
61 {
62 if (!encoded || !decoded_out)
63 return FALSE;
64
65 size_t encoded_length = strlen(encoded);
66 if (encoded_length >= MAX_PATH_LENGTH)
67 return FALSE;
68
69 char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1);
70 size_t output_length = 0;
71 for (size_t i = 0; i < encoded_length; i++)
72 {
73 unsigned char value = (unsigned char)encoded[i];
74 if (encoded[i] == '%')
34 { 75 {
35 if (i + 1 < len && input_path[i+1] == '.') 76 if (i + 2 >= encoded_length ||
77 !isxdigit((unsigned char)encoded[i + 1]) ||
78 !isxdigit((unsigned char)encoded[i + 2]))
79 return FALSE;
80
81 char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'};
82 value = (unsigned char)strtoul(hex, NULL, 16);
83 i += 2;
84 if (value == '\0')
85 return FALSE;
86 }
87 decoded[output_length++] = (char)value;
88 }
89 decoded[output_length] = '\0';
90
91 *decoded_out = decoded;
92 if (decoded_length_out)
93 *decoded_length_out = output_length;
94 return TRUE;
95 }
96
97 static boolean normalize_repository_path(
98 const char *encoded_path,
99 Dowa_Arena *arena,
100 char **normalized_out)
101 {
102 char *decoded = NULL;
103 size_t decoded_length = 0;
104 if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length))
105 return FALSE;
106
107 size_t start = 0;
108 size_t end = decoded_length;
109 if (start < end && decoded[start] == '/')
110 {
111 start++;
112 if (start < end && decoded[start] == '/')
113 return FALSE;
114 }
115 if (end > start && decoded[end - 1] == '/')
116 {
117 if (end - 1 > start && decoded[end - 2] == '/')
118 return FALSE;
119 end--;
120 }
121
122 size_t segment_start = start;
123 for (size_t i = start; i <= end; i++)
124 {
125 boolean at_end = i == end;
126 unsigned char c = at_end ? '/' : (unsigned char)decoded[i];
127 if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#'))
128 return FALSE;
129
130 if (c == '/')
131 {
132 size_t segment_length = i - segment_start;
133 if (segment_length == 0 && !at_end)
134 return FALSE;
135 if ((segment_length == 1 && decoded[segment_start] == '.') ||
136 (segment_length == 2 && decoded[segment_start] == '.' &&
137 decoded[segment_start + 1] == '.'))
138 return FALSE;
139 segment_start = i + 1;
140 }
141 }
142
143 size_t normalized_length = end - start;
144 char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1);
145 memcpy(normalized, decoded + start, normalized_length);
146 normalized[normalized_length] = '\0';
147 *normalized_out = normalized;
148 return TRUE;
149 }
150
151 static boolean validate_revision(const char *revision)
152 {
153 if (!revision || revision[0] == '\0')
154 return FALSE;
155 if (strcmp(revision, "tip") == 0)
156 return TRUE;
157
158 size_t length = strlen(revision);
159 if (length > 40)
160 return FALSE;
161 for (size_t i = 0; i < length; i++)
162 {
163 if (!isxdigit((unsigned char)revision[i]))
164 return FALSE;
165 }
166 return TRUE;
167 }
168
169 static char *encode_repository_path(const char *path, Dowa_Arena *arena)
170 {
171 static const char hex[] = "0123456789ABCDEF";
172 size_t length = strlen(path);
173 char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1);
174 size_t output = 0;
175 for (size_t i = 0; i < length; i++)
176 {
177 unsigned char c = (unsigned char)path[i];
178 if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/')
179 encoded[output++] = (char)c;
180 else
181 {
182 encoded[output++] = '%';
183 encoded[output++] = hex[c >> 4];
184 encoded[output++] = hex[c & 0x0F];
185 }
186 }
187 encoded[output] = '\0';
188 return encoded;
189 }
190
191 static boolean safe_header_value(const char *value, size_t maximum_length)
192 {
193 if (!value)
194 return TRUE;
195 size_t length = strlen(value);
196 return length <= maximum_length &&
197 strchr(value, '\r') == NULL &&
198 strchr(value, '\n') == NULL;
199 }
200
201 static boolean extension_is(const char *extension, const char *expected)
202 {
203 return extension && strcasecmp(extension, expected) == 0;
204 }
205
206 static const char *repository_file_content_type(
207 const char *path,
208 boolean *inline_preview,
209 boolean *sandbox_content)
210 {
211 const char *extension = strrchr(path, '.');
212 *inline_preview = TRUE;
213 *sandbox_content = FALSE;
214
215 if (extension_is(extension, ".png")) return "image/png";
216 if (extension_is(extension, ".jpg") ||
217 extension_is(extension, ".jpeg")) return "image/jpeg";
218 if (extension_is(extension, ".gif")) return "image/gif";
219 if (extension_is(extension, ".webp")) return "image/webp";
220 if (extension_is(extension, ".avif")) return "image/avif";
221 if (extension_is(extension, ".bmp")) return "image/bmp";
222 if (extension_is(extension, ".ico")) return "image/x-icon";
223 if (extension_is(extension, ".svg"))
224 {
225 *sandbox_content = TRUE;
226 return "image/svg+xml";
227 }
228 if (extension_is(extension, ".mp4") ||
229 extension_is(extension, ".m4v")) return "video/mp4";
230 if (extension_is(extension, ".webm")) return "video/webm";
231 if (extension_is(extension, ".mov")) return "video/quicktime";
232 if (extension_is(extension, ".ogv")) return "video/ogg";
233 if (extension_is(extension, ".mp3")) return "audio/mpeg";
234 if (extension_is(extension, ".wav")) return "audio/wav";
235 if (extension_is(extension, ".ogg") ||
236 extension_is(extension, ".oga")) return "audio/ogg";
237 if (extension_is(extension, ".flac")) return "audio/flac";
238 if (extension_is(extension, ".m4a")) return "audio/mp4";
239 if (extension_is(extension, ".aac")) return "audio/aac";
240 if (extension_is(extension, ".pdf")) return "application/pdf";
241 if (extension_is(extension, ".wasm")) return "application/wasm";
242
243 *inline_preview = FALSE;
244 if (extension_is(extension, ".md") ||
245 extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8";
246 if (extension_is(extension, ".txt") ||
247 extension_is(extension, ".log") ||
248 extension_is(extension, ".c") ||
249 extension_is(extension, ".h") ||
250 extension_is(extension, ".cc") ||
251 extension_is(extension, ".cpp") ||
252 extension_is(extension, ".js") ||
253 extension_is(extension, ".jsx") ||
254 extension_is(extension, ".ts") ||
255 extension_is(extension, ".tsx") ||
256 extension_is(extension, ".css") ||
257 extension_is(extension, ".html") ||
258 extension_is(extension, ".htm") ||
259 extension_is(extension, ".xml") ||
260 extension_is(extension, ".json") ||
261 extension_is(extension, ".yaml") ||
262 extension_is(extension, ".yml") ||
263 extension_is(extension, ".toml") ||
264 extension_is(extension, ".sh") ||
265 extension_is(extension, ".py") ||
266 extension_is(extension, ".rs") ||
267 extension_is(extension, ".go"))
268 return "text/plain; charset=utf-8";
269 return "application/octet-stream";
270 }
271
272 static Seobeo_Client_Response *hg_proxy_request(
273 const char *method,
274 const char *path,
275 const char *request_body,
276 size_t request_body_length,
277 const char *hg_argument,
278 const char *accept)
279 {
280 char url[MAX_PATH_LENGTH];
281 int url_length = snprintf(
282 url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
283 if (url_length < 0 || (size_t)url_length >= sizeof(url))
284 return NULL;
285
286 Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
287 if (!request)
288 return NULL;
289
290 Seobeo_Client_Request_Set_Method(request, method);
291 Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0");
292 Seobeo_Client_Request_Add_Header_Map(
293 request, "Accept", accept ? accept : "application/json");
294 Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS);
295
296 if (hg_argument && hg_argument[0] != '\0')
297 {
298 if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH))
299 {
300 Seobeo_Client_Request_Destroy(request);
301 return NULL;
302 }
303 Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument);
304 }
305
306 if (request_body && request_body_length > 0)
307 Seobeo_Client_Request_Set_Body(request, request_body, request_body_length);
308
309 Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
310 Seobeo_Client_Request_Destroy(request);
311 return response;
312 }
313
314 static Seobeo_Request_Entry *forward_hg_response(
315 Seobeo_Client_Response *hg_response,
316 const char *default_content_type,
317 const char *override_content_type,
318 Dowa_Arena *arena)
319 {
320 if (!hg_response)
321 return text_response(
322 arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}");
323
324 const char *upstream_content_type =
325 map_value_case_insensitive(hg_response->headers, "Content-Type");
326 const char *upstream_or_default_content_type =
327 override_content_type
328 ? override_content_type
329 : upstream_content_type ? upstream_content_type : default_content_type;
330 if (!upstream_or_default_content_type)
331 upstream_or_default_content_type = "application/octet-stream";
332 char *content_type = arena_string(arena, upstream_or_default_content_type);
333
334 char *status = Dowa_Arena_Allocate(arena, 8);
335 snprintf(status, 8, "%d", hg_response->status_code);
336
337 size_t body_length = hg_response->body ? hg_response->body_length : 0;
338 char *body = Dowa_Arena_Allocate(arena, body_length + 1);
339 if (body_length > 0)
340 memcpy(body, hg_response->body, body_length);
341 body[body_length] = '\0';
342 char *content_length = Dowa_Arena_Allocate(arena, 32);
343 snprintf(content_length, 32, "%zu", body_length);
344
345 Seobeo_Request_Entry *response = NULL;
346 Dowa_HashMap_Push_Arena(response, "status", status, arena);
347 Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena);
348 Dowa_HashMap_Push_Arena(response, "body", body, arena);
349 Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
350 Seobeo_Client_Response_Destroy(hg_response);
351 return response;
352 }
353
354 Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena)
355 {
356 const char *encoded_path = map_value_case_insensitive(request, "query_path");
357 char *path = NULL;
358 if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path))
359 return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}");
360
361 char *encoded = encode_repository_path(path, arena);
362 char hg_path[MAX_PATH_LENGTH];
363 int length = snprintf(
364 hg_path,
365 sizeof(hg_path),
366 encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json",
367 encoded);
368 if (length < 0 || (size_t)length >= sizeof(hg_path))
369 return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}");
370
371 return forward_hg_response(
372 hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
373 "application/json",
374 NULL,
375 arena);
376 }
377
378 Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena)
379 {
380 const char *encoded_path = map_value_case_insensitive(request, "query_path");
381 char *path = NULL;
382 if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) ||
383 path[0] == '\0')
384 return text_response(arena, "400", "text/plain", "A valid file path is required");
385
386 char *encoded = encode_repository_path(path, arena);
387 char hg_path[MAX_PATH_LENGTH];
388 int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
389 if (length < 0 || (size_t)length >= sizeof(hg_path))
390 return text_response(arena, "400", "text/plain", "File path is too long");
391
392 Seobeo_Client_Response *hg_response =
393 hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream");
394 if (!hg_response)
395 return forward_hg_response(NULL, "application/json", NULL, arena);
396
397 boolean inline_preview = FALSE;
398 boolean sandbox_content = FALSE;
399 const char *content_type =
400 repository_file_content_type(path, &inline_preview, &sandbox_content);
401 Seobeo_Request_Entry *response = forward_hg_response(
402 hg_response, "application/octet-stream", content_type, arena);
403 Dowa_HashMap_Push_Arena(
404 response,
405 "Content-Disposition",
406 inline_preview ? "inline" : "attachment",
407 arena);
408 Dowa_HashMap_Push_Arena(
409 response, "X-Content-Type-Options", "nosniff", arena);
410 if (sandbox_content)
411 Dowa_HashMap_Push_Arena(
412 response, "Content-Security-Policy", "sandbox", arena);
413 return response;
414 }
415
416 Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena)
417 {
418 const char *encoded_path = map_value_case_insensitive(request, "query_path");
419 char *directory = NULL;
420 if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory))
421 return text_response(arena, "400", "text/plain", "Invalid repository path");
422
423 size_t readme_length = strlen(directory) + strlen("/README.md") + 1;
424 if (readme_length >= MAX_PATH_LENGTH)
425 return text_response(arena, "400", "text/plain", "README path is too long");
426
427 char *readme_path = Dowa_Arena_Allocate(arena, readme_length);
428 snprintf(
429 readme_path,
430 readme_length,
431 directory[0] ? "%s/README.md" : "README.md",
432 directory);
433 char *encoded = encode_repository_path(readme_path, arena);
434
435 char hg_path[MAX_PATH_LENGTH];
436 int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
437 if (length < 0 || (size_t)length >= sizeof(hg_path))
438 return text_response(arena, "400", "text/plain", "README path is too long");
439
440 Seobeo_Client_Response *hg_response =
441 hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown");
442 if (hg_response && hg_response->status_code == HTTP_NOT_FOUND)
443 {
444 Seobeo_Client_Response_Destroy(hg_response);
445 return text_response(arena, "204", "text/markdown", "");
446 }
447 return forward_hg_response(
448 hg_response, "text/markdown", "text/markdown; charset=utf-8", arena);
449 }
450
451 Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
452 {
453 const char *graph_id = map_value_case_insensitive(request, ":graph_id");
454 if (!validate_revision(graph_id))
455 return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}");
456
457 const char *encoded_graph_top =
458 map_value_case_insensitive(request, "query_graphtop");
459 char *graph_top = NULL;
460 if (encoded_graph_top)
461 {
462 if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) ||
463 !validate_revision(graph_top))
464 return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}");
465 }
466
467 char hg_path[MAX_PATH_LENGTH];
468 int length = graph_top
469 ? snprintf(
470 hg_path,
471 sizeof(hg_path),
472 "/graph/%s?graphtop=%s&style=json",
473 graph_id,
474 graph_top)
475 : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id);
476 if (length < 0 || (size_t)length >= sizeof(hg_path))
477 return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}");
478
479 return forward_hg_response(
480 hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
481 "application/json",
482 NULL,
483 arena);
484 }
485
486 Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena)
487 {
488 const char *changeset_id = map_value_case_insensitive(request, ":changeset_id");
489 if (!validate_revision(changeset_id))
490 return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}");
491
492 char hg_path[128];
493 int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id);
494 if (length < 0 || (size_t)length >= sizeof(hg_path))
495 return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}");
496
497 return forward_hg_response(
498 hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
499 "application/json",
500 NULL,
501 arena);
502 }
503
504 static int64_t monotonic_milliseconds(void)
505 {
506 struct timespec now;
507 clock_gettime(CLOCK_MONOTONIC, &now);
508 return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
509 }
510
511 static size_t find_http_header_length(const uint8 *buffer, size_t length)
512 {
513 if (!buffer || length < 4)
514 return 0;
515 for (size_t i = 0; i + 3 < length; i++)
516 {
517 if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
518 buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
519 return i + 4;
520 }
521 return 0;
522 }
523
524 static void send_proxy_error(Seobeo_Handle *client, int status, const char *message)
525 {
526 const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway";
527 char response[512];
528 int length = snprintf(
529 response,
530 sizeof(response),
531 "HTTP/1.1 %d %s\r\n"
532 "Content-Type: text/plain\r\n"
533 "Content-Length: %zu\r\n"
534 "Connection: close\r\n"
535 "\r\n"
536 "%s",
537 status,
538 reason,
539 strlen(message),
540 message);
541 if (length > 0 && (size_t)length < sizeof(response))
542 {
543 Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length);
544 Seobeo_Handle_Flush(client);
545 }
546 }
547
548 static boolean parse_content_length(const char *value, size_t *length_out)
549 {
550 if (!value || !length_out || value[0] == '\0')
551 return FALSE;
552 errno = 0;
553 char *end = NULL;
554 unsigned long long parsed = strtoull(value, &end, 10);
555 if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX)
556 return FALSE;
557 *length_out = (size_t)parsed;
558 return TRUE;
559 }
560
561 void StreamHgWireProtocol(
562 Seobeo_Handle *client,
563 Seobeo_Request_Entry *request,
564 Dowa_Arena *arena)
565 {
566 (void)arena;
567 const char *method = map_value_case_insensitive(request, "HTTP_Method");
568 const char *query = map_value_case_insensitive(request, "QueryString");
569 const char *body = map_value_case_insensitive(request, "Body");
570 const char *content_length_value =
571 map_value_case_insensitive(request, "Content-Length");
572 const char *content_type = map_value_case_insensitive(request, "Content-Type");
573 const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1");
574
575 if (!method || !query ||
576 (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) ||
577 !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) ||
578 !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) ||
579 !safe_header_value(content_type, 256))
580 {
581 send_proxy_error(client, 502, "Invalid Mercurial proxy request");
582 return;
583 }
584
585 size_t body_length = 0;
586 if (content_length_value &&
587 !parse_content_length(content_length_value, &body_length))
588 {
589 send_proxy_error(client, 502, "Invalid Mercurial request length");
590 return;
591 }
592 if (body_length > 0 && !body)
593 {
594 send_proxy_error(client, 502, "Missing Mercurial request body");
595 return;
596 }
597 if (body_length > UINT32_MAX)
598 {
599 send_proxy_error(client, 502, "Mercurial request body is too large");
600 return;
601 }
602
603 Seobeo_Handle *upstream =
604 Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
605 if (!upstream || upstream->socket < 0)
606 {
607 if (upstream)
608 Seobeo_Handle_Destroy(upstream);
609 send_proxy_error(client, 502, "Mercurial backend unavailable");
610 return;
611 }
612
613 char request_header[16384];
614 int header_length = snprintf(
615 request_header,
616 sizeof(request_header),
617 "%s /?%s HTTP/1.1\r\n"
618 "Host: %s:%s\r\n"
619 "User-Agent: Seobeo/1.0\r\n"
620 "Connection: close\r\n",
621 method,
622 query,
623 HG_SERVE_HOST,
624 HG_SERVE_PORT);
625 if (header_length < 0 || (size_t)header_length >= sizeof(request_header))
626 {
627 Seobeo_Handle_Destroy(upstream);
628 send_proxy_error(client, 502, "Mercurial request headers are too large");
629 return;
630 }
631
632 #define APPEND_WIRE_HEADER(...) \
633 do { \
634 int appended = snprintf( \
635 request_header + header_length, \
636 sizeof(request_header) - (size_t)header_length, \
637 __VA_ARGS__); \
638 if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \
639 Seobeo_Handle_Destroy(upstream); \
640 send_proxy_error(client, 502, "Mercurial request headers are too large"); \
641 return; \
642 } \
643 header_length += appended; \
644 } while (0)
645
646 if (hg_argument && hg_argument[0] != '\0')
647 APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument);
648 if (content_type && content_type[0] != '\0')
649 APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type);
650 if (body_length > 0)
651 APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length);
652 APPEND_WIRE_HEADER("\r\n");
653 #undef APPEND_WIRE_HEADER
654
655 if (Seobeo_Handle_Queue(
656 upstream, (const uint8 *)request_header, (uint32)header_length) != 0 ||
657 (body_length > 0 &&
658 Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) ||
659 Seobeo_Handle_Flush(upstream) != 0)
660 {
661 Seobeo_Handle_Destroy(upstream);
662 send_proxy_error(client, 502, "Mercurial backend write failed");
663 return;
664 }
665
666 boolean response_started = FALSE;
667 int64_t last_progress = monotonic_milliseconds();
668 while (!response_started)
669 {
670 int read_result = Seobeo_Handle_Read(upstream);
671 if (read_result == -2 || read_result < 0)
672 {
673 Seobeo_Handle_Destroy(upstream);
674 send_proxy_error(client, 502, "Mercurial backend closed before responding");
675 return;
676 }
677 if (read_result > 0)
678 last_progress = monotonic_milliseconds();
679
680 size_t header_size =
681 find_http_header_length(upstream->read_buffer, upstream->read_buffer_len);
682 if (header_size > 0)
683 {
684 (void)header_size;
685 if (Seobeo_Handle_Queue(
686 client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
687 Seobeo_Handle_Flush(client) != 0)
36 { 688 {
37 // Skip ".." 689 Seobeo_Handle_Destroy(upstream);
38 i++; 690 return;
39 continue;
40 } 691 }
41 // Skip "." 692 Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
693 response_started = TRUE;
694 break;
695 }
696
697 if (read_result == 0)
698 {
699 if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
700 {
701 Seobeo_Handle_Destroy(upstream);
702 send_proxy_error(client, 504, "Mercurial backend response timed out");
703 return;
704 }
705 usleep(1000);
706 }
707 }
708
709 while (TRUE)
710 {
711 int read_result = Seobeo_Handle_Read(upstream);
712 if (read_result == -2)
713 break;
714 if (read_result < 0)
715 break;
716 if (read_result == 0)
717 {
718 if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
719 {
720 Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n");
721 break;
722 }
723 usleep(1000);
42 continue; 724 continue;
43 } 725 }
44 result[j++] = input_path[i]; 726
45 } 727 last_progress = monotonic_milliseconds();
46 result[j] = '\0'; 728 if (Seobeo_Handle_Queue(
47 729 client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
48 // Remove leading/trailing slashes 730 Seobeo_Handle_Flush(client) != 0)
49 while (result[0] == '/')
50 memmove(result, result + 1, strlen(result));
51 while (j > 0 && result[j-1] == '/')
52 result[--j] = '\0';
53
54 return result;
55 }
56
57 Seobeo_Client_Response *hg_proxy_request(
58 const char *method,
59 const char *path,
60 const char *req_body,
61 const char *hg_custom)
62 {
63 char full_path[MAX_PATH];
64 snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
65 Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path);
66 Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path);
67 Seobeo_Client_Request_Set_Method(p_req, method);
68 Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0");
69 Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json");
70
71 if (hg_custom && hg_custom[0] != '\0')
72 {
73 char buffer[1024];
74 snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom);
75 Seobeo_Client_Request_Add_Header_Array(p_req, buffer);
76 Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer);
77 }
78
79 if (req_body)
80 Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body));
81 Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req);
82 Seobeo_Client_Request_Destroy(p_req);
83 return p_resp;
84 }
85
86 Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
87 {
88 Seobeo_Request_Entry *resp = NULL;
89
90 void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
91 const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
92
93 char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
94 Seobeo_Url_Decode(decoded_path, rel_path);
95
96 char *safe_path = sanitize_path(decoded_path, arena);
97
98 Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path);
99
100 char hg_path[MAX_PATH];
101 if (strlen(safe_path) > 0)
102 snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path);
103 else
104 snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");
105
106 Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
107
108 Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
109
110 if (hg_response->status_code != 200)
111 {
112 Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
113 Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
114 Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
115 Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
116 return resp;
117 }
118
119 char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
120 char *temp2 = Dowa_Arena_Allocate(arena, 256);
121 snprintf(temp2, 256, "%zu", hg_response->body_length);
122
123 Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
124 Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
125 Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
126 Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
127 return resp;
128 }
129
130 Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena)
131 {
132 Seobeo_Request_Entry *resp = NULL;
133
134 void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
135 const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
136 char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
137 Seobeo_Url_Decode(decoded_path, rel_path);
138 char *safe_path = sanitize_path(decoded_path, arena);
139
140 Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);
141
142 if (strlen(safe_path) == 0)
143 {
144 Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
145 Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
146 Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
147 return resp;
148 }
149
150 char hg_path[MAX_PATH];
151 snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path);
152 Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
153
154 Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
155
156 char status[4];
157 snprintf(status, 4, "%i", hg_response->status_code);
158
159 if (!hg_response->body)
160 {
161 Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
162 Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
163 Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
164 return resp;
165 }
166
167 if (hg_response->status_code != 200)
168 {
169 Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body);
170 Dowa_HashMap_Push_Arena(resp, "status", status, arena);
171 Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
172 Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
173 return resp;
174 }
175
176
177 char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
178 char *temp2 = Dowa_Arena_Allocate(arena, 256);
179 snprintf(temp2, 256, "%zu", hg_response->body_length);
180
181 Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
182 Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
183 Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
184 Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
185
186 return resp;
187 }
188
189 Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) {
190 return ApiGetFile(req, arena);
191 }
192
193 // Streaming handler for hg wire protocol - pipes data directly without buffering
194 void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena)
195 {
196 void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
197 const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
198
199 void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
200 const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
201
202 void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
203 const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
204
205 const char *hg_custom = req[7].value;
206
207 Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string);
208
209 // THINKING: Connect to hg serve
210 // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in
211 // buffer and what not.
212 Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
213 if (!p_upstream || p_upstream->socket < 0)
214 {
215 const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream";
216 Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
217 Seobeo_Handle_Flush(p_client);
218 if (p_upstream)
219 Seobeo_Handle_Destroy(p_upstream);
220 return;
221 }
222
223 // Create headers
224 // we only allow x-hgarg-1 and content-length
225 char request_buf[8192];
226 int req_len = snprintf(request_buf, sizeof(request_buf),
227 "%s /?%s HTTP/1.1\r\n"
228 "Host: %s:%s\r\n"
229 "User-Agent: Seobeo/1.0\r\n"
230 "Connection: close\r\n",
231 method, query_string, HG_SERVE_HOST, HG_SERVE_PORT);
232
233 if (hg_custom && hg_custom[0] != '\0')
234 req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom);
235
236 if (req_body && req_body[0] != '\0')
237 req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body);
238 else
239 req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n");
240
241 Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len);
242 if (Seobeo_Handle_Flush(p_upstream) < 0)
243 {
244 const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed";
245 Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
246 Seobeo_Handle_Flush(p_client);
247 Seobeo_Handle_Destroy(p_upstream);
248 return;
249 }
250
251 // Responses
252 while (1)
253 {
254 int r = Seobeo_Handle_Read(p_upstream);
255 if (r < 0)
256 {
257 Seobeo_Handle_Destroy(p_upstream);
258 return;
259 }
260 if (p_upstream->read_buffer_len >= 4 &&
261 strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL)
262 break; 731 break;
263 if (r == 0) 732 Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
264 continue; 733 }
265 } 734
266 735 Seobeo_Handle_Destroy(upstream);
267 // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. 736 }
268 char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); 737
269 if (!hdr_end) 738 Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena)
270 { 739 {
271 Seobeo_Handle_Destroy(p_upstream); 740 (void)request;
272 return; 741 size_t file_size = 0;
273 } 742 char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
274 size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; 743 if (!html)
275 Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); 744 return text_response(arena, "500", "text/plain", "Application shell unavailable");
276 Seobeo_Handle_Flush(p_client); 745
277 746 Seobeo_Request_Entry *response = NULL;
278 // All body 747 char *content_length = Dowa_Arena_Allocate(arena, 32);
279 size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; 748 snprintf(content_length, 32, "%zu", file_size);
280 if (body_in_buffer > 0) 749 Dowa_HashMap_Push_Arena(response, "status", "200", arena);
281 { 750 Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena);
282 Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); 751 Dowa_HashMap_Push_Arena(response, "body", html, arena);
283 Seobeo_Handle_Flush(p_client); 752 Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
284 } 753 return response;
285 Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); 754 }
286 while (1) 755
287 { 756 int main(void)
288 int n = Seobeo_Handle_Read(p_upstream); 757 {
289 if (n > 0)
290 {
291 Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len);
292 Seobeo_Handle_Flush(p_client);
293 Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len);
294 }
295 else if (n == -2)
296 break;
297 else if (n < 0)
298 break;
299 }
300
301 Seobeo_Handle_Destroy(p_upstream);
302 }
303
304 Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena)
305 {
306 Seobeo_Request_Entry *resp = NULL;
307
308 void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
309 const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
310
311 void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
312 const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
313
314 void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
315 const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
316 size_t body_len = strlen(req_body);
317
318 const char *hg_custom = req[7].value;
319 Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len);
320
321 Seobeo_Client_Response *hg_response;
322
323 char hg_path[MAX_PATH];
324 snprintf(hg_path, sizeof(hg_path), "/?%s", query_string);
325
326 hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom);
327
328 Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length);
329
330 Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type");
331
332 char *status = Dowa_Arena_Allocate(arena, 5);
333 snprintf(status, 4, "%i", hg_response->status_code);
334
335 // Use binary-safe copy to handle null bytes in mercurial bundle data
336 char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
337 char *temp2 = Dowa_Arena_Allocate(arena, 256);
338 snprintf(temp2, 256, "%zu", hg_response->body_length);
339
340 Dowa_HashMap_Push_Arena(resp, "status", status, arena);
341 Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena);
342 Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
343 Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
344
345 return resp;
346 }
347
348 int main(void) {
349 Seobeo_Router_Init(); 758 Seobeo_Router_Init();
759
760 Seobeo_Router_Register("GET", "/", GetReactHome);
761 Seobeo_Router_Register("GET", "/directories", GetReactHome);
762 Seobeo_Router_Register("GET", "/directory", GetReactHome);
763 Seobeo_Router_Register("GET", "/graph", GetReactHome);
764 Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome);
350 765
351 Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); 766 Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory);
352 Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); 767 Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile);
353 Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); 768 Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
354 769 Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph);
355 // Use streaming handler for hg wire protocol... 770 Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset);
771
356 Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); 772 Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol);
357 Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); 773 Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol);
358 774
359 printf("Starting on Port 6970...\n"); 775 printf("Starting on Port 6970...\n");
360 776 int result =
361 int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); 777 Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
362
363 Seobeo_Router_Destroy(); 778 Seobeo_Router_Destroy();
364
365 return result; 779 return result;
366 } 780 }