view hg-web/main.c @ 138:1f023b8bf9c3

[Test]
author June Park <parkjune1995@gmail.com>
date Fri, 09 Jan 2026 11:35:07 -0800
parents ffb764d2fcc5
children 6de849867459
line wrap: on
line source

#include "seobeo/seobeo.h"
#include "dowa/dowa.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

#define HG_SERVE_HOST "127.0.0.1"
#define HG_SERVE_PORT 4444

#define MAX_PATH 4096

// TODO: Move this to seobeo....
// Asked AI to create this lol, probably should learn to decode it myself..
static void url_decode(char *dst, const char *src)
{
  char a, b;
  while (*src) {
    if ((*src == '%') &&
        ((a = src[1]) && (b = src[2])) &&
        (isxdigit(a) && isxdigit(b))) {
      if (a >= 'a') a -= 'a'-'A';
      if (a >= 'A') a -= ('A' - 10);
      else a -= '0';
      if (b >= 'a') b -= 'a'-'A';
      if (b >= 'A') b -= ('A' - 10);
      else b -= '0';
      *dst++ = 16*a+b;
      src+=3;
    } else if (*src == '+') {
      *dst++ = ' ';
      src++;
    } else {
      *dst++ = *src++;
    }
  }
  *dst = '\0';
}

static char* sanitize_path(const char *input_path, Dowa_Arena *arena)
{
  if (!input_path || strlen(input_path) == 0)
  {
    char *empty = Dowa_Arena_Allocate(arena, 1);
    empty[0] = '\0';
    return empty;
  }

  size_t len = strlen(input_path);
  char *result = Dowa_Arena_Allocate(arena, len + 1);
  size_t j = 0;

  for (size_t i = 0; i < len; i++)
  {
    if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) {
      if (i + 1 < len && input_path[i+1] == '.') {
        // Skip ".."
        i++;
        continue;
      }
      // Skip "."
      continue;
    }
    result[j++] = input_path[i];
  }
  result[j] = '\0';

  // Remove leading/trailing slashes
  while (result[0] == '/')
    memmove(result, result + 1, strlen(result));
  while (j > 0 && result[j-1] == '/')
    result[--j] = '\0';

  return result;
}

// Helper to connect to hg serve
static int hg_proxy_connect(void)
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0)
    {
        Seobeo_Log(SEOBEO_DEBUG, "Failed to create socket\n");
        return -1;
    }

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(HG_SERVE_PORT);
    inet_pton(AF_INET, HG_SERVE_HOST, &server_addr.sin_addr);

    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0)
    {
        Seobeo_Log(SEOBEO_DEBUG, "Failed to connect to hg serve at %s:%d\n", HG_SERVE_HOST, HG_SERVE_PORT);
        close(sock);
        return -1;
    }

    return sock;
}

// Generic helper to proxy a request to hg serve and get the response body
// Returns allocated body on success, NULL on failure
// out_status and out_content_type are optional output parameters
// out_body_len returns the actual body length (for binary content)
static char* hg_proxy_request(
    const char *method,
    const char *path,
    const char *req_body,
    size_t body_len,
    char *out_status,       // should be at least 4 bytes
    char *out_content_type, // should be at least 256 bytes
    size_t *out_body_len,   // optional: returns actual body length
    Dowa_Arena *arena)
{
    int sock = hg_proxy_connect();
    if (sock < 0) return NULL;

    // Build HTTP request
    char http_request[MAX_PATH * 2];
    snprintf(http_request, sizeof(http_request),
        "%s %s HTTP/1.1\r\n"
        "Host: %s:%d\r\n"
        "Connection: close\r\n"
        "Accept: application/json, text/plain, */*\r\n"
        "Content-Length: %zu\r\n"
        "\r\n",
        method, path, HG_SERVE_HOST, HG_SERVE_PORT, body_len);

    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy request: %s %s\n", method, path);

    if (send(sock, http_request, strlen(http_request), 0) < 0)
    {
        close(sock);
        return NULL;
    }

    if (body_len > 0 && req_body)
    {
        send(sock, req_body, body_len, 0);
    }

    // Read response
    int buffer_size = 1024 * 1024 * 5; // 5MB
    char *response_buf = Dowa_Arena_Allocate(arena, buffer_size);
    size_t total_read = 0;
    ssize_t bytes_read;

    while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0)
    {
        total_read += bytes_read;
        if (total_read >= (size_t)(buffer_size - 1)) break;
    }
    response_buf[total_read] = '\0';
    close(sock);

    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read);

    // Parse response headers - use memmem to handle binary content
    char *headers_end = NULL;
    for (size_t i = 0; i + 3 < total_read; i++)
    {
        if (response_buf[i] == '\r' && response_buf[i+1] == '\n' &&
            response_buf[i+2] == '\r' && response_buf[i+3] == '\n')
        {
            headers_end = response_buf + i;
            break;
        }
    }
    if (!headers_end) return NULL;

    // Extract status
    if (out_status && strncmp(response_buf, "HTTP/", 5) == 0)
    {
        char *status_start = strchr(response_buf, ' ');
        if (status_start)
        {
            strncpy(out_status, status_start + 1, 3);
            out_status[3] = '\0';
        }
    }

    // Extract content-type
    if (out_content_type)
    {
        out_content_type[0] = '\0';
        char *ct_header = strcasestr(response_buf, "Content-Type:");
        if (ct_header && ct_header < headers_end)
        {
            ct_header += 13;
            while (*ct_header == ' ') ct_header++;
            char *ct_end = strpbrk(ct_header, "\r\n");
            if (ct_end)
            {
                size_t ct_len = ct_end - ct_header;
                if (ct_len < 256)
                {
                    strncpy(out_content_type, ct_header, ct_len);
                    out_content_type[ct_len] = '\0';
                }
            }
        }
    }

    // Return body (copy to fresh allocation for clean pointer)
    char *body = headers_end + 4;
    size_t body_size = total_read - (body - response_buf);

    if (out_body_len) *out_body_len = body_size;

    char *result = Dowa_Arena_Allocate(arena, body_size + 1);
    memcpy(result, body, body_size);
    result[body_size] = '\0';

    return result;
}

Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
{
  Seobeo_Request_Entry *resp = NULL;

  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";

  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
  url_decode(decoded_path, rel_path);

  char *safe_path = sanitize_path(decoded_path, arena);

  Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path);

  char hg_path[MAX_PATH];
  if (strlen(safe_path) > 0)
    snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path);
  else
    snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");

  char status[4] = "200";
  char content_type[256] = "";
  size_t body_len = 0;
  char *hg_response = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena);

  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%s body_len=%zu\n", status, body_len);

  if (!hg_response || status[0] != '2')
  {
    Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
    return resp;
  }
  char *json = hg_response;

  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
  Dowa_HashMap_Push_Arena(resp, "body", json, arena);

  return resp;
}

Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena)
{
  Seobeo_Request_Entry *resp = NULL;

  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
  url_decode(decoded_path, rel_path);
  char *safe_path = sanitize_path(decoded_path, arena);

  Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);

  if (strlen(safe_path) == 0)
  {
    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
    return resp;
  }

  // Build hg serve URL: /raw-file/tip/<path>
  char hg_path[MAX_PATH];
  snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path);

  char status[4] = "200";
  char content_type[256] = "";
  size_t body_len = 0;
  char *body = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena);

  Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%s body_len=%zu\n", status, body_len);

  if (!body)
  {
    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
    return resp;
  }

  if (status[0] != '2')
  {
    Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error response: %s\n", body);
    Dowa_HashMap_Push_Arena(resp, "status", status, arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
    // Return actual error from hg serve if available
    Dowa_HashMap_Push_Arena(resp, "body", body_len > 0 ? body : "File not found", arena);
    return resp;
  }

  // Use content-type from hg serve, or determine from extension
  const char *final_content_type = content_type;
  if (strlen(content_type) == 0 || strcmp(content_type, "application/octet-stream") == 0)
  {
    final_content_type = "text/plain";
    if (strstr(safe_path, ".md")) final_content_type = "text/markdown";
    else if (strstr(safe_path, ".html")) final_content_type = "text/html";
    else if (strstr(safe_path, ".css")) final_content_type = "text/css";
    else if (strstr(safe_path, ".js")) final_content_type = "application/javascript";
    else if (strstr(safe_path, ".json")) final_content_type = "application/json";
  }

  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
  Dowa_HashMap_Push_Arena(resp, "content-type", final_content_type, arena);
  Dowa_HashMap_Push_Arena(resp, "body", body, arena);

  return resp;
}

Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) {
  return ApiGetFile(req, arena);
}

Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena)
{
    Seobeo_Request_Entry *resp = NULL;

    // Get method
    void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
    const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";

    // Get query string
    void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
    const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";

    // Get request body for POST
    void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
    const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
    size_t body_len = strlen(req_body);

    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len);

    // Connect to hg serve
    int sock = hg_proxy_connect();
    if (sock < 0)
    {
        Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
        Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
        Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
        return resp;
    }

    // Build the HTTP request to forward to hg serve
    char http_request[MAX_PATH * 2];
    if (strlen(query_string) > 0)
    {
        snprintf(http_request, sizeof(http_request),
            "%s /?%s HTTP/1.1\r\n"
            "Host: %s:%d\r\n"
            "Connection: close\r\n"
            "Content-Length: %zu\r\n"
            "\r\n",
            method, query_string, HG_SERVE_HOST, HG_SERVE_PORT, body_len);
    }
    else
    {
        snprintf(http_request, sizeof(http_request),
            "%s / HTTP/1.1\r\n"
            "Host: %s:%d\r\n"
            "Connection: close\r\n"
            "Content-Length: %zu\r\n"
            "\r\n",
            method, HG_SERVE_HOST, HG_SERVE_PORT, body_len);
    }

    // Send HTTP request headers
    if (send(sock, http_request, strlen(http_request), 0) < 0)
    {
        Seobeo_Log(SEOBEO_DEBUG, "Failed to send request to hg serve\n");
        close(sock);
        Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
        Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
        Dowa_HashMap_Push_Arena(resp, "body", "Failed to send to hg serve", arena);
        return resp;
    }

    // Send body if present
    if (body_len > 0)
    {
        send(sock, req_body, body_len, 0);
    }

    // Read response from hg serve
    int buffer_size = 1024 * 1024 * 5; // 5MB
    char *response_buf = Dowa_Arena_Allocate(arena, buffer_size);
    size_t total_read = 0;
    ssize_t bytes_read;

    while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0)
    {
        total_read += bytes_read;
        if (total_read >= (size_t)(buffer_size - 1)) break;
    }
    response_buf[total_read] = '\0';
    close(sock);

    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read);

    // Parse HTTP response - find headers end
    char *headers_end = strstr(response_buf, "\r\n\r\n");
    if (!headers_end)
    {
        Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
        Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
        Dowa_HashMap_Push_Arena(resp, "body", "Invalid response from hg serve", arena);
        return resp;
    }

    // Extract status code from first line (e.g., "HTTP/1.1 200 OK")
    char status_code[4] = "200";
    if (strncmp(response_buf, "HTTP/", 5) == 0)
    {
        char *status_start = strchr(response_buf, ' ');
        if (status_start)
        {
            strncpy(status_code, status_start + 1, 3);
            status_code[3] = '\0';
        }
    }

    // Extract content-type from headers
    const char *content_type = "application/mercurial-0.1";
    char *ct_header = strcasestr(response_buf, "Content-Type:");
    if (ct_header && ct_header < headers_end)
    {
        ct_header += 13; // Skip "Content-Type:"
        while (*ct_header == ' ') ct_header++;
        char *ct_end = strpbrk(ct_header, "\r\n");
        if (ct_end)
        {
            size_t ct_len = ct_end - ct_header;
            char *ct_copy = Dowa_Arena_Allocate(arena, ct_len + 1);
            strncpy(ct_copy, ct_header, ct_len);
            ct_copy[ct_len] = '\0';
            content_type = ct_copy;
        }
    }

    // Body starts after \r\n\r\n
    char *body = headers_end + 4;

    Dowa_HashMap_Push_Arena(resp, "status", status_code, arena);
    Dowa_HashMap_Push_Arena(resp, "content-type", content_type, arena);
    Dowa_HashMap_Push_Arena(resp, "body", body, arena);

    return resp;
}

int main(void) {
  Seobeo_Router_Init();

  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", "/repo", ApiHgWireProtocol);
  Seobeo_Router_Register("POST", "/repo", ApiHgWireProtocol);

  printf("Starting on Port 6970...\n");
  printf("Repository: %s\n", REPO_ROOT);

  int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 4);

  Seobeo_Router_Destroy();

  return result;
}