diff --git a/do.ps1 b/do.ps1 index 7aa73b1..5112f1c 100755 --- a/do.ps1 +++ b/do.ps1 @@ -234,11 +234,21 @@ switch ($command) { } {($_ -eq "run-wasm") -or ($_ -eq "rw")} { &"./do.ps1" build-cross wasm32-emscripten; - - $cmd = "$ZIG run " + (Join-Path $DEPS_DIR "tinyhttp.zig"); - Write-Host $cmd; - - Invoke-Expression $cmd | Write-Host; + + $server_in = (Join-Path $DEPS_DIR "tinyhttp.c"); + $server_out = (Join-Path $OUT_DIR ($EXE_NAME + "-server" + (Get-Ext $HOST_OS))); + + if (-not (Test-Path $server_out)) { + $cmd = "$ZIG cc $server_in -o $server_out"; + if ($IsWindows) { + $cmd += " -lws2_32"; + } + + Write-Host $cmd; + Invoke-Expression $cmd | Write-Host; + } + + Invoke-Expression $server_out | Write-Host; } {($_ -eq "clean") -or ($_ -eq "c")} { Write-Host ":: Cleaning up build artifacts..."; diff --git a/thirdparty/tinyhttp.c b/thirdparty/tinyhttp.c new file mode 100644 index 0000000..c65f144 --- /dev/null +++ b/thirdparty/tinyhttp.c @@ -0,0 +1,283 @@ +/* + * Portable static HTTP file server — serves files from ./out/ on port 8080. + * + * Build: + * Linux/macOS: cc -O2 -o server server.c + * Windows MSVC: cl /O2 server.c ws2_32.lib + * Windows MinGW: gcc -O2 -o server.exe server.c -lws2_32 + */ + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 + #define WIN32_LEAN_AND_MEAN + #include + #include + #pragma comment(lib, "ws2_32.lib") + typedef SOCKET socket_t; + #define CLOSESOCKET closesocket + #define ISVALIDSOCK(s) ((s) != INVALID_SOCKET) + #define STAT_FUNC _stat + #define STAT_ST struct _stat +#else + #include + #include + #include + #include + #include + typedef int socket_t; + #define CLOSESOCKET close + #define ISVALIDSOCK(s) ((s) >= 0) + #define INVALID_SOCKET (-1) + #define STAT_FUNC stat + #define STAT_ST struct stat +#endif + +#define LISTEN_PORT 8080 +#define REQ_BUF_SIZE 4096 +#define FILE_BUF_SIZE 8192 +#define HDR_BUF_SIZE 512 +#define PATH_BUF_SIZE 512 + +/* ------------------------------------------------------------------ */ +/* MIME type lookup */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *ext; + const char *mime; +} mime_entry_t; + +static const mime_entry_t mime_map[] = { + { ".html", "text/html;charset=utf-8" }, + { ".css", "text/css;charset=utf-8" }, + { ".js", "application/javascript;charset=utf-8" }, + { ".json", "application/json" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".woff", "font/woff" }, + { ".woff2", "font/woff2" }, + { ".ttf", "font/ttf" }, + { ".wasm", "application/wasm" }, + { ".pdf", "application/pdf" }, + { ".txt", "text/plain;charset=utf-8" }, + { ".xml", "application/xml" }, + { NULL, NULL } +}; + +static const char *mime_from_path(const char *path) +{ + const char *dot = strrchr(path, '.'); + if (dot) { + for (const mime_entry_t *e = mime_map; e->ext; ++e) { + if (strcmp(dot, e->ext) == 0) + return e->mime; + } + } + return "application/octet-stream"; +} + +/* ------------------------------------------------------------------ */ +/* Send an HTTP error response */ +/* ------------------------------------------------------------------ */ + +static void send_error(socket_t sock, const char *status) +{ + char hdr[256]; + int len = (int)strlen(status); + int n = snprintf(hdr, sizeof(hdr), + "HTTP/1.1 %s\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n" + "\r\n", + status, len); + if (n > 0 && n < (int)sizeof(hdr)) { + send(sock, hdr, n, 0); + send(sock, status, len, 0); + } +} + +/* ------------------------------------------------------------------ */ +/* Handle one accepted connection */ +/* ------------------------------------------------------------------ */ + +static void handle_request(socket_t sock) +{ + char req_buf[REQ_BUF_SIZE]; + int n = (int)recv(sock, req_buf, sizeof(req_buf) - 1, 0); + if (n <= 0) return; + req_buf[n] = '\0'; + + /* ---- Parse "GET /path HTTP/1.x\r\n..." ---- */ + if (strncmp(req_buf, "GET ", 4) != 0) { + send_error(sock, "405 Method Not Allowed"); + return; + } + + char *path_start = req_buf + 4; + char *path_end = strchr(path_start, ' '); + if (!path_end) { + send_error(sock, "400 Bad Request"); + return; + } + *path_end = '\0'; + + const char *path = path_start; + + /* Default document */ + if (strcmp(path, "/") == 0) + path = "p2601.html"; + + /* Strip leading '/' */ + while (*path == '/') + ++path; + + /* Reject suspicious paths */ + if (*path == '\0' || + strstr(path, "..") != NULL || + strchr(path, '\\') != NULL) + { + send_error(sock, "403 Forbidden"); + return; + } + + /* ---- Build filesystem path: out/ ---- */ + char filepath[PATH_BUF_SIZE]; + int pn = snprintf(filepath, sizeof(filepath), "out/%s", path); + if (pn < 0 || pn >= (int)sizeof(filepath)) { + send_error(sock, "414 URI Too Long"); + return; + } + + /* ---- Open and stat the file ---- */ + FILE *fp = fopen(filepath, "rb"); + if (!fp) { + send_error(sock, "404 Not Found"); + return; + } + + STAT_ST st; + if (STAT_FUNC(filepath, &st) != 0) { + fclose(fp); + send_error(sock, "500 Internal Server Error"); + return; + } + long long file_size = (long long)st.st_size; + + const char *mime = mime_from_path(path); + + /* ---- Send response header ---- */ + char hdr_buf[HDR_BUF_SIZE]; + int hdr_len = snprintf(hdr_buf, sizeof(hdr_buf), + "HTTP/1.1 200 OK\r\n" + "Content-Type: %s\r\n" + "Content-Length: %lld\r\n" + "Connection: close\r\n" + "\r\n", + mime, file_size); + + if (hdr_len < 0 || hdr_len >= (int)sizeof(hdr_buf)) { + fclose(fp); + return; + } + + if (send(sock, hdr_buf, hdr_len, 0) <= 0) { + fclose(fp); + return; + } + + /* ---- Stream the file body in chunks ---- */ + char file_buf[FILE_BUF_SIZE]; + size_t bytes; + while ((bytes = fread(file_buf, 1, sizeof(file_buf), fp)) > 0) { + size_t sent = 0; + while (sent < bytes) { + int s = send(sock, file_buf + sent, (int)(bytes - sent), 0); + if (s <= 0) { fclose(fp); return; } + sent += (size_t)s; + } + } + + fclose(fp); +} + +/* ------------------------------------------------------------------ */ +/* main — bind, listen, accept loop */ +/* ------------------------------------------------------------------ */ + +int main(void) +{ +#ifdef _WIN32 + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + fprintf(stderr, "WSAStartup failed\n"); + return 1; + } +#endif + + socket_t srv = socket(AF_INET, SOCK_STREAM, 0); + if (!ISVALIDSOCK(srv)) { + perror("socket"); + return 1; + } + + /* SO_REUSEADDR */ + int opt = 1; +#ifdef _WIN32 + setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&opt, sizeof(opt)); +#else + setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); +#endif + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(LISTEN_PORT); + + if (bind(srv, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + perror("bind"); + CLOSESOCKET(srv); + return 1; + } + + if (listen(srv, SOMAXCONN) != 0) { + perror("listen"); + CLOSESOCKET(srv); + return 1; + } + + fprintf(stderr, ":: Listening at http://localhost:%d\n", LISTEN_PORT); + + for (;;) { + struct sockaddr_in client_addr; + socklen_t client_len = sizeof(client_addr); +#ifdef _WIN32 + socket_t client = accept(srv, (struct sockaddr *)&client_addr, (int *)&client_len); +#else + socket_t client = accept(srv, (struct sockaddr *)&client_addr, &client_len); +#endif + if (!ISVALIDSOCK(client)) + continue; + + handle_request(client); + CLOSESOCKET(client); + } + + /* unreachable, but tidy */ + CLOSESOCKET(srv); +#ifdef _WIN32 + WSACleanup(); +#endif + return 0; +} diff --git a/thirdparty/tinyhttp.zig b/thirdparty/tinyhttp.zig deleted file mode 100644 index c90bf66..0000000 --- a/thirdparty/tinyhttp.zig +++ /dev/null @@ -1,116 +0,0 @@ -const std = @import("std"); - -pub fn main() !void { - const addr = try std.net.Address.parseIp("0.0.0.0", 8080); - var server = try addr.listen(.{ .reuse_address = true }); - defer server.deinit(); - - std.debug.print(":: Listening at http://localhost:8080\n", .{}); - - while (true) { - const conn = server.accept() catch continue; - defer conn.stream.close(); - handle_request(conn.stream) catch continue; - } -} - -fn handle_request(stream: std.net.Stream) !void { - var req_buf: [4096]u8 = undefined; - const n = try stream.read(&req_buf); - if (n == 0) return error.Empty; - - const request = req_buf[0..n]; - - // Parse "GET /path HTTP/1.x\r\n..." - const path = blk: { - if (!std.mem.startsWith(u8, request, "GET ")) return send_error(stream, "405 Method Not Allowed"); - const rest = request[4..]; - const end = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.BadRequest; - break :blk rest[0..end]; - }; - - const raw = if (std.mem.eql(u8, path, "/")) "/p2601.html" else path; - - // Strip leading '/' and reject anything suspicious - const rel = std.mem.trimLeft(u8, raw, "/"); - if (rel.len == 0 or - std.mem.indexOf(u8, rel, "..") != null or - std.mem.indexOfScalar(u8, rel, '\\') != null or - rel[0] == '/') - { - return send_error(stream, "403 Forbidden"); - } - - // Open from ./out/ - const dir = std.fs.cwd().openDir("out", .{}) catch return send_error(stream, "500 Internal Server Error"); - const file = dir.openFile(rel, .{}) catch return send_error(stream, "404 Not Found"); - defer file.close(); - - const stat = try file.stat(); - const size = stat.size; - const mime = mime_from_path(rel); - - // Send header - var hdr_buf: [512]u8 = undefined; - const header = std.fmt.bufPrint(&hdr_buf, - "HTTP/1.1 200 OK\r\n" ++ - "Content-Type: {s}\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: close\r\n" ++ - "\r\n", - .{ mime, size }, - ) catch return error.HeaderTooLarge; - - stream.writeAll(header) catch return; - - // Stream the file in chunks - var buf: [8192]u8 = undefined; - while (true) { - const bytes = file.read(&buf) catch return; - if (bytes == 0) break; - stream.writeAll(buf[0..bytes]) catch return; - } -} - -fn send_error(stream: std.net.Stream, comptime status: []const u8) error{SendFailed} { - const body = status; - var hdr_buf: [256]u8 = undefined; - const header = std.fmt.bufPrint(&hdr_buf, - "HTTP/1.1 " ++ status ++ "\r\n" ++ - "Content-Type: text/plain\r\n" ++ - "Content-Length: {d}\r\n" ++ - "Connection: close\r\n" ++ - "\r\n", - .{body.len}, - ) catch return error.SendFailed; - stream.writeAll(header) catch {}; - stream.writeAll(body) catch {}; - return error.SendFailed; -} - -fn mime_from_path(path: []const u8) []const u8 { - const ext = std.fs.path.extension(path); - const map = .{ - .{ ".html", "text/html;charset=utf-8" }, - .{ ".css", "text/css;charset=utf-8" }, - .{ ".js", "application/javascript;charset=utf-8" }, - .{ ".json", "application/json" }, - .{ ".png", "image/png" }, - .{ ".jpg", "image/jpeg" }, - .{ ".jpeg", "image/jpeg" }, - .{ ".gif", "image/gif" }, - .{ ".svg", "image/svg+xml" }, - .{ ".ico", "image/x-icon" }, - .{ ".woff", "font/woff" }, - .{ ".woff2", "font/woff2" }, - .{ ".ttf", "font/ttf" }, - .{ ".wasm", "application/wasm" }, - .{ ".pdf", "application/pdf" }, - .{ ".txt", "text/plain;charset=utf-8" }, - .{ ".xml", "application/xml" }, - }; - inline for (map) |entry| { - if (std.mem.eql(u8, ext, entry[0])) return entry[1]; - } - return "application/octet-stream"; -}