Mercurial
changeset 244:b8aa08503378
[tools] Add sandboxed online LaTeX editor
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 16:56:25 -0700 |
| parents | 823f2a8b16c8 |
| children | 3843bb6253ac |
| files | mrjunejune/BUILD mrjunejune/README.md mrjunejune/latex_renderer.c mrjunejune/latex_renderer.h mrjunejune/main.c mrjunejune/src/tools/index.html mrjunejune/src/tools/latex_editor/index.css mrjunejune/src/tools/latex_editor/index.html mrjunejune/src/tools/latex_editor/index.js mrjunejune/test/BUILD mrjunejune/test/auto_generated_test.c mrjunejune/test/integration_test.c mrjunejune/test/latex_editor_test.js mrjunejune/test/latex_renderer_test.c mrjunejune/test/snapshots/tools.snapshot mrjunejune/test/snapshots/tools_latex_editor.snapshot seobeo/os/s_linux_edge.c seobeo/s_network.c seobeo/s_web.c seobeo/seobeo.h |
| diffstat | 20 files changed, 2133 insertions(+), 30 deletions(-) [+] |
line wrap: on
line diff
--- a/mrjunejune/BUILD Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/BUILD Mon Aug 03 16:56:25 2026 -0700 @@ -171,6 +171,14 @@ visibility = ["//mrjunejune/test:__pkg__"], ) +cc_library( + name = "latex_renderer", + srcs = ["latex_renderer.c"], + hdrs = ["latex_renderer.h"], + copts = ["-D_GNU_SOURCE"], + visibility = ["//mrjunejune/test:__pkg__"], +) + # Server binary cc_binary( name = "mrjunejune_server", @@ -180,6 +188,7 @@ "//markdown_converter:markdown_to_html_c", "//s3:s3", "//deita:deita", + ":latex_renderer", ], copts = ["-D_GNU_SOURCE"], linkopts = ["-lpthread"], @@ -200,6 +209,7 @@ "//markdown_converter:markdown_to_html_c", "//s3:s3", "//deita:deita", + ":latex_renderer", ], copts = ["-D_GNU_SOURCE"], linkopts = ["-lpthread"],
--- a/mrjunejune/README.md Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/README.md Mon Aug 03 16:56:25 2026 -0700 @@ -39,6 +39,25 @@ bazel test //mrjunejune/test:theme_and_webp_test ``` +## LaTeX editor + +`/tools/latex_editor` sends source to the C server, which compiles it with the +host's `/usr/bin/pdflatex` and returns the PDF directly to the browser. Install +TeX Live on production hosts before deploying: + +```bash +sudo apt-get install texlive-latex-base texlive-latex-recommended +``` + +The compiler child uses restricted TeX file access, Linux Landlock, a seccomp +network filter, and CPU, memory, file-size, process, and wall-clock limits. +Compilation fails closed when the Linux sandbox cannot be applied. + +```bash +bazel test //mrjunejune/test:latex_renderer_test +bazel test //mrjunejune/test:latex_editor_test +``` + ## TODO - Add caching layer
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/latex_renderer.c Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,718 @@ +#include "mrjunejune/latex_renderer.h" + +#ifdef __linux__ + +#include <dirent.h> +#include <errno.h> +#include <fcntl.h> +#include <linux/audit.h> +#include <linux/filter.h> +#include <linux/landlock.h> +#include <linux/seccomp.h> +#include <limits.h> +#include <poll.h> +#include <signal.h> +#include <stdbool.h> +#include <stddef.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/prctl.h> +#include <sys/resource.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <sys/types.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +#ifndef LANDLOCK_ACCESS_FS_REFER +#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13) +#endif +#ifndef LANDLOCK_ACCESS_FS_TRUNCATE +#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) +#endif + +#if defined(__x86_64__) +#define LATEX_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) +#define LATEX_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#else +#define LATEX_AUDIT_ARCH 0 +#endif + +#define LATEX_DIAGNOSTICS_MAX_BYTES (16 * 1024) +#define LATEX_CPU_TIMEOUT_SECONDS 5 +#define LATEX_WALL_TIMEOUT_SECONDS 8 + +static char *duplicate_message(const char *message) +{ + size_t length = strlen(message); + char *copy = malloc(length + 1); + if (copy) + memcpy(copy, message, length + 1); + return copy; +} + +static Latex_Render_Result result_with_message( + Latex_Render_Status status, + const char *message) +{ + return (Latex_Render_Result){ + .status = status, + .diagnostics = duplicate_message(message), + }; +} + +static bool write_all(int fd, const uint8_t *data, size_t size) +{ + while (size > 0) + { + ssize_t written = write(fd, data, size); + if (written < 0) + { + if (errno == EINTR) + continue; + return false; + } + data += written; + size -= (size_t)written; + } + return true; +} + +static int remove_tree(const char *path, unsigned depth) +{ + if (depth > 16) + return -1; + + struct stat status; + if (lstat(path, &status) != 0) + return errno == ENOENT ? 0 : -1; + if (!S_ISDIR(status.st_mode)) + return unlink(path); + + DIR *directory = opendir(path); + if (!directory) + return -1; + + int result = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) + { + if (strcmp(entry->d_name, ".") == 0 || + strcmp(entry->d_name, "..") == 0) + continue; + + char child[1024]; + int length = snprintf( + child, + sizeof(child), + "%s/%s", + path, + entry->d_name); + if (length < 0 || + (size_t)length >= sizeof(child) || + remove_tree(child, depth + 1) != 0) + { + result = -1; + break; + } + } + closedir(directory); + return result == 0 ? rmdir(path) : result; +} + +static bool set_limit(int resource, rlim_t value) +{ + struct rlimit limit = { + .rlim_cur = value, + .rlim_max = value, + }; + return setrlimit(resource, &limit) == 0; +} + +static bool add_landlock_path_rule( + int ruleset_fd, + const char *path, + uint64_t access, + bool required) +{ + int path_fd = open(path, O_PATH | O_CLOEXEC); + if (path_fd < 0) + return !required && errno == ENOENT; + + struct stat status; + bool ok = fstat(path_fd, &status) == 0; + if (ok && !S_ISDIR(status.st_mode)) + { + access &= LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_WRITE_FILE; + } + + struct landlock_path_beneath_attr rule = { + .allowed_access = access, + .parent_fd = path_fd, + }; + if (ok) + ok = syscall( + SYS_landlock_add_rule, + ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, + &rule, + 0) == 0; + close(path_fd); + return ok; +} + +static bool apply_filesystem_sandbox(const char *job_directory) +{ + int abi = syscall( + SYS_landlock_create_ruleset, + NULL, + 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 1) + return false; + + uint64_t read_access = + LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR; + uint64_t write_access = + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; + if (abi >= 2) + write_access |= LANDLOCK_ACCESS_FS_REFER; + if (abi >= 3) + write_access |= LANDLOCK_ACCESS_FS_TRUNCATE; + + struct landlock_ruleset_attr ruleset = { + .handled_access_fs = read_access | write_access, + }; + int ruleset_fd = syscall( + SYS_landlock_create_ruleset, + &ruleset, + sizeof(ruleset), + 0); + if (ruleset_fd < 0) + return false; + + const char *read_only_directories[] = { + "/usr", + "/lib", + "/lib64", + "/bin", + "/var/lib/texmf", + "/etc/texmf", + "/etc/fonts", + }; + bool ok = true; + for (size_t i = 0; + ok && i < sizeof(read_only_directories) / sizeof(read_only_directories[0]); + i++) + { + ok = add_landlock_path_rule( + ruleset_fd, + read_only_directories[i], + read_access, + false); + } + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/etc/ld.so.cache", + LANDLOCK_ACCESS_FS_READ_FILE, + false); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/dev/null", + LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE, + true); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + "/dev/urandom", + LANDLOCK_ACCESS_FS_READ_FILE, + false); + if (ok) + ok = add_landlock_path_rule( + ruleset_fd, + job_directory, + read_access | write_access, + true); + if (ok) + ok = prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == 0; + if (ok) + ok = syscall( + SYS_landlock_restrict_self, + ruleset_fd, + 0) == 0; + close(ruleset_fd); + return ok; +} + +static bool apply_network_sandbox(void) +{ + if (LATEX_AUDIT_ARCH == 0) + return false; + + struct sock_filter filters[64]; + size_t count = 0; +#define ADD_FILTER(value) filters[count++] = (struct sock_filter)value +#define DENY_SYSCALL(number) \ + do { \ + ADD_FILTER(BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, number, 0, 1)); \ + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM)); \ + } while (0) + + ADD_FILTER(BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, arch))); + ADD_FILTER(BPF_JUMP( + BPF_JMP | BPF_JEQ | BPF_K, + LATEX_AUDIT_ARCH, + 1, + 0)); + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS)); + ADD_FILTER(BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, nr))); + DENY_SYSCALL(__NR_socket); + DENY_SYSCALL(__NR_socketpair); + DENY_SYSCALL(__NR_connect); + DENY_SYSCALL(__NR_accept); + DENY_SYSCALL(__NR_accept4); + DENY_SYSCALL(__NR_bind); + DENY_SYSCALL(__NR_listen); + DENY_SYSCALL(__NR_sendto); + DENY_SYSCALL(__NR_recvfrom); + DENY_SYSCALL(__NR_sendmsg); + DENY_SYSCALL(__NR_recvmsg); + ADD_FILTER(BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)); + + struct sock_fprog program = { + .len = (unsigned short)count, + .filter = filters, + }; +#undef DENY_SYSCALL +#undef ADD_FILTER + return prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program) == 0; +} + +static void configure_child_environment(const char *job_directory) +{ + clearenv(); + setenv("HOME", job_directory, 1); + setenv("TMPDIR", job_directory, 1); + setenv("TEXMFVAR", job_directory, 1); + setenv("TEXMFCONFIG", job_directory, 1); + setenv("openin_any", "p", 1); + setenv("openout_any", "p", 1); + setenv("shell_escape", "0", 1); + setenv("SOURCE_DATE_EPOCH", "0", 1); + setenv("LANG", "C.UTF-8", 1); +} + +static void close_inherited_descriptors(void) +{ +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, UINT_MAX, 0) == 0) + return; +#endif + struct rlimit limit; + rlim_t maximum = 65536; + if (getrlimit(RLIMIT_NOFILE, &limit) == 0 && + limit.rlim_cur != RLIM_INFINITY && + limit.rlim_cur < maximum) + maximum = limit.rlim_cur; + for (int fd = 3; fd < (int)maximum; fd++) + close(fd); +} + +static void run_compiler_child( + const char *compiler, + const char *job_directory, + int output_fd) +{ + if (dup2(output_fd, STDOUT_FILENO) < 0 || + dup2(output_fd, STDERR_FILENO) < 0) + _exit(120); + if (output_fd != STDOUT_FILENO && output_fd != STDERR_FILENO) + close(output_fd); + close_inherited_descriptors(); + if (setpgid(0, 0) != 0 || + prctl(PR_SET_PDEATHSIG, SIGKILL) != 0 || + getppid() == 1) + _exit(119); + if (chdir(job_directory) != 0) + _exit(121); + + configure_child_environment(job_directory); + if (!set_limit(RLIMIT_CPU, LATEX_CPU_TIMEOUT_SECONDS) || + !set_limit(RLIMIT_AS, 768 * 1024 * 1024) || + !set_limit(RLIMIT_FSIZE, LATEX_PDF_MAX_BYTES) || + !set_limit(RLIMIT_NOFILE, 64) || + !set_limit(RLIMIT_NPROC, 1)) + _exit(122); + if (!apply_filesystem_sandbox(job_directory) || + !apply_network_sandbox()) + _exit(123); + + char *const arguments[] = { + (char *)compiler, + "-interaction=nonstopmode", + "-halt-on-error", + "-no-shell-escape", + "-file-line-error", + "-output-directory=.", + "document.tex", + NULL, + }; + execv(compiler, arguments); + _exit(errno == ENOENT ? 127 : 126); +} + +static double elapsed_seconds( + const struct timespec *start, + const struct timespec *now) +{ + return (double)(now->tv_sec - start->tv_sec) + + (double)(now->tv_nsec - start->tv_nsec) / 1000000000.0; +} + +static int wait_for_compiler( + pid_t child, + int output_fd, + char *diagnostics, + size_t diagnostics_size, + bool *timed_out) +{ + int flags = fcntl(output_fd, F_GETFL, 0); + if (flags >= 0) + fcntl(output_fd, F_SETFL, flags | O_NONBLOCK); + + size_t used = 0; + int child_status = 0; + bool child_done = false; + struct timespec start; + clock_gettime(CLOCK_MONOTONIC, &start); + + while (!child_done) + { + char buffer[2048]; + ssize_t count; + while ((count = read(output_fd, buffer, sizeof(buffer))) > 0) + { + size_t available = diagnostics_size - used - 1; + size_t copy_size = (size_t)count < available ? (size_t)count : available; + if (copy_size > 0) + { + memcpy(diagnostics + used, buffer, copy_size); + used += copy_size; + } + } + + pid_t waited = waitpid(child, &child_status, WNOHANG); + if (waited == child) + { + child_done = true; + break; + } + if (waited < 0 && errno != EINTR) + break; + + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + if (elapsed_seconds(&start, &now) >= LATEX_WALL_TIMEOUT_SECONDS) + { + *timed_out = true; + kill(-child, SIGKILL); + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) + { + } + child_done = true; + break; + } + + struct pollfd poll_fd = { + .fd = output_fd, + .events = POLLIN, + }; + poll(&poll_fd, 1, 50); + } + + char buffer[2048]; + ssize_t count; + while ((count = read(output_fd, buffer, sizeof(buffer))) > 0) + { + size_t available = diagnostics_size - used - 1; + size_t copy_size = (size_t)count < available ? (size_t)count : available; + if (copy_size > 0) + { + memcpy(diagnostics + used, buffer, copy_size); + used += copy_size; + } + } + diagnostics[used] = '\0'; + kill(-child, SIGKILL); + return child_status; +} + +static bool read_pdf( + const char *path, + uint8_t **data, + size_t *size) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) + return false; + + struct stat status; + bool ok = fstat(fd, &status) == 0 && + S_ISREG(status.st_mode) && + status.st_size > 4 && + status.st_size <= LATEX_PDF_MAX_BYTES; + uint8_t *content = NULL; + if (ok) + { + content = malloc((size_t)status.st_size); + ok = content != NULL; + } + size_t offset = 0; + while (ok && offset < (size_t)status.st_size) + { + ssize_t count = read( + fd, + content + offset, + (size_t)status.st_size - offset); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + { + ok = false; + break; + } + offset += (size_t)count; + } + close(fd); + + if (!ok || + memcmp(content, "%PDF-", 5) != 0) + { + free(content); + return false; + } + *data = content; + *size = offset; + return true; +} + +Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size) +{ + if (!source || source_size == 0) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source is required."); + if (source_size > LATEX_SOURCE_MAX_BYTES) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source exceeds the 64 KiB limit."); + if (memchr(source, '\0', source_size)) + return result_with_message( + LATEX_RENDER_INVALID_INPUT, + "LaTeX source cannot contain NUL bytes."); + + const char *compiler = "/usr/bin/pdflatex"; + if (access(compiler, X_OK) != 0) + return result_with_message( + LATEX_RENDER_COMPILER_UNAVAILABLE, + "The server LaTeX compiler is unavailable."); + + char job_directory[] = "/tmp/mrjunejune-latex-XXXXXX"; + if (!mkdtemp(job_directory)) + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to create the LaTeX workspace."); + + char source_path[512]; + snprintf( + source_path, + sizeof(source_path), + "%s/document.tex", + job_directory); + int source_fd = open( + source_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (source_fd < 0 || !write_all(source_fd, source, source_size)) + { + if (source_fd >= 0) + close(source_fd); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to write the LaTeX source."); + } + close(source_fd); + + int output_pipe[2]; + if (pipe2(output_pipe, O_CLOEXEC) != 0) + { + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to capture compiler output."); + } + + pid_t child = fork(); + if (child < 0) + { + close(output_pipe[0]); + close(output_pipe[1]); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to start the LaTeX compiler."); + } + if (child == 0) + { + close(output_pipe[0]); + run_compiler_child(compiler, job_directory, output_pipe[1]); + } + setpgid(child, child); + + close(output_pipe[1]); + char *diagnostics = calloc(1, LATEX_DIAGNOSTICS_MAX_BYTES + 1); + if (!diagnostics) + { + kill(-child, SIGKILL); + waitpid(child, NULL, 0); + close(output_pipe[0]); + remove_tree(job_directory, 0); + return result_with_message( + LATEX_RENDER_INTERNAL_ERROR, + "Unable to allocate compiler diagnostics."); + } + + bool timed_out = false; + int child_status = wait_for_compiler( + child, + output_pipe[0], + diagnostics, + LATEX_DIAGNOSTICS_MAX_BYTES + 1, + &timed_out); + close(output_pipe[0]); + + Latex_Render_Result result = { + .status = LATEX_RENDER_INTERNAL_ERROR, + .diagnostics = diagnostics, + }; + if (timed_out) + { + result.status = LATEX_RENDER_TIMEOUT; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "Compilation exceeded the 8 second limit."); + } + else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 127) + { + result.status = LATEX_RENDER_COMPILER_UNAVAILABLE; + } + else if (WIFEXITED(child_status) && WEXITSTATUS(child_status) == 123) + { + result.status = LATEX_RENDER_SANDBOX_ERROR; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "The server could not apply the LaTeX security sandbox."); + } + else if (WIFSIGNALED(child_status) && + (WTERMSIG(child_status) == SIGXCPU || + WTERMSIG(child_status) == SIGKILL)) + { + result.status = LATEX_RENDER_TIMEOUT; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "Compilation exceeded its CPU or memory limit."); + } + else if (!WIFEXITED(child_status) || WEXITSTATUS(child_status) != 0) + { + result.status = LATEX_RENDER_COMPILE_ERROR; + if (!result.diagnostics[0]) + { + free(result.diagnostics); + result.diagnostics = duplicate_message("LaTeX compilation failed."); + } + } + else + { + char pdf_path[512]; + snprintf(pdf_path, sizeof(pdf_path), "%s/document.pdf", job_directory); + if (read_pdf(pdf_path, &result.pdf_data, &result.pdf_size)) + { + result.status = LATEX_RENDER_OK; + free(result.diagnostics); + result.diagnostics = NULL; + } + else + { + result.status = LATEX_RENDER_INTERNAL_ERROR; + free(result.diagnostics); + result.diagnostics = duplicate_message( + "The compiler did not produce a valid PDF."); + } + } + + remove_tree(job_directory, 0); + return result; +} + +void Latex_Render_Result_Destroy(Latex_Render_Result *result) +{ + if (!result) + return; + free(result->pdf_data); + free(result->diagnostics); + memset(result, 0, sizeof(*result)); +} + +#else + +#include <stdlib.h> +#include <string.h> + +Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size) +{ + (void)source; + (void)source_size; + const char *message = + "The LaTeX security sandbox is only available on Linux."; + Latex_Render_Result result = { + .status = LATEX_RENDER_SANDBOX_ERROR, + .diagnostics = malloc(strlen(message) + 1), + }; + if (result.diagnostics) + strcpy(result.diagnostics, message); + return result; +} + +void Latex_Render_Result_Destroy(Latex_Render_Result *result) +{ + if (!result) + return; + free(result->pdf_data); + free(result->diagnostics); + memset(result, 0, sizeof(*result)); +} + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/latex_renderer.h Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,30 @@ +#ifndef MRJUNEJUNE_LATEX_RENDERER_H +#define MRJUNEJUNE_LATEX_RENDERER_H + +#include <stddef.h> +#include <stdint.h> + +#define LATEX_SOURCE_MAX_BYTES (64 * 1024) +#define LATEX_PDF_MAX_BYTES (4 * 1024 * 1024) + +typedef enum { + LATEX_RENDER_OK = 0, + LATEX_RENDER_INVALID_INPUT, + LATEX_RENDER_COMPILE_ERROR, + LATEX_RENDER_TIMEOUT, + LATEX_RENDER_COMPILER_UNAVAILABLE, + LATEX_RENDER_SANDBOX_ERROR, + LATEX_RENDER_INTERNAL_ERROR, +} Latex_Render_Status; + +typedef struct { + Latex_Render_Status status; + uint8_t *pdf_data; + size_t pdf_size; + char *diagnostics; +} Latex_Render_Result; + +Latex_Render_Result Latex_Render(const uint8_t *source, size_t source_size); +void Latex_Render_Result_Destroy(Latex_Render_Result *result); + +#endif
--- a/mrjunejune/main.c Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/main.c Mon Aug 03 16:56:25 2026 -0700 @@ -2,9 +2,11 @@ #include "markdown_converter/markdown_to_html.h" #include "s3/s3_uploader.h" #include "deita/deita.h" +#include "mrjunejune/latex_renderer.h" #include <time.h> #include <sys/stat.h> #include <stdarg.h> +#include <stdatomic.h> #include <pthread.h> // UUID + /tmp/ + format (max 4) @@ -13,6 +15,7 @@ volatile sig_atomic_t stop_server = 0; static _Atomic uint32_t counter = 0; +static _Atomic boolean g_latex_rendering = FALSE; // Media Processing Context for background threads typedef struct { @@ -304,6 +307,106 @@ return resp; } +Seobeo_Request_Entry* GetLatexEditor(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + Seobeo_Render_Html_FilePath(final_body, "/tools/latex_editor/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +static Seobeo_Request_Entry *LatexErrorResponse( + Dowa_Arena *arena, + int status, + const char *message) +{ + Seobeo_Request_Entry *resp = NULL; + char *status_value = Dowa_Arena_Allocate(arena, 16); + snprintf(status_value, 16, "%d", status); + char *body = Dowa_Arena_Allocate(arena, strlen(message) + 1); + strcpy(body, message); + Dowa_HashMap_Push_Arena(resp, "status", status_value, arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", arena); + Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", arena); + Dowa_HashMap_Push_Arena(resp, "body", body, arena); + return resp; +} + +Seobeo_Request_Entry *RenderLatexPdf(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + void *length_kv = Dowa_HashMap_Get_Ptr(req, "Content-Length"); + if (!body_kv || !length_kv) + return LatexErrorResponse(arena, 400, "LaTeX source and Content-Length are required."); + + const char *length_value = ((Seobeo_Request_Entry *)length_kv)->value; + char *length_end = NULL; + errno = 0; + unsigned long long parsed_length = strtoull(length_value, &length_end, 10); + if (errno != 0 || + length_end == length_value || + *length_end != '\0' || + parsed_length == 0) + return LatexErrorResponse(arena, 400, "Content-Length is invalid."); + if (parsed_length > LATEX_SOURCE_MAX_BYTES) + return LatexErrorResponse(arena, 413, "LaTeX source exceeds the 64 KiB limit."); + + boolean expected = FALSE; + if (!atomic_compare_exchange_strong( + &g_latex_rendering, + &expected, + TRUE)) + return LatexErrorResponse(arena, 429, "The LaTeX compiler is busy. Try again shortly."); + + Latex_Render_Result render = Latex_Render( + (const uint8_t *)((Seobeo_Request_Entry *)body_kv)->value, + (size_t)parsed_length); + atomic_store(&g_latex_rendering, FALSE); + if (render.status != LATEX_RENDER_OK) + { + int status = 500; + if (render.status == LATEX_RENDER_INVALID_INPUT) + status = 400; + else if (render.status == LATEX_RENDER_COMPILE_ERROR) + status = 422; + else if (render.status == LATEX_RENDER_TIMEOUT) + status = 504; + else if (render.status == LATEX_RENDER_COMPILER_UNAVAILABLE) + status = 503; + + const char *diagnostics = render.diagnostics + ? render.diagnostics + : "LaTeX rendering failed."; + char *body = Dowa_Arena_Allocate(arena, strlen(diagnostics) + 1); + strcpy(body, diagnostics); + Latex_Render_Result_Destroy(&render); + return LatexErrorResponse(arena, status, body); + } + + char *pdf = Dowa_Arena_Allocate(arena, render.pdf_size + 1); + if (!pdf) + { + Latex_Render_Result_Destroy(&render); + return LatexErrorResponse(arena, 500, "The generated PDF is too large to return."); + } + memcpy(pdf, render.pdf_data, render.pdf_size); + pdf[render.pdf_size] = '\0'; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", render.pdf_size); + Latex_Render_Result_Destroy(&render); + + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/pdf", arena); + Dowa_HashMap_Push_Arena(resp, "content-length", content_length, arena); + Dowa_HashMap_Push_Arena(resp, "content-disposition", "inline; filename=\"document.pdf\"", arena); + Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", arena); + Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", arena); + Dowa_HashMap_Push_Arena(resp, "body", pdf, arena); + return resp; +} + // Background thread function for media processing void *Simple_WebpConverter_Background(void *arg) { @@ -746,6 +849,7 @@ CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html") CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter") CREATE_REDIRECT_HANDLER(HlsPlayer, "/tools/hls_player") +CREATE_REDIRECT_HANDLER(LatexEditor, "/tools/latex_editor") CREATE_REDIRECT_HANDLER(Talk, "/talk") CREATE_REDIRECT_HANDLER(Editor, "/editor") @@ -1254,28 +1358,26 @@ // Insert into database const char *insert_query = "INSERT INTO media_uploads (access_token, original_filename, content_type, s3_key_original, s3_key_processed, status) " - "VALUES (?, ?, ?, ?, ?, 'pending')"; + "VALUES (?, ?, ?, ?, ?, 'pending') RETURNING id"; const char *params[] = { token, filename, content_type, s3_key_original, s3_key_processed }; - int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, insert_query, 5, params); - - if (result < 0) + Deita_Result_Set *id_result = Deita_Query_Execute_Prepared( + g_db_connection, + insert_query, + 5, + params, + arena); + if (!id_result || !Deita_Result_Set_Next(id_result)) { + if (id_result) Deita_Result_Set_Free(id_result); Dowa_HashMap_Push_Arena(resp, "status", "500", arena); Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to create media record\"}", arena); return resp; } - // Get the inserted media_id using last_insert_rowid() - const char *last_id_query = "SELECT last_insert_rowid()"; - Deita_Result_Set *id_result = Deita_Query_Execute(g_db_connection, last_id_query, arena); - int64 media_id = 0; - if (id_result && Deita_Result_Set_Next(id_result)) - { - media_id = Deita_Result_Set_Get_Integer(id_result, 0); - } - if (id_result) Deita_Result_Set_Free(id_result); + int64 media_id = Deita_Result_Set_Get_Integer(id_result, 0); + Deita_Result_Set_Free(id_result); // Generate presigned PUT URL S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key_original, content_type, g_s3_url_expires); @@ -1868,11 +1970,14 @@ Seobeo_Router_Register("GET", "/tools/file_converter/index.html", GetRedirectFileConverter); Seobeo_Router_Register("GET", "/tools/hls_player", GetHlsPlayer); Seobeo_Router_Register("GET", "/tools/hls_player/index.html", GetRedirectHlsPlayer); + Seobeo_Router_Register("GET", "/tools/latex_editor", GetLatexEditor); + Seobeo_Router_Register("GET", "/tools/latex_editor/index.html", GetRedirectLatexEditor); // -- File converter --/ Seobeo_Router_Register("POST", "/api/convert/image-to-webp", ConvertImageToWebP); Seobeo_Router_Register("POST", "/api/convert/video-to-mp4", ConvertVideoToMP4); Seobeo_Router_Register("GET", "/api/download/:filename", DownloadConvertedFile); + Seobeo_Router_Register("POST", "/api/latex/render", RenderLatexPdf); // -- S3 Upload --/ Seobeo_Router_Register("POST", "/api/s3/upload-url", GetS3UploadUrl); @@ -1909,5 +2014,5 @@ const char *server_port = getenv("MRJUNEJUNE_PORT"); if (!server_port || server_port[0] == '\0') server_port = "6969"; - Seobeo_Web_Server_Start("mrjunejune/src", server_port, SEOBEO_MODE_EDGE, 1); + Seobeo_Web_Server_Start("mrjunejune/src", server_port, SEOBEO_MODE_EDGE, 4); }
--- a/mrjunejune/src/tools/index.html Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/src/tools/index.html Mon Aug 03 16:56:25 2026 -0700 @@ -12,12 +12,9 @@ <li><a href="/tools/markdown_to_html">MarkDown to HTML</a></li> <li><a href="/tools/file_converter">Images to Webp / Video to Mp4</a></li> <li><a href="/tools/hls_player">Online HLS Player</a></li> + <li><a href="/tools/latex_editor">Online LaTeX Editor</a></li> <li><a href="/notes">Personal Notes</a></li> </ul> - <h3> TODOs </h3> - <ul class="nav-list"> - <li>- Simple online LaTex editor.</li> - </ul> </main> {{/parts/footer.html}} </body>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/latex_editor/index.css Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,195 @@ +.latex-page { + width: min(1500px, calc(100% - 2rem)); + max-width: none; + margin: 1.5rem auto 3rem; +} + +.latex-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.latex-heading h1, +.latex-heading p { + margin-bottom: 0.4rem; +} + +.latex-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.65rem; +} + +.latex-actions button, +.button-link { + min-height: 42px; + padding: 0.65rem 1rem; + border: 2px solid rgb(var(--black)); + border-radius: 8px; + background: var(--awesome); + color: rgb(var(--black)); + font: inherit; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +.latex-actions button.secondary { + background: transparent; +} + +.latex-actions button:disabled, +.button-link.disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.auto-compile { + display: inline-flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +.latex-status { + min-height: 1.5em; + margin: 0.5rem 0 1rem; + font-weight: 700; +} + +.latex-status[data-state="working"] { + color: var(--orange); +} + +.latex-status[data-state="ready"] { + color: var(--blue); +} + +.latex-status[data-state="error"] { + color: var(--red); +} + +.latex-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + min-height: 72vh; +} + +.latex-panel { + min-width: 0; + overflow: hidden; + border: 2px solid rgb(var(--black)); + border-radius: 12px; + background: var(--white); + box-shadow: 6px 6px 0 rgba(var(--black), 0.12); +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 48px; + padding: 0.55rem 0.8rem; + border-bottom: 2px solid rgb(var(--black)); + background: var(--gray-gradient); +} + +.panel-heading h2, +.panel-heading span { + margin: 0; + font-size: 0.95rem; +} + +#latexSource { + display: block; + width: 100%; + height: calc(72vh - 50px); + min-height: 540px; + resize: vertical; + box-sizing: border-box; + padding: 1rem; + border: 0; + outline: 0; + background: rgb(var(--black)); + color: var(--white); + font: 15px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + tab-size: 2; +} + +#pdfPreview { + display: block; + width: 100%; + height: calc(72vh - 50px); + min-height: 540px; + border: 0; + background: #525659; +} + +.latex-diagnostics { + width: 100%; + height: calc(72vh - 50px); + min-height: 540px; + box-sizing: border-box; + overflow: auto; + margin: 0; + padding: 1rem; + white-space: pre-wrap; + background: #1e1e1e; + color: #ffb4ab; + font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.latex-notes { + margin-top: 1.5rem; +} + +.latex-notes h2, +.latex-notes p { + margin-bottom: 0.4rem; +} + +@media (max-width: 900px) { + .latex-heading { + align-items: stretch; + flex-direction: column; + } + + .latex-actions { + justify-content: flex-start; + } + + .latex-workspace { + grid-template-columns: 1fr; + } + + #latexSource, + #pdfPreview, + .latex-diagnostics { + height: 65vh; + min-height: 480px; + } +} + +@media (max-width: 520px) { + .latex-page { + width: min(100% - 1rem, 1500px); + } + + .latex-actions { + align-items: stretch; + } + + .latex-actions button, + .button-link { + flex: 1 1 45%; + text-align: center; + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/latex_editor/index.html Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,83 @@ +<!doctype html> +<html lang="en"> + <head> + {{/parts/base_head.html}} + <title>LaTeX Editor - MrJuneJune</title> + <link rel="stylesheet" href="/tools/latex_editor/index.css" /> + <script src="/tools/latex_editor/index.js" defer></script> + </head> + <body> + {{/parts/header.html}} + <main class="latex-page"> + <div class="latex-heading"> + <div> + <h1>Online LaTeX Editor</h1> + <p>Write LaTeX here. My server compiles it inside a locked-down sandbox and sends the PDF back to this preview.</p> + </div> + <div class="latex-actions"> + <label class="auto-compile"> + <input id="autoCompile" type="checkbox" checked /> + Auto compile + </label> + <button id="resetButton" type="button" class="secondary">Reset</button> + <button id="compileButton" type="button">Compile PDF</button> + <a id="downloadButton" class="button-link disabled" aria-disabled="true">Download</a> + </div> + </div> + + <p id="latexStatus" class="latex-status" role="status" aria-live="polite"> + Preparing your first PDF... + </p> + + <div class="latex-workspace"> + <section class="latex-panel editor-panel" aria-labelledby="sourceHeading"> + <div class="panel-heading"> + <h2 id="sourceHeading">document.tex</h2> + <span id="sourceSize">0 / 65,536 bytes</span> + </div> + <textarea id="latexSource" spellcheck="false" autocomplete="off" aria-label="LaTeX source">\documentclass[11pt]{article} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath} +\usepackage{xcolor} + +\title{Hello from my server} +\author{MrJuneJune} +\date{\today} + +\begin{document} +\maketitle + +This PDF was compiled by the custom C server behind this website. + +\[ + e^{i\pi} + 1 = 0 +\] + +\textcolor{blue}{Edit this document and watch the preview update.} + +\end{document} +</textarea> + </section> + + <section class="latex-panel preview-panel" aria-labelledby="previewHeading"> + <div class="panel-heading"> + <h2 id="previewHeading">PDF preview</h2> + <span>Server rendered</span> + </div> + <iframe + id="pdfPreview" + title="Compiled LaTeX PDF preview" + src="about:blank" + ></iframe> + <pre id="latexDiagnostics" class="latex-diagnostics" hidden></pre> + </section> + </div> + + <section class="latex-notes"> + <h2>Limits</h2> + <p>Source is capped at 64 KiB and compilation at 8 seconds. Shell commands, network access, and reads outside the isolated TeX workspace are blocked.</p> + </section> + </main> + {{/parts/footer.html}} + </body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/tools/latex_editor/index.js Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,200 @@ +(function latexEditor() { + "use strict"; + + const SOURCE_LIMIT = 64 * 1024; + const STORAGE_KEY = "mrjunejune-latex-source"; + const AUTO_COMPILE_DELAY = 700; + + window.addEventListener("DOMContentLoaded", () => { + const source = document.querySelector("#latexSource"); + const compileButton = document.querySelector("#compileButton"); + const resetButton = document.querySelector("#resetButton"); + const autoCompile = document.querySelector("#autoCompile"); + const status = document.querySelector("#latexStatus"); + const sourceSize = document.querySelector("#sourceSize"); + const preview = document.querySelector("#pdfPreview"); + const diagnostics = document.querySelector("#latexDiagnostics"); + const download = document.querySelector("#downloadButton"); + if (!source || + !compileButton || + !resetButton || + !autoCompile || + !status || + !sourceSize || + !preview || + !diagnostics || + !download) return; + + const defaultSource = source.value; + const savedSource = localStorage.getItem(STORAGE_KEY); + if (savedSource) source.value = savedSource; + + let currentPdfUrl = null; + let compileTimer = null; + let compileGeneration = 0; + let controller = null; + + const byteLength = value => new TextEncoder().encode(value).byteLength; + + const setStatus = (message, state) => { + status.textContent = message; + status.dataset.state = state; + }; + + const updateSize = () => { + const size = byteLength(source.value); + sourceSize.textContent = `${size.toLocaleString()} / ${SOURCE_LIMIT.toLocaleString()} bytes`; + sourceSize.dataset.overLimit = size > SOURCE_LIMIT ? "true" : "false"; + return size; + }; + + const clearPdf = () => { + if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); + currentPdfUrl = null; + preview.src = "about:blank"; + download.removeAttribute("href"); + download.removeAttribute("download"); + download.classList.add("disabled"); + download.setAttribute("aria-disabled", "true"); + }; + + const showDiagnostics = message => { + clearPdf(); + diagnostics.textContent = message; + diagnostics.hidden = false; + preview.hidden = true; + }; + + const showPdf = blob => { + if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); + currentPdfUrl = URL.createObjectURL(blob); + diagnostics.hidden = true; + preview.hidden = false; + preview.src = currentPdfUrl; + download.href = currentPdfUrl; + download.download = "document.pdf"; + download.classList.remove("disabled"); + download.setAttribute("aria-disabled", "false"); + }; + + const compile = async () => { + clearTimeout(compileTimer); + const generation = ++compileGeneration; + if (controller) controller.abort(); + controller = null; + compileButton.disabled = false; + const text = source.value; + const size = updateSize(); + if (!text.trim()) { + showDiagnostics("Write some LaTeX before compiling."); + setStatus("Nothing to compile.", "error"); + return; + } + if (size > SOURCE_LIMIT) { + showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); + setStatus("Source is too large.", "error"); + return; + } + + controller = new AbortController(); + compileButton.disabled = true; + setStatus("Compiling on the server...", "working"); + + try { + const response = await fetch("/api/latex/render", { + method: "POST", + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + body: text, + cache: "no-store", + signal: controller.signal, + }); + if (generation !== compileGeneration) return; + + if (!response.ok) { + const message = await response.text(); + showDiagnostics(message || `Compilation failed (${response.status}).`); + setStatus("Fix the LaTeX errors shown in the preview pane.", "error"); + return; + } + + const blob = await response.blob(); + if (blob.type !== "application/pdf" || blob.size < 5) { + throw new Error("The server returned an invalid PDF."); + } + showPdf(blob); + setStatus( + `PDF ready (${Math.ceil(blob.size / 1024).toLocaleString()} KiB).`, + "ready", + ); + } catch (error) { + if (error.name === "AbortError" || generation !== compileGeneration) return; + showDiagnostics(error.message || "Unable to compile this document."); + setStatus("The PDF request failed.", "error"); + } finally { + if (generation === compileGeneration) { + compileButton.disabled = false; + controller = null; + } + } + }; + + const queueCompile = () => { + clearTimeout(compileTimer); + if (!autoCompile.checked) return; + compileTimer = setTimeout(compile, AUTO_COMPILE_DELAY); + }; + + const invalidateCompile = () => { + compileGeneration++; + if (controller) controller.abort(); + controller = null; + compileButton.disabled = false; + }; + + source.addEventListener("input", () => { + localStorage.setItem(STORAGE_KEY, source.value); + invalidateCompile(); + const size = updateSize(); + if (!source.value.trim()) { + showDiagnostics("Write some LaTeX before compiling."); + setStatus("Nothing to compile.", "error"); + return; + } + if (size > SOURCE_LIMIT) { + showDiagnostics("The server accepts at most 64 KiB of LaTeX source."); + setStatus("Source is too large.", "error"); + return; + } + setStatus( + autoCompile.checked ? "Waiting to compile..." : "Changes ready to compile.", + "working", + ); + queueCompile(); + }); + source.addEventListener("keydown", event => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + compile(); + } + }); + compileButton.addEventListener("click", compile); + resetButton.addEventListener("click", () => { + source.value = defaultSource; + localStorage.removeItem(STORAGE_KEY); + updateSize(); + compile(); + }); + autoCompile.addEventListener("change", () => { + if (autoCompile.checked) queueCompile(); + }); + window.addEventListener("beforeunload", () => { + if (controller) controller.abort(); + if (currentPdfUrl) URL.revokeObjectURL(currentPdfUrl); + }); + + updateSize(); + compile(); + }); +})();
--- a/mrjunejune/test/BUILD Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/test/BUILD Mon Aug 03 16:56:25 2026 -0700 @@ -48,6 +48,15 @@ args = ["$(location //mrjunejune:mrjunejune_server)"], ) +cc_test( + name = "latex_renderer_test", + srcs = ["latex_renderer_test.c"], + deps = ["//mrjunejune:latex_renderer"], + size = "medium", + timeout = "moderate", + target_compatible_with = ["@platforms//os:linux"], +) + js_test( name = "hls_player_test", entry_point = "hls_player_test.js", @@ -57,6 +66,27 @@ ) js_test( + name = "latex_editor_test", + entry_point = "latex_editor_test.js", + data = [ + "//hg-web/e2e:node_modules/playwright-core", + "//mrjunejune:mrjunejune_server", + "@playwright_chromium_linux//:chrome", + "@playwright_chromium_linux//:chromium", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +) + +js_test( name = "theme_and_webp_test", entry_point = "theme_and_webp_test.js", data = [
--- a/mrjunejune/test/auto_generated_test.c Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/test/auto_generated_test.c Mon Aug 03 16:56:25 2026 -0700 @@ -30,6 +30,8 @@ {"/tools/file_converter/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, {"/tools/hls_player", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, {"/tools/hls_player/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, + {"/tools/latex_editor", 200, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, + {"/tools/latex_editor/index.html", 301, SNAPSHOT_DIR, TEST_HOST, TEST_PORT}, // TODO: POST route - POST /api/convert/image-to-webp - requires request body // TODO: POST route - POST /api/convert/video-to-mp4 - requires request body // TODO: Dynamic route - GET /api/download/:filename - fill in actual path @@ -40,7 +42,28 @@ }; int count = sizeof(configs) / sizeof(configs[0]); - int result = Seobeo_Snapshots_Create_Batch(configs, count); + int result = 0; + if (argc > 2) + { + int found = 0; + for (int i = 0; i < count; i++) + { + if (strcmp(configs[i].path, argv[2]) != 0) + continue; + result = Seobeo_Snapshot_Create(&configs[i]); + found = 1; + break; + } + if (!found) + { + fprintf(stderr, "No snapshot route configured for %s\n", argv[2]); + result = 1; + } + } + else + { + result = Seobeo_Snapshots_Create_Batch(configs, count); + } stop_test_server(server_pid); return result;
--- a/mrjunejune/test/integration_test.c Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/test/integration_test.c Mon Aug 03 16:56:25 2026 -0700 @@ -392,6 +392,7 @@ {"/tools/markdown_to_html", 200, NULL, NULL, NULL, 0}, {"/tools/file_converter", 200, NULL, NULL, NULL, 0}, {"/tools/hls_player", 200, NULL, NULL, NULL, 0}, + {"/tools/latex_editor", 200, NULL, NULL, NULL, 0}, {"/talk", 200, NULL, NULL, NULL, 0}, }; int num_success_tests = sizeof(success_tests) / sizeof(success_tests[0]); @@ -403,6 +404,7 @@ {"/tools/markdown_to_html/index.html", 301, NULL, NULL, NULL, 0}, {"/tools/file_converter/index.html", 301, NULL, NULL, NULL, 0}, {"/tools/hls_player/index.html", 301, NULL, NULL, NULL, 0}, + {"/tools/latex_editor/index.html", 301, NULL, NULL, NULL, 0}, }; int num_redirect_tests = sizeof(redirect_tests) / sizeof(redirect_tests[0]);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/latex_editor_test.js Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,298 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); +const playwrightPath = path.join( + runfilesWorkspace, + 'hg-web/e2e/node_modules/playwright-core', +); +const { chromium } = require(playwrightPath); + +function findFreePort() { + return new Promise((resolve, reject) => { + const socket = net.createServer(); + socket.once('error', reject); + socket.listen(0, '127.0.0.1', () => { + const address = socket.address(); + socket.close(error => { + if (error) reject(error); + else resolve(String(address.port)); + }); + }); + }); +} + +async function waitForServer(server, baseUrl, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Server startup timed out\n${logs.join('')}`); +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function render(baseUrl, source) { + return fetch(`${baseUrl}/api/latex/render`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + body: source, + }); +} + +function requestWithoutReuse(baseUrl, method, requestPath, body = '') { + const url = new URL(requestPath, baseUrl); + return new Promise((resolve, reject) => { + const request = http.request({ + host: url.hostname, + port: url.port, + path: url.pathname, + method, + agent: false, + headers: { + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, response => { + const chunks = []; + response.on('data', chunk => chunks.push(chunk)); + response.on('end', () => resolve({ + status: response.statusCode, + body: Buffer.concat(chunks), + })); + }); + request.on('error', reject); + request.end(body); + }); +} + +(async () => { + assert.ok(RUNFILES); + assert.ok(WORKSPACE); + const serverBinary = path.join( + runfilesWorkspace, + 'mrjunejune/mrjunejune_server', + ); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logs = []; + let server; + let browser; + + try { + server = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + MRJUNEJUNE_PORT: port, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout.on('data', chunk => logs.push(chunk.toString())); + server.stderr.on('data', chunk => logs.push(chunk.toString())); + await waitForServer(server, baseUrl, logs); + + const valid = String.raw`\documentclass{article} +\begin{document} +Hello from the API. $a^2 + b^2 = c^2$ +\end{document} +`; + let response = await render(baseUrl, valid); +assert.equal(response.status, 200, await response.clone().text()); + assert.match(response.headers.get('content-type') || '', /^application\/pdf/); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const pdf = Buffer.from(await response.arrayBuffer()); + assert.ok(pdf.length > 1000); + assert.equal(pdf.subarray(0, 5).toString(), '%PDF-'); + + response = await render( + baseUrl, + String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, + ); + assert.equal(response.status, 422); + assert.match(await response.text(), /Undefined control sequence/); + + response = await render(baseUrl, 'x'.repeat(64 * 1024 + 1)); + assert.equal(response.status, 413); + assert.match(await response.text(), /64 KiB/); + + response = await render( + baseUrl, + String.raw`\documentclass{article}\begin{document}\input{/etc/passwd}\end{document}`, + ); + assert.equal(response.status, 422); + assert.doesNotMatch(await response.text(), /root:x:/); + + const shellPath = `/tmp/mrjunejune-latex-browser-${process.pid}`; + response = await render( + baseUrl, + String.raw`\documentclass{article} +\begin{document} +\immediate\write18{touch ${shellPath}} +Shell escape stays disabled. +\end{document}`, + ); + assert.equal(response.status, 200); + assert.equal(fs.existsSync(shellPath), false); + + const infiniteRender = requestWithoutReuse( + baseUrl, + 'POST', + '/api/latex/render', + String.raw`\documentclass{article}\begin{document}\loop\iftrue\repeat\end{document}`, + ); + await new Promise(resolve => setTimeout(resolve, 200)); + const healthStarted = Date.now(); + let isolatedResponse = await requestWithoutReuse(baseUrl, 'GET', '/tools'); + assert.equal(isolatedResponse.status, 200); + assert.ok(Date.now() - healthStarted < 1500); + isolatedResponse = await requestWithoutReuse( + baseUrl, + 'POST', + '/api/latex/render', + valid, + ); + assert.equal(isolatedResponse.status, 429); + assert.match(isolatedResponse.body.toString(), /compiler is busy/); + isolatedResponse = await infiniteRender; + assert.equal(isolatedResponse.status, 504); + assert.match( + isolatedResponse.body.toString(), + /CPU or memory limit|8 second limit/, + ); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + const context = await browser.newContext(); + await context.addInitScript(() => { + window.__pdfUrls = { created: [], revoked: [] }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = value => { + const url = createObjectURL(value); + window.__pdfUrls.created.push(url); + return url; + }; + URL.revokeObjectURL = url => { + window.__pdfUrls.revoked.push(url); + revokeObjectURL(url); + }; + }); + const page = await context.newPage(); + const browserErrors = []; + let testingExpectedFailure = false; + page.on('pageerror', error => browserErrors.push(error.message)); + page.on('console', message => { + if (message.type() !== 'error') return; + if (testingExpectedFailure && + message.text().startsWith('Failed to load resource:')) return; + browserErrors.push(message.text()); + }); + + await page.goto(`${baseUrl}/tools/latex_editor`, { + waitUntil: 'networkidle', + }); + await page.locator('#latexStatus[data-state="ready"]').waitFor({ + timeout: 15000, + }); + assert.match( + await page.locator('#pdfPreview').getAttribute('src'), + /^blob:/, + ); + assert.equal( + await page.locator('#downloadButton').getAttribute('download'), + 'document.pdf', + ); + + await page.locator('#autoCompile').uncheck(); + await page.locator('#latexSource').fill( + String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, + ); + testingExpectedFailure = true; + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="error"]').waitFor(); + testingExpectedFailure = false; + assert.match( + await page.locator('#latexDiagnostics').textContent(), + /Undefined control sequence/, + ); + + await page.locator('#latexSource').fill(valid); + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="ready"]').waitFor(); + const objectUrls = await page.evaluate(() => window.__pdfUrls); + assert.ok(objectUrls.created.length >= 2, JSON.stringify(objectUrls)); + assert.ok(objectUrls.revoked.length >= 1, JSON.stringify(objectUrls)); + + let delayNextRender = true; + await page.route('**/api/latex/render', async route => { + if (!delayNextRender) { + await route.continue(); + return; + } + delayNextRender = false; + await new Promise(resolve => setTimeout(resolve, 300)); + try { + await route.fulfill({ + status: 200, + contentType: 'application/pdf', + body: pdf, + }); + } catch { + // The corrected client aborts this stale request. + } + }); + await page.locator('#latexSource').fill(valid); + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="working"]').waitFor(); + await page.locator('#latexSource').fill('x'.repeat(64 * 1024 + 1)); + await page.locator('#latexStatus[data-state="error"]').waitFor(); + await page.waitForTimeout(500); + assert.equal( + await page.locator('#latexStatus').getAttribute('data-state'), + 'error', + ); + await page.unroute('**/api/latex/render'); + + assert.deepEqual(browserErrors, []); + await context.close(); + } finally { + if (browser) await browser.close(); + await stopProcess(server); + } +})().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/latex_renderer_test.c Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,105 @@ +#include "mrjunejune/latex_renderer.h" + +#include <assert.h> +#include <dirent.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> + +static size_t latex_workspace_count(void) +{ + DIR *directory = opendir("/tmp"); + assert(directory); + size_t count = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) + { + if (strncmp(entry->d_name, "mrjunejune-latex-", 19) == 0) + count++; + } + closedir(directory); + return count; +} + +int main(void) +{ + size_t initial_workspaces = latex_workspace_count(); + + const char *valid = + "\\documentclass{article}\n" + "\\begin{document}\n" + "Hello from the sandbox. $x^2 + y^2 = z^2$\n" + "\\end{document}\n"; + Latex_Render_Result result = Latex_Render( + (const uint8_t *)valid, + strlen(valid)); + if (result.status != LATEX_RENDER_OK) + fprintf(stderr, "%s\n", result.diagnostics ? result.diagnostics : "unknown error"); + assert(result.status == LATEX_RENDER_OK); + assert(result.pdf_size > 1000); + assert(memcmp(result.pdf_data, "%PDF-", 5) == 0); + Latex_Render_Result_Destroy(&result); + + const char *invalid = + "\\documentclass{article}\n" + "\\begin{document}\n" + "\\notarealcommand\n" + "\\end{document}\n"; + result = Latex_Render((const uint8_t *)invalid, strlen(invalid)); + assert(result.status == LATEX_RENDER_COMPILE_ERROR); + assert(result.diagnostics); + assert(strstr(result.diagnostics, "Undefined control sequence")); + Latex_Render_Result_Destroy(&result); + + const char *host_read = + "\\documentclass{article}\n" + "\\begin{document}\n" + "\\input{/etc/passwd}\n" + "\\end{document}\n"; + result = Latex_Render((const uint8_t *)host_read, strlen(host_read)); + assert(result.status == LATEX_RENDER_COMPILE_ERROR); + assert(result.diagnostics); + assert(!strstr(result.diagnostics, "root:x:")); + Latex_Render_Result_Destroy(&result); + + char shell_path[128]; + snprintf( + shell_path, + sizeof(shell_path), + "/tmp/mrjunejune-latex-shell-%ld", + (long)getpid()); + char shell_escape[512]; + snprintf( + shell_escape, + sizeof(shell_escape), + "\\documentclass{article}\n" + "\\begin{document}\n" + "\\immediate\\write18{touch %s}\n" + "Shell escape must stay disabled.\n" + "\\end{document}\n", + shell_path); + result = Latex_Render( + (const uint8_t *)shell_escape, + strlen(shell_escape)); + assert(result.status == LATEX_RENDER_OK); + assert(access(shell_path, F_OK) != 0); + Latex_Render_Result_Destroy(&result); + + uint8_t oversized[LATEX_SOURCE_MAX_BYTES + 1] = {0}; + memset(oversized, 'x', sizeof(oversized)); + result = Latex_Render(oversized, sizeof(oversized)); + assert(result.status == LATEX_RENDER_INVALID_INPUT); + Latex_Render_Result_Destroy(&result); + + const char *infinite = + "\\documentclass{article}\n" + "\\begin{document}\n" + "\\loop\\iftrue\\repeat\n" + "\\end{document}\n"; + result = Latex_Render((const uint8_t *)infinite, strlen(infinite)); + assert(result.status == LATEX_RENDER_TIMEOUT); + Latex_Render_Result_Destroy(&result); + + assert(latex_workspace_count() == initial_workspaces); + return 0; +}
--- a/mrjunejune/test/snapshots/tools.snapshot Mon Aug 03 15:26:44 2026 -0700 +++ b/mrjunejune/test/snapshots/tools.snapshot Mon Aug 03 16:56:25 2026 -0700 @@ -172,12 +172,9 @@ <li><a href="/tools/markdown_to_html">MarkDown to HTML</a></li> <li><a href="/tools/file_converter">Images to Webp / Video to Mp4</a></li> <li><a href="/tools/hls_player">Online HLS Player</a></li> + <li><a href="/tools/latex_editor">Online LaTeX Editor</a></li> <li><a href="/notes">Personal Notes</a></li> </ul> - <h3> TODOs </h3> - <ul class="nav-list"> - <li>- Simple online LaTex editor.</li> - </ul> </main> <div style="display: flex; align-items: center; justify-content: center; margin: 30px 0px;"> <small>© 2026 June Park</small>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/snapshots/tools_latex_editor.snapshot Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,246 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<meta name="theme-color" content="#544e43"> +<link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg"> +<link rel="manifest" href="/public/manifest.json"> +<link rel="apple-touch-icon" href="/public/epi_all_colors.svg"> + +<link rel="preload" href="/public/fonts/Roboto-Regular.ttf" as="font" crossorigin> +<link rel="preload" href="/public/fonts/Roboto-Thin.ttf"as="font" crossorigin> + +<!-- <link rel="preload" href="/public/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin> --> +<!-- <link rel="preload" href="/public/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin> --> + +<!-- <link rel="preload" href="/public/fonts/more-sugar.extras.otf" as="font" type="font/otf" crossorigin> --> +<link rel="preload" href="/public/fonts/more-sugar.regular.otf" as="font" type="font/otf" crossorigin> +<link rel="preload" href="/public/fonts/more-sugar.thin.otf" as="font" type="font/otf" crossorigin> +<link rel="preload" href="/public/epi_all_colors.svg" as="image"> + +<link rel="preload" href="/base.css" as="style" /> +<link rel="stylesheet" href="/base.css" /> + +<script src="/public/pwa-register.js" defer></script> + + + <title>LaTeX Editor - MrJuneJune</title> + <link rel="stylesheet" href="/tools/latex_editor/index.css" /> + <script src="/tools/latex_editor/index.js" defer></script> + </head> + <body> + <style> + :root { + --header-background: var(--white); + --header-color: rgb(var(--black)); + --link-hover-accent: var(--awesome); + } + + /* Fixed icon in top left corner */ + #themeToggle { + position: fixed; + top: 20px; + left: 20px; + background: var(--header-background); + display: flex; + align-items: center; + border-radius: 50%; + cursor: pointer; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease; + } + + #themeToggle:hover { + transform: scale(1.05); + } + + /* Professional header */ + header { + margin: auto; + padding: 1.5em 1em; + font-family: "More", sans-serif; + box-shadow: 0 2px 8px rgba(var(--black), 5%); + width: 720px; + max-width: calc(100% - 2em); + text-align: center; + } + + header h1 { + margin: 0; + font-size: 1.8em; + font-weight: 700; + letter-spacing: -0.5px; + } + + header h1 a { + text-decoration: none; + color: var(--header-color); + } + + header h1 a::before { + display: none; + } + + /* Mobile responsiveness */ + @media (max-width: 720px) { + #themeToggle { + top: 15px; + left: 15px; + } + + header { + padding: 1em; + } + + header h1 { + font-size: 1.5em; + } + } + + @media (max-width: 480px) { + #themeToggle { + top: 10px; + left: 10px; + } + + #themeToggle img { + height: 40px; + width: 40px; + } + + header h1 { + font-size: 1.3em; + } + } + + #logo { + width: 300px; + } + + /* 1. DEFINE THE DEFAULTS (Light Mode) */ + :root { + --logo-invert: invert(0); + --epi-grayscale: grayscale(0) brightness(1); + } + + /* 2. MANUAL DARK OVERRIDE */ + html.dark { + --logo-invert: invert(1); + --epi-grayscale: grayscale(1); + } + + /* 3. MANUAL LIGHT OVERRIDE */ + html.light-mode { + --logo-invert: invert(0); + --epi-grayscale: brightness(2.9) grayscale(1); + } + + /* 4. SYSTEM PREFERENCE */ + @media (prefers-color-scheme: dark) { + :root:not(.light-mode) { + --logo-invert: invert(1); + } + } + + /* 5. APPLY TO ELEMENTS */ + #logo { + -webkit-filter: var(--logo-invert); + filter: var(--logo-invert); + transition: filter 0.3s ease; + } + + .epi-logo { + -webkit-filter: var(--epi-grayscale); + filter: var(--epi-grayscale); + transition: filter 0.3s ease; + } +</style> + +<div id="themeToggle"> + <img id="epiChan" class="epi-logo" aria-label="Toggle dark mode" src="/public/epi_all_colors.svg" height="50" width="50"> +</div> + +<header> + <h1><a href="/">MrJuneJune</a></h1> +</header> +<script src="/index.js"></script> + + + <main class="latex-page"> + <div class="latex-heading"> + <div> + <h1>Online LaTeX Editor</h1> + <p>Write LaTeX here. My server compiles it inside a locked-down sandbox and sends the PDF back to this preview.</p> + </div> + <div class="latex-actions"> + <label class="auto-compile"> + <input id="autoCompile" type="checkbox" checked /> + Auto compile + </label> + <button id="resetButton" type="button" class="secondary">Reset</button> + <button id="compileButton" type="button">Compile PDF</button> + <a id="downloadButton" class="button-link disabled" aria-disabled="true">Download</a> + </div> + </div> + + <p id="latexStatus" class="latex-status" role="status" aria-live="polite"> + Preparing your first PDF... + </p> + + <div class="latex-workspace"> + <section class="latex-panel editor-panel" aria-labelledby="sourceHeading"> + <div class="panel-heading"> + <h2 id="sourceHeading">document.tex</h2> + <span id="sourceSize">0 / 65,536 bytes</span> + </div> + <textarea id="latexSource" spellcheck="false" autocomplete="off" aria-label="LaTeX source">\documentclass[11pt]{article} +\usepackage[margin=1in]{geometry} +\usepackage{amsmath} +\usepackage{xcolor} + +\title{Hello from my server} +\author{MrJuneJune} +\date{\today} + +\begin{document} +\maketitle + +This PDF was compiled by the custom C server behind this website. + +\[ + e^{i\pi} + 1 = 0 +\] + +\textcolor{blue}{Edit this document and watch the preview update.} + +\end{document} +</textarea> + </section> + + <section class="latex-panel preview-panel" aria-labelledby="previewHeading"> + <div class="panel-heading"> + <h2 id="previewHeading">PDF preview</h2> + <span>Server rendered</span> + </div> + <iframe + id="pdfPreview" + title="Compiled LaTeX PDF preview" + src="about:blank" + ></iframe> + <pre id="latexDiagnostics" class="latex-diagnostics" hidden></pre> + </section> + </div> + + <section class="latex-notes"> + <h2>Limits</h2> + <p>Source is capped at 64 KiB and compilation at 8 seconds. Shell commands, network access, and reads outside the isolated TeX workspace are blocked.</p> + </section> + </main> + <div style="display: flex; align-items: center; justify-content: center; margin: 30px 0px;"> + <small>© 2026 June Park</small> +</div> + + </body> +</html>
--- a/seobeo/os/s_linux_edge.c Mon Aug 03 15:26:44 2026 -0700 +++ b/seobeo/os/s_linux_edge.c Mon Aug 03 16:56:25 2026 -0700 @@ -37,7 +37,7 @@ } struct epoll_event ev = { - .events = EPOLLIN | EPOLLET, + .events = EPOLLIN | EPOLLET | EPOLLEXCLUSIVE, .data.ptr = args->srv }; if (epoll_ctl(epfd, EPOLL_CTL_ADD, args->srv->socket, &ev) < 0)
--- a/seobeo/s_network.c Mon Aug 03 15:26:44 2026 -0700 +++ b/seobeo/s_network.c Mon Aug 03 16:56:25 2026 -0700 @@ -17,6 +17,13 @@ #endif } +static void Seobeo_Socket_Set_Close_On_Exec(int socket_fd) +{ + int flags = fcntl(socket_fd, F_GETFD, 0); + if (flags >= 0) + fcntl(socket_fd, F_SETFD, flags | FD_CLOEXEC); +} + static ssize_t Seobeo_Socket_Write( int socket_fd, const void *buffer, @@ -52,9 +59,14 @@ free_server_info = free_server_info->ai_next ) { + int socket_type = free_server_info->ai_socktype; +#ifdef SOCK_CLOEXEC + socket_type |= SOCK_CLOEXEC; +#endif if((socket_fd = socket(free_server_info->ai_family, - free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1) + socket_type, free_server_info->ai_protocol)) == -1) { perror("socket"); continue; } + Seobeo_Socket_Set_Close_On_Exec(socket_fd); Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) @@ -126,9 +138,14 @@ { perror("getaddrinfo"); return NULL; } + int socket_type = server_infos->ai_socktype; +#ifdef SOCK_CLOEXEC + socket_type |= SOCK_CLOEXEC; +#endif if((socket_fd = socket(server_infos->ai_family, - server_infos->ai_socktype, server_infos->ai_protocol)) == -1) + socket_type, server_infos->ai_protocol)) == -1) { perror("socket"); return NULL; } + Seobeo_Socket_Set_Close_On_Exec(socket_fd); Seobeo_Socket_Disable_Sigpipe(socket_fd); if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0) @@ -178,14 +195,16 @@ socklen_t addrlen = sizeof addr; char client_inet_addr[INET6_ADDRSTRLEN]; - int client_fd = accept(p_server_handle->socket, - (struct sockaddr*)&addr, - &addrlen); + int client_fd = accept( + p_server_handle->socket, + (struct sockaddr*)&addr, + &addrlen); inet_ntop( addr.ss_family, Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr), client_inet_addr, sizeof client_inet_addr); if (client_fd == -1) return NULL; + Seobeo_Socket_Set_Close_On_Exec(client_fd); Seobeo_Socket_Disable_Sigpipe(client_fd); // Set non blocking...
--- a/seobeo/s_web.c Mon Aug 03 15:26:44 2026 -0700 +++ b/seobeo/s_web.c Mon Aug 03 16:56:25 2026 -0700 @@ -4,6 +4,20 @@ static char g_folder_path[512] = "."; +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]; @@ -58,7 +72,11 @@ 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; @@ -494,7 +512,11 @@ memcpy(val, val_start, value_len); val[value_len] = '\0'; - Dowa_HashMap_Push_Arena(*pp_map, key, val, p_arena); + Dowa_HashMap_Push_Arena( + *pp_map, + canonical_request_header(key), + val, + p_arena); } line = next + 2;
--- a/seobeo/seobeo.h Mon Aug 03 15:26:44 2026 -0700 +++ b/seobeo/seobeo.h Mon Aug 03 16:56:25 2026 -0700 @@ -37,6 +37,10 @@ #define HTTP_UNAUTHORIZED 401 #define HTTP_FORBIDDEN 403 #define HTTP_NOT_FOUND 404 +#define HTTP_PAYLOAD_TOO_LARGE 413 +#define HTTP_UNPROCESSABLE_CONTENT 422 +#define HTTP_TOO_MANY_REQUESTS 429 +#define HTTP_SERVICE_UNAVAILABLE 503 #define HTTP_INTERNAL_ERROR 500 #define CREATE_REDIRECT_HANDLER(name, target_url) \