joeylib2/tests/gate/httpServe.c

359 lines
12 KiB
C

// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
// See LICENSE.md for license information.
// SPDX-License-Identifier: MIT
//
// httpServe.c - serve a folder over HTTP on 127.0.0.1, for the web gates'
// browser to load a built web program from (scripts/lib/webGate.sh).
//
// httpServe PORT DIR
//
// GET and HEAD of the files under DIR, each with its Content-Type (a .wasm
// is application/wasm, which the browser's streaming compile needs). Of a
// request's path the query and fragment are dropped, %XX is decoded, and
// "." and ".." segments are resolved within DIR, never above it. A folder
// is its index.html, and a folder's URL without its trailing slash is
// redirected to the one with it. No file
// is 404 (a folder without an index too: there are no listings), a request
// that cannot be read is 400, and a method other than GET or HEAD is 501.
//
// Each connection is served by a child process, so a browser's parallel
// requests are answered together; each answers one request and closes, as
// HTTP/1.0 does. Says where it serves once listening, and runs until
// stopped; exit 1 when the port cannot be had or DIR is no folder, 2 on bad
// arguments.
#define _DEFAULT_SOURCE
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <unistd.h>
#include "gateArg.h"
#include "gateNet.h"
#include "toolFile.h"
#define TOOL_NAME "httpServe"
#define ARG_COUNT 3
#define ARG_PORT 1
#define ARG_DIR 2
#define LISTEN_BACKLOG 64
// A request's head (its line and headers) holds at most this; the files
// go out this much at a time; a client quiet this long is dropped.
#define HEADER_MAX 16384u
#define SEND_CHUNK 65536u
#define CLIENT_SECONDS 30
#define PATH_MAX_BYTES 4096u
#define RESPONSE_HEAD_MAX 512u
#define HEX_BASE 16
#define HEX_LETTER 10
#define HEADER_END "\r\n\r\n"
#define HEADER_END_BARE "\n\n"
#define DEFAULT_TYPE "application/octet-stream"
#define ERROR_TYPE "text/plain"
#define INDEX_FILE "index.html"
#define STATUS_OK 200
#define STATUS_MOVED 301
#define STATUS_BAD_REQUEST 400
#define STATUS_NOT_FOUND 404
#define STATUS_NO_METHOD 501
// A file name extension and the type it is served as.
typedef struct TypeT {
const char *extension;
const char *type;
} TypeT;
static const char *contentType(const char *path);
static int hexValue(char c);
static bool resolve(const char *root, const char *target, char *path, size_t cap, bool *slash);
static void sendError(int conn, int status, const char *message, bool head);
static void sendFile(int conn, const char *path, bool head);
static void sendRedirect(int conn, const char *target);
static void serve(int conn, const char *root);
static const TypeT kTypes[] = {
{".html", "text/html"},
{".htm", "text/html"},
{".js", "text/javascript"},
{".mjs", "text/javascript"},
{".wasm", "application/wasm"},
{".data", "application/octet-stream"},
{".css", "text/css"},
{".png", "image/png"},
{".json", "application/json"},
{".txt", "text/plain"},
{".ico", "image/vnd.microsoft.icon"},
{".svg", "image/svg+xml"},
{".jpg", "image/jpeg"},
{".gif", "image/gif"},
{".wav", "audio/x-wav"}
};
// The type a file is served as, by its name's extension (any case).
static const char *contentType(const char *path) {
const char *dot;
size_t i;
dot = strrchr(path, '.');
if (dot != NULL && strchr(dot, '/') == NULL) {
for (i = 0; i < sizeof(kTypes) / sizeof(kTypes[0]); i++) {
if (strcasecmp(dot, kTypes[i].extension) == 0) {
return kTypes[i].type;
}
}
}
return DEFAULT_TYPE;
}
// A hex digit's value, or -1.
static int hexValue(char c) {
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + HEX_LETTER;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + HEX_LETTER;
}
return -1;
}
// The file a request target names, under root, in path; slash says the
// target's path ended with one. False when it can name none (too long, or
// a NUL in it).
static bool resolve(const char *root, const char *target, char *path, size_t cap, bool *slash) {
char decoded[HEADER_MAX];
char *segment;
char *cut;
size_t rootLen;
size_t len;
size_t i;
int high;
int low;
// The path alone, %XX decoded (a bad escape stays as it is).
len = 0u;
for (i = 0; target[i] != '\0' && target[i] != '?' && target[i] != '#' && len + 1u < sizeof(decoded); i++) {
high = target[i] == '%' ? hexValue(target[i + 1u]) : -1;
low = high >= 0 ? hexValue(target[i + 2u]) : -1;
if (low >= 0) {
decoded[len++] = (char)(high * HEX_BASE + low);
i += 2u;
} else {
decoded[len++] = target[i];
}
}
decoded[len] = '\0';
*slash = len > 0u && target[i - 1u] == '/';
if (strlen(decoded) != len) {
return false;
}
// Segment by segment onto root: none empty or ".", and ".." takes off
// the one before it, never root's own.
rootLen = strlen(root);
if (rootLen >= cap) {
return false;
}
memcpy(path, root, rootLen + 1u);
for (segment = strtok(decoded, "/"); segment != NULL; segment = strtok(NULL, "/")) {
if (strcmp(segment, ".") == 0) {
continue;
}
if (strcmp(segment, "..") == 0) {
cut = strrchr(path + rootLen, '/');
if (cut != NULL) {
*cut = '\0';
}
continue;
}
len = strlen(path);
if (len + 1u + strlen(segment) >= cap) {
return false;
}
path[len] = '/';
memcpy(path + len + 1u, segment, strlen(segment) + 1u);
}
return true;
}
// An error: its status line and the same as the body (the head alone, for
// HEAD).
static void sendError(int conn, int status, const char *message, bool head) {
char top[RESPONSE_HEAD_MAX];
char body[RESPONSE_HEAD_MAX];
int bodyLen;
int topLen;
bodyLen = snprintf(body, sizeof(body), "%d %s\n", status, message);
topLen = snprintf(top, sizeof(top), "HTTP/1.0 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n", status, message, ERROR_TYPE, bodyLen);
if (gateNetSendAll(conn, top, (size_t)topLen) && !head) {
gateNetSendAll(conn, body, (size_t)bodyLen);
}
}
// A file, whole (its head alone, for HEAD); 404 when it cannot be opened.
static void sendFile(int conn, const char *path, bool head) {
struct stat info;
uint8_t chunk[SEND_CHUNK];
char top[RESPONSE_HEAD_MAX];
ssize_t got;
int topLen;
int fd;
fd = open(path, O_RDONLY);
if (fd < 0 || fstat(fd, &info) != 0 || !S_ISREG(info.st_mode)) {
sendError(conn, STATUS_NOT_FOUND, "File not found", head);
if (fd >= 0) {
close(fd);
}
return;
}
topLen = snprintf(top, sizeof(top), "HTTP/1.0 %d OK\r\nContent-Type: %s\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", STATUS_OK, contentType(path), (long long)info.st_size);
if (gateNetSendAll(conn, top, (size_t)topLen) && !head) {
while ((got = read(fd, chunk, sizeof(chunk))) > 0 && gateNetSendAll(conn, chunk, (size_t)got)) {
// Chunk by chunk to the end, or until the client goes.
}
}
close(fd);
}
// A folder's URL without its trailing slash: to the one with it (the
// query and fragment kept).
static void sendRedirect(int conn, const char *target) {
char top[RESPONSE_HEAD_MAX + HEADER_MAX];
size_t pathLen;
int topLen;
pathLen = strcspn(target, "?#");
topLen = snprintf(top, sizeof(top), "HTTP/1.0 %d Moved Permanently\r\nLocation: %.*s/%s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", STATUS_MOVED, (int)pathLen, target, target + pathLen);
gateNetSendAll(conn, top, (size_t)topLen);
}
// One request on conn, answered.
static void serve(int conn, const char *root) {
struct stat info;
char request[HEADER_MAX + 1u];
char path[PATH_MAX_BYTES];
char index[PATH_MAX_BYTES];
const char *method;
const char *target;
const char *version;
size_t len;
ssize_t got;
bool head;
bool slash;
len = 0u;
for (;;) {
request[len] = '\0';
if (strstr(request, HEADER_END) != NULL || strstr(request, HEADER_END_BARE) != NULL) {
break;
}
if (len == HEADER_MAX) {
sendError(conn, STATUS_BAD_REQUEST, "Request too long", false);
return;
}
got = read(conn, request + len, HEADER_MAX - len);
if (got <= 0) {
return;
}
len += (size_t)got;
}
// The request line alone: its method, target, and version.
request[strcspn(request, "\r\n")] = '\0';
method = strtok(request, " ");
target = strtok(NULL, " ");
version = strtok(NULL, " ");
if (method == NULL || target == NULL || version == NULL || strtok(NULL, " ") != NULL) {
sendError(conn, STATUS_BAD_REQUEST, "Bad request syntax", false);
return;
}
head = strcmp(method, "HEAD") == 0;
if (!head && strcmp(method, "GET") != 0) {
sendError(conn, STATUS_NO_METHOD, "Unsupported method", false);
return;
}
if (!resolve(root, target, path, sizeof(path), &slash)) {
sendError(conn, STATUS_NOT_FOUND, "File not found", head);
return;
}
if (stat(path, &info) != 0 || !S_ISDIR(info.st_mode)) {
if (slash) {
sendError(conn, STATUS_NOT_FOUND, "File not found", head);
} else {
sendFile(conn, path, head);
}
return;
}
if (!slash) {
sendRedirect(conn, target);
return;
}
snprintf(index, sizeof(index), "%s/%s", path, INDEX_FILE);
sendFile(conn, index, head);
}
int main(int argc, char **argv) {
struct timeval timeout;
struct stat info;
const char *root;
uint16_t port;
pid_t child;
int listener;
int conn;
gateNetStart(TOOL_NAME);
if (argc != ARG_COUNT) {
fprintf(stderr, "usage: %s PORT DIR\n", TOOL_NAME);
return GATE_USAGE_EXIT;
}
port = gateArgPort(argv[ARG_PORT]);
root = argv[ARG_DIR];
if (stat(root, &info) != 0 || !S_ISDIR(info.st_mode)) {
toolFail("%s is no folder", root);
}
listener = gateNetListen(port, LISTEN_BACKLOG);
printf("%s: serving %s at http://%s:%u/\n", TOOL_NAME, root, GATE_NET_HOST, (unsigned)port);
// Children are reaped by the system; a client quiet too long is dropped.
signal(SIGCHLD, SIG_IGN);
timeout.tv_sec = CLIENT_SECONDS;
timeout.tv_usec = 0;
for (;;) {
conn = accept(listener, NULL, NULL);
if (conn < 0) {
continue;
}
setsockopt(conn, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
setsockopt(conn, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
child = fork();
if (child == 0) {
close(listener);
serve(conn, root);
close(conn);
_exit(0);
}
// Without a process to spare, serve it here.
if (child < 0) {
serve(conn, root);
}
close(conn);
}
}