view hg-web/main.c @ 263:ee04e4e69fed

Add functional JRPG frame and tools Add full-screen background_2 apertures, functional frame chrome, card-driven details, dual-window tools, live conversion workflows, and bounded cleanup for generated downloads. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Thu, 06 Aug 2026 11:31:30 -0700
parents c5129452493e
children
line wrap: on
line source

#include "seobeo/seobeo.h"
#include "dowa/dowa.h"

#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#include <unistd.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 3600000
#define MAX_PATH_LENGTH 4096
#define MAX_WIRE_QUERY_LENGTH 8192
#define MAX_WIRE_HEADER_LENGTH 8192

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 *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 (!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++)
  {
    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 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;

  size_t length = strlen(revision);
  if (length > 40)
    return FALSE;
  for (size_t i = 0; i < length; i++)
  {
    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
    {
      encoded[output++] = '%';
      encoded[output++] = hex[c >> 4];
      encoded[output++] = hex[c & 0x0F];
    }
  }
  encoded[output] = '\0';
  return encoded;
}

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;
}

static boolean extension_is(const char *extension, const char *expected)
{
  return extension && strcasecmp(extension, expected) == 0;
}

static const char *repository_file_content_type(
    const char *path,
    boolean *inline_preview,
    boolean *sandbox_content)
{
  const char *extension = strrchr(path, '.');
  *inline_preview = TRUE;
  *sandbox_content = FALSE;

  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"))
  {
    *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";

  *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";
}

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)
{
  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_Client_Request *request = Seobeo_Client_Request_Create(url);
  if (!request)
    return NULL;

  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);

  if (hg_argument && hg_argument[0] != '\0')
  {
    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);
  }

  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\"}");

  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);

  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;
}

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)
  {
    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);
}

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)
  {
    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 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\"}");

  return forward_hg_response(
      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
      "application/json",
      NULL,
      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 monotonic_milliseconds(void)
{
  struct timespec now;
  clock_gettime(CLOCK_MONOTONIC, &now);
  return (int64)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;
}

static void send_proxy_error(Seobeo_Handle *client, int status, const char *message)
{
  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);
  }
}

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;
}

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))
  {
    send_proxy_error(client, 502, "Invalid Mercurial proxy request");
    return;
  }

  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;
  }

  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;
  }

  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;
  }

#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)
  {
    Seobeo_Handle_Destroy(upstream);
    send_proxy_error(client, 502, "Mercurial backend write failed");
    return;
  }

  boolean response_started = FALSE;
  int64 last_progress = monotonic_milliseconds();
  while (!response_started)
  {
    int read_result = Seobeo_Handle_Read(upstream);
    if (read_result == -2 || read_result < 0)
    {
      Seobeo_Handle_Destroy(upstream);
      send_proxy_error(client, 502, "Mercurial backend closed before responding");
      return;
    }
    if (read_result > 0)
      last_progress = monotonic_milliseconds();

    size_t header_size =
        find_http_header_length(upstream->read_buffer, upstream->read_buffer_len);
    if (header_size > 0)
    {
      (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;
    }

    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);
    }
  }

  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 *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena)
{
  (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");

  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)
{
  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);

  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);
  Seobeo_Router_Destroy();
  return result;
}