view seobeo/s_web.c @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents 04fee26ecce0
children
line wrap: on
line source

#include "seobeo/seobeo.h"
#include <strings.h>
#include <time.h>

static char g_folder_path[512] = ".";
static volatile sig_atomic_t g_server_stopping = 0;

void Seobeo_Web_Server_Stop(void)
{
  g_server_stopping = 1;
}

boolean Seobeo_Web_Server_Is_Stopping(void)
{
  return g_server_stopping ? TRUE : FALSE;
}

static boolean Seobeo_Static_Path_Is_Safe(const char *path)
{
  if (!path || strchr(path, '\\'))
    return FALSE;
  const char *segment = path;
  while (*segment)
  {
    while (*segment == '/')
      segment++;
    const char *end = segment;
    while (*end && *end != '/')
      end++;
    if ((size_t)(end - segment) == 2 &&
        segment[0] == '.' &&
        segment[1] == '.')
      return FALSE;
    segment = end;
  }
  return TRUE;
}

static char *canonical_request_header(char *header)
{
  if (strcasecmp(header, "content-length") == 0) return "Content-Length";
  if (strcasecmp(header, "content-type") == 0) return "Content-Type";
  if (strcasecmp(header, "connection") == 0) return "Connection";
  if (strcasecmp(header, "authorization") == 0) return "Authorization";
  if (strcasecmp(header, "cookie") == 0) return "Cookie";
  if (strcasecmp(header, "origin") == 0) return "Origin";
  if (strcasecmp(header, "x-csrf-token") == 0) return "X-CSRF-Token";
  if (strcasecmp(header, "host") == 0) return "Host";
  if (strcasecmp(header, "upgrade") == 0) return "Upgrade";
  if (strcasecmp(header, "x-real-ip") == 0) return "X-Real-IP";
  if (strcasecmp(header, "sec-websocket-key") == 0) return "Sec-WebSocket-Key";
  if (strcasecmp(header, "sec-websocket-version") == 0) return "Sec-WebSocket-Version";
  return header;
}

char* Seobeo_Web_LoadFile(const char *file_path, size_t *p_file_size)
{
  char full_path[1024];
  snprintf(full_path, sizeof(full_path), "%s/%s", g_folder_path, file_path);

  FILE *p_file = fopen(full_path, "rb");
  if (!p_file)
    return NULL;

  fseek(p_file, 0, SEEK_END);
  size_t file_size = ftell(p_file);
  fseek(p_file, 0, SEEK_SET);

  char *p_content = (char*)malloc(file_size + 1);
  if (!p_content)
  {
    fclose(p_file);
    return NULL;
  }

  fread(p_content, 1, file_size, p_file);
  p_content[file_size] = '\0';
  fclose(p_file);

  if (p_file_size)
    *p_file_size = file_size;

  return p_content;
}

void Seobeo_Web_Header_Generate(
    void *buffer, int status,
    const char *content_type, const int content_length)
{
  Seobeo_Web_Header_Generate_KeepAlive(buffer, status, content_type, content_length, FALSE);
}

void Seobeo_Web_Header_Generate_KeepAlive(
    void *buffer, int status,
    const char *content_type, const int content_length,
    boolean keep_alive)
{
  const char *status_text;
  switch(status)
  {
    case HTTP_OK: status_text = "OK"; break;
    case HTTP_CREATED: status_text = "Created"; break;
    case HTTP_NO_CONTENT: status_text = "No Content"; break;
    case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break;
    case HTTP_FOUND: status_text = "Found"; break;
    case HTTP_BAD_REQUEST: status_text = "Bad Request"; break;
    case HTTP_UNAUTHORIZED: status_text = "Unauthorized"; break;
    case HTTP_FORBIDDEN: status_text = "Forbidden"; break;
    case HTTP_NOT_FOUND: status_text = "Not Found"; break;
    case HTTP_PAYLOAD_TOO_LARGE: status_text = "Content Too Large"; break;
    case HTTP_UNPROCESSABLE_CONTENT: status_text = "Unprocessable Content"; break;
    case HTTP_TOO_MANY_REQUESTS: status_text = "Too Many Requests"; break;
    case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break;
    case HTTP_SERVICE_UNAVAILABLE: status_text = "Service Unavailable"; break;
    case 502: status_text = "Bad Gateway"; break;
    case 504: status_text = "Gateway Timeout"; break;
    default: status_text = "Unknown"; break;
  }

  snprintf(
    (char*)buffer, 1024,
    "HTTP/1.1 %d %s\r\n"
    "Content-Type: %s\r\n"
    "Content-Length: %d\r\n"
    "Connection: %s\r\n"
    "\r\n",
    status, status_text, content_type, content_length,
    keep_alive ? "keep-alive" : "close"
  );
}

// Default arena size (5MB) - will allocate more if Content-Length requires it
#define DEFAULT_ARENA_SIZE (5 * 1024 * 1024)

// Helper to check if Connection header contains a specific value (case-insensitive)
static boolean connection_header_contains(const char *header_value, const char *target)
{
  if (!header_value || !target) return FALSE;

  // Make a copy to tokenize
  char *copy = strdup(header_value);
  if (!copy) return FALSE;

  boolean found = FALSE;
  char *token = strtok(copy, ", \t");
  while (token) {
    if (strcasecmp(token, target) == 0) {
      found = TRUE;
      break;
    }
    token = strtok(NULL, ", \t");
  }
  Dowa_Free(copy);
  return found;
}

// Returns TRUE if connection should be kept alive, FALSE if it should be closed
boolean Seobeo_Web_ClientHandle_Request(Seobeo_Handle *p_cli_handle,
                                        Seobeo_Cache_Entry *p_html_cache,
                                        boolean use_keep_alive)
{
  boolean should_keep_alive = FALSE;

  // Start with default arena size (5MB)
  size_t arena_size = DEFAULT_ARENA_SIZE;

  // We'll peek at Content-Length to potentially allocate larger arena
  // For now, start with default and handle body reading separately
  Dowa_Arena *p_request_arena = Dowa_Arena_Create(arena_size);
  if (!p_request_arena) { perror("Dowa_Arena_Create request"); return FALSE; }

  Dowa_Arena *p_response_arena = Dowa_Arena_Create(1024 * 1024 * 16);
  if (!p_response_arena) { perror("Dowa_Arena_Create response"); Dowa_Arena_Free(p_request_arena); return FALSE; }

  void *p_response_header = Dowa_Arena_Allocate(p_response_arena, 1024*5); // 5Kb
  if (!p_response_header) { perror("Dowa_Arena_Allocate"); goto clean_up_arenas; }

  Seobeo_Request_Entry *p_req_map = NULL;
  int parse_result = Seobeo_Web_Header_Parse(p_cli_handle, &p_req_map, p_request_arena);

  // Handle parse errors
  if (parse_result < 0) {
    // Fatal error or connection closed
    Seobeo_Log(SEOBEO_DEBUG, "Seobeo_Web_Header_Parse failed with code %d\n", parse_result);
    if (parse_result == -2) {
      // Connection closed by client - don't send error response
      goto clean_up_arenas;
    }
    Seobeo_Web_Header_Generate(p_response_header,
                               HTTP_BAD_REQUEST,
                               "text/plain", 0);
    Seobeo_Handle_Queue(p_cli_handle,
                        (const uint8*)p_response_header,
                        (uint32)strlen(p_response_header));
    Seobeo_Handle_Flush(p_cli_handle);
    goto clean_up_arenas;
  }

  // Determine keep-alive based on HTTP version and Connection header
  // HTTP/1.1: keep-alive by default unless "Connection: close"
  // HTTP/1.0: close by default unless "Connection: keep-alive"

  void *p_ver_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Version");
  const char *http_version = p_ver_kv ? ((Seobeo_Request_Entry*)p_ver_kv)->value : "HTTP/1.0";
  boolean is_http11 = (strstr(http_version, "1.1") != NULL);

  void *p_conn_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Connection");
  const char *conn_header = p_conn_kv ? ((Seobeo_Request_Entry*)p_conn_kv)->value : NULL;

  if (conn_header)
  {
    if (connection_header_contains(conn_header, "close"))
      should_keep_alive = FALSE;
    else if (connection_header_contains(conn_header, "keep-alive"))
      should_keep_alive = use_keep_alive;
    else
      should_keep_alive = is_http11 && use_keep_alive;  // Unknown value, use version default
  }
  else
    should_keep_alive = is_http11 && use_keep_alive;

  void *p_method_kv = Dowa_HashMap_Get_Ptr(p_req_map, "HTTP_Method");
  const char *method = p_method_kv ? ((Seobeo_Request_Entry*)p_method_kv)->value : NULL;

  void *p_path_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Path");
  const char *path = p_path_kv ? ((Seobeo_Request_Entry*)p_path_kv)->value : "/";

  if (!method)
  {
    Seobeo_Log(SEOBEO_DEBUG, "No HTTP method found in request\n");
    Seobeo_Web_Header_Generate(p_response_header,
                               HTTP_BAD_REQUEST,
                               "text/plain", 0);
    Seobeo_Handle_Queue(p_cli_handle,
                        (const uint8*)p_response_header,
                        (uint32)strlen(p_response_header));
    Seobeo_Handle_Flush(p_cli_handle);
    should_keep_alive = FALSE;
    goto clean_up_arenas;
  }

  // --- Check for WebSocket upgrade request ---
  #ifdef SEOBEO_WEBSOCKET_SERVER
  Seobeo_Log(SEOBEO_DEBUG, "Web socket path \n");
  if (Seobeo_WebSocket_Server_Handle_Upgrade(p_cli_handle, p_req_map, path))
  {
    Seobeo_Log(SEOBEO_INFO, "WebSocket connection established\n");
    Dowa_Arena_Free(p_request_arena);
    Dowa_Arena_Free(p_response_arena);
    return FALSE; // WebSocket takes over, don't keep-alive in HTTP sense
  }
  #endif

  // --- Try to match streaming route first ---
  Seobeo_SSE_Handler sse_handler = Seobeo_Router_Find_SSE_Handler(
      method,
      path,
      &p_req_map,
      p_request_arena);
  if (sse_handler != NULL)
  {
    Seobeo_SSE_Stream *p_stream = Seobeo_SSE_Server_Attach(
        p_cli_handle,
        path);
    if (p_stream)
    {
      sse_handler(p_stream, p_req_map, p_response_arena);
      should_keep_alive = Seobeo_SSE_Is_Open(p_stream);
    }
    else
    {
      should_keep_alive = FALSE;
    }
    goto clean_up_arenas;
  }

  // --- Try to match generic streaming route ---
  Seobeo_Stream_Handler stream_handler = Seobeo_Router_Find_Stream_Handler(method, path, &p_req_map, p_request_arena);
  if (stream_handler != NULL)
  {
    stream_handler(p_cli_handle, p_req_map, p_response_arena);
    should_keep_alive = p_cli_handle->is_sse;
    goto clean_up_arenas;
  }

  // --- Try to match API route ---
  Seobeo_Route_Handler handler = Seobeo_Router_Find_Handler(method, path, &p_req_map, p_request_arena);
  if (handler != NULL)
  {
    Seobeo_Request_Entry *p_response_map = handler(p_req_map, p_response_arena);
    Seobeo_Router_Send_Response_KeepAlive(p_cli_handle, p_response_map, p_response_arena, should_keep_alive);
    goto clean_up_arenas;
  }

  // --- Static files fallback for GET (use original large arena logic) ---
  if (strcmp(method, "GET") == 0)
  {
    if (!Seobeo_Static_Path_Is_Safe(path))
    {
      Seobeo_Web_Header_Generate_KeepAlive(
          p_response_header,
          HTTP_BAD_REQUEST,
          "text/plain",
          0,
          should_keep_alive);
      Seobeo_Handle_Queue(
          p_cli_handle,
          (const uint8 *)p_response_header,
          (uint32)strlen(p_response_header));
      Seobeo_Handle_Flush(p_cli_handle);
      goto clean_up_arenas;
    }

    char *file_path = Dowa_Arena_Allocate(p_response_arena, (size_t)5 * 1024);

    if (!path || strcmp(path, "/") == 0)
    {
      strcpy(file_path, "index.html");
    }
    else
    {
      size_t L = strlen(path);
      if (path[0] == '/')
      {
        if (strchr(path, '.') == NULL)
          snprintf(file_path, 512, "%.*s/index.html", (int)(L-1), path+1);
        else
          snprintf(file_path, 512, "%.*s", (int)(L-1), path+1);
      }
      else
        strcpy(file_path, path);
    }

    char *file_content = NULL;
    size_t body_size = 0;
    (void)p_html_cache;
    file_content = Seobeo_Web_LoadFile(file_path, &body_size);

    if (!file_content)
    {
      Seobeo_Web_Header_Generate_KeepAlive(p_response_header,
                                 HTTP_NOT_FOUND,
                                 "text/html", 0, should_keep_alive);
      Seobeo_Handle_Queue(p_cli_handle,
                          (const uint8*)p_response_header,
                          (uint32)strlen(p_response_header));
      Seobeo_Handle_Flush(p_cli_handle);
      goto clean_up_arenas;
    }

    const char *mime = "application/octet-stream";
    if (strstr(file_path, ".html")) mime = "text/html; charset=utf-8";
    else if (strstr(file_path, ".css")) mime = "text/css";
    else if (strstr(file_path, ".js")) mime = "application/javascript";
    else if (strstr(file_path, ".png")) mime = "image/png";
    else if (strstr(file_path, ".jpg") || strstr(file_path, ".jpeg")) mime = "image/jpeg";
    else if (strstr(file_path, ".gif")) mime = "image/gif";
    else if (strstr(file_path, ".svg")) mime = "image/svg+xml";
    else if (strstr(file_path, ".ico")) mime = "image/x-icon";
    else if (strstr(file_path, ".webp")) mime = "image/webp";
    else if (strstr(file_path, ".json")) mime = "application/json";
    else if (strstr(file_path, ".wasm")) mime = "application/wasm";
    else if (strstr(file_path, ".xml")) mime = "application/xml";
    else if (strstr(file_path, ".pdf")) mime = "application/pdf";
    else if (strstr(file_path, ".txt")) mime = "text/plain";
    else if (strstr(file_path, ".m3u8")) mime = "application/vnd.apple.mpegurl";
    else if (strstr(file_path, ".m4s")) mime = "video/iso.segment";
    else if (strstr(file_path, ".ts")) mime = "video/mp2t";
    else if (strstr(file_path, ".mp4")) mime = "video/mp4";
    else if (strstr(file_path, ".webm")) mime = "video/webm";
    else if (strstr(file_path, ".mp3")) mime = "audio/mpeg";
    else if (strstr(file_path, ".woff2")) mime = "font/woff2";
    else if (strstr(file_path, ".woff")) mime = "font/woff";
    else if (strstr(file_path, ".ttf")) mime = "font/ttf";
    else if (strstr(file_path, ".webp")) mime = "image/webp";
    else if (strstr(file_path, ".glb")) mime = "model/gltf-binary";
    else if (strstr(file_path, ".gltf")) mime = "model/gltf+json";


    Seobeo_Web_Header_Generate_KeepAlive(p_response_header,
                               HTTP_OK,
                               mime,
                               body_size, should_keep_alive);

    Seobeo_Handle_Queue(p_cli_handle,
                        (const uint8*)p_response_header,
                        (uint32)strlen(p_response_header));
    Seobeo_Handle_Queue(p_cli_handle,
                        (const uint8*)file_content,
                        (uint32)body_size);
    Seobeo_Handle_Flush(p_cli_handle);
    Dowa_Free(file_content);
  }
  else
  {
    Seobeo_Web_Header_Generate_KeepAlive(p_response_header,
                               HTTP_NOT_FOUND,
                               "text/plain", 0, should_keep_alive);
    Seobeo_Handle_Queue(p_cli_handle,
                        (const uint8*)p_response_header,
                        (uint32)strlen(p_response_header));
    Seobeo_Handle_Flush(p_cli_handle);
  }

clean_up_arenas:
  if (p_request_arena)
    Dowa_Arena_Free(p_request_arena);
  if (p_response_arena)
    Dowa_Arena_Free(p_response_arena);
  return should_keep_alive;
}

int Seobeo_Web_Header_Parse(Seobeo_Handle *p_handle, Seobeo_Request_Entry **pp_map, Dowa_Arena *p_arena)
{
  while (1)
  {
    int r = Seobeo_Handle_Read(p_handle);
    if (r < 0)
      return -1;   // fatal error
    if (r == -2)
      return -2;   // connection closed TODO: Add this as part of Handle struct.

    if (p_handle->read_buffer_len >= 4 &&
        strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL)
      break;

    if (r == 0)
    {
      Seobeo_Log(SEOBEO_INFO, "Waiting?\n");
      continue;     // EAGAIN, try again later TODO: Add this as part of Handle struct.
    }
  }

  // "METHOD SP PATH SP VERSION CRLF"
  char *buf       = (char*)p_handle->read_buffer;
  char *hdr_end   = strstr(buf, "\r\n\r\n");
  size_t hdr_len  = hdr_end - buf + 4;

  // Debug: Print the first line of the request
  char *first_line_end = strstr(buf, "\r\n");
  if (first_line_end)
  {
    size_t first_line_len = first_line_end - buf;
    Seobeo_Log(SEOBEO_DEBUG, "Request line (first %zu bytes)\n", first_line_len > 200 ? 200 : first_line_len);
  }

  // This seems kinda bad ?
  char method[16], path[256], version[16];
  int scan_result = sscanf(buf, "%15s %255s %15s", method, path, version);
  Seobeo_Log(SEOBEO_DEBUG, "sscanf returned %d (method='%s', path_length=%zu)\n",
         scan_result,
         scan_result >= 1 ? method : "N/A",
         scan_result >= 2 ? strlen(path) : (size_t)0);

  if (scan_result != 3)
  {
    Seobeo_Log(SEOBEO_ERROR, "Failed to parse request line\n");
    return -1;
  }

  Seobeo_Log(SEOBEO_DEBUG, "Allocating method_copy\n");
  char *method_copy = Dowa_Arena_Allocate(p_arena, strlen(method) + 1);
  if (!method_copy) { Seobeo_Log(SEOBEO_ERROR, "Failed to allocate method_copy\n"); return -1; }
  strcpy(method_copy, method);

  Seobeo_Log(SEOBEO_DEBUG, "Allocating version_copy\n");
  char *version_copy = Dowa_Arena_Allocate(p_arena, strlen(version) + 1);
  if (!version_copy) { Seobeo_Log(SEOBEO_ERROR, "Failed to allocate version_copy\n"); return -1; }
  strcpy(version_copy, version);

  Seobeo_Log(SEOBEO_DEBUG, "Pushing HTTP_Method and Version to map\n");
  Dowa_HashMap_Push_Arena(*pp_map, "HTTP_Method", method_copy, p_arena);
  Dowa_HashMap_Push_Arena(*pp_map, "Version", version_copy, p_arena);
  if (p_handle->host)
  {
    char *remote_addr = Dowa_Arena_Allocate(p_arena, strlen(p_handle->host) + 1);
    if (!remote_addr)
      return -1;
    strcpy(remote_addr, p_handle->host);
    Dowa_HashMap_Push_Arena(*pp_map, "Remote-Addr", remote_addr, p_arena);
  }
  Seobeo_Log(SEOBEO_DEBUG, "Map now has %zu entries\n", Dowa_Array_Length(*pp_map));

  char *raw_path = path;  
  char *query_start = strchr(raw_path, '?');
  char *query_str   = NULL;
  
  if (query_start)
  {
    *query_start  = '\0';          // now raw_path ends before '?'
    query_str     = query_start + 1;
  }
  
  char *path_copy = Dowa_Arena_Allocate(p_arena, strlen(raw_path) + 1);
  if (!path_copy) return -1;
  strcpy(path_copy, raw_path);
  Dowa_HashMap_Push_Arena(*pp_map, "Path", path_copy, p_arena);

  // Store full query string for proxying
  if (query_str && *query_str)
  {
    char *qs_copy = Dowa_Arena_Allocate(p_arena, strlen(query_str) + 1);
    if (qs_copy)
    {
      strcpy(qs_copy, query_str);
      Dowa_HashMap_Push_Arena(*pp_map, "QueryString", qs_copy, p_arena);
    }
  }

  // GET Params
  if (query_str && *query_str)
  {
    char *cur = query_str;
    while (cur && *cur)
    {
      char *next_amp = strchr(cur, '&');
      char *pair_end = next_amp ? next_amp : cur + strlen(cur);

      char *eq = memchr(cur, '=', pair_end - cur);
      if (eq)
      {
        size_t key_len = eq - cur;
        size_t val_len = pair_end - (eq + 1);

        char key_buf[256];
        // Adding query_ in front to separate GET params
        snprintf(key_buf, sizeof(key_buf), "query_%.*s", (int)key_len, cur);

        char *val_copy = Dowa_Arena_Allocate(p_arena, val_len + 1);
        if (!val_copy) return -1;
        memcpy(val_copy, eq + 1, val_len);
        val_copy[val_len] = '\0';

        Dowa_HashMap_Push_Arena(*pp_map, key_buf, val_copy, p_arena);
      }

      cur = next_amp ? next_amp + 1 : NULL;
    }
  }

  // int qp = Dowa_HashMap_Get_Position(map, "QueryParams");
  // Dowa_HashEntry *p_qp_entry = map->entries[qp];
  // printf("query param key: %s\n", p_qp_entry->key);
  // printf("query param value: %s\n",(char *)Dowa_HashMap_Get(p_qp_entry->buffer, "hello"));

  // Parse headers 
  char *line = buf + strlen(method) + 1 + strlen(path) + 1 + strlen(version) + 2;
  while (line < hdr_end)
  {
    char *next = strstr(line, "\r\n");
    if (!next) break;

    char *colon = memchr(line, ':', next - line);
    if (colon)
    {
      size_t key_len   = colon - line;
      size_t value_len = next - colon - 1;

      char *val_start  = colon + 1;
      if (*val_start == ' ')
      {
        val_start++;
        value_len--;
      }

      char *key = Dowa_Arena_Allocate(p_arena, key_len + 1);
      if (!key) return -1;
      memcpy(key, line, key_len);
      key[key_len] = '\0';

      if (strcasecmp(key, "remote-addr") == 0)
      {
        line = next + 2;
        continue;
      }

      char *val = Dowa_Arena_Allocate(p_arena, value_len + 1);
      if (!val) return -1;
      memcpy(val, val_start, value_len);
      val[value_len] = '\0';

      Dowa_HashMap_Push_Arena(
          *pp_map,
          canonical_request_header(key),
          val,
          p_arena);
    }

    line = next + 2;
  }

  Seobeo_Handle_Consume(p_handle, (uint32)hdr_len);

  // Reading Body
  void *p_cl_kv = Dowa_HashMap_Get_Ptr(*pp_map, "Content-Length");
  if (p_cl_kv)
  {
    const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value;
    size_t body_len = atoi(content_length_str);

    Seobeo_Log(SEOBEO_DEBUG, "Content-Length=%zu, reading body in chunks...\n", body_len);

    char *body = Dowa_Arena_Allocate(p_arena, body_len + 1);
    if (!body)
    {
      Seobeo_Log(SEOBEO_ERROR, "Failed to allocate %zu bytes for body\n", body_len);
      return -1;
    }

    size_t total_read = 0;

    while (total_read < body_len)
    {
      size_t available = p_handle->read_buffer_len;
      size_t to_copy = (body_len - total_read) < available ? (body_len - total_read) : available;

      if (to_copy > 0)
      {
        memcpy(body + total_read, p_handle->read_buffer, to_copy);
        total_read += to_copy;
        Seobeo_Handle_Consume(p_handle, (uint32)to_copy);

        Seobeo_Log(SEOBEO_DEBUG, "Copied %zu bytes, total %zu/%zu\n", to_copy, total_read, body_len);
      }

      if (total_read < body_len)
      {
        int r = Seobeo_Handle_Read(p_handle);
        if (r < 0)
        {
          Seobeo_Log(SEOBEO_ERROR, "Read failed with %d\n", r);
          return -1;
        }
        if (r == 0)
        {
          // No data available yet, continue waiting
          // printf("DEBUG: Waiting for more data... (have %zu/%zu bytes)\n", total_read, body_len);
          continue;
        }
      }
    }

    body[body_len] = '\0';
    Seobeo_Log(SEOBEO_DEBUG, "Body fully received (%zu bytes)\n", body_len);

    Dowa_HashMap_Push_Arena(*pp_map, "Body", body, p_arena);
  }

  return 0; 
}

void SigchildHandler(int s)
{
  (void)s; 

  // waitpid() might overwrite errno, so we save and restore it:
  int saved_errno = errno;

  while(waitpid(-1, NULL, WNOHANG) > 0);

  errno = saved_errno;
}

int Seobeo_Web_Server_Start(
    const char       *folder_path,
    const char       *port,
    Seobeo_ServerMode mode,
    int                thread_count)
{
  return Seobeo_Web_Server_Start_On(
      NULL,
      folder_path,
      port,
      mode,
      thread_count);
}

int Seobeo_Web_Server_Start_On(
    const char       *host,
    const char       *folder_path,
    const char       *port,
    Seobeo_ServerMode mode,
    int                thread_count)
{
  g_server_stopping = 0;
  if (folder_path)
    strncpy(g_folder_path, folder_path, sizeof(g_folder_path) - 1);

  Seobeo_Cache_Entry *p_html_cache = NULL;

  Seobeo_Handle *p_server_handle =
    Seobeo_Stream_Handle_Server_Create(host, port);
  if (!p_server_handle || p_server_handle->socket < 0)
  {
    if (p_server_handle)
      Seobeo_Handle_Destroy(p_server_handle);
    return 1;
  }

  Seobeo_Log(SEOBEO_INFO, "Listening on port %s\n", port);

  if (mode == SEOBEO_MODE_FORK)
  {
    Seobeo_Log(SEOBEO_INFO, "Server mode: FORK\n");
    struct sigaction sa;
    sa.sa_handler = SigchildHandler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGCHLD, &sa, NULL);

    while (!Seobeo_Web_Server_Is_Stopping())
    {
      Seobeo_Handle *p_cli_handle =
        Seobeo_Stream_Handle_Server_Accept(p_server_handle);
      if (!p_cli_handle) continue;

      if (fork() == 0)
      {
        Seobeo_Web_ClientHandle_Request(p_cli_handle, p_html_cache, FALSE);
        _exit(0);
      }
    }
  }

  if (mode == SEOBEO_MODE_EDGE)
  {
    Seobeo_Log(SEOBEO_INFO, "Server mode: EDGE\n");
    Seobeo_Web_Edge(p_server_handle, thread_count, p_html_cache);
  }

  Seobeo_Handle_Destroy(p_server_handle);
  return 0;
}

/* Router logic */
struct Seobeo_Route_Struct {
  char *method; // "GET", "POST", "PUT", "DELETE"
  char *path_pattern; // "/v1/users/:id/posts/:post_id"
  Seobeo_Route_Handler handler;
  Seobeo_Stream_Handler stream_handler; // For streaming responses
  Seobeo_SSE_Handler sse_handler;

  // Pre-parsed path segments for efficient matching
  char **path_segments; // ["v1", "users", ":id", "posts", ":post_id"]
  boolean *is_param; // [false, false, true, false, true]
  size_t segment_count;
};

static Seobeo_Route *g_routes = NULL;

void Seobeo_Router_Init()
{
  Dowa_Array_Reserve(g_routes, 20);
}

void Seobeo_Router_Register(const char *method, const char *path_pattern, Seobeo_Route_Handler handler)
{
  Seobeo_Route route = {0};

  route.method = strdup(method);
  route.path_pattern = strdup(path_pattern);
  route.handler = handler;
  route.stream_handler = NULL;
  route.sse_handler = NULL;
  route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL);
  route.segment_count = Dowa_Array_Length(route.path_segments);
  route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count);

  for (size_t i = 0; i < route.segment_count; i++)
    route.is_param[i] = (route.path_segments[i][0] == ':');

  Dowa_Array_Push(g_routes, route);
}

void Seobeo_Router_Register_Stream(const char *method, const char *path_pattern, Seobeo_Stream_Handler handler)
{
  Seobeo_Route route = {0};

  route.method = strdup(method);
  route.path_pattern = strdup(path_pattern);
  route.handler = NULL;
  route.stream_handler = handler;
  route.sse_handler = NULL;
  route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL);
  route.segment_count = Dowa_Array_Length(route.path_segments);
  route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count);

  for (size_t i = 0; i < route.segment_count; i++)
    route.is_param[i] = (route.path_segments[i][0] == ':');

  Dowa_Array_Push(g_routes, route);
}

void Seobeo_Router_Register_SSE_Method(
    const char *method,
    const char *path_pattern,
    Seobeo_SSE_Handler handler)
{
  Seobeo_Route route = {0};

  route.method = strdup(method);
  route.path_pattern = strdup(path_pattern);
  route.handler = NULL;
  route.stream_handler = NULL;
  route.sse_handler = handler;
  route.path_segments = Dowa_String_Split(
      path_pattern,
      "/",
      strlen(path_pattern),
      1,
      NULL);
  route.segment_count = Dowa_Array_Length(route.path_segments);
  route.is_param = (boolean*)malloc(
      sizeof(boolean) * route.segment_count);

  for (size_t i = 0; i < route.segment_count; i++)
    route.is_param[i] = (route.path_segments[i][0] == ':');

  Dowa_Array_Push(g_routes, route);
}

void Seobeo_Router_Register_SSE(
    const char *path_pattern,
    Seobeo_SSE_Handler handler)
{
  Seobeo_Router_Register_SSE_Method("GET", path_pattern, handler);
}

static boolean match_route_and_extract(
  Seobeo_Route *route,
  const char *request_path,
  Seobeo_Request_Entry **pp_request_map,
  Dowa_Arena *p_arena)
{
  Dowa_Arena *p_temp_arena = Dowa_Arena_Create(1024);
  char **request_segments = Dowa_String_Split(request_path, "/", strlen(request_path), 1, p_temp_arena);
  size_t request_segment_count = Dowa_Array_Length(request_segments);

  // Check segment count matches
  if (request_segment_count != route->segment_count)
  {
    Dowa_Arena_Free(p_temp_arena);
    return FALSE;
  }

  for (size_t i = 0; i < route->segment_count; i++)
  {
    if (!route->is_param[i])
    {
      if (strcmp(route->path_segments[i], request_segments[i]) != 0)
      {
        Dowa_Arena_Free(p_temp_arena);
        return FALSE;
      }
    }
  }

  for (size_t i = 0; i < route->segment_count; i++)
  {
    if (!route->is_param[i])
      continue;
    char *key = Dowa_String_Copy_Arena(route->path_segments[i], p_arena);
    char *value = Dowa_String_Copy_Arena(request_segments[i], p_arena);
    Dowa_HashMap_Push_Arena(*pp_request_map, key, value, p_arena);
  }

  Dowa_Arena_Free(p_temp_arena);
  return TRUE;
}

Seobeo_Route_Handler Seobeo_Router_Find_Handler(const char *method,
                         const char *path,
                         Seobeo_Request_Entry **pp_request_map,
                         Dowa_Arena *p_arena) {
  if (g_routes == NULL)
  {
    return NULL;
  }

  size_t route_count = Dowa_Array_Length(g_routes);
  for (size_t i = 0; i < route_count; i++)
  {
    Seobeo_Route *route = &g_routes[i];
    if (strcmp(route->method, method) != 0)
    {
      continue;
    }

    if (match_route_and_extract(route, path, pp_request_map, p_arena))
    {
      return route->handler;
    }
  }

  return NULL;
}

Seobeo_Stream_Handler Seobeo_Router_Find_Stream_Handler(
  const char *method,
  const char *path,
  Seobeo_Request_Entry **pp_request_map,
  Dowa_Arena *p_arena)
{
  if (g_routes == NULL || method == NULL || path == NULL)
    return NULL;

  size_t route_count = Dowa_Array_Length(g_routes);
  for (size_t i = 0; i < route_count; i++)
  {
    Seobeo_Route *route = &g_routes[i];
    if (strcmp(route->method, method) != 0)
      continue;

    if (route->stream_handler && match_route_and_extract(route, path, pp_request_map, p_arena))
      return route->stream_handler;
  }

  return NULL;
}

Seobeo_SSE_Handler Seobeo_Router_Find_SSE_Handler(
    const char *method,
    const char *path,
    Seobeo_Request_Entry **pp_request_map,
    Dowa_Arena *p_arena)
{
  if (g_routes == NULL || method == NULL || path == NULL)
    return NULL;

  size_t route_count = Dowa_Array_Length(g_routes);
  for (size_t i = 0; i < route_count; i++)
  {
    Seobeo_Route *route = &g_routes[i];
    if (strcmp(route->method, method) != 0)
      continue;
    if (route->sse_handler &&
        match_route_and_extract(
            route,
            path,
            pp_request_map,
            p_arena))
    {
      return route->sse_handler;
    }
  }
  return NULL;
}

void Seobeo_Router_Send_Response(
    Seobeo_Handle *p_handle,
    Seobeo_Request_Entry *p_response_map,
    Dowa_Arena *p_arena)
{
  Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE);
}

static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value)
{
  return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL;
}

void Seobeo_Router_Send_Response_KeepAlive(
    Seobeo_Handle *p_handle,
    Seobeo_Request_Entry *p_response_map,
    Dowa_Arena *p_arena,
    boolean keep_alive)
{
  if (p_response_map == NULL)
  {
    char *header = Dowa_Arena_Allocate(p_arena, 1024);
    Seobeo_Web_Header_Generate_KeepAlive(header, HTTP_INTERNAL_ERROR, "text/plain", 21, keep_alive);
    Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header));
    Seobeo_Handle_Queue(p_handle, (uint8_t*)"Internal Server Error", 21);
    Seobeo_Handle_Flush(p_handle);
    return;
  }

  int status = HTTP_OK;
  void *p_status_kv = Dowa_HashMap_Get_Ptr(p_response_map, "status");
  if (p_status_kv)
  {
    const char *status_str = ((Seobeo_Request_Entry*)p_status_kv)->value;
    status = atoi(status_str);
  }

  const char *body = "";
  void *p_body_kv = Dowa_HashMap_Get_Ptr(p_response_map, "body");
  if (p_body_kv)
    body = ((Seobeo_Request_Entry*)p_body_kv)->value;

  const char *content_type = "text/html";
  void *p_content_type_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-type");
  if (p_content_type_kv)
    content_type = ((Seobeo_Request_Entry*)p_content_type_kv)->value;

  // TODO: Update this to be integer
  size_t body_length;
  void *p_content_length_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-length");
  if (p_content_length_kv)
  {
    const char *content_length_str = ((Seobeo_Request_Entry*)p_content_length_kv)->value;
    body_length = atoi(content_length_str);
  }
  else
    body_length = strlen(body);

  size_t header_capacity = 1024;
  for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
  {
    const char *key = p_response_map[i].key;
    const char *value = p_response_map[i].value;
    if (
      strcasecmp(key, "status") == 0 ||
      strcasecmp(key, "body") == 0 ||
      strcasecmp(key, "content-type") == 0 ||
      strcasecmp(key, "content-length") == 0
    )
      continue;
    if (Seobeo_Response_Header_Value_Is_Safe(key) &&
        Seobeo_Response_Header_Value_Is_Safe(value))
      header_capacity += strlen(key) + strlen(value) + 4;
  }

  char *header = Dowa_Arena_Allocate(p_arena, header_capacity);
  Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive);
  size_t header_length = strlen(header);
  if (header_length < 2)
    return;
  header_length -= 2;

  for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
  {
    const char *key = p_response_map[i].key;
    const char *value = p_response_map[i].value;
    if (
      strcasecmp(key, "status") == 0 ||
      strcasecmp(key, "body") == 0 ||
      strcasecmp(key, "content-type") == 0 ||
      strcasecmp(key, "content-length") == 0
    )
      continue;

    if (!Seobeo_Response_Header_Value_Is_Safe(key) ||
        !Seobeo_Response_Header_Value_Is_Safe(value))
    {
      Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n");
      continue;
    }

    int written = snprintf(
        header + header_length,
        header_capacity - header_length,
        "%s: %s\r\n",
        key,
        value);
    if (written < 0 || (size_t)written >= header_capacity - header_length)
    {
      Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n");
      return;
    }
    header_length += (size_t)written;
  }
  memcpy(header + header_length, "\r\n", 3);

  Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header));
  Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
  Seobeo_Handle_Flush(p_handle);
}

void Seobeo_Router_Destroy()
{
  Seobeo_SSE_Server_Destroy();
  if (g_routes == NULL)
    return;

  size_t route_count = Dowa_Array_Length(g_routes);
  for (size_t i = 0; i < route_count; i++)
  {
    Seobeo_Route *route = &g_routes[i];
    if (route->method) Dowa_Free(route->method);
    if (route->path_pattern) Dowa_Free(route->path_pattern);
    if (route->path_segments) Dowa_Array_Free(route->path_segments);
    if (route->is_param) Dowa_Free(route->is_param);
  }
  Dowa_Array_Free(g_routes);
  g_routes = NULL;
}

// Written by AI. I don't know what it does.
void Seobeo_Url_Decode(char *dst, const char *src)
{
  char a, b;
  while (*src) {
    /* Check if we have a % followed by two valid hex characters */
    if (*src == '%' && src[1] && src[2]) {
      a = src[1];
      b = src[2];

      /* Manual isxdigit check and conversion for 'a' */
      int a_val = -1;
      if (a >= '0' && a <= '9')    a_val = a - '0';
      else if (a >= 'a' && a <= 'f') a_val = a - 'a' + 10;
      else if (a >= 'A' && a <= 'F') a_val = a - 'A' + 10;

      /* Manual isxdigit check and conversion for 'b' */
      int b_val = -1;
      if (b >= '0' && b <= '9')    b_val = b - '0';
      else if (b >= 'a' && b <= 'f') b_val = b - 'a' + 10;
      else if (b >= 'A' && b <= 'F') b_val = b - 'A' + 10;

      /* If both were valid hex, combine them */
      if (a_val != -1 && b_val != -1) {
        *dst++ = (char)((a_val << 4) | b_val);
        src += 3;
        continue;
      }
    }

    /* Handle '+' as space, otherwise copy character literally */
    if (*src == '+') {
      *dst++ = ' ';
    } else {
      *dst++ = *src;
    }
    src++;
  }
  *dst = '\0';
}