Mercurial
diff 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 |
line wrap: on
line diff
--- a/hg-web/main.c Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/main.c Sun Aug 02 16:50:48 2026 -0700 @@ -1,366 +1,780 @@ #include "seobeo/seobeo.h" #include "dowa/dowa.h" + +#include <ctype.h> +#include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> -#include <ctype.h> +#include <strings.h> +#include <time.h> #include <unistd.h> -#include <sys/socket.h> -#include <netinet/in.h> -#include <arpa/inet.h> -#include <netdb.h> #define HG_SERVE_HOST "127.0.0.1" #define HG_SERVE_PORT "4444" +#define HG_API_TIMEOUT_MS 15000 +#define HG_STREAM_IDLE_TIMEOUT_MS 60000 +#define MAX_PATH_LENGTH 4096 +#define MAX_WIRE_QUERY_LENGTH 8192 +#define MAX_WIRE_HEADER_LENGTH 8192 -#define MAX_PATH 4096 +static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key) +{ + if (!map || !key) + return NULL; + + for (size_t i = 0; i < Dowa_Array_Length(map); i++) + { + if (map[i].key && strcasecmp(map[i].key, key) == 0) + return map[i].value; + } + return NULL; +} -static char* sanitize_path(const char *input_path, Dowa_Arena *arena) +static char *arena_string(Dowa_Arena *arena, const char *value) +{ + size_t length = strlen(value); + char *copy = Dowa_Arena_Allocate(arena, length + 1); + memcpy(copy, value, length + 1); + return copy; +} + +static Seobeo_Request_Entry *text_response( + Dowa_Arena *arena, + const char *status, + const char *content_type, + const char *body) +{ + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena); + Dowa_HashMap_Push_Arena( + response, "content-type", arena_string(arena, content_type), arena); + Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena); + return response; +} + +static boolean decode_url_component( + const char *encoded, + Dowa_Arena *arena, + char **decoded_out, + size_t *decoded_length_out) { - if (!input_path || strlen(input_path) == 0) + if (!encoded || !decoded_out) + return FALSE; + + size_t encoded_length = strlen(encoded); + if (encoded_length >= MAX_PATH_LENGTH) + return FALSE; + + char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1); + size_t output_length = 0; + for (size_t i = 0; i < encoded_length; i++) { - char *empty = Dowa_Arena_Allocate(arena, 1); - empty[0] = '\0'; - return empty; + unsigned char value = (unsigned char)encoded[i]; + if (encoded[i] == '%') + { + if (i + 2 >= encoded_length || + !isxdigit((unsigned char)encoded[i + 1]) || + !isxdigit((unsigned char)encoded[i + 2])) + return FALSE; + + char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'}; + value = (unsigned char)strtoul(hex, NULL, 16); + i += 2; + if (value == '\0') + return FALSE; + } + decoded[output_length++] = (char)value; + } + decoded[output_length] = '\0'; + + *decoded_out = decoded; + if (decoded_length_out) + *decoded_length_out = output_length; + return TRUE; +} + +static boolean normalize_repository_path( + const char *encoded_path, + Dowa_Arena *arena, + char **normalized_out) +{ + char *decoded = NULL; + size_t decoded_length = 0; + if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length)) + return FALSE; + + size_t start = 0; + size_t end = decoded_length; + if (start < end && decoded[start] == '/') + { + start++; + if (start < end && decoded[start] == '/') + return FALSE; + } + if (end > start && decoded[end - 1] == '/') + { + if (end - 1 > start && decoded[end - 2] == '/') + return FALSE; + end--; + } + + size_t segment_start = start; + for (size_t i = start; i <= end; i++) + { + boolean at_end = i == end; + unsigned char c = at_end ? '/' : (unsigned char)decoded[i]; + if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#')) + return FALSE; + + if (c == '/') + { + size_t segment_length = i - segment_start; + if (segment_length == 0 && !at_end) + return FALSE; + if ((segment_length == 1 && decoded[segment_start] == '.') || + (segment_length == 2 && decoded[segment_start] == '.' && + decoded[segment_start + 1] == '.')) + return FALSE; + segment_start = i + 1; + } } - size_t len = strlen(input_path); - char *result = Dowa_Arena_Allocate(arena, len + 1); - size_t j = 0; + size_t normalized_length = end - start; + char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1); + memcpy(normalized, decoded + start, normalized_length); + normalized[normalized_length] = '\0'; + *normalized_out = normalized; + return TRUE; +} + +static boolean validate_revision(const char *revision) +{ + if (!revision || revision[0] == '\0') + return FALSE; + if (strcmp(revision, "tip") == 0) + return TRUE; - for (size_t i = 0; i < len; i++) + size_t length = strlen(revision); + if (length > 40) + return FALSE; + for (size_t i = 0; i < length; i++) { - if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) + if (!isxdigit((unsigned char)revision[i])) + return FALSE; + } + return TRUE; +} + +static char *encode_repository_path(const char *path, Dowa_Arena *arena) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t length = strlen(path); + char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1); + size_t output = 0; + for (size_t i = 0; i < length; i++) + { + unsigned char c = (unsigned char)path[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + encoded[output++] = (char)c; + else { - if (i + 1 < len && input_path[i+1] == '.') - { - // Skip ".." - i++; - continue; - } - // Skip "." - continue; + encoded[output++] = '%'; + encoded[output++] = hex[c >> 4]; + encoded[output++] = hex[c & 0x0F]; } - result[j++] = input_path[i]; } - result[j] = '\0'; + encoded[output] = '\0'; + return encoded; +} - // Remove leading/trailing slashes - while (result[0] == '/') - memmove(result, result + 1, strlen(result)); - while (j > 0 && result[j-1] == '/') - result[--j] = '\0'; +static boolean safe_header_value(const char *value, size_t maximum_length) +{ + if (!value) + return TRUE; + size_t length = strlen(value); + return length <= maximum_length && + strchr(value, '\r') == NULL && + strchr(value, '\n') == NULL; +} - return result; +static boolean extension_is(const char *extension, const char *expected) +{ + return extension && strcasecmp(extension, expected) == 0; } -Seobeo_Client_Response *hg_proxy_request( - const char *method, - const char *path, - const char *req_body, - const char *hg_custom) +static const char *repository_file_content_type( + const char *path, + boolean *inline_preview, + boolean *sandbox_content) { - char full_path[MAX_PATH]; - snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path); - Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path); - Seobeo_Client_Request_Set_Method(p_req, method); - Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0"); - Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json"); + const char *extension = strrchr(path, '.'); + *inline_preview = TRUE; + *sandbox_content = FALSE; - if (hg_custom && hg_custom[0] != '\0') + if (extension_is(extension, ".png")) return "image/png"; + if (extension_is(extension, ".jpg") || + extension_is(extension, ".jpeg")) return "image/jpeg"; + if (extension_is(extension, ".gif")) return "image/gif"; + if (extension_is(extension, ".webp")) return "image/webp"; + if (extension_is(extension, ".avif")) return "image/avif"; + if (extension_is(extension, ".bmp")) return "image/bmp"; + if (extension_is(extension, ".ico")) return "image/x-icon"; + if (extension_is(extension, ".svg")) { - char buffer[1024]; - snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom); - Seobeo_Client_Request_Add_Header_Array(p_req, buffer); - Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer); + *sandbox_content = TRUE; + return "image/svg+xml"; } + if (extension_is(extension, ".mp4") || + extension_is(extension, ".m4v")) return "video/mp4"; + if (extension_is(extension, ".webm")) return "video/webm"; + if (extension_is(extension, ".mov")) return "video/quicktime"; + if (extension_is(extension, ".ogv")) return "video/ogg"; + if (extension_is(extension, ".mp3")) return "audio/mpeg"; + if (extension_is(extension, ".wav")) return "audio/wav"; + if (extension_is(extension, ".ogg") || + extension_is(extension, ".oga")) return "audio/ogg"; + if (extension_is(extension, ".flac")) return "audio/flac"; + if (extension_is(extension, ".m4a")) return "audio/mp4"; + if (extension_is(extension, ".aac")) return "audio/aac"; + if (extension_is(extension, ".pdf")) return "application/pdf"; + if (extension_is(extension, ".wasm")) return "application/wasm"; - if (req_body) - Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body)); - Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); - Seobeo_Client_Request_Destroy(p_req); - return p_resp; + *inline_preview = FALSE; + if (extension_is(extension, ".md") || + extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8"; + if (extension_is(extension, ".txt") || + extension_is(extension, ".log") || + extension_is(extension, ".c") || + extension_is(extension, ".h") || + extension_is(extension, ".cc") || + extension_is(extension, ".cpp") || + extension_is(extension, ".js") || + extension_is(extension, ".jsx") || + extension_is(extension, ".ts") || + extension_is(extension, ".tsx") || + extension_is(extension, ".css") || + extension_is(extension, ".html") || + extension_is(extension, ".htm") || + extension_is(extension, ".xml") || + extension_is(extension, ".json") || + extension_is(extension, ".yaml") || + extension_is(extension, ".yml") || + extension_is(extension, ".toml") || + extension_is(extension, ".sh") || + extension_is(extension, ".py") || + extension_is(extension, ".rs") || + extension_is(extension, ".go")) + return "text/plain; charset=utf-8"; + return "application/octet-stream"; } -Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static Seobeo_Client_Response *hg_proxy_request( + const char *method, + const char *path, + const char *request_body, + size_t request_body_length, + const char *hg_argument, + const char *accept) { - Seobeo_Request_Entry *resp = NULL; - - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - - char *safe_path = sanitize_path(decoded_path, arena); + char url[MAX_PATH_LENGTH]; + int url_length = snprintf( + url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); + if (url_length < 0 || (size_t)url_length >= sizeof(url)) + return NULL; - Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + if (!request) + return NULL; - char hg_path[MAX_PATH]; - if (strlen(safe_path) > 0) - snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path); - else - snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json"); + Seobeo_Client_Request_Set_Method(request, method); + Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0"); + Seobeo_Client_Request_Add_Header_Map( + request, "Accept", accept ? accept : "application/json"); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - if (hg_response->status_code != 200) + if (hg_argument && hg_argument[0] != '\0') { - Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n"); - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena); - return resp; - } - - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - return resp; -} - -Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena) -{ - Seobeo_Request_Entry *resp = NULL; - - void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path"); - const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : ""; - char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1); - Seobeo_Url_Decode(decoded_path, rel_path); - char *safe_path = sanitize_path(decoded_path, arena); - - Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path); - - if (strlen(safe_path) == 0) - { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena); - return resp; + if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH)) + { + Seobeo_Client_Request_Destroy(request); + return NULL; + } + Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument); } - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + if (request_body && request_body_length > 0) + Seobeo_Client_Request_Set_Body(request, request_body, request_body_length); + + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + Seobeo_Client_Request_Destroy(request); + return response; +} + +static Seobeo_Request_Entry *forward_hg_response( + Seobeo_Client_Response *hg_response, + const char *default_content_type, + const char *override_content_type, + Dowa_Arena *arena) +{ + if (!hg_response) + return text_response( + arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); + + const char *upstream_content_type = + map_value_case_insensitive(hg_response->headers, "Content-Type"); + const char *upstream_or_default_content_type = + override_content_type + ? override_content_type + : upstream_content_type ? upstream_content_type : default_content_type; + if (!upstream_or_default_content_type) + upstream_or_default_content_type = "application/octet-stream"; + char *content_type = arena_string(arena, upstream_or_default_content_type); + + char *status = Dowa_Arena_Allocate(arena, 8); + snprintf(status, 8, "%d", hg_response->status_code); + + size_t body_length = hg_response->body ? hg_response->body_length : 0; + char *body = Dowa_Arena_Allocate(arena, body_length + 1); + if (body_length > 0) + memcpy(body, hg_response->body, body_length); + body[body_length] = '\0'; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", body_length); + + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", status, arena); + Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena); + Dowa_HashMap_Push_Arena(response, "body", body, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + Seobeo_Client_Response_Destroy(hg_response); + return response; +} + +Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf( + hg_path, + sizeof(hg_path), + encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json", + encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}"); - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} + +Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) || + path[0] == '\0') + return text_response(arena, "400", "text/plain", "A valid file path is required"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "File path is too long"); + + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"); + if (!hg_response) + return forward_hg_response(NULL, "application/json", NULL, arena); - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); + boolean inline_preview = FALSE; + boolean sandbox_content = FALSE; + const char *content_type = + repository_file_content_type(path, &inline_preview, &sandbox_content); + Seobeo_Request_Entry *response = forward_hg_response( + hg_response, "application/octet-stream", content_type, arena); + Dowa_HashMap_Push_Arena( + response, + "Content-Disposition", + inline_preview ? "inline" : "attachment", + arena); + Dowa_HashMap_Push_Arena( + response, "X-Content-Type-Options", "nosniff", arena); + if (sandbox_content) + Dowa_HashMap_Push_Arena( + response, "Content-Security-Policy", "sandbox", arena); + return response; +} - if (!hg_response->body) +Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *directory = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory)) + return text_response(arena, "400", "text/plain", "Invalid repository path"); + + size_t readme_length = strlen(directory) + strlen("/README.md") + 1; + if (readme_length >= MAX_PATH_LENGTH) + return text_response(arena, "400", "text/plain", "README path is too long"); + + char *readme_path = Dowa_Arena_Allocate(arena, readme_length); + snprintf( + readme_path, + readme_length, + directory[0] ? "%s/README.md" : "README.md", + directory); + char *encoded = encode_repository_path(readme_path, arena); + + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "README path is too long"); + + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"); + if (hg_response && hg_response->status_code == HTTP_NOT_FOUND) { - Dowa_HashMap_Push_Arena(resp, "status", "502", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena); - return resp; + Seobeo_Client_Response_Destroy(hg_response); + return text_response(arena, "204", "text/markdown", ""); } + return forward_hg_response( + hg_response, "text/markdown", "text/markdown; charset=utf-8", arena); +} - if (hg_response->status_code != 200) +Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *graph_id = map_value_case_insensitive(request, ":graph_id"); + if (!validate_revision(graph_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}"); + + const char *encoded_graph_top = + map_value_case_insensitive(request, "query_graphtop"); + char *graph_top = NULL; + if (encoded_graph_top) { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; + if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) || + !validate_revision(graph_top)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}"); } - - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); + char hg_path[MAX_PATH_LENGTH]; + int length = graph_top + ? snprintf( + hg_path, + sizeof(hg_path), + "/graph/%s?graphtop=%s&style=json", + graph_id, + graph_top) + : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}"); - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); } -Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) { - return ApiGetFile(req, arena); +Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *changeset_id = map_value_case_insensitive(request, ":changeset_id"); + if (!validate_revision(changeset_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}"); + + char hg_path[128]; + int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static size_t find_http_header_length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; } -// Streaming handler for hg wire protocol - pipes data directly without buffering -void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena) +static void send_proxy_error(Seobeo_Handle *client, int status, const char *message) { - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - - const char *hg_custom = req[7].value; + const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway"; + char response[512]; + int length = snprintf( + response, + sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n" + "\r\n" + "%s", + status, + reason, + strlen(message), + message); + if (length > 0 && (size_t)length < sizeof(response)) + { + Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length); + Seobeo_Handle_Flush(client); + } +} - Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string); +static boolean parse_content_length(const char *value, size_t *length_out) +{ + if (!value || !length_out || value[0] == '\0') + return FALSE; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX) + return FALSE; + *length_out = (size_t)parsed; + return TRUE; +} - // THINKING: Connect to hg serve - // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in - // buffer and what not. - Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); - if (!p_upstream || p_upstream->socket < 0) +void StreamHgWireProtocol( + Seobeo_Handle *client, + Seobeo_Request_Entry *request, + Dowa_Arena *arena) +{ + (void)arena; + const char *method = map_value_case_insensitive(request, "HTTP_Method"); + const char *query = map_value_case_insensitive(request, "QueryString"); + const char *body = map_value_case_insensitive(request, "Body"); + const char *content_length_value = + map_value_case_insensitive(request, "Content-Length"); + const char *content_type = map_value_case_insensitive(request, "Content-Type"); + const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1"); + + if (!method || !query || + (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) || + !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) || + !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) || + !safe_header_value(content_type, 256)) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - if (p_upstream) - Seobeo_Handle_Destroy(p_upstream); + send_proxy_error(client, 502, "Invalid Mercurial proxy request"); return; } - // Create headers - // we only allow x-hgarg-1 and content-length - char request_buf[8192]; - int req_len = snprintf(request_buf, sizeof(request_buf), - "%s /?%s HTTP/1.1\r\n" - "Host: %s:%s\r\n" - "User-Agent: Seobeo/1.0\r\n" - "Connection: close\r\n", - method, query_string, HG_SERVE_HOST, HG_SERVE_PORT); + size_t body_length = 0; + if (content_length_value && + !parse_content_length(content_length_value, &body_length)) + { + send_proxy_error(client, 502, "Invalid Mercurial request length"); + return; + } + if (body_length > 0 && !body) + { + send_proxy_error(client, 502, "Missing Mercurial request body"); + return; + } + if (body_length > UINT32_MAX) + { + send_proxy_error(client, 502, "Mercurial request body is too large"); + return; + } - if (hg_custom && hg_custom[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom); + Seobeo_Handle *upstream = + Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); + if (!upstream || upstream->socket < 0) + { + if (upstream) + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend unavailable"); + return; + } - if (req_body && req_body[0] != '\0') - 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); - else - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n"); + char request_header[16384]; + int header_length = snprintf( + request_header, + sizeof(request_header), + "%s /?%s HTTP/1.1\r\n" + "Host: %s:%s\r\n" + "User-Agent: Seobeo/1.0\r\n" + "Connection: close\r\n", + method, + query, + HG_SERVE_HOST, + HG_SERVE_PORT); + if (header_length < 0 || (size_t)header_length >= sizeof(request_header)) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial request headers are too large"); + return; + } - Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len); - if (Seobeo_Handle_Flush(p_upstream) < 0) +#define APPEND_WIRE_HEADER(...) \ + do { \ + int appended = snprintf( \ + request_header + header_length, \ + sizeof(request_header) - (size_t)header_length, \ + __VA_ARGS__); \ + if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \ + Seobeo_Handle_Destroy(upstream); \ + send_proxy_error(client, 502, "Mercurial request headers are too large"); \ + return; \ + } \ + header_length += appended; \ + } while (0) + + if (hg_argument && hg_argument[0] != '\0') + APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument); + if (content_type && content_type[0] != '\0') + APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type); + if (body_length > 0) + APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length); + APPEND_WIRE_HEADER("\r\n"); +#undef APPEND_WIRE_HEADER + + if (Seobeo_Handle_Queue( + upstream, (const uint8 *)request_header, (uint32)header_length) != 0 || + (body_length > 0 && + Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) || + Seobeo_Handle_Flush(upstream) != 0) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend write failed"); return; } - // Responses - while (1) + boolean response_started = FALSE; + int64_t last_progress = monotonic_milliseconds(); + while (!response_started) { - int r = Seobeo_Handle_Read(p_upstream); - if (r < 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2 || read_result < 0) { - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend closed before responding"); return; } - if (p_upstream->read_buffer_len >= 4 && - strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL) - break; - if (r == 0) - continue; - } - - // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. - char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); - if (!hdr_end) - { - Seobeo_Handle_Destroy(p_upstream); - return; - } - size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); - Seobeo_Handle_Flush(p_client); + if (read_result > 0) + last_progress = monotonic_milliseconds(); - // All body - size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; - if (body_in_buffer > 0) - { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); - Seobeo_Handle_Flush(p_client); - } - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); - while (1) - { - int n = Seobeo_Handle_Read(p_upstream); - if (n > 0) + size_t header_size = + find_http_header_length(upstream->read_buffer, upstream->read_buffer_len); + if (header_size > 0) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + (void)header_size; + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + { + Seobeo_Handle_Destroy(upstream); + return; + } + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + response_started = TRUE; + break; } - else if (n == -2) - break; - else if (n < 0) - break; + + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 504, "Mercurial backend response timed out"); + return; + } + usleep(1000); + } } - Seobeo_Handle_Destroy(p_upstream); + while (TRUE) + { + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2) + break; + if (read_result < 0) + break; + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n"); + break; + } + usleep(1000); + continue; + } + + last_progress = monotonic_milliseconds(); + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + break; + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + } + + Seobeo_Handle_Destroy(upstream); } -Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - size_t body_len = strlen(req_body); - - const char *hg_custom = req[7].value; - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); - - Seobeo_Client_Response *hg_response; + (void)request; + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); + if (!html) + return text_response(arena, "500", "text/plain", "Application shell unavailable"); - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/?%s", query_string); - - hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length); - - Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type"); - - char *status = Dowa_Arena_Allocate(arena, 5); - snprintf(status, 4, "%i", hg_response->status_code); - - // Use binary-safe copy to handle null bytes in mercurial bundle data - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + Seobeo_Request_Entry *response = NULL; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", file_size); + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(response, "body", html, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + return response; } -int main(void) { +int main(void) +{ Seobeo_Router_Init(); + Seobeo_Router_Register("GET", "/", GetReactHome); + Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/directory", GetReactHome); + Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome); + Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory); Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile); Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme); + Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph); + Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset); - // Use streaming handler for hg wire protocol... Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); printf("Starting on Port 6970...\n"); - - int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); - + int result = + Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); Seobeo_Router_Destroy(); - return result; }