view seobeo/s_web.c @ 261:b401627fc49e

Add JRPG mock flows and interactive previews Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <mrjunejune@users.noreply.github.com>
date Wed, 05 Aug 2026 20:38:32 -0700
parents 1f9877b637e9
children 04fee26ecce0
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, "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;

  void *p_real_ip_kv = Dowa_HashMap_Get_Ptr(p_req_map, "X-Real-IP");
  const char *real_ip = p_real_ip_kv ? ((Seobeo_Request_Entry*)p_real_ip_kv)->value : NULL;
  if (!real_ip)
    real_ip = p_cli_handle->host;

  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='%s', version='%s')\n",
         scan_result,
         scan_result >= 1 ? method : "N/A",
         scan_result >= 2 ? path : "N/A",
         scan_result >= 3 ? version : "N/A");

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

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