Mercurial
changeset 231:09a96dcb2b4c hg-web
[merge] Join existing hg-web branch head
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 16:50:48 -0700 |
| parents | 7ef4c9d2a72d (current diff) 2de49ee4fdd6 (diff) |
| children | 7edad9623498 |
| files | hg-web/deploy.sh medi/BUILD medi/README.md medi/main.c medi/src/app.tsx medi/src/index.html medi/src/main.tsx media_processor/BUILD media_processor/README.md media_processor/media_processor.c media_processor/media_processor.h media_processor/media_processor_test.c third_party/README.md third_party/ffmpeg/BUILD third_party/ffmpeg/README.md third_party/ffmpeg/ffmpeg.h third_party/ffmpeg/ffmpeg_cli.c third_party/ffmpeg/ffmpeg_cli.h third_party/ffmpeg/ffmpeg_wrapper.c |
| diffstat | 137 files changed, 11250 insertions(+), 3840 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.env.development Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,2 @@ +AWS_MRJUNEJUNE_ACCESS_KEY=FILL +AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=FILL
--- a/.hgignore Sun Jan 25 10:44:04 2026 -0800 +++ b/.hgignore Sun Aug 02 16:50:48 2026 -0700 @@ -40,3 +40,10 @@ # OS related .DS_Store + +# Environment var. +.env +# Server config +.config +# DB +mrjunejune.db
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/AGENT.md Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,21 @@ +# Agent instructions + +## Bazel is the project interface + +All project execution must happen through Bazel. Use existing `bazel build`, +`bazel test`, and `bazel run` targets instead of invoking compiled binaries, +language runtimes, package managers, generated scripts, or multiple services +manually. + +When a workflow needs several processes or commands, add a Bazel target that +owns their startup, runfiles, supervision, shutdown, and exit status. The user +should need one `bazel run` command. + +For hg-web development, run the complete local stack with: + +```bash +bazel run //hg-web:dev +``` + +Add or update the smallest relevant Bazel target whenever code cannot be built, +tested, run, bundled, or orchestrated through Bazel.
--- a/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ b/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,7 @@ +exports_files([".env"]) + +filegroup( + name = "env_file", + srcs = [".env"], + visibility = ["//visibility:public"], +)
--- a/MODULE.bazel Sun Jan 25 10:44:04 2026 -0800 +++ b/MODULE.bazel Sun Aug 02 16:50:48 2026 -0700 @@ -2,6 +2,8 @@ bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "bazel_skylib", version = "1.8.2") bazel_dep(name = "rules_shell", version = "0.6.1") +bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True) +bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True) bazel_dep(name = "openssl", version = "3.3.1.bcr.7") bazel_dep(name = "rules_foreign_cc", version = "0.14.0") bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True) @@ -13,6 +15,36 @@ ) http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file") +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "playwright_chromium_linux", + urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"], + sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5", + strip_prefix = "chrome-linux", + build_file_content = """ +exports_files(["chrome"]) + +filegroup( + name = "chromium", + srcs = glob(["**"], exclude = ["BUILD.bazel"]), + visibility = ["//visibility:public"], +) +""", +) + +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "20.18.0") +use_repo(node, "nodejs") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "hg_web_npm", + data = ["//hg-web/e2e:package.json"], + npm_package_lock = "//hg-web/e2e:package-lock.json", + pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml", +) +use_repo(npm, "hg_web_npm") # Bun http_file(
--- a/MODULE.bazel.lock Sun Jan 25 10:44:04 2026 -0800 +++ b/MODULE.bazel.lock Sun Aug 02 16:50:48 2026 -0700 @@ -1,5 +1,5 @@ { - "lockFileVersion": 26, + "lockFileVersion": 28, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -43,8 +43,9 @@ "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", - "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", @@ -65,8 +66,8 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/MODULE.bazel": "d18b017dddf219626ea5b04028ff0db2397655fbdaa9d9258a6443ddafb303ab", "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/source.json": "5fb3a2433f6508f4b23934eaa2986d604ae65aff95d7476d1273bf5cd434eedc", - "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", - "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", + "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", + "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", @@ -135,7 +136,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", + "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2", @@ -153,8 +155,8 @@ "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", - "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea", - "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e", + "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", + "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", @@ -228,9 +230,105 @@ }, "selectedYankedVersions": {}, "moduleExtensions": { + "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { + "general": { + "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=", + "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=", + "recordedInputs": [ + "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+", + "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+", + "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report", + "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+", + "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools", + "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", + "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version", + "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools", + "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+", + "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+", + "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+" + ], + "generatedRepoSpecs": { + "pnpm": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "root_package": "", + "link_workspace": "", + "link_packages": {}, + "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==", + "url": "", + "commit": "", + "patch_args": [ + "-p0" + ], + "patches": [], + "custom_postinstall": "", + "npm_auth": "", + "npm_auth_basic": "", + "npm_auth_username": "", + "npm_auth_password": "", + "lifecycle_hooks": [], + "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])", + "extract_full_archive": true, + "exclude_package_contents": [] + } + }, + "pnpm__links": { + "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", + "attributes": { + "package": "pnpm", + "version": "8.15.9", + "dev": false, + "root_package": "", + "link_packages": {}, + "deps": {}, + "transitive_closure": {}, + "lifecycle_build_target": false, + "lifecycle_hooks_env": [], + "lifecycle_hooks_execution_requirements": [ + "no-sandbox" + ], + "lifecycle_hooks_use_default_shell_env": false, + "bins": {}, + "package_visibility": [ + "//visibility:public" + ], + "exclude_package_contents": [] + } + } + } + } + }, + "@@aspect_tools_telemetry+//:extension.bzl%telemetry": { + "general": { + "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=", + "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=", + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], + "generatedRepoSpecs": { + "aspect_tools_telemetry_report": { + "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", + "attributes": { + "deps": { + "aspect_rules_js": "2.9.2", + "aspect_tools_telemetry": "0.3.3" + } + } + } + } + } + }, "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": { "general": { - "bzlTransitiveDigest": "27R0vlu7+T5uJVRkrJWwjP+5K4Pi5H1nJbI3JBLJyKI=", + "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=", "usagesDigest": "BWWW2nG29mUymMGpRgfP+nxndKWaf1fCZZ33EDPaVIc=", "recordedInputs": [ "REPO_MAPPING:buildifier_prebuilt+,bazel_skylib bazel_skylib+", @@ -374,7 +472,7 @@ }, "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": { "general": { - "bzlTransitiveDigest": "QDGe6PSXCStGFYJd3Yj2kvRMiXLid2u8mNHBRjJoOEw=", + "bzlTransitiveDigest": "+1O9FmrwgJwK/ywCKhwouNuu7NWC/hfkkzjuGp/1c9Q=", "usagesDigest": "f1E1WkCSGt50uwBJp/heJk2R/iAm39ixgGS93wpdywA=", "recordedInputs": [ "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals", @@ -444,9 +542,31 @@ } } }, + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + ], + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + } + } + }, "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": { "general": { - "bzlTransitiveDigest": "dWWiLQWKST2+NU4SkbVS2OhS6KgYdZ4jBI5MBsDEBt0=", + "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=", "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=", "recordedInputs": [ "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools", @@ -819,7 +939,7 @@ }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "ABI1D/sbS1ovwaW/kHDoj8nnXjQ0oKU9fzmzEG4iT8o=", + "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedInputs": [ "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" @@ -877,7 +997,7 @@ "@@rules_nodejs+//nodejs:extensions.bzl%node": { "general": { "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=", - "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=", + "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=", "recordedInputs": [], "generatedRepoSpecs": { "nodejs_linux_amd64": { @@ -1007,7 +1127,7 @@ }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { - "bzlTransitiveDigest": "2hLgIvNVTLgxus0ZuXtleBe70intCfo0cHs8qvt6cdM=", + "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", @@ -1275,5 +1395,6 @@ } } }, - "facts": {} + "facts": {}, + "factsVersions": {} }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/assets/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,5 @@ +filegroup( + name = "icons", + srcs = glob(["icons/*.png"]), + visibility = ["//visibility:public"], +)
--- a/benchmark/bun-http-framework-benchmark/results/bun/bun-web-standard.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/bun-web-standard.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 228408.02,228408.02 +Reqs/sec 228466.99,228466.99 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 153303.58,153303.58 +Reqs/sec 152460.20,152460.20 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 151110.26,151110.26 +Reqs/sec 156344.05,156344.05
--- a/benchmark/bun-http-framework-benchmark/results/bun/bun.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/bun.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 254858.08,254858.08 +Reqs/sec 258730.63,258730.63 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 205117.00,205117.00 +Reqs/sec 208220.35,208220.35 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 156658.25,156658.25 +Reqs/sec 158675.80,158675.80
--- a/benchmark/bun-http-framework-benchmark/results/bun/elysia.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/elysia.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 400960.78,400960.78 +Reqs/sec 394707.12,394707.12 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 200374.15,200374.15 +Reqs/sec 196336.94,196336.94 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 164129.36,164129.36 +Reqs/sec 161899.39,161899.39
--- a/benchmark/bun-http-framework-benchmark/results/bun/express.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/express.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 94358.98,94358.98 +Reqs/sec 89438.11,89438.11 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 83982.98,83982.98 +Reqs/sec 80241.22,80241.22 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 84024.83,84024.83 +Reqs/sec 83023.74,83023.74
--- a/benchmark/bun-http-framework-benchmark/results/bun/h3.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/h3.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 98530.52,98530.52 +Reqs/sec 96603.44,96603.44 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 81602.75,81602.75 +Reqs/sec 79062.78,79062.78 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 9829.93,9829.93 +Reqs/sec 9958.75,9958.75
--- a/benchmark/bun-http-framework-benchmark/results/bun/hono.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/hono.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 202727.16,202727.16 +Reqs/sec 197638.70,197638.70 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 147760.89,147760.89 +Reqs/sec 146857.99,146857.99 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 130184.98,130184.98 +Reqs/sec 129630.67,129630.67
--- a/benchmark/bun-http-framework-benchmark/results/bun/nbit.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/nbit.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 166352.26,166352.26 +Reqs/sec 161889.19,161889.19 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 130437.58,130437.58 +Reqs/sec 128469.38,128469.38 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 109477.61,109477.61 +Reqs/sec 107344.24,107344.24
--- a/benchmark/bun-http-framework-benchmark/results/bun/nhttp.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/nhttp.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 260256.20,260256.20 +Reqs/sec 251208.25,251208.25 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 155733.48,155733.48 +Reqs/sec 146262.76,146262.76 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 164285.02,164285.02 +Reqs/sec 150412.21,150412.21
--- a/benchmark/bun-http-framework-benchmark/results/bun/oak.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/oak.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 66974.27,66974.27 +Reqs/sec 69435.13,69435.13 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 55255.71,55255.71 +Reqs/sec 55855.49,55855.49 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 49373.25,49373.25 +Reqs/sec 48430.51,48430.51
--- a/benchmark/bun-http-framework-benchmark/results/bun/wobe.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/bun/wobe.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 177452.13,177452.13 +Reqs/sec 164622.23,164622.23 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 159185.52,159185.52 +Reqs/sec 140543.28,140543.28 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 133618.73,133618.73 +Reqs/sec 125350.31,125350.31
--- a/benchmark/bun-http-framework-benchmark/results/c/seobeo.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/c/seobeo.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 35694.89,35694.89 +Reqs/sec 762500.23,762500.23 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 35792.17,35792.17 +Reqs/sec 664161.76,664161.76 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 35844.77,35844.77 +Reqs/sec 680603.95,680603.95
--- a/benchmark/bun-http-framework-benchmark/results/node/express.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/express.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 16803.98,16803.98 +Reqs/sec 16270.25,16270.25 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 15956.22,15956.22 +Reqs/sec 15420.94,15420.94 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 17518.46,17518.46 +Reqs/sec 17296.20,17296.20
--- a/benchmark/bun-http-framework-benchmark/results/node/fastify.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/fastify.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 114069.22,114069.22 +Reqs/sec 108144.21,108144.21 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 106418.14,106418.14 +Reqs/sec 102523.21,102523.21 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 32150.27,32150.27 +Reqs/sec 31702.05,31702.05
--- a/benchmark/bun-http-framework-benchmark/results/node/h3.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/h3.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 102794.32,102794.32 +Reqs/sec 98213.55,98213.55 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 86779.18,86779.18 +Reqs/sec 83364.88,83364.88 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 79135.52,79135.52 +Reqs/sec 77675.20,77675.20
--- a/benchmark/bun-http-framework-benchmark/results/node/hono.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/hono.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 102406.51,102406.51 +Reqs/sec 101150.50,101150.50 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 91815.44,91815.44 +Reqs/sec 89469.69,89469.69 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 23631.62,23631.62 +Reqs/sec 22884.58,22884.58
--- a/benchmark/bun-http-framework-benchmark/results/node/hyper-express.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/hyper-express.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 270288.86,270288.86 +Reqs/sec 261221.09,261221.09 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 207667.76,207667.76 +Reqs/sec 202783.27,202783.27 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 145951.54,145951.54 +Reqs/sec 139488.27,139488.27
--- a/benchmark/bun-http-framework-benchmark/results/node/koa.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/koa.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 57452.05,57452.05 +Reqs/sec 52681.21,52681.21 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 48624.37,48624.37 +Reqs/sec 46472.31,46472.31 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 35546.79,35546.79 +Reqs/sec 34781.23,34781.23
--- a/benchmark/bun-http-framework-benchmark/results/node/ultimate-express.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/ultimate-express.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 398161.89,398161.89 +Reqs/sec 390978.83,390978.83 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 377012.33,377012.33 +Reqs/sec 371893.76,371893.76 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 134859.38,134859.38 +Reqs/sec 131597.36,131597.36
--- a/benchmark/bun-http-framework-benchmark/results/node/uws.txt Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/node/uws.txt Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,6 @@ /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/ -Reqs/sec 405159.40,405159.40 +Reqs/sec 395545.79,395545.79 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s http://127.0.0.1:3000/id/1?name=bun -Reqs/sec 376621.69,376621.69 +Reqs/sec 372478.96,372478.96 /home/june/zenbu/benchmark/bun-http-framework-benchmark/bombardier-linux-386 --fasthttp -c 500 -d 10s -m POST -H 'Content-Type:application/json' -f ./scripts/body.json http://127.0.0.1:3000/json -Reqs/sec 216381.92,216381.92 +Reqs/sec 211797.72,211797.72
--- a/benchmark/bun-http-framework-benchmark/results/results.md Sun Jan 25 10:44:04 2026 -0800 +++ b/benchmark/bun-http-framework-benchmark/results/results.md Sun Aug 02 16:50:48 2026 -0700 @@ -1,22 +1,22 @@ | Framework | Runtime | Average | Ping | Query | Body | | ---------------- | ------- | ------- | ---------- | ---------- | ---------- | -| uws | node | 332,721.003 | 405,159.4 | 376,621.69 | 216,381.92 | -| ultimate-express | node | 303,344.533 | 398,161.89 | 377,012.33 | 134,859.38 | -| elysia | bun | 255,154.763 | 400,960.78 | 200,374.15 | 164,129.36 | -| hyper-express | node | 207,969.387 | 270,288.86 | 207,667.76 | 145,951.54 | -| bun | bun | 205,544.443 | 254,858.08 | 205,117 | 156,658.25 | -| nhttp | bun | 193,424.9 | 260,256.2 | 155,733.48 | 164,285.02 | -| bun-web-standard | bun | 177,607.287 | 228,408.02 | 153,303.58 | 151,110.26 | -| hono | bun | 160,224.343 | 202,727.16 | 147,760.89 | 130,184.98 | -| wobe | bun | 156,752.127 | 177,452.13 | 159,185.52 | 133,618.73 | -| nbit | bun | 135,422.483 | 166,352.26 | 130,437.58 | 109,477.61 | -| h3 | node | 89,569.673 | 102,794.32 | 86,779.18 | 79,135.52 | -| express | bun | 87,455.597 | 94,358.98 | 83,982.98 | 84,024.83 | -| fastify | node | 84,212.543 | 114,069.22 | 106,418.14 | 32,150.27 | -| hono | node | 72,617.857 | 102,406.51 | 91,815.44 | 23,631.62 | -| h3 | bun | 63,321.067 | 98,530.52 | 81,602.75 | 9,829.93 | -| oak | bun | 57,201.077 | 66,974.27 | 55,255.71 | 49,373.25 | -| koa | node | 47,207.737 | 57,452.05 | 48,624.37 | 35,546.79 | -| seobeo | c | 35,777.277 | 35,694.89 | 35,792.17 | 35,844.77 | -| express | node | 16,759.553 | 16,803.98 | 15,956.22 | 17,518.46 | \ No newline at end of file +| seobeo | c | 702,421.98 | 762,500.23 | 664,161.76 | 680,603.95 | +| uws | node | 326,607.49 | 395,545.79 | 372,478.96 | 211,797.72 | +| ultimate-express | node | 298,156.65 | 390,978.83 | 371,893.76 | 131,597.36 | +| elysia | bun | 250,981.15 | 394,707.12 | 196,336.94 | 161,899.39 | +| bun | bun | 208,542.26 | 258,730.63 | 208,220.35 | 158,675.8 | +| hyper-express | node | 201,164.21 | 261,221.09 | 202,783.27 | 139,488.27 | +| nhttp | bun | 182,627.74 | 251,208.25 | 146,262.76 | 150,412.21 | +| bun-web-standard | bun | 179,090.413 | 228,466.99 | 152,460.2 | 156,344.05 | +| hono | bun | 158,042.453 | 197,638.7 | 146,857.99 | 129,630.67 | +| wobe | bun | 143,505.273 | 164,622.23 | 140,543.28 | 125,350.31 | +| nbit | bun | 132,567.603 | 161,889.19 | 128,469.38 | 107,344.24 | +| h3 | node | 86,417.877 | 98,213.55 | 83,364.88 | 77,675.2 | +| express | bun | 84,234.357 | 89,438.11 | 80,241.22 | 83,023.74 | +| fastify | node | 80,789.823 | 108,144.21 | 102,523.21 | 31,702.05 | +| hono | node | 71,168.257 | 101,150.5 | 89,469.69 | 22,884.58 | +| h3 | bun | 61,874.99 | 96,603.44 | 79,062.78 | 9,958.75 | +| oak | bun | 57,907.043 | 69,435.13 | 55,855.49 | 48,430.51 | +| koa | node | 44,644.917 | 52,681.21 | 46,472.31 | 34,781.23 | +| express | node | 16,329.13 | 16,270.25 | 15,420.94 | 17,296.2 | \ No newline at end of file
--- a/gui_ze/gui_ze.bzl Sun Jan 25 10:44:04 2026 -0800 +++ b/gui_ze/gui_ze.bzl Sun Aug 02 16:50:48 2026 -0700 @@ -132,7 +132,7 @@ {copy_commands} export NODE_PATH=./third_party/bun/node_modules cp ./third_party/bun/tsconfig.json . -{bun} build {entry} --outfile {output} --target browser +{bun} build {entry} --outfile {output} --target browser """.format( copy_commands = "\n".join(copy_commands), src_package = src_package,
--- a/hg-web/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -1,4 +1,5 @@ load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_shell//shell:sh_binary.bzl", "sh_binary") load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle") # Source files @@ -41,9 +42,15 @@ dest = "src/public", ) +move_files_into_dir( + name = "public_fonts_files", + srcs = ["//mrjunejune:public_fonts_files"], + dest = "src/public/fonts", +) + filegroup( name = "all_assets", - srcs = glob(["src/**"]) + [":compiled_js", ":public_files"], + srcs = glob(["src/**"]) + [":compiled_js", ":public_files", ":public_fonts_files"], ) # Server binaries @@ -52,6 +59,7 @@ srcs = ["main.c"], deps = ["//seobeo:seobeo"], data = [":all_assets"], + visibility = ["//hg-web:__subpackages__"], ) cc_binary( @@ -61,6 +69,20 @@ data = [":all_assets"], ) +sh_binary( + name = "dev", + srcs = ["dev.sh"], + data = [ + ":hg_web_server", + "@bazel_tools//tools/bash/runfiles", + ], +) + +test_suite( + name = "tests", + tests = ["//hg-web/e2e:app_e2e_test"], +) + bundle( name = "hg_web_server_bundle", binary = ":hg_web_server",
--- a/hg-web/README.md Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/README.md Sun Aug 02 16:50:48 2026 -0700 @@ -1,34 +1,286 @@ # hg-web -A web-based Mercurial repository browser. Provides a GitHub-style interface for browsing files, viewing code with syntax highlighting, and reading markdown documentation. +A custom Mercurial forge and repository browser. The application keeps the +networking and HTTP stack in C with Seobeo, while the hand-drawn web interface +is implemented in React and TypeScript. + +## Wiki tree -## Features +```text +hg-web +├── Runtime +│ ├── hg_web_server :6970 +│ │ ├── React application and static assets +│ │ ├── repository browsing API +│ │ ├── commit graph API +│ │ └── Mercurial wire-protocol proxy +│ └── hg serve :4444 +│ ├── repository files and graph data +│ └── Mercurial pull/push protocol +├── Backend +│ ├── main.c +│ └── ../seobeo +│ ├── s_network.c +│ ├── s_web.c +│ ├── s_http_client.c +│ └── s_ssl.c +├── Frontend +│ └── src +│ ├── main.tsx +│ ├── components +│ │ ├── app.tsx +│ │ ├── directory-browser.tsx +│ │ ├── graph.tsx +│ │ ├── header.tsx +│ │ ├── footer.tsx +│ │ ├── theme.tsx +│ │ └── repo-browser.tsx +│ ├── index.html +│ ├── index.css +│ ├── base.css +│ └── custom pencil, panda, and icon assets +├── Build +│ ├── BUILD +│ ├── ../gui_ze/gui_ze.bzl +│ ├── ../markdown_converter +│ └── ../third_party/highlight +└── Operations + └── deploy.sh +``` + +`repo-browser.tsx` is an older standalone browser implementation. The active +entry point is `main.tsx`, which renders `app.tsx`; `app.tsx` embeds +`directory-browser.tsx`. + +## Runtime topology -- Browse repository files and directories -- View code files with syntax highlighting (highlight.js) -- Render markdown files with WASM-based converter -- Dark/light theme support with system preference detection -- Prefetch on hover for faster navigation +```text +Browser + | + | HTTPS + v +nginx + | + | HTTP :6970 + v +hg_web_server (main.c + Seobeo) + | + | HTTP :4444 + v +hg serve (Zenbu Mercurial repository) +``` + +The C server owns the public routes. It serves the application shell and +assets, translates browser API requests into `hg serve` requests, and streams +Mercurial wire-protocol traffic without routing bundle data through the normal +buffered HTTP response path. + +## Request flows + +### Application shell + +```text +GET /, /directories, or /graph + -> GetReactHome + -> hg-web/src/index.html + -> /page.js + -> src/main.tsx + -> components/app.tsx +``` + +### Repository browser -## Structure +```text +directory-browser.tsx + -> GET /api/repo/list?path=... + -> ApiListDirectory + -> GET hg-serve/file/tip/<path>?style=json +file or README selection + -> GET /api/repo/file?path=... + -> ApiGetFile + -> GET hg-serve/raw-file/tip/<path> + -> highlight.js, markdown_converter WASM, or inline static preview ``` -hg-web/ -├── BUILD # Bazel build configuration -├── deploy.sh # Deployment script -├── main.c # C server handling API routes -└── src/ # Frontend source files + +Images, SVG, video, audio, and PDF files open in the forge preview modal. +Unknown binary files retain a download link. The raw file API supplies explicit +MIME types, `nosniff`, inline disposition for supported previews, and sandboxed +SVG responses. + +### Commit graph + +```text +graph.tsx + -> GET /api/graph/<revision>?graphtop=...&style=json + -> ApiGetGraph + -> GET hg-serve/graph/<revision>?... + -> React rows + custom pencil/panda canvas + +graph row selection + -> /changeset/<revision> + -> GET /api/changeset/<revision> + -> ApiGetChangeset + -> GET hg-serve/json-rev/<revision> + -> changeset metadata + rendered diff +``` + +### Mercurial clone, pull, and push + +```text +Mercurial client + -> GET or POST /repo + -> StreamHgWireProtocol + -> hg serve /?cmd=... + -> binary-safe streamed response ``` -## Building +## Route map + +| Method | Route | Owner | Purpose | +| --- | --- | --- | --- | +| `GET` | `/` | `GetReactHome` | Application shell | +| `GET` | `/directories` | `GetReactHome` | Legacy application-shell route | +| `GET` | `/directory` | `GetReactHome` | Repository browser application route | +| `GET` | `/graph` | `GetReactHome` | Commit graph application route | +| `GET` | `/changeset/:changeset_id` | `GetReactHome` | Changeset application route | +| `GET` | `/api/repo/list` | `ApiListDirectory` | Directory listing JSON | +| `GET` | `/api/repo/file` | `ApiGetFile` | Raw tracked file | +| `GET` | `/api/repo/readme` | `ApiGetReadme` | Directory README content | +| `GET` | `/api/graph/:graph_id` | `ApiGetGraph` | Mercurial graph JSON | +| `GET` | `/api/changeset/:changeset_id` | `ApiGetChangeset` | Changeset metadata and diff JSON | +| `GET`, `POST` | `/repo` | `StreamHgWireProtocol` | Mercurial wire protocol | + +## Frontend ownership + +| File | Responsibility | +| --- | --- | +| `src/components/app.tsx` | Client-side routes, landing page, tabs, and history | +| `src/components/directory-browser.tsx` | Breadcrumbs, listings, prefetch, file modals, README rendering | +| `src/components/graph.tsx` | Graph fetching, pagination, canvas edges, panda nodes | +| `src/components/theme.tsx` | Stored light/dark preference and system-theme integration | +| `src/components/header.tsx` | Forge identity and theme control | +| `src/components/footer.tsx` | Shared footer | +| `src/index.css`, `src/base.css` | Custom visual language and layout | + +Keep the custom assets and visual language in this package. New forge screens +should reuse the existing CSS variables, typography, textures, and components +instead of introducing a generic design system. + +## Build and local run + +Build the server and deployable bundle from the repository root: ```bash -bazel build //hg-web:hg_web +bazel build //hg-web:hg_web_server +bazel build //hg-web:hg_web_server_bundle +bazel test //hg-web:tests +bazel test //markdown_converter/tests:markdown_to_html_test +bazel test //seobeo/tests:all +``` + +`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs +the application in pinned Chromium through Playwright. It covers shell routes, +files and README rendering, graph and changeset navigation, browser history, +console and page errors, API validation, Mercurial wire protocol, Markdown +script escaping, static assets, and backend outages. The test invokes the +workspace Mercurial CLI with an isolated configuration and home directory. + +Build and run the complete local stack with one Bazel command: + +```bash +bazel run //hg-web:dev +``` + +This starts `hg serve` on `127.0.0.1:4444` and `hg_web_server` on port `6970`. +Stopping the Bazel target stops both child processes. + +The bundle contains `hg_web_server` and `hg-web/src/`, including generated +`page.js`, markdown WASM, highlight.js, styles, and image assets. + +## Deployment contract + +The expected production layout is: + +```text +nginx + -> hg_web_server.service + -> /opt/hg_web_server_bundle_active/hg_web_server + -> working directory: /opt/hg_web_server_bundle_active + -> hg serve service on 127.0.0.1:4444 ``` -## API Endpoints +`deploy.sh` builds an optimized bundle into a revisioned release directory, +atomically repoints `/opt/hg_web_server_bundle_active`, restarts +`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart +or health check restores the previous release and restarts it. The service, +release root, active path, health URL, user, and group can be overridden with +environment variables. + +## Forge capability tree + +```text +Zenbu Forge +├── Repository +│ ├── File and directory browsing available +│ ├── Syntax highlighting available +│ ├── README rendering available +│ ├── Commit graph available +│ ├── Changeset detail and diff available +│ ├── Branches, bookmarks, and tags planned +│ ├── File history and blame planned +│ └── Search planned +├── Collaboration +│ ├── Authentication and authorization planned +│ ├── Changeset review planned +│ ├── Issues planned +│ └── Releases and artifacts planned +└── Automation + ├── Mercurial incoming/changegroup hook planned + ├── Durable SQLite job queue planned + ├── Isolated Bazel runner planned + ├── Live logs and job status API planned + ├── Forge status and log screens planned + ├── Artifact retention planned + └── Atomic deploy, health check, rollback available +``` + +## Automation data flow -The C server (`main.c`) provides: +The Actions-like subsystem should extend the existing server rather than +replace it: + +```text +hg push + -> Mercurial hook records repository + revision + -> small enqueue command writes a SQLite job + -> runner service claims one queued job + -> isolated shared checkout updates to the exact revision + -> Bazel build and test steps stream logs + -> job result and artifacts are recorded + -> hg-web status API exposes the result + -> custom React screens render runs, steps, logs, and artifacts + -> successful protected jobs may call atomic deployment +``` -- `GET /api/repo/list?path=` - List directory contents -- `GET /api/repo/file?path=` - Fetch file contents +Start with one runner on the same host. Keep the hook fast, never execute build +steps inside the Mercurial request, and make the queue durable before adding +parallel or remote runners. + +## Safe extension order + +1. Add branches, bookmarks, tags, file history, blame, and search. +2. Add the hook, SQLite queue, single runner, and retained logs. +3. Add run/status/log pages using the existing custom UI. +4. Add authentication and authorization before accepting public pushes or + user-defined automation. + +## Invariants + +- Keep public application code on Seobeo APIs. +- Decode and validate every path before forwarding it to Mercurial. +- Keep `/repo` binary-safe; bundle data can contain null bytes. +- Do not buffer large wire-protocol responses through the regular HTTP client. +- Keep backend route names synchronized with frontend fetch and navigation code. +- Preserve accurate status codes, content lengths, and content types. +- Keep secrets in service-owned environment files, not in source or job logs.
--- a/hg-web/deploy.sh Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/deploy.sh Sun Aug 02 16:50:48 2026 -0700 @@ -1,15 +1,82 @@ -#!/bin/bash -# sudo groupadd zenbu_team -- already added -# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server +#!/usr/bin/env bash +set -Eeuo pipefail + +SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}" +RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}" +ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}" +SERVICE_USER="${SERVICE_USER:-hg_web_server}" +SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}" + +workspace="$(hg root)" +cd "$workspace" + +revision="$(hg log -r . -T '{node|short}')" +release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)" +release_dir="${RELEASE_ROOT}/${release_name}" +staging_dir="${RELEASE_ROOT}/.${release_name}.tmp" +next_link="${ACTIVE_PATH}.next" +bundle_dir="bazel-bin/hg-web/hg_web_server_bundle" +previous_release="" +promoted=0 + +health_check() { + for _ in $(seq 1 20); do + if curl --fail --silent --max-time 3 "$HEALTH_URL" >/dev/null; then + return 0 + fi + sleep 1 + done + echo "Health check failed: $HEALTH_URL" >&2 + return 1 +} + +point_active_at() { + local target="$1" + sudo rm -f "$next_link" + sudo ln -s "$target" "$next_link" + sudo mv -Tf "$next_link" "$ACTIVE_PATH" +} + +rollback() { + trap - ERR + if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then + echo "Deployment failed; rolling back to $previous_release" >&2 + point_active_at "$previous_release" + sudo systemctl restart "$SERVICE_NAME" + health_check || echo "Rollback completed, but the health check still fails." >&2 + else + echo "Deployment failed and no previous release is available for rollback." >&2 + fi + sudo rm -rf "$staging_dir" + exit 1 +} +trap rollback ERR + bazel build -c opt //hg-web:hg_web_server_bundle -# Create -sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new -sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new +sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT" +sudo rm -rf "$staging_dir" +sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir" +sudo cp -a "${bundle_dir}/." "$staging_dir/" +sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir" + +sudo test -x "$staging_dir/hg_web_server" +sudo test -f "$staging_dir/hg-web/src/index.html" +sudo test -f "$staging_dir/hg-web/src/page.js" +sudo mv "$staging_dir" "$release_dir" -# Swap -sudo rm -rf /opt/hg_web_server_bundle_active -sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active +if [[ -L "$ACTIVE_PATH" ]]; then + previous_release="$(readlink -f "$ACTIVE_PATH")" +elif [[ -d "$ACTIVE_PATH" ]]; then + previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)" + sudo mv "$ACTIVE_PATH" "$previous_release" +fi -sudo systemctl restart hg_web_server.service -echo "Deployment complete!" +point_active_at "$release_dir" +promoted=1 +sudo systemctl restart "$SERVICE_NAME" +health_check + +trap - ERR +echo "Deployment complete: $release_dir"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/dev.sh Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -n "${RUNFILES_DIR:-}" ]]; then + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +elif [[ -d "$0.runfiles" ]]; then + RUNFILES_DIR="$0.runfiles" + export RUNFILES_DIR + source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash" +elif [[ -f "$0.runfiles_manifest" ]]; then + RUNFILES_MANIFEST_FILE="$0.runfiles_manifest" + export RUNFILES_MANIFEST_FILE + runfiles_library="$( + grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \ + "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2- + )" + source "$runfiles_library" +else + echo "Bazel runfiles are unavailable." >&2 + exit 1 +fi + +workspace="${BUILD_WORKSPACE_DIRECTORY:-}" +if [[ -z "$workspace" || ! -d "$workspace/.hg" ]]; then + echo "Run this target from the Zenbu workspace." >&2 + exit 1 +fi + +server="$(rlocation _main/hg-web/hg_web_server)" +runfiles_workspace="$(dirname "$(dirname "$server")")" +hg_pid="" +server_pid="" + +cleanup() { + trap - EXIT INT TERM + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + for pid in "$server_pid" "$hg_pid"; do + if [[ -n "$pid" ]]; then + wait "$pid" 2>/dev/null || true + fi + done +} + +trap cleanup EXIT +trap 'exit 130' INT TERM + +hg --repository "$workspace" serve \ + --address 127.0.0.1 \ + --port 4444 \ + --accesslog - \ + --errorlog - & +hg_pid=$! + +( + cd "$runfiles_workspace" + exec "$server" +) & +server_pid=$! + +echo "hg-web: http://127.0.0.1:6970" +echo "Mercurial wire endpoint: http://127.0.0.1:6970/repo" +echo "Press Ctrl-C to stop both servers." + +set +e +wait -n "$hg_pid" "$server_pid" +status=$? +set -e +exit "$status"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,31 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@hg_web_npm//:defs.bzl", "npm_link_all_packages") + +exports_files([ + "package-lock.json", + "package.json", + "pnpm-lock.yaml", +]) + +npm_link_all_packages(name = "node_modules") + +js_test( + name = "app_e2e_test", + entry_point = "app_e2e_test.js", + data = [ + ":node_modules/playwright-core", + "//hg-web:hg_web_server", + "@playwright_chromium_linux//:chromium", + "@playwright_chromium_linux//:chrome", + ], + 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", +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,417 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); +const { chromium } = require('playwright-core'); + +const BASE_URL = 'http://127.0.0.1:6970'; +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`, + ); + } + return result.stdout.trim(); +} + +function mercurialEnvironment(home) { + return { + ...process.env, + HGPLAIN: '1', + HGRCPATH: '', + HOME: home, + }; +} + +function stopProcess(child) { + if (!child || child.exitCode !== null) return Promise.resolve(); + child.kill('SIGTERM'); + return new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForHttp(url, child, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(url); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`); +} + +function createFixtureRepository(root) { + const options = { env: mercurialEnvironment(root) }; + run('hg', ['init', root], options); + fs.mkdirSync(path.join(root, 'docs'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n', + ); + fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); + fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); + fs.writeFileSync( + path.join(root, 'BUILD'), + 'cc_library(\n name = "fixture",\n srcs = ["src/main.c"],\n)\n', + ); + fs.writeFileSync( + path.join(root, 'pixel.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', + 'base64', + ), + ); + fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex')); + fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3')); + fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n')); + fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3])); + run('hg', ['--repository', root, 'add'], options); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options); + const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + + fs.writeFileSync( + path.join(root, 'README.md'), + '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n', + ); + fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); + run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); + run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options); + const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); + return { firstNode, tipNode }; +} + +async function assertJson(pathname, status = 200) { + const response = await fetch(`${BASE_URL}${pathname}`); + const body = await response.text(); + assert.equal(response.status, status, `${pathname} status: ${body}`); + return JSON.parse(body); +} + +async function assertPageHasNoBrowserErrors(browser, pathname, assertion) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`)); + page.on('console', message => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('requestfailed', request => { + errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' }); + assert.equal(response.status(), 200, `${pathname} document status`); + await assertion(page); + if (errors.length > 0) { + throw new Error(`${pathname} browser errors\n${errors.join('\n')}`); + } + await page.close(); +} + +async function main() { + assert.ok(RUNFILES, 'rules_js runfiles path is required'); + assert.ok(WORKSPACE, 'rules_js workspace name is required'); + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-')); + const appLogs = []; + const hgLogs = []; + let hgServer; + let appServer; + let browser; + + try { + const { firstNode, tipNode } = createFixtureRepository(fixtureRoot); + const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); + const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server'); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + + assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`); + assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`); + + hgServer = spawn( + 'hg', + [ + '--repository', fixtureRoot, + 'serve', + '--address', '127.0.0.1', + '--port', '4444', + '--accesslog', '-', + '--errorlog', '-', + ], + { + env: mercurialEnvironment(fixtureRoot), + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString())); + hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString())); + + appServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + stdio: ['ignore', 'pipe', 'pipe'], + }); + appServer.stdout.on('data', chunk => appLogs.push(chunk.toString())); + appServer.stderr.on('data', chunk => appLogs.push(chunk.toString())); + + await waitForHttp(`${BASE_URL}/`, appServer, appLogs); + if (hgServer.exitCode !== null) { + throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`); + } + await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs); + + for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} shell route`); + assert.match(await response.text(), /<title>Zenbu Repository<\/title>/); + } + for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 200, `${pathname} static asset`); + assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`); + } + for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) { + assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404); + } + + const rootList = await assertJson('/api/repo/list'); + assert.ok(rootList.directories.some(entry => entry.basename === 'docs')); + assert.ok(rootList.files.some(entry => entry.basename === 'README.md')); + + const nestedList = await assertJson('/api/repo/list?path=docs'); + assert.ok(nestedList.files.some(entry => entry.basename === 'README.md')); + + const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`); + assert.equal(fileResponse.status, 200); + assert.match(await fileResponse.text(), /int main/); + + for (const [filename, contentType, disposition] of [ + ['pixel.png', 'image/png', 'inline'], + ['movie.mp4', 'video/mp4', 'inline'], + ['sound.mp3', 'audio/mpeg', 'inline'], + ['document.pdf', 'application/pdf', 'inline'], + ['archive.bin', 'application/octet-stream', 'attachment'], + ]) { + const response = await fetch( + `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`, + ); + assert.equal(response.status, 200, `${filename} response`); + assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`)); + assert.equal(response.headers.get('content-disposition'), disposition); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.ok((await response.arrayBuffer()).byteLength > 0); + } + const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`); + assert.deepEqual( + Buffer.from(await binaryResponse.arrayBuffer()), + Buffer.from([0, 1, 2, 3]), + ); + + const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`); + assert.equal(readmeResponse.status, 200); + assert.match(await readmeResponse.text(), /Nested README/); + assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204); + + const graph = await assertJson('/api/graph/tip?style=json'); + assert.equal(graph.node, tipNode); + assert.ok(graph.changesets.length >= 2); + + const changeset = await assertJson(`/api/changeset/${tipNode}`); + assert.equal(changeset.node, tipNode); + assert.equal(changeset.desc, 'Update fixture'); + assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added')); + assert.ok(changeset.diff.length > 0); + + for (const pathname of [ + '/api/repo/list?path=..%2Fetc', + '/api/repo/file?path=..%2FREADME.md', + '/api/graph/not-a-node?style=json', + '/api/changeset/not-a-node', + ]) { + const response = await fetch(`${BASE_URL}${pathname}`); + assert.equal(response.status, 400, `${pathname} rejects invalid input`); + } + assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404); + + const identify = run( + 'hg', + ['identify', `${BASE_URL}/repo`], + { env: mercurialEnvironment(fixtureRoot) }, + ); + assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`)); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + + await assertPageHasNoBrowserErrors(browser, '/', async page => { + await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor(); + await page.getByText('Recent Commits').waitFor(); + await page.getByText('Repository Files').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + assert.equal( + await page.locator('.graph-container').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.locator('.theme-toggle').click(); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory', async page => { + await page.getByText('Repository Files').waitFor(); + await page.getByRole('link', { name: 'README.md' }).first().click(); + await page.getByText('Updated Fixture Repository').waitFor(); + assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); + await page.keyboard.press('Escape'); + + let delayedReadmeRequested = false; + let markReadmeRequested; + const readmeRequested = new Promise(resolve => { + markReadmeRequested = resolve; + }); + await page.route(/\/api\/repo\/readme\?path=/, async route => { + delayedReadmeRequested = true; + markReadmeRequested(); + await new Promise(resolve => setTimeout(resolve, 500)); + await route.continue(); + }); + await page.getByRole('link', { name: 'docs' }).click(); + await Promise.race([ + readmeRequested, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000); + }), + ]); + await page.getByRole('link', { name: 'root' }).click(); + await page.getByText('Updated Fixture Repository').waitFor(); + await page.waitForTimeout(600); + assert.equal(await page.getByText('Documentation').count(), 0); + assert.equal(delayedReadmeRequested, true); + + await page.getByRole('link', { name: 'pixel.png' }).click(); + const image = page.locator('.static-file-image'); + await image.waitFor(); + await image.evaluate(element => { + const imageElement = element; + if (imageElement.complete) return; + return new Promise((resolve, reject) => { + imageElement.addEventListener('load', resolve, { once: true }); + imageElement.addEventListener('error', reject, { once: true }); + }); + }); + assert.equal(await image.evaluate(element => element.naturalWidth), 1); + await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor(); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'BUILD' }).click(); + const buildCode = page.locator('code.language-python'); + await buildCode.waitFor(); + await page.getByText('cc_library').waitFor(); + assert.match(await buildCode.textContent(), /name = "fixture"/); + await page.keyboard.press('Escape'); + + await page.getByRole('link', { name: 'src' }).click(); + await page.getByRole('link', { name: 'main.c' }).click(); + await page.getByText('int main(void)').waitFor(); + await page.keyboard.press('Escape'); + }); + + await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => { + await page.getByText('Documentation').waitFor(); + await page.getByRole('link', { name: 'README.md' }).click(); + await page.getByText('Nested README.').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/graph', async page => { + await page.getByText('Commit Graph').waitFor(); + await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip')); + const graphUrl = page.url(); + const rows = page.locator('.graph-row'); + await rows.first().click(); + await page.waitForURL(/\/changeset\/[0-9a-f]+$/); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForFunction(expected => window.location.href === expected, graphUrl); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => { + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); + await page.getByText('added', { exact: true }).waitFor(); + await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.locator('.diff-column-headings').getByText('Before').first().waitFor(); + await page.locator('.diff-column-headings').getByText('After').first().waitFor(); + await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor(); + await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor(); + assert.equal( + await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(), + '<script>window.__hgWebXss = true</script>', + ); + assert.equal( + await page.locator('.changeset-paper').evaluate( + element => getComputedStyle(element).backgroundImage, + ), + 'none', + ); + await page.getByRole('button', { name: firstNode.slice(0, 12) }).click(); + await page.waitForFunction( + expectedPath => window.location.pathname === expectedPath, + `/changeset/${firstNode}`, + ); + await page.getByRole('heading', { name: 'Initial fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('heading', { name: 'Update fixture' }).waitFor(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByText('Commit Graph').waitFor(); + }); + + await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => { + await page.getByText('Recent Commits').waitFor(); + }); + + await stopProcess(hgServer); + const unavailable = await fetch(`${BASE_URL}/api/repo/list`); + assert.equal(unavailable.status, 502); + assert.equal((await fetch(`${BASE_URL}/`)).status, 200); + } finally { + if (browser) await browser.close(); + await stopProcess(appServer); + await stopProcess(hgServer); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package-lock.json Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,24 @@ +{ + "name": "e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "playwright-core": "1.48.2" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/package.json Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "playwright-core": "1.48.2" + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/e2e/pnpm-lock.yaml Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,18 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + playwright-core: + specifier: 1.48.2 + version: 1.48.2 + +packages: + + /[email protected]: + resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==} + engines: {node: '>=18'} + hasBin: true + dev: false
--- a/hg-web/main.c Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/main.c Sun Aug 02 16:50:48 2026 -0700 @@ -1,366 +1,780 @@ #include "seobeo/seobeo.h" #include "dowa/dowa.h" + +#include <ctype.h> +#include <errno.h> #include <stdio.h> #include <stdlib.h> #include <string.h> -#include <ctype.h> +#include <strings.h> +#include <time.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 HG_API_TIMEOUT_MS 15000 +#define HG_STREAM_IDLE_TIMEOUT_MS 60000 +#define MAX_PATH_LENGTH 4096 +#define MAX_WIRE_QUERY_LENGTH 8192 +#define MAX_WIRE_HEADER_LENGTH 8192 -#define MAX_PATH 4096 +static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key) +{ + if (!map || !key) + return NULL; + + for (size_t i = 0; i < Dowa_Array_Length(map); i++) + { + if (map[i].key && strcasecmp(map[i].key, key) == 0) + return map[i].value; + } + return NULL; +} -static char* sanitize_path(const char *input_path, Dowa_Arena *arena) +static char *arena_string(Dowa_Arena *arena, const char *value) +{ + size_t length = strlen(value); + char *copy = Dowa_Arena_Allocate(arena, length + 1); + memcpy(copy, value, length + 1); + return copy; +} + +static Seobeo_Request_Entry *text_response( + Dowa_Arena *arena, + const char *status, + const char *content_type, + const char *body) +{ + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena); + Dowa_HashMap_Push_Arena( + response, "content-type", arena_string(arena, content_type), arena); + Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena); + return response; +} + +static boolean decode_url_component( + const char *encoded, + Dowa_Arena *arena, + char **decoded_out, + size_t *decoded_length_out) { - if (!input_path || strlen(input_path) == 0) + if (!encoded || !decoded_out) + return FALSE; + + size_t encoded_length = strlen(encoded); + if (encoded_length >= MAX_PATH_LENGTH) + return FALSE; + + char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1); + size_t output_length = 0; + for (size_t i = 0; i < encoded_length; i++) { - char *empty = Dowa_Arena_Allocate(arena, 1); - empty[0] = '\0'; - return empty; + unsigned char value = (unsigned char)encoded[i]; + if (encoded[i] == '%') + { + if (i + 2 >= encoded_length || + !isxdigit((unsigned char)encoded[i + 1]) || + !isxdigit((unsigned char)encoded[i + 2])) + return FALSE; + + char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'}; + value = (unsigned char)strtoul(hex, NULL, 16); + i += 2; + if (value == '\0') + return FALSE; + } + decoded[output_length++] = (char)value; + } + decoded[output_length] = '\0'; + + *decoded_out = decoded; + if (decoded_length_out) + *decoded_length_out = output_length; + return TRUE; +} + +static boolean normalize_repository_path( + const char *encoded_path, + Dowa_Arena *arena, + char **normalized_out) +{ + char *decoded = NULL; + size_t decoded_length = 0; + if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length)) + return FALSE; + + size_t start = 0; + size_t end = decoded_length; + if (start < end && decoded[start] == '/') + { + start++; + if (start < end && decoded[start] == '/') + return FALSE; + } + if (end > start && decoded[end - 1] == '/') + { + if (end - 1 > start && decoded[end - 2] == '/') + return FALSE; + end--; + } + + size_t segment_start = start; + for (size_t i = start; i <= end; i++) + { + boolean at_end = i == end; + unsigned char c = at_end ? '/' : (unsigned char)decoded[i]; + if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#')) + return FALSE; + + if (c == '/') + { + size_t segment_length = i - segment_start; + if (segment_length == 0 && !at_end) + return FALSE; + if ((segment_length == 1 && decoded[segment_start] == '.') || + (segment_length == 2 && decoded[segment_start] == '.' && + decoded[segment_start + 1] == '.')) + return FALSE; + segment_start = i + 1; + } } - size_t len = strlen(input_path); - char *result = Dowa_Arena_Allocate(arena, len + 1); - size_t j = 0; + size_t normalized_length = end - start; + char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1); + memcpy(normalized, decoded + start, normalized_length); + normalized[normalized_length] = '\0'; + *normalized_out = normalized; + return TRUE; +} + +static boolean validate_revision(const char *revision) +{ + if (!revision || revision[0] == '\0') + return FALSE; + if (strcmp(revision, "tip") == 0) + return TRUE; - for (size_t i = 0; i < len; i++) + size_t length = strlen(revision); + if (length > 40) + return FALSE; + for (size_t i = 0; i < length; i++) { - if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) + if (!isxdigit((unsigned char)revision[i])) + return FALSE; + } + return TRUE; +} + +static char *encode_repository_path(const char *path, Dowa_Arena *arena) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t length = strlen(path); + char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1); + size_t output = 0; + for (size_t i = 0; i < length; i++) + { + unsigned char c = (unsigned char)path[i]; + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + encoded[output++] = (char)c; + else { - if (i + 1 < len && input_path[i+1] == '.') - { - // Skip ".." - i++; - continue; - } - // Skip "." - continue; + encoded[output++] = '%'; + encoded[output++] = hex[c >> 4]; + encoded[output++] = hex[c & 0x0F]; } - result[j++] = input_path[i]; } - result[j] = '\0'; + encoded[output] = '\0'; + return encoded; +} - // Remove leading/trailing slashes - while (result[0] == '/') - memmove(result, result + 1, strlen(result)); - while (j > 0 && result[j-1] == '/') - result[--j] = '\0'; +static boolean safe_header_value(const char *value, size_t maximum_length) +{ + if (!value) + return TRUE; + size_t length = strlen(value); + return length <= maximum_length && + strchr(value, '\r') == NULL && + strchr(value, '\n') == NULL; +} - return result; +static boolean extension_is(const char *extension, const char *expected) +{ + return extension && strcasecmp(extension, expected) == 0; } -Seobeo_Client_Response *hg_proxy_request( - const char *method, - const char *path, - const char *req_body, - const char *hg_custom) +static const char *repository_file_content_type( + const char *path, + boolean *inline_preview, + boolean *sandbox_content) { - char full_path[MAX_PATH]; - snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path); - Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path); - Seobeo_Client_Request_Set_Method(p_req, method); - Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0"); - Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json"); + const char *extension = strrchr(path, '.'); + *inline_preview = TRUE; + *sandbox_content = FALSE; - if (hg_custom && hg_custom[0] != '\0') + if (extension_is(extension, ".png")) return "image/png"; + if (extension_is(extension, ".jpg") || + extension_is(extension, ".jpeg")) return "image/jpeg"; + if (extension_is(extension, ".gif")) return "image/gif"; + if (extension_is(extension, ".webp")) return "image/webp"; + if (extension_is(extension, ".avif")) return "image/avif"; + if (extension_is(extension, ".bmp")) return "image/bmp"; + if (extension_is(extension, ".ico")) return "image/x-icon"; + if (extension_is(extension, ".svg")) { - char buffer[1024]; - snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom); - Seobeo_Client_Request_Add_Header_Array(p_req, buffer); - Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer); + *sandbox_content = TRUE; + return "image/svg+xml"; } + if (extension_is(extension, ".mp4") || + extension_is(extension, ".m4v")) return "video/mp4"; + if (extension_is(extension, ".webm")) return "video/webm"; + if (extension_is(extension, ".mov")) return "video/quicktime"; + if (extension_is(extension, ".ogv")) return "video/ogg"; + if (extension_is(extension, ".mp3")) return "audio/mpeg"; + if (extension_is(extension, ".wav")) return "audio/wav"; + if (extension_is(extension, ".ogg") || + extension_is(extension, ".oga")) return "audio/ogg"; + if (extension_is(extension, ".flac")) return "audio/flac"; + if (extension_is(extension, ".m4a")) return "audio/mp4"; + if (extension_is(extension, ".aac")) return "audio/aac"; + if (extension_is(extension, ".pdf")) return "application/pdf"; + if (extension_is(extension, ".wasm")) return "application/wasm"; - if (req_body) - Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body)); - Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); - Seobeo_Client_Request_Destroy(p_req); - return p_resp; + *inline_preview = FALSE; + if (extension_is(extension, ".md") || + extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8"; + if (extension_is(extension, ".txt") || + extension_is(extension, ".log") || + extension_is(extension, ".c") || + extension_is(extension, ".h") || + extension_is(extension, ".cc") || + extension_is(extension, ".cpp") || + extension_is(extension, ".js") || + extension_is(extension, ".jsx") || + extension_is(extension, ".ts") || + extension_is(extension, ".tsx") || + extension_is(extension, ".css") || + extension_is(extension, ".html") || + extension_is(extension, ".htm") || + extension_is(extension, ".xml") || + extension_is(extension, ".json") || + extension_is(extension, ".yaml") || + extension_is(extension, ".yml") || + extension_is(extension, ".toml") || + extension_is(extension, ".sh") || + extension_is(extension, ".py") || + extension_is(extension, ".rs") || + extension_is(extension, ".go")) + return "text/plain; charset=utf-8"; + return "application/octet-stream"; } -Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena) +static Seobeo_Client_Response *hg_proxy_request( + const char *method, + const char *path, + const char *request_body, + size_t request_body_length, + const char *hg_argument, + const char *accept) { - 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); - Seobeo_Url_Decode(decoded_path, rel_path); - - char *safe_path = sanitize_path(decoded_path, arena); + char url[MAX_PATH_LENGTH]; + int url_length = snprintf( + url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path); + if (url_length < 0 || (size_t)url_length >= sizeof(url)) + return NULL; - Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + if (!request) + return NULL; - 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"); + Seobeo_Client_Request_Set_Method(request, method); + Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0"); + Seobeo_Client_Request_Add_Header_Map( + request, "Accept", accept ? accept : "application/json"); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); - - Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); - - if (hg_response->status_code != 200) + if (hg_argument && hg_argument[0] != '\0') { - 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 *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, 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); - Seobeo_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; + if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH)) + { + Seobeo_Client_Request_Destroy(request); + return NULL; + } + Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument); } - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path); - Seobeo_Client_Response *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL); + if (request_body && request_body_length > 0) + Seobeo_Client_Request_Set_Body(request, request_body, request_body_length); + + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + Seobeo_Client_Request_Destroy(request); + return response; +} + +static Seobeo_Request_Entry *forward_hg_response( + Seobeo_Client_Response *hg_response, + const char *default_content_type, + const char *override_content_type, + Dowa_Arena *arena) +{ + if (!hg_response) + return text_response( + arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}"); + + const char *upstream_content_type = + map_value_case_insensitive(hg_response->headers, "Content-Type"); + const char *upstream_or_default_content_type = + override_content_type + ? override_content_type + : upstream_content_type ? upstream_content_type : default_content_type; + if (!upstream_or_default_content_type) + upstream_or_default_content_type = "application/octet-stream"; + char *content_type = arena_string(arena, upstream_or_default_content_type); + + char *status = Dowa_Arena_Allocate(arena, 8); + snprintf(status, 8, "%d", hg_response->status_code); + + size_t body_length = hg_response->body ? hg_response->body_length : 0; + char *body = Dowa_Arena_Allocate(arena, body_length + 1); + if (body_length > 0) + memcpy(body, hg_response->body, body_length); + body[body_length] = '\0'; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", body_length); + + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", status, arena); + Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena); + Dowa_HashMap_Push_Arena(response, "body", body, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + Seobeo_Client_Response_Destroy(hg_response); + return response; +} + +Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf( + hg_path, + sizeof(hg_path), + encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json", + encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}"); - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length); + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} + +Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *path = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) || + path[0] == '\0') + return text_response(arena, "400", "text/plain", "A valid file path is required"); + + char *encoded = encode_repository_path(path, arena); + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "File path is too long"); + + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream"); + if (!hg_response) + return forward_hg_response(NULL, "application/json", NULL, arena); - char status[4]; - snprintf(status, 4, "%i", hg_response->status_code); + boolean inline_preview = FALSE; + boolean sandbox_content = FALSE; + const char *content_type = + repository_file_content_type(path, &inline_preview, &sandbox_content); + Seobeo_Request_Entry *response = forward_hg_response( + hg_response, "application/octet-stream", content_type, arena); + Dowa_HashMap_Push_Arena( + response, + "Content-Disposition", + inline_preview ? "inline" : "attachment", + arena); + Dowa_HashMap_Push_Arena( + response, "X-Content-Type-Options", "nosniff", arena); + if (sandbox_content) + Dowa_HashMap_Push_Arena( + response, "Content-Security-Policy", "sandbox", arena); + return response; +} - if (!hg_response->body) +Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *encoded_path = map_value_case_insensitive(request, "query_path"); + char *directory = NULL; + if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory)) + return text_response(arena, "400", "text/plain", "Invalid repository path"); + + size_t readme_length = strlen(directory) + strlen("/README.md") + 1; + if (readme_length >= MAX_PATH_LENGTH) + return text_response(arena, "400", "text/plain", "README path is too long"); + + char *readme_path = Dowa_Arena_Allocate(arena, readme_length); + snprintf( + readme_path, + readme_length, + directory[0] ? "%s/README.md" : "README.md", + directory); + char *encoded = encode_repository_path(readme_path, arena); + + char hg_path[MAX_PATH_LENGTH]; + int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "text/plain", "README path is too long"); + + Seobeo_Client_Response *hg_response = + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown"); + if (hg_response && hg_response->status_code == HTTP_NOT_FOUND) { - 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; + Seobeo_Client_Response_Destroy(hg_response); + return text_response(arena, "204", "text/markdown", ""); } + return forward_hg_response( + hg_response, "text/markdown", "text/markdown; charset=utf-8", arena); +} - if (hg_response->status_code != 200) +Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *graph_id = map_value_case_insensitive(request, ":graph_id"); + if (!validate_revision(graph_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}"); + + const char *encoded_graph_top = + map_value_case_insensitive(request, "query_graphtop"); + char *graph_top = NULL; + if (encoded_graph_top) { - Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body); - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena); - return resp; + if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) || + !validate_revision(graph_top)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}"); } - - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); + char hg_path[MAX_PATH_LENGTH]; + int length = graph_top + ? snprintf( + hg_path, + sizeof(hg_path), + "/graph/%s?graphtop=%s&style=json", + graph_id, + graph_top) + : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}"); - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); } -Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) { - return ApiGetFile(req, arena); +Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *changeset_id = map_value_case_insensitive(request, ":changeset_id"); + if (!validate_revision(changeset_id)) + return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}"); + + char hg_path[128]; + int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id); + if (length < 0 || (size_t)length >= sizeof(hg_path)) + return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}"); + + return forward_hg_response( + hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"), + "application/json", + NULL, + arena); +} + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static size_t find_http_header_length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; } -// Streaming handler for hg wire protocol - pipes data directly without buffering -void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena) +static void send_proxy_error(Seobeo_Handle *client, int status, const char *message) { - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); - const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : ""; - - const char *hg_custom = req[7].value; + const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway"; + char response[512]; + int length = snprintf( + response, + sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n" + "\r\n" + "%s", + status, + reason, + strlen(message), + message); + if (length > 0 && (size_t)length < sizeof(response)) + { + Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length); + Seobeo_Handle_Flush(client); + } +} - Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string); +static boolean parse_content_length(const char *value, size_t *length_out) +{ + if (!value || !length_out || value[0] == '\0') + return FALSE; + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX) + return FALSE; + *length_out = (size_t)parsed; + return TRUE; +} - // THINKING: Connect to hg serve - // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in - // buffer and what not. - Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); - if (!p_upstream || p_upstream->socket < 0) +void StreamHgWireProtocol( + Seobeo_Handle *client, + Seobeo_Request_Entry *request, + Dowa_Arena *arena) +{ + (void)arena; + const char *method = map_value_case_insensitive(request, "HTTP_Method"); + const char *query = map_value_case_insensitive(request, "QueryString"); + const char *body = map_value_case_insensitive(request, "Body"); + const char *content_length_value = + map_value_case_insensitive(request, "Content-Length"); + const char *content_type = map_value_case_insensitive(request, "Content-Type"); + const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1"); + + if (!method || !query || + (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) || + !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) || + !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) || + !safe_header_value(content_type, 256)) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - if (p_upstream) - Seobeo_Handle_Destroy(p_upstream); + send_proxy_error(client, 502, "Invalid Mercurial proxy request"); return; } - // Create headers - // we only allow x-hgarg-1 and content-length - char request_buf[8192]; - int req_len = snprintf(request_buf, sizeof(request_buf), - "%s /?%s HTTP/1.1\r\n" - "Host: %s:%s\r\n" - "User-Agent: Seobeo/1.0\r\n" - "Connection: close\r\n", - method, query_string, HG_SERVE_HOST, HG_SERVE_PORT); + size_t body_length = 0; + if (content_length_value && + !parse_content_length(content_length_value, &body_length)) + { + send_proxy_error(client, 502, "Invalid Mercurial request length"); + return; + } + if (body_length > 0 && !body) + { + send_proxy_error(client, 502, "Missing Mercurial request body"); + return; + } + if (body_length > UINT32_MAX) + { + send_proxy_error(client, 502, "Mercurial request body is too large"); + return; + } - if (hg_custom && hg_custom[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom); + Seobeo_Handle *upstream = + Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE); + if (!upstream || upstream->socket < 0) + { + if (upstream) + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend unavailable"); + return; + } - if (req_body && req_body[0] != '\0') - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body); - else - req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n"); + char request_header[16384]; + int header_length = snprintf( + request_header, + sizeof(request_header), + "%s /?%s HTTP/1.1\r\n" + "Host: %s:%s\r\n" + "User-Agent: Seobeo/1.0\r\n" + "Connection: close\r\n", + method, + query, + HG_SERVE_HOST, + HG_SERVE_PORT); + if (header_length < 0 || (size_t)header_length >= sizeof(request_header)) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial request headers are too large"); + return; + } - Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len); - if (Seobeo_Handle_Flush(p_upstream) < 0) +#define APPEND_WIRE_HEADER(...) \ + do { \ + int appended = snprintf( \ + request_header + header_length, \ + sizeof(request_header) - (size_t)header_length, \ + __VA_ARGS__); \ + if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \ + Seobeo_Handle_Destroy(upstream); \ + send_proxy_error(client, 502, "Mercurial request headers are too large"); \ + return; \ + } \ + header_length += appended; \ + } while (0) + + if (hg_argument && hg_argument[0] != '\0') + APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument); + if (content_type && content_type[0] != '\0') + APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type); + if (body_length > 0) + APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length); + APPEND_WIRE_HEADER("\r\n"); +#undef APPEND_WIRE_HEADER + + if (Seobeo_Handle_Queue( + upstream, (const uint8 *)request_header, (uint32)header_length) != 0 || + (body_length > 0 && + Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) || + Seobeo_Handle_Flush(upstream) != 0) { - const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed"; - Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp)); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend write failed"); return; } - // Responses - while (1) + boolean response_started = FALSE; + int64_t last_progress = monotonic_milliseconds(); + while (!response_started) { - int r = Seobeo_Handle_Read(p_upstream); - if (r < 0) + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2 || read_result < 0) { - Seobeo_Handle_Destroy(p_upstream); + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 502, "Mercurial backend closed before responding"); return; } - if (p_upstream->read_buffer_len >= 4 && - strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL) - break; - if (r == 0) - continue; - } - - // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows. - char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n"); - if (!hdr_end) - { - Seobeo_Handle_Destroy(p_upstream); - return; - } - size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4; - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len); - Seobeo_Handle_Flush(p_client); + if (read_result > 0) + last_progress = monotonic_milliseconds(); - // All body - size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len; - if (body_in_buffer > 0) - { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer); - Seobeo_Handle_Flush(p_client); - } - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); - while (1) - { - int n = Seobeo_Handle_Read(p_upstream); - if (n > 0) + size_t header_size = + find_http_header_length(upstream->read_buffer, upstream->read_buffer_len); + if (header_size > 0) { - Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len); - Seobeo_Handle_Flush(p_client); - Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len); + (void)header_size; + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + { + Seobeo_Handle_Destroy(upstream); + return; + } + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + response_started = TRUE; + break; } - else if (n == -2) - break; - else if (n < 0) - break; + + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Handle_Destroy(upstream); + send_proxy_error(client, 504, "Mercurial backend response timed out"); + return; + } + usleep(1000); + } } - Seobeo_Handle_Destroy(p_upstream); + while (TRUE) + { + int read_result = Seobeo_Handle_Read(upstream); + if (read_result == -2) + break; + if (read_result < 0) + break; + if (read_result == 0) + { + if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS) + { + Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n"); + break; + } + usleep(1000); + continue; + } + + last_progress = monotonic_milliseconds(); + if (Seobeo_Handle_Queue( + client, upstream->read_buffer, upstream->read_buffer_len) != 0 || + Seobeo_Handle_Flush(client) != 0) + break; + Seobeo_Handle_Consume(upstream, upstream->read_buffer_len); + } + + Seobeo_Handle_Destroy(upstream); } -Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena) +Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena) { - Seobeo_Request_Entry *resp = NULL; - - void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method"); - const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET"; - - void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString"); - const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : ""; - - 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); - - const char *hg_custom = req[7].value; - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len); - - Seobeo_Client_Response *hg_response; + (void)request; + size_t file_size = 0; + char *html = Seobeo_Web_LoadFile("/index.html", &file_size); + if (!html) + return text_response(arena, "500", "text/plain", "Application shell unavailable"); - char hg_path[MAX_PATH]; - snprintf(hg_path, sizeof(hg_path), "/?%s", query_string); - - hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom); - - Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length); - - Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type"); - - char *status = Dowa_Arena_Allocate(arena, 5); - snprintf(status, 4, "%i", hg_response->status_code); - - // Use binary-safe copy to handle null bytes in mercurial bundle data - char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length); - char *temp2 = Dowa_Arena_Allocate(arena, 256); - snprintf(temp2, 256, "%zu", hg_response->body_length); - - Dowa_HashMap_Push_Arena(resp, "status", status, arena); - Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena); - Dowa_HashMap_Push_Arena(resp, "body", temp1, arena); - Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena); - - return resp; + Seobeo_Request_Entry *response = NULL; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", file_size); + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena); + Dowa_HashMap_Push_Arena(response, "body", html, arena); + Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena); + return response; } -int main(void) { +int main(void) +{ Seobeo_Router_Init(); + Seobeo_Router_Register("GET", "/", GetReactHome); + Seobeo_Router_Register("GET", "/directories", GetReactHome); + Seobeo_Router_Register("GET", "/directory", GetReactHome); + Seobeo_Router_Register("GET", "/graph", GetReactHome); + Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome); + 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", "/api/graph/:graph_id", ApiGetGraph); + Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset); - // Use streaming handler for hg wire protocol... Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol); Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol); printf("Starting on Port 6970...\n"); - - int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); - + int result = + Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1); Seobeo_Router_Destroy(); - return result; }
--- a/hg-web/src/base.css Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/src/base.css Sun Aug 02 16:50:48 2026 -0700 @@ -1,123 +1,169 @@ -/* --- Colors ---*/ +/* Reset CSS: https://meyerweb.com/eric/tools/css/reset/ */ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} + +/* =========================================== + Base CSS - Color Variables and Basic Setup + =========================================== */ + +/* Light mode (default) */ :root { --bg: #ffffff; - --fg: #1a1a1a; - --border: #e0e0e0; - --hover: #f5f5f5; - --accent: #0066cc; - --accent-hover: #0052a3; - --secondary: #6c757d; - --success: #28a745; - --warning: #ffc107; - --danger: #dc3545; - --code-bg: #f6f8fa; - --link: #0066cc; - --link-hover: #0052a3; + --bg-subtle: #f6f8fa; + --bg-code: #f6f8fa; + --border: #d0d7de; + --accent: #0969da; + --text-primary: #1f2328; + --text-secondary: #656d76; + --hover: #f3f4f6; + --success: #1a7f37; + --danger: #cf222e; + --danger-bg: #ffebe9; + --danger-border: #ffdce0; + --overlay: rgba(0, 0, 0, 0.5); + + /* Graph colors - light mode */ + --graph-1: #495057; + --graph-2: #1971c2; + --graph-3: #099268; + --graph-4: #e67700; + --graph-5: #7048e8; + --graph-6: #c92a2a; + --graph-7: #c2255c; + --graph-node-border: #ffffff; } -.dark { +/* Dark mode - applied when html has .dark class */ +:root.dark { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + /* Graph colors - dark mode */ + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } +/* System preference fallback (when no explicit class is set) */ @media (prefers-color-scheme: dark) { - :root:not(.light-mode) { + :root:not(.light) { --bg: #0d1117; - --fg: #c9d1d9; + --bg-subtle: #161b22; + --bg-code: #161b22; --border: #30363d; - --hover: #161b22; --accent: #58a6ff; - --accent-hover: #79c0ff; - --secondary: #8b949e; - --success: #3fb950; - --warning: #d29922; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --hover: #1c2128; + --success: #238636; --danger: #f85149; - --code-bg: #161b22; - --link: #58a6ff; - --link-hover: #79c0ff; + --danger-bg: #f8514926; + --danger-border: #f8514966; + --overlay: rgba(0, 0, 0, 0.7); + + --graph-1: #868e96; + --graph-2: #4dabf7; + --graph-3: #63e6be; + --graph-4: #ffbc42; + --graph-5: #b197fc; + --graph-6: #ff8787; + --graph-7: #f06595; + --graph-node-border: #1a1a1a; } } -/* --- Reset and Base Styles --- */ +/* Fonts */ +@font-face { + font-family: "Roboto"; + src: url("/public/fonts/Roboto-Regular.ttf"); +} +@font-face { + font-family: "Roboto Light"; + src: url("/public/fonts/Roboto-Thin.ttf"); +} +@font-face { + font-family: "More Thin"; + src: url("/public/fonts/more-sugar.thin.otf"); +} +@font-face { + font-family: "More"; + src: url("/public/fonts/more-sugar.regular.otf"); +} + +button { + font-family: "More Thin", sans-serif; +} + +/* Reset and Base */ * { margin: 0; padding: 0; box-sizing: border-box; } -html { - background: var(--bg); - color: var(--fg); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; - line-height: 1.6; +html, body { background: var(--bg); - color: var(--fg); - font-size: 16px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -main { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; + color: var(--text-primary); + font-family: "More Thin", sans-serif; + line-height: 1.6; + transition: background 0.2s, color 0.2s; } a { - color: var(--link); + color: var(--accent); text-decoration: none; } a:hover { - color: var(--link-hover); text-decoration: underline; } -h1, h2, h3, h4, h5, h6 { - margin-bottom: 1rem; - font-weight: 600; - line-height: 1.25; -} - -h1 { font-size: 2rem; } -h2 { font-size: 1.75rem; } -h3 { font-size: 1.5rem; } -h4 { font-size: 1.25rem; } -h5 { font-size: 1.1rem; } -h6 { font-size: 1rem; } - -p { - margin-bottom: 1rem; -} - code { - background: var(--code-bg); + background: var(--bg-code); padding: 0.2em 0.4em; border-radius: 3px; - font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9em; } pre { - background: var(--code-bg); + background: var(--bg-code); padding: 1rem; border-radius: 6px; overflow-x: auto; - margin-bottom: 1rem; } pre code { @@ -125,17 +171,18 @@ padding: 0; } -/* Mobile responsive */ -@media (max-width: 768px) { - body { - font-size: 14px; - } +/* Icon invert for dark mode */ +:root.dark .icon-invert { + filter: invert(0.8); +} - main { - padding: 1rem; - } +:root.light .icon-invert, +:root:not(.dark):not(.light) .icon-invert { + filter: none; +} - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } +@media (prefers-color-scheme: dark) { + :root:not(.light) .icon-invert { + filter: invert(0.8); + } }
--- a/hg-web/src/build.ts Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,24 +0,0 @@ -import { readdir } from "node:fs/promises"; -const files = await readdir(import.meta.dir); -console.log(files); - - -const outputPath = Bun.argv[2]; - -if (!outputPath) { - console.error("Please provide an output path. Usage: bun build.ts <output_path>"); - process.exit(1); -} - -const build = await Bun.build({ - entrypoints: ["./hg-web/src/main.tsx"], - outdir: outputPath, - metafile: true, -}); - -if (build.success) { - console.log(`Build successful! Files saved to: ${outputPath}`); - console.log(JSON.stringify(build.metafile, null, 2)); -} else { - console.error("Build failed:", build.logs); -}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/app.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,725 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Graph, useGraphData } from "hg-web/src/components/graph"; +import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +type Page = 'landing' | 'graph' | 'directory' | 'changeset'; + +type RouteState = { + page: Page; + graphCommit?: string; + graphTip?: string; + dirPath?: string; + changesetId?: string; + returnDepth?: number; +} + +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: Array<{ + file: string; + status: string; + }>; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + +type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; + +type DiffCell = { + lineNumber: number | null; + text: string; + kind: 'context' | 'add' | 'remove' | 'meta'; +}; + +type SideBySideRow = { + left?: DiffCell; + right?: DiffCell; + range?: string; +}; + +function trimDiffLine(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function contentDiffLine(line: DiffLine): string { + const text = trimDiffLine(line.l); + if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && + text.startsWith(line.t || ' ')) { + return text.slice(1); + } + return text; +} + +function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let removals: DiffCell[] = []; + let additions: DiffCell[] = []; + let oldLine: number | null = null; + let newLine: number | null = null; + let inHunk = false; + + const flushChanges = () => { + const count = Math.max(removals.length, additions.length); + for (let index = 0; index < count; index++) { + rows.push({ left: removals[index], right: additions[index] }); + } + removals = []; + additions = []; + }; + + for (const line of lines) { + if (line.t === '@') { + flushChanges(); + const range = trimDiffLine(line.l); + const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + oldLine = match ? Number(match[1]) : null; + newLine = match ? Number(match[2]) : null; + inHunk = true; + rows.push({ range }); + continue; + } + + if (!inHunk && (line.t === '-' || line.t === '+')) { + const cell: DiffCell = { + lineNumber: null, + text: trimDiffLine(line.l), + kind: 'meta', + }; + if (line.t === '-') removals.push(cell); + else additions.push(cell); + continue; + } + + if (line.t === '-') { + removals.push({ + lineNumber: oldLine, + text: contentDiffLine(line), + kind: 'remove', + }); + if (oldLine !== null) oldLine++; + continue; + } + + if (line.t === '+') { + additions.push({ + lineNumber: newLine, + text: contentDiffLine(line), + kind: 'add', + }); + if (newLine !== null) newLine++; + continue; + } + + flushChanges(); + const text = contentDiffLine(line); + rows.push({ + left: { lineNumber: oldLine, text, kind: 'context' }, + right: { lineNumber: newLine, text, kind: 'context' }, + }); + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + + flushChanges(); + return rows; +} + +function diffBlockFilename( + block: ChangesetDetail['diff'][number], + fallback?: string, +): string { + if (fallback) return fallback; + const newFileHeader = block.lines.find( + line => line.t === '+' && line.l.startsWith('+++ '), + ); + if (!newFileHeader) return `Diff block ${block.blockno}`; + return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; +} + +function SideBySideDiff({ + block, + filename, +}: { + block: ChangesetDetail['diff'][number]; + filename: string; +}) { + const rows = buildSideBySideRows(block.lines); + return ( + <div className="side-by-side-diff"> + <div className="diff-file-header">{filename}</div> + <div className="diff-column-headings"> + <span>Before</span> + <span>After</span> + </div> + <div className="diff-grid"> + {rows.map((row, index) => ( + row.range ? ( + <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> + ) : ( + <React.Fragment key={`row-${index}`}> + <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> + {row.left?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> + {row.left?.text ?? ''} + </code> + <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> + {row.right?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> + {row.right?.text ?? ''} + </code> + </React.Fragment> + ) + ))} + </div> + </div> + ); +} + +// Icons +const ICONS = { + folder: "/icons/folder.png", +}; + +const GraphIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> + <circle cx="18" cy="12" r="3"/> + <line x1="6" y1="9" x2="6" y2="15"/> + <path d="M8.5 7.5L15.5 11"/> + </svg> +); + +const FolderIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> + <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/> + </svg> +); + +const API_BASE = '/api/repo'; + +function parseRoute(): RouteState { + const params = new URLSearchParams(window.location.search); + const pathname = window.location.pathname; + const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/); + + if (changesetMatch) { + try { + const changesetId = decodeURIComponent(changesetMatch[1]); + if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) { + return { page: 'changeset', changesetId }; + } + } catch { + return { page: 'landing' }; + } + } + + if (pathname.startsWith('/graph') || params.has('graph')) { + return { + page: 'graph', + graphCommit: params.get('commit') || undefined, + graphTip: params.get('tip') || undefined, + }; + } + + if (pathname.startsWith('/directory') || params.has('path')) { + return { + page: 'directory', + dirPath: params.get('path') || '', + }; + } + + return { page: 'landing' }; +} + +function buildUrl(state: RouteState): string { + const params = new URLSearchParams(); + + switch (state.page) { + case 'graph': + if (state.graphCommit) params.set('commit', state.graphCommit); + if (state.graphTip) params.set('tip', state.graphTip); + return `/graph${params.toString() ? '?' + params.toString() : ''}`; + case 'directory': + if (state.dirPath) params.set('path', state.dirPath); + return `/directory${params.toString() ? '?' + params.toString() : ''}`; + case 'changeset': + return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph'; + default: + return '/'; + } +} + +function isRouteState(value: unknown): value is RouteState { + if (!value || typeof value !== 'object' || !('page' in value)) return false; + return ['landing', 'graph', 'directory', 'changeset'].includes( + String((value as { page: unknown }).page), + ); +} + +// Landing Page Component +function LandingPage({ + onNavigateToGraph, + onNavigateToDirectory, + onNavigateToChangeset, +}: { + onNavigateToGraph: () => void; + onNavigateToDirectory: (path?: string) => void; + onNavigateToChangeset: (node: string) => void; +}) { + const [directories, setDirectories] = useState<any[]>([]); + const [files, setFiles] = useState<any[]>([]); + const [dirLoading, setDirLoading] = useState(true); + + const { data: graphData, loading: graphLoading } = useGraphData(); + + useEffect(() => { + fetch(`${API_BASE}/list`) + .then(r => r.json()) + .then(data => { + setDirectories(data.directories || []); + setFiles(data.files || []); + setDirLoading(false); + }) + .catch(() => setDirLoading(false)); + }, []); + + const previewItems = [ + ...directories.slice(0, 6), + ...files.slice(0, Math.max(0, 6 - directories.length)) + ].slice(0, 6); + + return ( + <div className="landing-grid"> + {/* Graph Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <GraphIcon /> + Recent Commits + </span> + <a href="/graph" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToGraph(); + }}> + View all + </a> + </div> + <div className="landing-section-content"> + {graphLoading ? ( + <div className="loading-state">Loading commits...</div> + ) : graphData ? ( + <Graph + data={graphData} + maxRows={8} + onCommitClick={onNavigateToChangeset} + /> + ) : ( + <div className="empty-state">Failed to load commits</div> + )} + </div> + </div> + + {/* Directory Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <FolderIcon /> + Repository Files + </span> + <a href="/directory" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToDirectory(); + }}> + Browse all + </a> + </div> + <div className="landing-section-content"> + {dirLoading ? ( + <div className="loading-state">Loading files...</div> + ) : previewItems.length > 0 ? ( + previewItems.map((item) => ( + <div + key={item.abspath} + className="dir-item" + onClick={() => onNavigateToDirectory(item.abspath)} + > + <span className="dir-item-icon"> + <img + className="icon-invert" + src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"} + alt="" + /> + </span> + <span className="dir-item-name">{item.basename}</span> + </div> + )) + ) : ( + <div className="empty-state">No files found</div> + )} + </div> + </div> + </div> + ); +} + +// Graph Page Component +function GraphPage({ + onBack, + initialCommit, + initialTip, + onOpenChangeset, +}: { + onBack: () => void; + initialCommit?: string; + initialTip?: string; + onOpenChangeset: (node: string) => void; +}) { + const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ + initialCommit: initialCommit || null, + graphTop: initialTip || null, + }); + + useEffect(() => { + if (tip && currentCommit) { + const params = new URLSearchParams(); + params.set('commit', currentCommit); + params.set('tip', tip); + const newUrl = `/graph?${params.toString()}`; + window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); + } + }, [currentCommit, tip]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Commit Graph</span> + </div> + + {tip && ( + <div className="graph-params"> + <span className="graph-param"> + <span className="graph-param-label">Tip:</span> + <span className="graph-param-value">{tip.substring(0, 12)}</span> + </span> + {currentCommit && currentCommit !== tip && ( + <span className="graph-param"> + <span className="graph-param-label">Current:</span> + <span className="graph-param-value">{currentCommit.substring(0, 12)}</span> + </span> + )} + </div> + )} + + {error && ( + <div className="error-message">Error: {error}</div> + )} + + <Graph + data={data} + loading={loading} + hasMore={hasMore} + onLoadMore={loadMore} + onCommitClick={onOpenChangeset} + /> + </div> + ); +} + +function ChangesetPage({ + changesetId, + onBack, + onOpenChangeset, +}: { + changesetId: string; + onBack: () => void; + onOpenChangeset: (node: string) => void; +}) { + const [changeset, setChangeset] = useState<ChangesetDetail | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const controller = new AbortController(); + + setLoading(true); + setError(null); + setChangeset(null); + + fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal }) + .then(async response => { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Unable to load changeset (${response.status})`); + } + return response.json() as Promise<ChangesetDetail>; + }) + .then(setChangeset) + .catch(err => { + if (err.name !== 'AbortError') setError(err.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [changesetId]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Changeset</span> + </div> + + {loading && <div className="loading-state">Loading changeset...</div>} + {error && <div className="error-message">Error: {error}</div>} + + {changeset && ( + <article className="changeset-paper"> + <div className="changeset-heading"> + <code>{changeset.node}</code> + <span className="changeset-branch">{changeset.branch}</span> + </div> + <h2>{changeset.desc}</h2> + <div className="changeset-meta"> + <span>{changeset.user}</span> + <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}> + {new Date(changeset.date[0] * 1000).toLocaleString()} + </time> + </div> + + {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && ( + <div className="changeset-labels"> + {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)} + {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)} + </div> + )} + + {changeset.parents.length > 0 && ( + <div className="changeset-parents"> + <strong>Parents</strong> + {changeset.parents.map(parent => ( + <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}> + {parent.substring(0, 12)} + </button> + ))} + </div> + )} + + {changeset.files.length > 0 && ( + <div className="changeset-files"> + <strong>Files</strong> + {changeset.files.map(file => ( + <code key={file.file}> + <span className={`changeset-file-status status-${file.status}`}> + {file.status} + </span> + {file.file} + </code> + ))} + </div> + )} + + <section className="changeset-diff" aria-label="Changeset diff"> + <h3>Diff</h3> + {changeset.diff.length === 0 ? ( + <div className="empty-state">No textual changes in this changeset.</div> + ) : changeset.diff.map((block, index) => ( + <SideBySideDiff + key={block.blockno} + block={block} + filename={diffBlockFilename(block, changeset.files[index]?.file)} + /> + ))} + </section> + </article> + )} + </div> + ); +} + +// Directory Page Component +function DirectoryPage({ + onBack, + initialPath, + onPathChange, +}: { + onBack: () => void; + initialPath?: string; + onPathChange: (path: string) => void; +}) { + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Repository Files</span> + </div> + + <DirectoryBrowser + initialPath={initialPath} + onPathChange={onPathChange} + /> + </div> + ); +} + +// Main App Content (uses theme context) +function AppContent() { + const [route, setRoute] = useState<RouteState>(parseRoute); + const { isDark, toggleTheme } = useTheme(); + + // Handle browser back/forward + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + setRoute(isRouteState(event.state) ? event.state : parseRoute()); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const navigate = useCallback((newRoute: RouteState) => { + const url = buildUrl(newRoute); + window.history.pushState(newRoute, '', url); + setRoute(newRoute); + }, []); + + const navigateToLanding = useCallback(() => { + navigate({ page: 'landing' }); + }, [navigate]); + + const navigateToGraph = useCallback((commit?: string, tip?: string) => { + navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); + }, [navigate]); + + const navigateToDirectory = useCallback((path?: string) => { + navigate({ page: 'directory', dirPath: path || '' }); + }, [navigate]); + + const navigateToChangeset = useCallback((changesetId: string) => { + const returnDepth = route.page === 'changeset' + ? (route.returnDepth ?? 0) + 1 + : 1; + navigate({ page: 'changeset', changesetId, returnDepth }); + }, [navigate, route.page, route.returnDepth]); + + const navigateBackFromChangeset = useCallback(() => { + if (route.returnDepth !== undefined && route.returnDepth > 0) { + window.history.go(-route.returnDepth); + return; + } + navigateToGraph(); + }, [navigateToGraph, route.returnDepth]); + + const handleDirectoryPathChange = useCallback((path: string) => { + // Update URL without full navigation + const params = new URLSearchParams(); + if (path) params.set('path', path); + const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; + window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); + setRoute(prev => ({ ...prev, dirPath: path })); + }, []); + + return ( + <div className="app-container"> + <Header + title="Zenbu Repository" + showThemeToggle={true} + isDark={isDark} + onToggleTheme={toggleTheme} + /> + + {/* Navigation Tabs */} + <div className="nav-tabs"> + <button + className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`} + onClick={navigateToLanding} + > + Home + </button> + <button + className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`} + onClick={() => navigateToGraph()} + > + <GraphIcon /> + Graph + </button> + <button + className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`} + onClick={() => navigateToDirectory()} + > + <FolderIcon /> + Files + </button> + </div> + + {/* Page Content */} + {route.page === 'landing' && ( + <LandingPage + onNavigateToGraph={() => navigateToGraph()} + onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} + /> + )} + + {route.page === 'graph' && ( + <GraphPage + onBack={navigateToLanding} + initialCommit={route.graphCommit} + initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} + /> + )} + + {route.page === 'directory' && ( + <DirectoryPage + onBack={navigateToLanding} + initialPath={route.dirPath} + onPathChange={handleDirectoryPathChange} + /> + )} + + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={navigateBackFromChangeset} + onOpenChangeset={navigateToChangeset} + /> + )} + + <Footer /> + </div> + ); +} + +// App wrapper with ThemeProvider +function App() { + return ( + <ThemeProvider> + <AppContent /> + </ThemeProvider> + ); +} + +export { App };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,649 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; +import hljs from 'third_party/highlight/highlight.min.js'; + +// --- ICONS (served as static files) --- +const ICONS = { + folder: "/icons/folder.png", + file: "/icons/file.svg", + close: "/icons/close.png" +}; + +const API_BASE = '/api/repo'; + +// File extensions that should be displayed as code +const CODE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', + 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', + 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', + 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', + 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', + 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', + 'bzl', 'bazel' +]); + +const BAZEL_FILENAMES = new Set([ + 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' +]); + +type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' +]); +const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); + +// Prefetch cache +const prefetchCache = new Map<string, Promise<any>>(); + +function isCodeFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + return CODE_EXTENSIONS.has(ext) || + CODE_EXTENSIONS.has(basename) || + BAZEL_FILENAMES.has(basename) || + basename === 'dockerfile' || + basename === 'makefile' || + basename.startsWith('.'); +} + +function isMarkdownFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + return ext === 'md' || ext === 'markdown'; +} + +function getStaticPreviewKind(filename: string): StaticPreviewKind | null { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + if (ext === 'pdf') return 'pdf'; + return null; +} + +function prefetchDirectory(path: string): void { + const cacheKey = `dir:${path}`; + if (prefetchCache.has(cacheKey)) return; + + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + + prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); +} + +function prefetchFile(path: string): void { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) return; + + prefetchCache.set(cacheKey, + fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) + .then(r => r.ok ? r.text() : null) + .catch(() => null) + ); +} + +async function getCachedFile(path: string): Promise<string | null> { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) { + return prefetchCache.get(cacheKey); + } + prefetchFile(path); + return prefetchCache.get(cacheKey)!; +} + +/** + * Component: Breadcrumb + */ +function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { + if (!currentPath) { + return ( + <nav className="breadcrumb"> + <span className="nav-item active">root</span> + </nav> + ); + } + + const parts = currentPath.split('/').filter(p => p); + const crumbs = parts.map((part, index) => ({ + name: part, + fullPath: parts.slice(0, index + 1).join('/') + })); + + return ( + <nav className="breadcrumb"> + <a + href="#" + onClick={(e) => { e.preventDefault(); onNavigate(''); }} + title="Go to Root" + > + root + </a> + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + return ( + <React.Fragment key={crumb.fullPath}> + <span className="separator">/</span> + {isLast ? ( + <span className="nav-item active">{crumb.name}</span> + ) : ( + <a + href="#" + onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} + > + {crumb.name} + </a> + )} + </React.Fragment> + ); + })} + </nav> + ); +} + +/** + * Component: FileViewer + * Shows file content inline with syntax highlighting + */ +function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const codeRef = useRef<HTMLElement>(null); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (content && codeRef.current) { + hljs.highlightElement(codeRef.current); + } + }, [content]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const getLanguage = () => { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { + return 'python'; + } + const langMap: Record<string, string> = { + js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', + json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', + html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', + sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', + dockerfile: 'dockerfile', makefile: 'makefile' + }; + return langMap[ext] || 'plaintext'; + }; + + const addLineNumbers = (text: string) => { + const lines = text.split('\n'); + return lines.map((_, i) => i + 1).join('\n'); + }; + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div className="file-viewer" onClick={(e) => e.stopPropagation()}> + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </div> + <div className="file-viewer-content"> + {loading ? ( + <div className="file-viewer-loading">Loading...</div> + ) : content ? ( + <pre style={{ display: 'flex' }}> + <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> + <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> + </pre> + ) : ( + <div className="file-viewer-loading">Unable to load file</div> + )} + </div> + </div> + </div> + ); +} + +/** + * Component: MarkdownViewerModal + */ +function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const contentRef = useRef<HTMLDivElement>(null); + const moduleRef = useRef<any>(null); + const [wasmReady, setWasmReady] = useState(false); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; + + const Module = moduleRef.current; + const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); + const markdownFree = Module.cwrap('markdown_free', null, ['number']); + + const ptr = markdownToHtmlPtr(content); + const html = Module.UTF8ToString(ptr); + markdownFree(ptr); + contentRef.current.innerHTML = html; + }, [content, wasmReady]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div className="file-viewer" onClick={(e) => e.stopPropagation()}> + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </div> + <div className="file-viewer-content"> + {loading || !wasmReady ? ( + <div className="file-viewer-loading">Loading...</div> + ) : content ? ( + <div className="readme-content" ref={contentRef} /> + ) : ( + <div className="file-viewer-loading">Unable to load file</div> + )} + </div> + </div> + </div> + ); +} + +function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const filename = filePath.split('/').pop() || filePath; + const previewKind = getStaticPreviewKind(filename); + const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; + const [failed, setFailed] = useState(false); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div + className="file-viewer static-file-viewer" + role="dialog" + aria-modal="true" + aria-label={`Preview ${filename}`} + onClick={(event) => event.stopPropagation()} + > + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <span className="file-viewer-actions"> + <a href={fileUrl} download={filename}>Download</a> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </span> + </div> + <div className="file-viewer-content static-file-preview"> + {failed && <div className="error-message">Unable to preview this file.</div>} + {!failed && previewKind === 'image' && ( + <img + className="static-file-image" + src={fileUrl} + alt={filename} + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'video' && ( + <video + className="static-file-video" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'audio' && ( + <audio + className="static-file-audio" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'pdf' && ( + <iframe + className="static-file-pdf" + src={fileUrl} + title={filename} + onError={() => setFailed(true)} + /> + )} + </div> + </div> + </div> + ); +} + +/** + * Component: FileList + */ +function FileList({ directories, files, onNavigate, onOpenFile }: { + directories: any[]; + files: any[]; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const isEmpty = directories.length === 0 && files.length === 0; + + if (isEmpty) { + return ( + <div className="file-list-container"> + <div className="empty-state">This directory is empty.</div> + </div> + ); + } + + return ( + <div className="file-list-container"> + <div className="file-header">Files</div> + <div id="fileListBody"> + {directories.map((dir) => ( + <FileRow + key={dir.abspath} + item={dir} + iconUrl={ICONS.folder} + isDir={true} + onNavigate={onNavigate} + onOpenFile={onOpenFile} + /> + ))} + {files.map((file) => ( + <FileRow + key={file.abspath} + item={file} + iconUrl={ICONS.file} + isDir={false} + onNavigate={onNavigate} + onOpenFile={onOpenFile} + /> + ))} + </div> + </div> + ); +} + +/** + * Component: FileRow + */ +function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { + item: { abspath: string; basename: string }; + iconUrl: string; + isDir: boolean; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (isDir) { + onNavigate(item.abspath); + } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { + onOpenFile(item.abspath); + } else { + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); + } + }; + + const handleMouseEnter = () => { + if (isDir) { + prefetchDirectory(item.abspath); + } else if (isCodeFile(item.basename)) { + prefetchFile(item.abspath); + } + }; + + const href = isDir + ? `#` + : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; + + return ( + <div className="file-row" onMouseEnter={handleMouseEnter}> + <span className="icon"> + <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} /> + </span> + <span className="name"> + <a href={href} onClick={handleClick}> + {item.basename} + </a> + </span> + </div> + ); +} + +/** + * Component: ReadmeViewer + */ +function ReadmeViewer({ content }: { content: string | null }) { + const contentRef = useRef<HTMLDivElement>(null); + const moduleRef = useRef<any>(null); + const [wasmReady, setWasmReady] = useState(false); + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + useEffect(() => { + if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; + + const Module = moduleRef.current; + const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); + const markdownFree = Module.cwrap('markdown_free', null, ['number']); + + const ptr = markdownToHtmlPtr(content); + const html = Module.UTF8ToString(ptr); + markdownFree(ptr); + contentRef.current.innerHTML = html; + }, [content, wasmReady]); + + if (!content) return null; + + return ( + <div className="readme-section"> + <div className="readme-header"> + <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} /> + README.md + </div> + <div className="readme-content" ref={contentRef}> + {!wasmReady && 'Loading...'} + </div> + </div> + ); +} + +/** + * Directory Browser Component (no header/footer - for embedding in app) + */ +interface DirectoryBrowserProps { + initialPath?: string; + onPathChange?: (path: string) => void; +} + +function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath); + const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); + const [readme, setReadme] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [viewingFile, setViewingFile] = useState<string | null>(null); + const requestGeneration = useRef(0); + + // Sync with initialPath prop + useEffect(() => { + setCurrentPath(initialPath); + }, [initialPath]); + + useEffect(() => { + const generation = ++requestGeneration.current; + fetchDirectory(currentPath, generation); + fetchReadme(currentPath, generation); + }, [currentPath]); + + const navigate = useCallback((path: string) => { + setCurrentPath(path); + onPathChange?.(path); + }, [onPathChange]); + + const fetchDirectory = async (path: string, generation: number) => { + setLoading(true); + setError(null); + try { + const cacheKey = `dir:${path}`; + let data; + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); + } else { + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + const response = await fetch(url); + if (response.ok) { + data = await response.json(); + } + } + + if (data?.error) { + throw new Error(data.error); + } + + if (generation === requestGeneration.current) { + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } + } catch (err: any) { + console.error('Error loading directory:', err); + if (generation === requestGeneration.current) setError(err.message); + } finally { + if (generation === requestGeneration.current) setLoading(false); + } + }; + + const fetchReadme = async (path: string, generation: number) => { + if (generation === requestGeneration.current) setReadme(null); + try { + const url = path + ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` + : `${API_BASE}/readme`; + const response = await fetch(url); + if (response.ok) { + const text = await response.text(); + if (generation === requestGeneration.current) setReadme(text || null); + } + } catch (err) { + // Readme is optional + } + }; + + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + return ( + <> + <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> + + {error && <div className="error-message">Error: {error}</div>} + + {loading ? ( + <div className="file-list-container"> + <div className="loading-state">Loading files...</div> + </div> + ) : ( + <> + <FileList + directories={content.directories} + files={content.files} + onNavigate={navigate} + onOpenFile={handleOpenFile} + /> + <ReadmeViewer content={readme} /> + </> + )} + + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : getStaticPreviewKind(viewingFile) ? ( + <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> + ) : ( + <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> + ) + )} + </> + ); +} + +export { DirectoryBrowser };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/footer.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,31 @@ +import React from 'react'; + +interface FooterProps { + showCloneUrl?: boolean; + cloneUrl?: string; +} + +function Footer({ + showCloneUrl = false, + cloneUrl = "hg clone http://zenbu.babocoder.com/repo", +}: FooterProps) { + const currentYear = new Date().getFullYear(); + + return ( + <footer className="footer"> + {showCloneUrl && ( + <div className="clone-box"> + <div className="clone-box-inner"> + <span className="clone-label">Clone HTTPS</span> + <code className="clone-url">{cloneUrl}</code> + </div> + </div> + )} + <div className="footer-content"> + <span className="footer-text">© 2026 June Park</span> + </div> + </footer> + ); +} + +export { Footer };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/graph.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,313 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; + +// Configuration constants for the layout +const rowHeight = 40; +const colWidth = 20; +const nodeRadius = 4.5; + +// --- Interfaces --- + +interface Changeset { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + phase: string; + col: number; + row: number; + color: number; + edges: Array<{ + bcolor: string; + col: number; + color: number; + nextcol: number; + width: number; + }>; + parents: string[]; +} + +interface GraphData { + node: string; + changeset_count: number; + changesets: Changeset[]; +} + +interface UseGraphDataOptions { + initialCommit?: string | null; + graphTop?: string | null; +} + +interface UseGraphDataResult { + data: GraphData | null; + loading: boolean; + error: string | null; + loadMore: () => void; + hasMore: boolean; + tip: string | null; + currentCommit: string | null; +} + +// --- Hook Logic --- + +function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { + const [data, setData] = useState<GraphData | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [tip, setTip] = useState<string | null>(graphTop); + const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit); + const [hasMore, setHasMore] = useState(true); + + const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { + if (loading) return; + setLoading(true); + setError(null); + + try { + const url = !commit + ? `/api/graph/tip?style=json` + : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; + + const response = await fetch(url); + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); + + const result: GraphData = await response.json(); + + setData(prev => { + if (append && prev) { + const existingNodes = new Set(prev.changesets.map(cs => cs.node)); + const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); + + // Re-index rows to ensure they increment correctly for the canvas height + const startRow = prev.changesets.length; + const reindexed = newChangesets.map((cs, idx) => ({ + ...cs, + row: startRow + idx + })); + + return { + ...result, + changesets: [...prev.changesets, ...reindexed] + }; + } + return result; + }); + + if (!tip && !append) setTip(result.node); + + if (result.changesets.length > 0) { + const lastNode = result.changesets[result.changesets.length - 1].node; + setCurrentCommit(lastNode); + setHasMore(result.changesets.length >= 30); + } else { + setHasMore(false); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [tip, loading]); + + useEffect(() => { + fetchData(initialCommit, graphTop, false); + }, [initialCommit, graphTop]); + + const loadMore = useCallback(() => { + if (!loading && hasMore && currentCommit && tip) { + fetchData(currentCommit, tip, true); + } + }, [loading, hasMore, currentCommit, tip, fetchData]); + + return { data, loading, error, loadMore, hasMore, tip, currentCommit }; +} + +// --- Pencil Rendering Logic --- + +const drawPencilLine = ( + ctx: CanvasRenderingContext2D, + x1: number, y1: number, + x2: number, y2: number, + texture: CanvasPattern | null, // Ensure type safety + isCurve: boolean = false +) => { + const strokes = 3; + ctx.save(); + + for (let s = 0; s < strokes; s++) { + ctx.beginPath(); + ctx.strokeStyle = texture; + ctx.globalAlpha = 0.2 + (s * 0.2); + ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner + + // 2. Realistic Jitter: Actually return a random small number + const jitter = () => (Math.random() - 0.5) * 1.5; + + ctx.moveTo(x1 + jitter(), y1 + jitter()); + + if (isCurve) { + const cpY = y1 + (y2 - y1) / 2; + ctx.bezierCurveTo( + x1 + jitter(), cpY + jitter(), + x2 + jitter(), cpY + jitter(), + x2 + jitter(), y2 + jitter() + ); + } else { + ctx.lineTo(x2 + jitter(), y2 + jitter()); + } + + ctx.stroke(); + } + ctx.restore(); +} + +// --- Main Component --- + +interface GraphProps { + data: GraphData | null; + loading?: boolean; + hasMore?: boolean; + onLoadMore?: () => void; + onCommitClick?: (node: string) => void; + maxRows?: number; +} + +const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [assetError, setAssetError] = useState<string | null>(null); + + const changesets = useMemo(() => + maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !changesets.length) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let cancelled = false; + let pencilPattern: CanvasPattern | null = null; + let loadedAssets = 0; + const pencilImage = new Image(); + const pandaImage = new Image(); + const dpr = window.devicePixelRatio || 1; + const maxCol = Math.max(...changesets.map(cs => cs.col), 0); + const canvasWidth = (maxCol + 2) * colWidth; + + // Scale for high-DPI screens + canvas.width = canvasWidth * dpr; + canvas.height = changesets.length * rowHeight * dpr; + canvas.style.width = `${canvasWidth}px`; + canvas.style.height = `${changesets.length * rowHeight}px`; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); + + const getX = (col: number) => (col + 1) * colWidth; + const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); + + const renderCanvas = () => { + if (!pencilPattern) return; // Don't draw if the pattern isn't ready + + // Pass 1: Draw Connecting Edges + changesets.forEach((cs, i) => { + if (!cs.edges) return; + cs.edges.forEach(edge => { + const sX = getX(edge.col), sY = getY(i); + const eX = getX(edge.nextcol), eY = getY(i + 1); + + drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); + }); + }); + + // Pass 2: Draw Commit Nodes + changesets.forEach((cs, i) => { + const x = getX(cs.col), y = getY(i); + ctx.drawImage(pandaImage, x-10, y-10, 20, 20); + }); + }; + + const handleAssetLoad = () => { + loadedAssets++; + if (loadedAssets !== 2 || cancelled) return; + pencilPattern = ctx.createPattern(pencilImage, "repeat"); + renderCanvas(); + }; + + const handleAssetError = () => { + if (!cancelled) setAssetError('Unable to load the graph artwork.'); + }; + + setAssetError(null); + pencilImage.onload = handleAssetLoad; + pencilImage.onerror = handleAssetError; + pandaImage.onload = handleAssetLoad; + pandaImage.onerror = handleAssetError; + pencilImage.src = "/pencil_lines.png"; + pandaImage.src = "/panda.png"; + + return () => { + cancelled = true; + pencilImage.onload = null; + pencilImage.onerror = null; + pandaImage.onload = null; + pandaImage.onerror = null; + }; + }, [changesets]); + + // Handle Infinite Scroll via Intersection Observer + useEffect(() => { + if (!onLoadMore || !hasMore) return; + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !loading) { + onLoadMore(); + } + }, { threshold: 0.1 }); + + const sentinel = document.getElementById('infinite-scroll-sentinel'); + if (sentinel) observer.observe(sentinel); + return () => observer.disconnect(); + }, [onLoadMore, hasMore, loading]); + + return ( + <div className="graph-container"> + {assetError && <div className="error-message">{assetError}</div>} + <div + ref={containerRef} + className="graph-wrapper" + > + <div className="graph-canvas-column"> + <canvas ref={canvasRef} style={{ display: 'block' }} /> + </div> + + <div className="graph-details-column"> + {changesets.map((cs) => ( + <button + type="button" + key={cs.node} + className="graph-row" + onClick={() => onCommitClick?.(cs.node)} + aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} + > + <span className="graph-row-meta"> + <span className="graph-hash">{cs.node.substring(0, 12)}</span> + <span className="graph-user">{cs.user.split(' <')[0]}</span> + {cs.branch && <span className="graph-branch">{cs.branch}</span>} + </span> + <span className="graph-desc">{cs.desc}</span> + </button> + ))} + <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> + </div> + </div> + + {loading && <div className="graph-loading-row">Loading repository history...</div>} + </div> + ); +}; + +export { Graph, useGraphData }; +export type { GraphData, Changeset, UseGraphDataResult };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/header.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,60 @@ +import React from 'react'; + +// Icons +const ICONS = { + repo: "/public/epi_all_colors.svg", +}; + +const SunIcon = () => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> +); + +const MoonIcon = () => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> +); + +interface HeaderProps { + title?: string; + subtitle?: string; + showThemeToggle?: boolean; + isDark?: boolean; + onToggleTheme?: () => void; +} + +function Header({ + title = "Zenbu Repository", + subtitle, + showThemeToggle = true, + isDark = false, + onToggleTheme, +}: HeaderProps) { + return ( + <header className="header"> + <img src={ICONS.repo} alt="Zenbu" className="header-icon" /> + <div className="header-content"> + <h1><a href="/">{title}</a></h1> + {subtitle && <p className="header-subtitle">{subtitle}</p>} + </div> + {showThemeToggle && onToggleTheme && ( + <button className="theme-toggle" onClick={onToggleTheme} title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}> + {isDark ? <SunIcon /> : <MoonIcon />} + <span>{isDark ? 'Light' : 'Dark'}</span> + </button> + )} + </header> + ); +} + +export { Header };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/repo-browser.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,584 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; +import hljs from 'third_party/highlight/highlight.min.js'; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +// --- ICONS (served as static files) --- +const ICONS = { + folder: "/icons/folder.png", + file: "/icons/file.svg", + close: "/icons/close.png" +}; + +const API_BASE = '/api/repo'; + +// File extensions that should be displayed as code +const CODE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', + 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', + 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', + 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', + 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', + 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' +]); + +// Prefetch cache +const prefetchCache = new Map<string, Promise<any>>(); + +function isCodeFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + return CODE_EXTENSIONS.has(ext) || + CODE_EXTENSIONS.has(basename) || + basename === 'dockerfile' || + basename === 'makefile' || + basename.startsWith('.'); +} + +function isMarkdownFile(filename: string): boolean { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + return ext === 'md' || ext === 'markdown'; +} + +function prefetchDirectory(path: string): void { + const cacheKey = `dir:${path}`; + if (prefetchCache.has(cacheKey)) return; + + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + + prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); +} + +function prefetchFile(path: string): void { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) return; + + prefetchCache.set(cacheKey, + fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) + .then(r => r.ok ? r.text() : null) + .catch(() => null) + ); +} + +async function getCachedFile(path: string): Promise<string | null> { + const cacheKey = `file:${path}`; + if (prefetchCache.has(cacheKey)) { + return prefetchCache.get(cacheKey); + } + prefetchFile(path); + return prefetchCache.get(cacheKey)!; +} + +/** + * Component: Breadcrumb + */ +function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { + if (!currentPath) { + return ( + <nav className="breadcrumb"> + <span className="nav-item active">root</span> + </nav> + ); + } + + const parts = currentPath.split('/').filter(p => p); + const crumbs = parts.map((part, index) => ({ + name: part, + fullPath: parts.slice(0, index + 1).join('/') + })); + + return ( + <nav className="breadcrumb"> + <a + href="/" + onClick={(e) => { e.preventDefault(); onNavigate(''); }} + title="Go to Root" + > + root + </a> + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + return ( + <React.Fragment key={crumb.fullPath}> + <span className="separator">/</span> + {isLast ? ( + <span className="nav-item active">{crumb.name}</span> + ) : ( + <a + href={`?path=${encodeURIComponent(crumb.fullPath)}`} + onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} + > + {crumb.name} + </a> + )} + </React.Fragment> + ); + })} + </nav> + ); +} + +/** + * Component: FileViewer + * Shows file content inline with syntax highlighting + */ +function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const codeRef = useRef<HTMLElement>(null); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (content && codeRef.current) { + hljs.highlightElement(codeRef.current); + } + }, [content]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Get language from file extension for highlight.js + const getLanguage = () => { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const langMap: Record<string, string> = { + js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', + json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', + html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', + sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', + dockerfile: 'dockerfile', makefile: 'makefile' + }; + return langMap[ext] || 'plaintext'; + }; + + const addLineNumbers = (text: string) => { + const lines = text.split('\n'); + return lines.map((_, i) => i + 1).join('\n'); + }; + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div className="file-viewer" onClick={(e) => e.stopPropagation()}> + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </div> + <div className="file-viewer-content"> + {loading ? ( + <div className="file-viewer-loading">Loading...</div> + ) : content ? ( + <pre style={{ display: 'flex' }}> + <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> + <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> + </pre> + ) : ( + <div className="file-viewer-loading">Unable to load file</div> + )} + </div> + </div> + </div> + ); +} + +/** + * Component: MarkdownViewerModal + * Shows markdown content rendered in a modal + */ +function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const [content, setContent] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const contentRef = useRef<HTMLDivElement>(null); + const moduleRef = useRef<any>(null); + const [wasmReady, setWasmReady] = useState(false); + + const filename = filePath.split('/').pop() || filePath; + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + useEffect(() => { + setLoading(true); + getCachedFile(filePath).then((text) => { + setContent(text); + setLoading(false); + }); + }, [filePath]); + + useEffect(() => { + if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; + + const Module = moduleRef.current; + const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); + const markdownFree = Module.cwrap('markdown_free', null, ['number']); + + const ptr = markdownToHtmlPtr(content); + const html = Module.UTF8ToString(ptr); + markdownFree(ptr); + contentRef.current.innerHTML = html; + }, [content, wasmReady]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div className="file-viewer" onClick={(e) => e.stopPropagation()}> + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </div> + <div className="file-viewer-content"> + {loading || !wasmReady ? ( + <div className="file-viewer-loading">Loading...</div> + ) : content ? ( + <div className="readme-content" ref={contentRef} /> + ) : ( + <div className="file-viewer-loading">Unable to load file</div> + )} + </div> + </div> + </div> + ); +} + +/** + * Component: FileList + */ +function FileList({ directories, files, onNavigate, onOpenFile }: { + directories: any[]; + files: any[]; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const isEmpty = directories.length === 0 && files.length === 0; + + if (isEmpty) { + return ( + <div className="file-list-container"> + <div className="empty-state">This directory is empty.</div> + </div> + ); + } + + return ( + <div className="file-list-container"> + <div className="file-header">Files</div> + + <div id="fileListBody"> + {directories.map((dir) => ( + <FileRow + key={dir.abspath} + item={dir} + iconUrl={ICONS.folder} + isDir={true} + onNavigate={onNavigate} + onOpenFile={onOpenFile} + /> + ))} + + {files.map((file) => ( + <FileRow + key={file.abspath} + item={file} + iconUrl={ICONS.file} + isDir={false} + onNavigate={onNavigate} + onOpenFile={onOpenFile} + /> + ))} + </div> + </div> + ); +} + +/** + * Component: FileRow + */ +function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { + item: { abspath: string; basename: string }; + iconUrl: string; + isDir: boolean; + onNavigate: (path: string) => void; + onOpenFile: (path: string) => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (isDir) { + onNavigate(item.abspath); + } else if (isCodeFile(item.basename)) { + onOpenFile(item.abspath); + } else { + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); + } + }; + + const handleMouseEnter = () => { + if (isDir) { + prefetchDirectory(item.abspath); + } else if (isCodeFile(item.basename)) { + prefetchFile(item.abspath); + } + }; + + const href = isDir + ? `?path=${encodeURIComponent(item.abspath)}` + : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; + + return ( + <div className="file-row" onMouseEnter={handleMouseEnter}> + <span className="icon"> + <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} /> + </span> + <span className="name"> + <a href={href} onClick={handleClick}> + {item.basename} + </a> + </span> + </div> + ); +} + +/** + * Component: ReadmeViewer + */ +function ReadmeViewer({ content }: { content: string | null }) { + const contentRef = useRef<HTMLDivElement>(null); + const moduleRef = useRef<any>(null); + const [wasmReady, setWasmReady] = useState(false); + + useEffect(() => { + createMarkdownModule().then((Module: any) => { + moduleRef.current = Module; + setWasmReady(true); + }); + }, []); + + useEffect(() => { + if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; + + const Module = moduleRef.current; + const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); + const markdownFree = Module.cwrap('markdown_free', null, ['number']); + + const ptr = markdownToHtmlPtr(content); + const html = Module.UTF8ToString(ptr); + markdownFree(ptr); + contentRef.current.innerHTML = html; + }, [content, wasmReady]); + + if (!content) return null; + + return ( + <div className="readme-section"> + <div className="readme-header"> + <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} /> + README.md + </div> + <div className="readme-content" ref={contentRef}> + {!wasmReady && 'Loading...'} + </div> + </div> + ); +} + +/** + * Repository Browser Content (uses theme context) + */ +function RepoBrowserContent() { + const [currentPath, setCurrentPath] = useState(getCurrentPath()); + const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); + const [readme, setReadme] = useState<string | null>(null); + const [error, setError] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [viewingFile, setViewingFile] = useState<string | null>(null); + + const { isDark, toggleTheme } = useTheme(); + + function getCurrentPath() { + const params = new URLSearchParams(window.location.search); + return params.get('path') || ''; + } + + useEffect(() => { + const handlePopState = () => setCurrentPath(getCurrentPath()); + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + useEffect(() => { + fetchDirectory(currentPath); + fetchReadme(currentPath); + }, [currentPath]); + + const navigate = (path: string) => { + const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; + window.history.pushState({ path }, '', newUrl); + setCurrentPath(path); + }; + + const fetchDirectory = async (path: string) => { + setLoading(true); + setError(null); + try { + const cacheKey = `dir:${path}`; + let data; + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); + } else { + const url = path + ? `${API_BASE}/list?path=${encodeURIComponent(path)}` + : `${API_BASE}/list`; + const response = await fetch(url); + if (response.ok) { + data = await response.json(); + } + } + + if (data?.error) { + throw new Error(data.error); + } + + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } catch (err: any) { + console.error('Error loading directory:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + const fetchReadme = async (path: string) => { + setReadme(null); + const readmePath = path ? `${path}/README.md` : 'README.md'; + try { + const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + if (response.ok) { + const text = await response.text(); + setReadme(text); + } + } catch (err) { + // Readme is optional, ignore errors + } + }; + + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + return ( + <> + <div className="repo-container"> + <Header + title="Zenbu Repository" + subtitle="Browse and manage the mercurial codebase" + showThemeToggle={true} + isDark={isDark} + onToggleTheme={toggleTheme} + /> + + {/* Clone Bar */} + <div className="clone-box"> + <div className="clone-box-inner"> + <span className="clone-label">Clone HTTPS</span> + <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code> + </div> + </div> + + {/* Navigation & Content */} + <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> + + {error && <div className="error-message">Error: {error}</div>} + + {loading ? ( + <div className="file-list-container"> + <div className="loading-state">Loading files...</div> + </div> + ) : ( + <> + <FileList + directories={content.directories} + files={content.files} + onNavigate={navigate} + onOpenFile={handleOpenFile} + /> + <ReadmeViewer content={readme} /> + </> + )} + + <Footer /> + </div> + + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : ( + <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> + ) + )} + </> + ); +} + +/** + * Main Application Component with ThemeProvider + */ +function RepoBrowser() { + return ( + <ThemeProvider> + <RepoBrowserContent /> + </ThemeProvider> + ); +} + +export { RepoBrowser };
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/theme.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,72 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +interface ThemeContextType { + isDark: boolean; + toggleTheme: () => void; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +// Apply theme class to document root +function applyTheme(isDark: boolean) { + const root = document.documentElement; + if (isDark) { + root.classList.add('dark'); + root.classList.remove('light'); + } else { + root.classList.add('light'); + root.classList.remove('dark'); + } +} + +interface ThemeProviderProps { + children: ReactNode; +} + +function ThemeProvider({ children }: ThemeProviderProps) { + const [isDark, setIsDark] = useState(() => { + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + + // Apply theme on mount and change + useEffect(() => { + applyTheme(isDark); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + }, [isDark]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (e: MediaQueryListEvent) => { + // Only apply if no explicit preference is saved + if (!localStorage.getItem('theme')) { + setIsDark(e.matches); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = useCallback(() => { + setIsDark(prev => !prev); + }, []); + + return ( + <ThemeContext.Provider value={{ isDark, toggleTheme }}> + {children} + </ThemeContext.Provider> + ); +} + +function useTheme(): ThemeContextType { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} + +export { ThemeProvider, useTheme };
--- a/hg-web/src/index.css Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/src/index.css Sun Aug 02 16:50:48 2026 -0700 @@ -1,179 +1,1004 @@ +/* =========================================== + Component Styles + Import base.css before this file + =========================================== */ + +/* =========================================== + App Layout + =========================================== */ +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +/* =========================================== + Header + =========================================== */ .header { - border-bottom: 1px solid var(--border); - padding-bottom: 1rem; - margin-bottom: 2rem; + display: flex; + align-items: center; + margin-bottom: 24px; + gap: 15px; +} + +.header-icon { + width: 32px; + height: 32px; + opacity: 0.8; + cursor: pointer; } .header h1 { - margin-bottom: 0.5rem; + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header h1 a { + color: inherit; + text-decoration: none; +} + +.header-subtitle { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -.header .description { - color: var(--secondary); - font-size: 0.95rem; +/* =========================================== + Navigation Tabs + =========================================== */ +.nav-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; + border-bottom: 1px solid var(--border); + padding-bottom: 8px; } -.clone-info { - background: var(--code-bg); - border: 1px solid var(--border); +.nav-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; border-radius: 6px; - padding: 1rem; - margin-bottom: 2rem; + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.nav-tab:hover { + background: var(--bg-subtle); + color: var(--text-primary); +} + +.nav-tab.active { + background: var(--bg-subtle); + border-color: var(--border); + color: var(--text-primary); + font-weight: 500; } -.clone-info code { - background: none; - color: var(--fg); - font-size: 0.95rem; +.nav-tab svg { + color: var(--text-secondary); +} + +/* =========================================== + Landing Page + =========================================== */ +.landing-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; +} + +@media (max-width: 768px) { + .landing-grid { + grid-template-columns: 1fr; + } +} + +.landing-section { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; } -.breadcrumb { - margin-bottom: 1.5rem; - font-size: 0.95rem; +.landing-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); } -.breadcrumb a { - color: var(--link); +.landing-section-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); } -.breadcrumb a:hover { +.landing-section-title svg { + color: var(--text-secondary); +} + +.landing-section-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + padding: 4px 8px; + border-radius: 4px; + transition: background 0.2s; +} + +.landing-section-link:hover { + background: var(--bg-subtle); text-decoration: underline; } -.breadcrumb span { - color: var(--secondary); - margin: 0 0.5rem; +.landing-section-content { + padding: 0; +} + +/* =========================================== + Directory Items (Landing Preview) + =========================================== */ +.dir-item { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; + cursor: pointer; + transition: background 0.1s; +} + +.dir-item:last-child { + border-bottom: none; +} + +.dir-item:hover { + background: var(--hover); +} + +.dir-item-icon { + margin-right: 12px; + opacity: 0.7; +} + +.dir-item-icon img { + width: 18px; + height: 18px; +} + +.dir-item-name { + color: var(--text-primary); +} + +.dir-item-name:hover { + color: var(--accent); +} + +/* =========================================== + Page Header (Graph Page) + =========================================== */ +.page-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; } -.file-list { +.back-button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.back-button:hover { + background: var(--hover); + color: var(--text-primary); +} + +.page-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* =========================================== + Graph Params + =========================================== */ +.graph-params { + display: flex; + gap: 12px; + margin-bottom: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.graph-param { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--bg-subtle); + border-radius: 4px; +} + +.graph-param-label { + font-weight: 500; +} + +.graph-param-value { + font-family: monospace; + color: var(--accent); +} + +/* =========================================== + Graph Component + =========================================== */ +.graph-container { + background: var(--bg); + color: var(--text-primary); + font-family: "More Thin", sans-serif; + border-radius: 6px; + border: 1px solid var(--border); overflow: hidden; } -.file-item { +.graph-wrapper { display: flex; - align-items: center; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border); - transition: background-color 0.2s; + align-items: flex-start; + max-height: 600px; + overflow-y: auto; + position: relative; +} + +.graph-canvas-column { + flex-shrink: 0; + position: sticky; + left: 0; + z-index: 1; + border-right: 1px solid var(--border); +} + +.graph-details-column { + flex-grow: 1; + overflow-x: hidden; } -.file-item:last-child { - border-bottom: none; +.graph-row { + width: 100%; + height: 40px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 12px; + background: transparent; + color: inherit; + text-align: left; + border: 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; + transition: background-color 0.1s; } -.file-item:hover { +.graph-row:hover { + background: var(--hover); +} + +.graph-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; background: var(--hover); } -.file-item .icon { - margin-right: 0.75rem; - font-size: 1.2rem; - width: 20px; - text-align: center; +.graph-row-meta { + display: flex; + gap: 10px; + margin-bottom: 2px; + align-items: center; +} + +.graph-hash { + color: var(--accent); + font-family: monospace; +} + +.graph-user { + color: var(--text-secondary); + font-weight: 500; +} + +.graph-branch { + color: var(--text-secondary); + font-size: 10px; + background: var(--bg-subtle); + padding: 1px 6px; + border-radius: 3px; +} + +.graph-desc { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.graph-badge-tip { + background: var(--success); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; +} + +.graph-badge-tag { + background: var(--accent); + color: #fff; + padding: 0 4px; + border-radius: 2px; + font-size: 10px; + font-weight: bold; + flex-shrink: 0; +} + +.graph-loading-row { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 12px; +} + +/* =========================================== + Changeset Detail + =========================================== */ +.changeset-paper { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 24px; +} + +.changeset-heading, +.changeset-meta, +.changeset-parents, +.changeset-files, +.changeset-labels { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.changeset-heading { + justify-content: space-between; + margin-bottom: 12px; +} + +.changeset-heading code { + overflow-wrap: anywhere; +} + +.changeset-paper h2 { + margin-bottom: 8px; +} + +.changeset-meta { + color: var(--text-secondary); + justify-content: space-between; + margin-bottom: 16px; } -.file-item .name { - flex: 1; - font-family: 'Monaco', 'Courier New', monospace; - font-size: 0.9rem; +.changeset-branch, +.changeset-labels span { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; +} + +.changeset-labels, +.changeset-parents, +.changeset-files { + margin: 12px 0; +} + +.changeset-parents button { + border: 0; + background: transparent; + color: var(--accent); + cursor: pointer; + font-family: monospace; +} + +.changeset-parents button:hover { + text-decoration: underline; +} + +.changeset-parents button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.changeset-files code { + display: inline-flex; + align-items: center; + gap: 6px; + overflow-wrap: anywhere; +} + +.changeset-file-status { + color: var(--text-secondary); + font-size: 10px; + text-transform: uppercase; +} + +.changeset-file-status.status-added { + color: var(--success); } -.file-item .name a { - color: var(--fg); +.changeset-file-status.status-removed { + color: var(--danger); +} + +.changeset-diff { + margin-top: 24px; +} + +.changeset-diff h3 { + margin-bottom: 10px; +} + +.side-by-side-diff { + margin-bottom: 18px; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-code); +} + +.diff-file-header { + padding: 9px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-subtle); + font-family: monospace; + font-weight: 600; +} + +.diff-column-headings { + display: grid; + grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); + min-width: 760px; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.diff-column-headings span { + padding: 6px 12px; +} + +.diff-column-headings span + span { + border-left: 1px solid var(--border); +} + +.diff-grid { + display: grid; + grid-template-columns: 52px minmax(328px, 1fr) 52px minmax(328px, 1fr); + min-width: 760px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; } -.file-item .name a:hover { - color: var(--link); +.diff-side-number, +.diff-side-code { + min-height: 22px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.diff-side-number { + padding: 2px 8px; + color: var(--text-secondary); + text-align: right; + user-select: none; + border-right: 1px solid var(--border); +} + +.diff-side-code { + display: block; + margin: 0; + padding: 2px 10px; + border-radius: 0; + background: transparent; + white-space: pre; +} + +.diff-column-divider { + border-left: 1px solid var(--border); +} + +.diff-add { + background: color-mix(in srgb, var(--success) 18%, transparent); +} + +.diff-remove { + background: color-mix(in srgb, var(--danger) 18%, transparent); +} + +.diff-empty { + background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); +} + +.diff-meta { + color: var(--text-secondary); + background: var(--bg-subtle); +} + +.diff-range-row { + grid-column: 1 / -1; + padding: 4px 10px; + color: var(--accent); + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + white-space: pre; } -.file-item.directory .icon { - color: var(--accent); +/* =========================================== + Common States + =========================================== */ +.empty-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.loading-state { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.error-message { + padding: 15px; + border: 1px solid var(--danger-border); + background: var(--danger-bg); + color: var(--danger); + border-radius: 6px; + margin-bottom: 20px; +} + +/* =========================================== + Repository Browser + =========================================== */ +.repo-container { + font-family: "More Thin", sans-serif; + max-width: 980px; + margin: 40px auto; + color: var(--text-primary); + padding: 0 20px; +} + +/* Clone Box */ +.clone-box { + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.clone-label { + font-weight: 600; + font-size: 13px; + margin-right: 10px; + color: var(--text-primary); } -.file-item.file .icon { - color: var(--secondary); +.clone-url { + font-family: "More Thin", sans-serif; + background: var(--bg); + border: 1px solid var(--border); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + flex-grow: 1; +} + +/* Breadcrumb */ +.breadcrumb { + display: flex; + align-items: center; + font-size: 14px; + margin-bottom: 16px; + color: var(--text-secondary); + padding: 8px 0; +} + +.breadcrumb a { + color: var(--accent); + text-decoration: none; + border-radius: 4px; + padding: 2px 6px; +} + +.breadcrumb a:hover { + background: var(--bg-subtle); + text-decoration: underline; +} + +.breadcrumb .separator { + margin: 0 4px; + color: var(--text-secondary); + opacity: 0.5; +} + +.breadcrumb .nav-item.active { + font-weight: 600; + color: var(--text-primary); + padding: 2px 6px; +} + +/* File List */ +.file-list-container { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + background: var(--bg); } -.readme-section { - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid var(--border); +.file-header { + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + padding: 12px 16px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.file-row { + display: flex; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} + +.file-row:last-child { + border-bottom: none; +} + +.file-row:hover { + background: var(--hover); } -.readme-section h2 { - margin-bottom: 1rem; - font-size: 1.5rem; +.file-row .icon img { + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 12px; +} + +.file-row .name a { + color: var(--text-primary); + text-decoration: none; + font-size: 14px; +} + +.file-row .name a:hover { + color: var(--accent); + text-decoration: underline; +} + +/* Readme */ +.readme-section { + margin-top: 32px; + border: 1px solid var(--border); + border-radius: 6px; +} + +.readme-header { + background: var(--bg-subtle); + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; } .readme-content { + padding: 32px; + background: var(--bg); + overflow-x: auto; + color: var(--text-primary); +} + +/* File Viewer Modal */ +.file-viewer-overlay { + position: fixed; + inset: 0; + background: var(--overlay); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; +} + +.file-viewer { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; - padding: 1.5rem; - background: var(--code-bg); + width: 100%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.file-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + border-radius: 6px 6px 0 0; +} + +.file-viewer-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.file-viewer-close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; } -.readme-content h1 { font-size: 1.75rem; margin-top: 1.5rem; } -.readme-content h2 { font-size: 1.5rem; margin-top: 1.25rem; } -.readme-content h3 { font-size: 1.25rem; margin-top: 1rem; } +.file-viewer-close:hover { + background: var(--hover); +} + +.file-viewer-close img { + width: 16px; + height: 16px; + opacity: 0.7; +} -.readme-content h1:first-child, -.readme-content h2:first-child, -.readme-content h3:first-child { - margin-top: 0; +.file-viewer-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.file-viewer-actions a { + font-size: 13px; +} + +.file-viewer-content { + overflow: auto; + flex: 1; +} + +.static-file-viewer { + max-width: 1100px; } -.readme-content ul, -.readme-content ol { - margin-left: 2rem; - margin-bottom: 1rem; +.static-file-preview { + min-height: 240px; + padding: 20px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-subtle); +} + +.static-file-image, +.static-file-video { + display: block; + max-width: 100%; + max-height: 75vh; + object-fit: contain; } -.readme-content li { - margin-bottom: 0.5rem; +.static-file-audio { + width: min(100%, 640px); } -.readme-content img { - max-width: 100%; - height: auto; - border-radius: 6px; +.static-file-pdf { + width: 100%; + height: 75vh; + border: 0; + background: #fff; +} + +.file-viewer-content pre { + margin: 0; + padding: 16px; + background: var(--bg-code); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; } -.empty-state { +.file-viewer-content code { + background: transparent; + padding: 0; +} + +.file-viewer-loading { + padding: 40px; text-align: center; - padding: 3rem 1rem; - color: var(--secondary); + color: var(--text-secondary); +} + +.file-viewer-line-numbers { + display: inline-block; + user-select: none; + text-align: right; + padding-right: 16px; + margin-right: 16px; + border-right: 1px solid var(--border); + color: var(--text-secondary); + opacity: 0.5; } -.error-message { - background: var(--danger); - color: white; - padding: 1rem; +/* Theme Toggle Button */ +.theme-toggle { + margin-left: auto; + background: var(--bg-subtle); + border: 1px solid var(--border); border-radius: 6px; - margin-bottom: 1rem; + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + transition: all 0.2s; +} + +.theme-toggle:hover { + background: var(--hover); + color: var(--text-primary); +} + +.theme-toggle svg { + flex-shrink: 0; +} + +/* Description */ +.description { + color: var(--text-secondary); + margin: 0; + font-size: 14px; } -/* Mobile responsive */ +/* =========================================== + Mobile Responsive + =========================================== */ @media (max-width: 768px) { - main { - padding: 1rem; + .app-container { + padding: 20px 15px; } - .file-item { - padding: 0.5rem 0.75rem; + .repo-container { + padding: 0 15px; + } + + .file-row { + padding: 8px 12px; } - .file-item .name { - font-size: 0.85rem; - } - - .clone-info { - padding: 0.75rem; - overflow-x: auto; - } - - .readme-content { - padding: 1rem; + .clone-box { + padding: 10px 12px; + flex-direction: column; + align-items: flex-start; + gap: 8px; } } + +/* =========================================== + Footer + =========================================== */ +.footer { + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.footer-content { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px 0; + color: var(--text-secondary); + font-size: 13px; +} + +.footer-separator { + opacity: 0.5; +} + +.footer-text { + color: var(--text-secondary); +} + +.clone-box-inner { + display: flex; + align-items: center; + width: 100%; +} + +/* Header content wrapper */ +.header-content { + flex: 1; +} + +.header-content h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.header-content h1 a { + color: inherit; + text-decoration: none; +} + +.header-content h1 a:hover { + text-decoration: none; +}
--- a/hg-web/src/index.html Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/src/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -4,8 +4,13 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Zenbu Repository</title> + <link rel="stylesheet" href="/index.css"> + <link rel="stylesheet" href="/base.css"> + <link rel="stylesheet" href="/a11y-dark.min.css" media="(prefers-color-scheme: dark)"> <link rel="stylesheet" href="/a11y-light.min.css" media="(prefers-color-scheme: light)"> + + <link rel="preload" href="/public/fonts/more-sugar.regular.otf" as="font" type="font/otf" crossorigin> <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg"> </head> <body>
--- a/hg-web/src/main.tsx Sun Jan 25 10:44:04 2026 -0800 +++ b/hg-web/src/main.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -1,8 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { RepoBrowser } from "hg-web/src/repo-browser"; +import { App } from "hg-web/src/components/app"; -const root = ReactDOM.createRoot(document.getElementById('root')); - -// Use JSX syntax (<RepoBrowser />) -root.render(<RepoBrowser />); +const root = ReactDOM.createRoot(document.getElementById('root')!); +root.render(<App />);
--- a/hg-web/src/repo-browser.tsx Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,879 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; -import hljs from 'third_party/highlight/highlight.min.js'; - -// --- ICONS (served as static files) --- -const ICONS = { - folder: "/icons/folder.png", - file: "/icons/file.svg", - home: "/icons/home.png", - repo: "/public/epi_all_colors.svg", - close: "/icons/close.png" -}; - -// SVG Icons for theme toggle -const SunIcon = ({ color = "currentColor" }: { color?: string }) => ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <circle cx="12" cy="12" r="5"/> - <line x1="12" y1="1" x2="12" y2="3"/> - <line x1="12" y1="21" x2="12" y2="23"/> - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> - <line x1="1" y1="12" x2="3" y2="12"/> - <line x1="21" y1="12" x2="23" y2="12"/> - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> - </svg> -); - -const MoonIcon = ({ color = "currentColor" }: { color?: string }) => ( - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> - </svg> -); - -const API_BASE = '/api/repo'; - -// File extensions that should be displayed as code -const CODE_EXTENSIONS = new Set([ - 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', - 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', - 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', - 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', - 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', - 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', - 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' -]); - -// Prefetch cache -const prefetchCache = new Map<string, Promise<any>>(); - -function isCodeFile(filename: string): boolean { - const ext = filename.split('.').pop()?.toLowerCase() || ''; - const basename = filename.toLowerCase(); - return CODE_EXTENSIONS.has(ext) || - CODE_EXTENSIONS.has(basename) || - basename === 'dockerfile' || - basename === 'makefile' || - basename.startsWith('.'); -} - -function isMarkdownFile(filename: string): boolean { - const ext = filename.split('.').pop()?.toLowerCase() || ''; - return ext === 'md' || ext === 'markdown'; -} - -function prefetchDirectory(path: string): void { - const cacheKey = `dir:${path}`; - if (prefetchCache.has(cacheKey)) return; - - const url = path - ? `${API_BASE}/list?path=${encodeURIComponent(path)}` - : `${API_BASE}/list`; - - prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); -} - -function prefetchFile(path: string): void { - const cacheKey = `file:${path}`; - if (prefetchCache.has(cacheKey)) return; - - prefetchCache.set(cacheKey, - fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) - .then(r => r.ok ? r.text() : null) - .catch(() => null) - ); -} - -async function getCachedFile(path: string): Promise<string | null> { - const cacheKey = `file:${path}`; - if (prefetchCache.has(cacheKey)) { - return prefetchCache.get(cacheKey); - } - prefetchFile(path); - return prefetchCache.get(cacheKey)!; -} - -/** - * Component: Styles - * Injected CSS for the polished look with dark/light mode support - */ -const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( - <style>{` - :root { - --bg-color: ${isDark ? '#0d1117' : '#ffffff'}; - --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'}; - --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'}; - --border-color: ${isDark ? '#30363d' : '#d0d7de'}; - --accent-color: ${isDark ? '#58a6ff' : '#0969da'}; - --text-primary: ${isDark ? '#e6edf3' : '#1f2328'}; - --text-secondary: ${isDark ? '#8b949e' : '#656d76'}; - --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'}; - --radius: 6px; - --code-bg: ${isDark ? '#161b22' : '#ffffff'}; - } - - body { - background: var(--bg-color); - transition: background 0.2s; - } - - .repo-container { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - max-width: 980px; - margin: 40px auto; - color: var(--text-primary); - padding: 0 20px; - } - - /* Header */ - .header { - display: flex; - align-items: center; - margin-bottom: 20px; - gap: 15px; - } - .header-icon { width: 32px; height: 32px; opacity: 0.8; } - .header h1 { margin: 0; font-size: 24px; font-weight: 600; } - .description { color: var(--text-secondary); margin: 0; font-size: 14px; } - - /* Theme Toggle */ - .theme-toggle { - margin-left: auto; - background: var(--bg-subtle); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 8px 12px; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - color: var(--text-secondary); - font-size: 13px; - transition: all 0.2s; - } - .theme-toggle:hover { - background: var(--hover-color); - color: var(--text-primary); - } - .theme-toggle svg { - flex-shrink: 0; - } - - /* Clone Box */ - .clone-box { - background: var(--bg-subtle); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 12px 16px; - margin-bottom: 24px; - display: flex; - justify-content: space-between; - align-items: center; - } - .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } - .clone-url { - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - background: var(--bg-color); - border: 1px solid var(--border-color); - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - color: var(--text-secondary); - flex-grow: 1; - } - - /* Breadcrumb */ - #breadcrumb { - display: flex; - align-items: center; - font-size: 14px; - margin-bottom: 16px; - color: var(--text-secondary); - padding: 8px 0; - } - #breadcrumb a { - color: var(--accent-color); - text-decoration: none; - border-radius: 4px; - padding: 2px 6px; - } - #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } - #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } - #breadcrumb .nav-item.active { font-weight: 600; color: var(--text-primary); padding: 2px 6px;} - - /* File List Table Structure */ - .file-list-container { - border: 1px solid var(--border-color); - border-radius: var(--radius); - overflow: hidden; - background: var(--bg-color); - } - .file-header { - background: var(--bg-subtle); - border-bottom: 1px solid var(--border-color); - padding: 12px 16px; - font-size: 13px; - font-weight: 600; - color: var(--text-secondary); - } - .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } - .error-message { - padding: 15px; border: 1px solid ${isDark ? '#f8514966' : '#ffdce0'}; - background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#cf222e'}; - border-radius: var(--radius); margin-bottom: 20px; - } - - /* File Row */ - .file-row { - display: flex; - align-items: center; - padding: 10px 16px; - border-bottom: 1px solid var(--border-color); - transition: background 0.1s; - } - .file-row:last-child { border-bottom: none; } - .file-row:hover { background: var(--hover-color); } - - .file-row .icon img { - width: 20px; - height: 20px; - vertical-align: middle; - margin-right: 12px; - filter: ${isDark ? 'invert(0.8)' : 'none'}; - } - .file-row .name a { - color: var(--text-primary); - text-decoration: none; - font-size: 14px; - } - .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } - - /* Readme */ - #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } - .readme-header { - background: var(--bg-subtle); - padding: 10px 16px; - font-size: 12px; font-weight: 600; - border-bottom: 1px solid var(--border-color); - display: flex; align-items: center; gap: 8px; - } - .readme-header img { - filter: ${isDark ? 'invert(0.7)' : 'none'}; - } - #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); } - - /* File Viewer */ - .file-viewer-overlay { - position: fixed; - inset: 0; - background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'}; - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - padding: 20px; - } - .file-viewer { - background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: var(--radius); - width: 100%; - max-width: 900px; - max-height: 90vh; - display: flex; - flex-direction: column; - box-shadow: 0 8px 32px rgba(0,0,0,0.3); - } - .file-viewer-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--bg-subtle); - border-bottom: 1px solid var(--border-color); - border-radius: var(--radius) var(--radius) 0 0; - } - .file-viewer-title { - font-weight: 600; - font-size: 14px; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 8px; - } - .file-viewer-close { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - } - .file-viewer-close:hover { - background: var(--hover-color); - } - .file-viewer-close img { - width: 16px; - height: 16px; - filter: ${isDark ? 'invert(0.7)' : 'none'}; - opacity: 0.7; - } - .file-viewer-content { - overflow: auto; - flex: 1; - } - .file-viewer-content pre { - margin: 0; - padding: 16px; - background: var(--code-bg); - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - font-size: 13px; - line-height: 1.5; - overflow-x: auto; - } - .file-viewer-content code { - background: transparent; - padding: 0; - } - .file-viewer-loading { - padding: 40px; - text-align: center; - color: var(--text-secondary); - } - .file-viewer-line-numbers { - display: inline-block; - user-select: none; - text-align: right; - padding-right: 16px; - margin-right: 16px; - border-right: 1px solid var(--border-color); - color: var(--text-secondary); - opacity: 0.5; - } - `}</style> -); - -/** - * Component: Breadcrumb - */ -function Breadcrumb({ currentPath, onNavigate }) { - if (!currentPath) { - return ( - <nav id="breadcrumb"> - <span className="nav-item active">root</span> - </nav> - ); - } - - const parts = currentPath.split('/').filter(p => p); - const crumbs = parts.map((part, index) => ({ - name: part, - fullPath: parts.slice(0, index + 1).join('/') - })); - - return ( - <nav id="breadcrumb"> - <a - href="/" - onClick={(e) => { e.preventDefault(); onNavigate(''); }} - title="Go to Root" - > - root - </a> - {crumbs.map((crumb, index) => { - const isLast = index === crumbs.length - 1; - return ( - <React.Fragment key={crumb.fullPath}> - <span className="separator">/</span> - {isLast ? ( - <span className="nav-item active">{crumb.name}</span> - ) : ( - <a - href={`?path=${encodeURIComponent(crumb.fullPath)}`} - onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} - > - {crumb.name} - </a> - )} - </React.Fragment> - ); - })} - </nav> - ); -} - -/** - * Component: FileViewer - * Shows file content inline with syntax highlighting - */ -function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { - const [content, setContent] = useState<string | null>(null); - const [loading, setLoading] = useState(true); - const codeRef = useRef<HTMLElement>(null); - - const filename = filePath.split('/').pop() || filePath; - - useEffect(() => { - setLoading(true); - getCachedFile(filePath).then((text) => { - setContent(text); - setLoading(false); - }); - }, [filePath]); - - useEffect(() => { - if (content && codeRef.current) { - hljs.highlightElement(codeRef.current); - } - }, [content]); - - // Close on escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); - - // Get language from file extension for highlight.js - const getLanguage = () => { - const ext = filename.split('.').pop()?.toLowerCase() || ''; - const langMap: Record<string, string> = { - js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', - py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', - c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', - sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', - json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', - html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', - sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', - dockerfile: 'dockerfile', makefile: 'makefile' - }; - return langMap[ext] || 'plaintext'; - }; - - const addLineNumbers = (text: string) => { - const lines = text.split('\n'); - return lines.map((_, i) => i + 1).join('\n'); - }; - - return ( - <div className="file-viewer-overlay" onClick={onClose}> - <div className="file-viewer" onClick={(e) => e.stopPropagation()}> - <div className="file-viewer-header"> - <span className="file-viewer-title"> - <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> - {filename} - </span> - <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> - <img src={ICONS.close} alt="Close" /> - </button> - </div> - <div className="file-viewer-content"> - {loading ? ( - <div className="file-viewer-loading">Loading...</div> - ) : content ? ( - <pre style={{ display: 'flex' }}> - <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> - <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> - </pre> - ) : ( - <div className="file-viewer-loading">Unable to load file</div> - )} - </div> - </div> - </div> - ); -} - -/** - * Component: MarkdownViewerModal - * Shows markdown content rendered in a modal - */ -function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { - const [content, setContent] = useState<string | null>(null); - const [loading, setLoading] = useState(true); - const contentRef = useRef<HTMLDivElement>(null); - const moduleRef = useRef<any>(null); - const [wasmReady, setWasmReady] = useState(false); - - const filename = filePath.split('/').pop() || filePath; - - useEffect(() => { - createMarkdownModule().then((Module: any) => { - moduleRef.current = Module; - setWasmReady(true); - }); - }, []); - - useEffect(() => { - setLoading(true); - getCachedFile(filePath).then((text) => { - setContent(text); - setLoading(false); - }); - }, [filePath]); - - useEffect(() => { - if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; - - const Module = moduleRef.current; - const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); - const markdownFree = Module.cwrap('markdown_free', null, ['number']); - - const ptr = markdownToHtmlPtr(content); - const html = Module.UTF8ToString(ptr); - markdownFree(ptr); - contentRef.current.innerHTML = html; - }, [content, wasmReady]); - - // Close on escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); - - return ( - <div className="file-viewer-overlay" onClick={onClose}> - <div className="file-viewer" onClick={(e) => e.stopPropagation()}> - <div className="file-viewer-header"> - <span className="file-viewer-title"> - <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> - {filename} - </span> - <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> - <img src={ICONS.close} alt="Close" /> - </button> - </div> - <div className="file-viewer-content"> - {loading || !wasmReady ? ( - <div className="file-viewer-loading">Loading...</div> - ) : content ? ( - <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} /> - ) : ( - <div className="file-viewer-loading">Unable to load file</div> - )} - </div> - </div> - </div> - ); -} - -/** - * Component: FileList - */ -function FileList({ directories, files, onNavigate, onOpenFile }: { - directories: any[]; - files: any[]; - onNavigate: (path: string) => void; - onOpenFile: (path: string) => void; -}) { - const isEmpty = directories.length === 0 && files.length === 0; - - if (isEmpty) { - return ( - <div className="file-list-container"> - <div className="empty-state">This directory is empty.</div> - </div> - ); - } - - return ( - <div className="file-list-container"> - {/* Optional header row like GitHub */} - <div className="file-header"> - Files - </div> - - <div id="fileListBody"> - {directories.map((dir) => ( - <FileRow - key={dir.abspath} - item={dir} - iconUrl={ICONS.folder} - isDir={true} - onNavigate={onNavigate} - onOpenFile={onOpenFile} - /> - ))} - - {files.map((file) => ( - <FileRow - key={file.abspath} - item={file} - iconUrl={ICONS.file} - isDir={false} - onNavigate={onNavigate} - onOpenFile={onOpenFile} - /> - ))} - </div> - </div> - ); -} - -/** - * Component: FileRow - */ -function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { - item: { abspath: string; basename: string }; - iconUrl: string; - isDir: boolean; - onNavigate: (path: string) => void; - onOpenFile: (path: string) => void; -}) { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - if (isDir) { - onNavigate(item.abspath); - } else if (isCodeFile(item.basename)) { - onOpenFile(item.abspath); - } else { - // For non-code files, open in new tab - window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); - } - }; - - const handleMouseEnter = () => { - // Prefetch on hover - if (isDir) { - prefetchDirectory(item.abspath); - } else if (isCodeFile(item.basename)) { - prefetchFile(item.abspath); - } - }; - - const href = isDir - ? `?path=${encodeURIComponent(item.abspath)}` - : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; - - return ( - <div className="file-row" onMouseEnter={handleMouseEnter}> - <span className="icon"> - <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> - </span> - <span className="name"> - <a href={href} onClick={handleClick}> - {item.basename} - </a> - </span> - </div> - ); -} - -/** - * Component: ReadmeViewer - */ -function ReadmeViewer({ content }: { content: string | null }) { - const contentRef = useRef<HTMLDivElement>(null); - const moduleRef = useRef<any>(null); - const [wasmReady, setWasmReady] = useState(false); - - useEffect(() => { - createMarkdownModule().then((Module: any) => { - moduleRef.current = Module; - setWasmReady(true); - }); - }, []); - - useEffect(() => { - if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; - - const Module = moduleRef.current; - const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); - const markdownFree = Module.cwrap('markdown_free', null, ['number']); - - const ptr = markdownToHtmlPtr(content); - const html = Module.UTF8ToString(ptr); - markdownFree(ptr); - contentRef.current.innerHTML = html; - }, [content, wasmReady]); - - if (!content) return null; - - return ( - <div id="readmeSection"> - <div className="readme-header"> - <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} /> - README.md - </div> - <div id="readmeContent" ref={contentRef}> - {!wasmReady && 'Loading...'} - </div> - </div> - ); -} - -/** - * Main Application Component - */ -function RepoBrowser() { - const [currentPath, setCurrentPath] = useState(getCurrentPath()); - const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); - const [readme, setReadme] = useState<string | null>(null); - const [error, setError] = useState<string | null>(null); - const [loading, setLoading] = useState(false); - const [isDarkMode, setIsDarkMode] = useState(() => { - // Check localStorage or system preference - const saved = localStorage.getItem('theme'); - if (saved) return saved === 'dark'; - return window.matchMedia('(prefers-color-scheme: dark)').matches; - }); - const [viewingFile, setViewingFile] = useState<string | null>(null); - - function getCurrentPath() { - const params = new URLSearchParams(window.location.search); - return params.get('path') || ''; - } - - // Persist theme preference - useEffect(() => { - localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); - }, [isDarkMode]); - - useEffect(() => { - const handlePopState = () => setCurrentPath(getCurrentPath()); - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); - - useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); - }, [currentPath]); - - const navigate = (path: string) => { - const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; - window.history.pushState({ path }, '', newUrl); - setCurrentPath(path); - }; - - const fetchDirectory = async (path: string) => { - setLoading(true); - setError(null); - try { - // Check prefetch cache first - const cacheKey = `dir:${path}`; - let data; - if (prefetchCache.has(cacheKey)) { - data = await prefetchCache.get(cacheKey); - prefetchCache.delete(cacheKey); // Clear after use for fresh data next time - } else { - const url = path - ? `${API_BASE}/list?path=${encodeURIComponent(path)}` - : `${API_BASE}/list`; - const response = await fetch(url); - if (response.ok) { - data = await response.json(); - } - } - - if (data?.error) { - throw new Error(data.error); - } - - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); - } catch (err: any) { - console.error('Error loading directory:', err); - setError(err.message); - } finally { - setLoading(false); - } - }; - - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; - try { - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); - if (response.ok) { - const text = await response.text(); - setReadme(text); - } - } catch (err) { - // Readme is optional, ignore errors - } - }; - - const handleOpenFile = useCallback((path: string) => { - setViewingFile(path); - }, []); - - const handleCloseFile = useCallback(() => { - setViewingFile(null); - }, []); - - const toggleTheme = () => { - setIsDarkMode(prev => !prev); - }; - - return ( - <> - <GlobalStyles isDark={isDarkMode} /> - <div className="repo-container"> - - {/* Header */} - <div className="header"> - <img src={ICONS.repo} alt="Repo" className="header-icon" /> - <div> - <h1>Zenbu Repository</h1> - <p className="description">Browse and manage the mercurial codebase</p> - </div> - <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}> - {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />} - {isDarkMode ? 'Light' : 'Dark'} - </button> - </div> - - {/* Clone Bar */} - <div className="clone-box"> - <div style={{display:'flex', alignItems:'center', width:'100%'}}> - <span className="clone-label">Clone HTTPS</span> - <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code> - </div> - </div> - - {/* Navigation & Content */} - <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> - - {error && <div className="error-message">Error: {error}</div>} - - {loading ? ( - <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color: 'var(--text-secondary)'}}> - Loading files... - </div> - ) : ( - <> - <FileList - directories={content.directories} - files={content.files} - onNavigate={navigate} - onOpenFile={handleOpenFile} - /> - <ReadmeViewer content={readme} /> - </> - )} - </div> - - {/* File Viewer Modal */} - {viewingFile && ( - isMarkdownFile(viewingFile) ? ( - <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> - ) : ( - <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> - ) - )} - </> - ); -} - -export { RepoBrowser };
--- a/markdown_converter/markdown_to_html.c Sun Jan 25 10:44:04 2026 -0800 +++ b/markdown_converter/markdown_to_html.c Sun Aug 02 16:50:48 2026 -0700 @@ -61,14 +61,6 @@ buf->length += len; } -static void buffer_append_n(StringBuffer *buf, const char *str, size_t n) -{ - buffer_grow(buf, n); - memcpy(buf->data + buf->length, str, n); - buf->length += n; - buf->data[buf->length] = '\0'; -} - static void buffer_append_char(StringBuffer *buf, char c) { buffer_grow(buf, 1); @@ -76,6 +68,39 @@ buf->data[buf->length] = '\0'; } +static void buffer_append_html_escaped_n(StringBuffer *buf, const char *text, size_t len) +{ + for (size_t i = 0; i < len; i++) { + switch (text[i]) { + case '&': buffer_append(buf, "&"); break; + case '<': buffer_append(buf, "<"); break; + case '>': buffer_append(buf, ">"); break; + case '"': buffer_append(buf, """); break; + case '\'': buffer_append(buf, "'"); break; + default: buffer_append_char(buf, text[i]); break; + } + } +} + +static int is_safe_url(const char *url, size_t len, int is_image) +{ + if (len == 0) return 0; + + for (size_t i = 0; i < len; i++) { + unsigned char c = (unsigned char)url[i]; + if (iscntrl(c) || isspace(c)) return 0; + } + + const char *colon = memchr(url, ':', len); + if (!colon) return 1; + + size_t scheme_len = (size_t)(colon - url); + if (scheme_len == 4 && strncasecmp(url, "http", scheme_len) == 0) return 1; + if (scheme_len == 5 && strncasecmp(url, "https", scheme_len) == 0) return 1; + if (!is_image && scheme_len == 6 && strncasecmp(url, "mailto", scheme_len) == 0) return 1; + return 0; +} + static void buffer_free(StringBuffer *buf) { if (buf) { @@ -160,28 +185,6 @@ return 1; } -// Check if line starts with a specific HTML tag (e.g., "script", "style") -static int is_html_tag(const char *line, const char *tag) -{ - line = skip_whitespace(line); - if (*line != '<') return 0; - line++; - - // Skip optional / - int is_closing = 0; - if (*line == '/') { - is_closing = 1; - line++; - } - - size_t tag_len = strlen(tag); - if (strncasecmp(line, tag, tag_len) != 0) return 0; - - char next = line[tag_len]; - // Tag must be followed by space, >, or end for closing tags - return next == '>' || next == ' ' || next == '\t' || next == '\n' || next == '\0'; -} - // Check if line is ordered list item static int is_ordered_list(const char *line) { @@ -359,11 +362,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<a href=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\">"); - buffer_append_n(buf, text + link_start, link_end - link_start); - buffer_append(buf, "</a>"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 0)) { + buffer_append(buf, "<a href=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\">"); + process_inline(buf, text + link_start, link_end - link_start); + buffer_append(buf, "</a>"); + } else { + process_inline(buf, text + link_start, link_end - link_start); + } i = url_end + 1; continue; } @@ -382,11 +390,16 @@ while (url_end < len && text[url_end] != ')') url_end++; if (url_end < len) { - buffer_append(buf, "<img src=\""); - buffer_append_n(buf, text + url_start, url_end - url_start); - buffer_append(buf, "\" alt=\""); - buffer_append_n(buf, text + alt_start, alt_end - alt_start); - buffer_append(buf, "\">"); + size_t url_len = url_end - url_start; + if (is_safe_url(text + url_start, url_len, 1)) { + buffer_append(buf, "<img src=\""); + buffer_append_html_escaped_n(buf, text + url_start, url_len); + buffer_append(buf, "\" alt=\""); + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + buffer_append(buf, "\">"); + } else { + buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start); + } i = url_end + 1; continue; } @@ -449,25 +462,14 @@ if (end < len) { buffer_append(buf, "<code>"); - buffer_append_n(buf, text + start, end - start); + buffer_append_html_escaped_n(buf, text + start, end - start); buffer_append(buf, "</code>"); i = end + 1; continue; } } - // This might not be needed for now. - // HTML escape special characters - // if (text[i] == '<') { - // buffer_append(buf, "<"); - // } else if (text[i] == '>') { - // buffer_append(buf, ">"); - // } else if (text[i] == '&') { - // buffer_append(buf, "&"); - // } else { - // buffer_append_char(buf, text[i]); - // } - buffer_append_char(buf, text[i]); + buffer_append_html_escaped_n(buf, text + i, 1); i++; } } @@ -759,48 +761,11 @@ } } - // HTML block - pass through unchanged + // Repository markdown is untrusted. Render raw HTML as text. if (is_html_block_start(line)) { - // Check if it's a script or style tag that needs special handling - int is_script = is_html_tag(line, "script"); - int is_style = is_html_tag(line, "style"); - - if (is_script || is_style) { - const char *end_tag = is_script ? "</script>" : "</style>"; - - // Output the opening line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - free(line); - if (*ptr == '\n') ptr++; - - // Collect content until closing tag - while (*ptr) { - line_start = ptr; - while (*ptr && *ptr != '\n') ptr++; - line_len = ptr - line_start; - - line = (char *)malloc(line_len + 1); - if (!line) break; - memcpy(line, line_start, line_len); - line[line_len] = '\0'; - - buffer_append(buf, line); - buffer_append_char(buf, '\n'); - - int found_end = (strstr(line, end_tag) != NULL); - free(line); - if (*ptr == '\n') ptr++; - - if (found_end) break; - } - continue; - } - - // Regular HTML tag - just pass through the line - buffer_append(buf, line); - buffer_append_char(buf, '\n'); + buffer_append(buf, "<p>"); + process_inline(buf, line, line_len); + buffer_append(buf, "</p>"); free(line); if (*ptr == '\n') ptr++; continue;
--- a/markdown_converter/tests/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ b/markdown_converter/tests/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -1,3 +1,11 @@ +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_test( + name = "markdown_to_html_test", + srcs = ["markdown_to_html_test.c"], + deps = ["//markdown_converter:markdown_to_html_c"], +) + # load("//gui_ze:gui_ze.bzl", "bun_run", "move_files_into_dir") # # # Test for WASM module (run with: bazel test //markdown_converter:markdown_to_html_wasm_test)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/markdown_converter/tests/markdown_to_html_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,32 @@ +#include "markdown_converter/markdown_to_html.h" + +#include <stdio.h> +#include <string.h> + +static int failures = 0; + +static void expect_equal(const char *name, const char *markdown, const char *expected) +{ + char *actual = markdown_to_html(markdown); + if (!actual || strcmp(actual, expected) != 0) { + fprintf(stderr, "%s\nexpected: %s\nactual: %s\n", name, expected, actual ? actual : "(null)"); + failures++; + } + markdown_free(actual); +} + +int main(void) +{ + expect_equal("normal link", "[link](https://example.com)", + "<p><a href=\"https://example.com\">link</a></p>"); + expect_equal("escape paragraph", "a & <b>", "<p>a & <b></p>"); + expect_equal("escape raw script", "<script>alert('x')</script>", + "<p><script>alert('x')</script></p>"); + expect_equal("reject script link", "[open](javascript:alert)", "<p>open</p>"); + expect_equal("reject data image", "", "<p>preview</p>"); + expect_equal("escape link attribute", "[link](https://example.com/\"x)", + "<p><a href=\"https://example.com/"x\">link</a></p>"); + expect_equal("escape inline code", "`<script>`", "<p><code><script></code></p>"); + + return failures == 0 ? 0 : 1; +}
--- a/medi/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,49 +0,0 @@ -load("@rules_cc//cc:cc_binary.bzl", "cc_binary") -load("//gui_ze:gui_ze.bzl", "bun_bundle", "move_files_into_dir") - -cc_binary( - name = "medi", - srcs = ["main.c"], - deps = [ - "//seobeo:seobeo", - "//media_processor:media_processor", - "//dowa:dowa", - ], - data = [ - ":all_assets", - ], -) - -filegroup( - name = "all_assets", - srcs = glob(["src/**"]) + [":compiled_js"], -) - -move_files_into_dir( - name = "compiled_js", - srcs = [ - ":medi_bundle", - ], - dest = "src", -) - -filegroup( - name = "src_ts_files", - srcs = glob([ - "src/**/*.ts", - "src/**/*.tsx", - "src/**/*.js", - "src/**/*.jsx", - ], allow_empty = True), -) - - - -bun_bundle( - name = "medi_bundle", - src = "src/main.tsx", - deps = [ - ":src_ts_files" - ], - visibility = ["//visibility:public"], -)
--- a/medi/README.md Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,111 +0,0 @@ -# medi - -Media upload and processing server. Upload images and videos, get optimized WebP images and HLS video streams. - -## Features - -- **Image Upload**: Converts to WebP format with configurable quality -- **Video Upload**: Converts to HLS streaming format with thumbnails -- **Background Processing**: Non-blocking uploads with status polling -- **Web UI**: Drag-and-drop upload interface -- **REST API**: Programmatic access to upload and retrieve media - -## Running - -```bash -# Build and run -bazel run //medi:medi - -# Run on custom port -bazel run //medi:medi -- 3000 -``` - -Server starts at `http://localhost:8080` by default. - -## API - -### Upload Media - -```bash -POST /api/upload -Content-Type: multipart/form-data - -# Response -{ - "id": "1706123456_12345_0", - "type": "image", - "status": "processing" -} -``` - -### Get Media Info - -```bash -GET /api/media/{id} - -# Response (completed image) -{ - "id": "1706123456_12345_0", - "type": "image", - "status": "completed", - "webp": "/media/1706123456_12345_0/image.webp", - "original": "/media/1706123456_12345_0/original.jpg" -} - -# Response (completed video) -{ - "id": "1706123456_12345_1", - "type": "video", - "status": "completed", - "hls": "/media/1706123456_12345_1/video.m3u8", - "thumbnail": "/media/1706123456_12345_1/video_thumb.jpg", - "original": "/media/1706123456_12345_1/original.mp4" -} -``` - -### List All Media - -```bash -GET /api/media - -# Response -{ - "items": [ - {"id": "1706123456_12345_0", "type": "image"}, - {"id": "1706123456_12345_1", "type": "video"} - ] -} -``` - -## Output Structure - -``` -uploads/ # Temporary upload storage (deleted after processing) -media/ # Processed media -├── {id}/ -│ ├── original.{ext} # Original uploaded file -│ ├── image.webp # (images) WebP conversion -│ ├── video.m3u8 # (videos) HLS playlist -│ ├── video_000.ts # (videos) HLS segments -│ └── video_thumb.jpg # (videos) Thumbnail -``` - -## Supported Formats - -### Images -JPG, JPEG, PNG, GIF, WebP, BMP, TIFF, HEIC, AVIF - -### Videos -MP4, MOV, AVI, MKV, WebM, FLV, WMV, M4V, 3GP - -## Dependencies - -- `//seobeo:seobeo` - HTTP server -- `//media_processor:media_processor` - Media processing -- FFmpeg (system installed) - -## Building - -```bash -bazel build //medi:medi -```
--- a/medi/main.c Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,531 +0,0 @@ -#include "seobeo/seobeo.h" -#include "media_processor/media_processor.h" - -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <sys/stat.h> -#include <dirent.h> -#include <pthread.h> -#include <unistd.h> - -#define UPLOAD_DIR "./uploads" -#define PROCESSED_DIR "./media" -#define MAX_UPLOAD_SIZE (100 * 1024 * 1024) // 100MB - -// Background processing queue -typedef struct ProcessJob { - char* input_path; - char* id; - MediaType type; - struct ProcessJob* next; -} ProcessJob; - -static ProcessJob* job_queue_head = NULL; -static ProcessJob* job_queue_tail = NULL; -static pthread_mutex_t job_queue_mutex = PTHREAD_MUTEX_INITIALIZER; -static pthread_cond_t job_queue_cond = PTHREAD_COND_INITIALIZER; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -static void ensure_directories(void) { - mkdir(UPLOAD_DIR, 0755); - mkdir(PROCESSED_DIR, 0755); -} - -static char* extract_filename_from_content_disposition(const char* header, Dowa_Arena* arena) { - const char* filename_start = strstr(header, "filename=\""); - if (!filename_start) return NULL; - - filename_start += 10; // Skip 'filename="' - const char* filename_end = strchr(filename_start, '"'); - if (!filename_end) return NULL; - - size_t len = filename_end - filename_start; - char* filename = Dowa_Arena_Allocate(arena, len + 1); - memcpy(filename, filename_start, len); - filename[len] = '\0'; - return filename; -} - -static char* find_multipart_boundary(const char* content_type, Dowa_Arena* arena) { - const char* boundary_start = strstr(content_type, "boundary="); - if (!boundary_start) return NULL; - - boundary_start += 9; // Skip 'boundary=' - - // Handle quoted boundary - if (*boundary_start == '"') { - boundary_start++; - const char* boundary_end = strchr(boundary_start, '"'); - if (!boundary_end) return NULL; - size_t len = boundary_end - boundary_start; - char* boundary = Dowa_Arena_Allocate(arena, len + 1); - memcpy(boundary, boundary_start, len); - boundary[len] = '\0'; - return boundary; - } - - // Unquoted boundary - size_t len = strlen(boundary_start); - char* boundary = Dowa_Arena_Allocate(arena, len + 1); - strcpy(boundary, boundary_start); - // Trim whitespace/newline - while (len > 0 && (boundary[len-1] == '\r' || boundary[len-1] == '\n' || boundary[len-1] == ' ')) { - boundary[--len] = '\0'; - } - return boundary; -} - -// ============================================================================ -// Background Processing Worker -// ============================================================================ - -static void enqueue_job(const char* input_path, const char* id, MediaType type) { - ProcessJob* job = malloc(sizeof(ProcessJob)); - job->input_path = strdup(input_path); - job->id = strdup(id); - job->type = type; - job->next = NULL; - - pthread_mutex_lock(&job_queue_mutex); - if (job_queue_tail) { - job_queue_tail->next = job; - job_queue_tail = job; - } else { - job_queue_head = job_queue_tail = job; - } - pthread_cond_signal(&job_queue_cond); - pthread_mutex_unlock(&job_queue_mutex); -} - -static void* processing_worker(void* arg) { - (void)arg; - - while (1) { - pthread_mutex_lock(&job_queue_mutex); - while (!job_queue_head) { - pthread_cond_wait(&job_queue_cond, &job_queue_mutex); - } - ProcessJob* job = job_queue_head; - job_queue_head = job->next; - if (!job_queue_head) job_queue_tail = NULL; - pthread_mutex_unlock(&job_queue_mutex); - - // Process the job - Seobeo_Log(SEOBEO_INFO, "Processing media: %s (id=%s)", job->input_path, job->id); - - MediaProcessorOpts opts = media_processor_opts_default(PROCESSED_DIR); - MediaResult* result = media_process_file(job->input_path, job->id, &opts); - - if (result->status == MEDIA_STATUS_COMPLETED) { - Seobeo_Log(SEOBEO_INFO, "Processing completed for %s", job->id); - } else { - Seobeo_Log(SEOBEO_ERROR, "Processing failed for %s: %s", job->id, result->error_message); - } - - media_result_free(result); - - // Clean up temp upload file - unlink(job->input_path); - - free(job->input_path); - free(job->id); - free(job); - } - - return NULL; -} - -void Seobeo_Render_Html( - char *final_body, - char *template, - Dowa_Arena *arena -) -{ - size_t current_offset = 0; - char *cursor = template; - - int32 token_len = 2; - - while (1) - { - char *start_tag = strstr(cursor, "{{"); - if (!start_tag) break; - - char *end_tag = strstr(start_tag, "}}"); - if (!end_tag) break; - - size_t leading_len = start_tag - cursor; - memcpy(final_body + current_offset, cursor, leading_len); - current_offset += leading_len; - - size_t name_len = end_tag - (start_tag + token_len); - char *include_name = Dowa_Arena_Allocate(arena, name_len + 1); - memcpy(include_name, start_tag + token_len, name_len); - include_name[name_len] = '\0'; - - size_t sub_file_size = 0; - char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size); - Seobeo_Log(SEOBEO_DEBUG, "[Curr] Sub content: %s\n", sub_content); - if (sub_content) - { - memcpy(final_body + current_offset, sub_content, sub_file_size); - current_offset += sub_file_size; - free(sub_content); - } - - cursor = end_tag + 2; - } - strcpy(final_body + current_offset, cursor); -} - - -void Seobeo_Render_Html_FilePath( - char *final_body, - char *path, - Dowa_Arena *arena -) { - Seobeo_Log(SEOBEO_DEBUG, "[Curr] %s\n", path); - size_t html_size = 0; - char *template = Seobeo_Web_LoadFile(path, &html_size); - if (!template) return; - Seobeo_Render_Html(final_body, template, arena); -} - -// ============================================================================ -// API Route Handlers -// ============================================================================ - -// GET / - Serve the main HTML page -Seobeo_Request_Entry* handle_index(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, "/index.html", arena); - Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); - return resp; -} - -// POST /api/upload - Handle file upload -Seobeo_Request_Entry* handle_upload(Seobeo_Request_Entry* req, Dowa_Arena* arena) { - const char* content_type = Dowa_HashMap_Get(req, "content-type"); - const char* body = Dowa_HashMap_Get(req, "Body"); - const char* content_length_str = Dowa_HashMap_Get(req, "content-length"); - - Seobeo_Request_Entry* resp = NULL; - - if (!content_type || !body) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Missing content\"}", arena); - return resp; - } - - size_t content_length = content_length_str ? atol(content_length_str) : strlen(body); - - if (content_length > MAX_UPLOAD_SIZE) { - Dowa_HashMap_Push_Arena(resp, "status", "413", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"File too large\"}", arena); - return resp; - } - - // Parse multipart form data - char* boundary = find_multipart_boundary(content_type, arena); - if (!boundary) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Invalid multipart data\"}", arena); - return resp; - } - - // Find file content in multipart data - char boundary_marker[256]; - snprintf(boundary_marker, sizeof(boundary_marker), "--%s", boundary); - - const char* part_start = strstr(body, boundary_marker); - if (!part_start) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"No file found\"}", arena); - return resp; - } - - // Skip to headers - part_start = strstr(part_start, "\r\n"); - if (!part_start) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Invalid format\"}", arena); - return resp; - } - part_start += 2; - - // Find filename from Content-Disposition - char* filename = NULL; - const char* header_end = strstr(part_start, "\r\n\r\n"); - if (header_end) { - char headers[1024] = {0}; - size_t headers_len = header_end - part_start; - if (headers_len < sizeof(headers)) { - memcpy(headers, part_start, headers_len); - filename = extract_filename_from_content_disposition(headers, arena); - } - } - - if (!filename) { - filename = "upload"; - } - - // Find file content - const char* file_start = header_end + 4; // Skip \r\n\r\n - - // Find end boundary - char end_boundary[256]; - snprintf(end_boundary, sizeof(end_boundary), "\r\n--%s", boundary); - const char* file_end = strstr(file_start, end_boundary); - - if (!file_end) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Incomplete upload\"}", arena); - return resp; - } - - size_t file_size = file_end - file_start; - - // Generate ID and determine type - char* id = media_generate_id(); - MediaType type = media_detect_type(filename); - - if (type == MEDIA_TYPE_UNKNOWN) { - free(id); - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Unsupported file type\"}", arena); - return resp; - } - - // Save to temp file - const char* ext = media_get_extension(filename); - char temp_path[512]; - snprintf(temp_path, sizeof(temp_path), "%s/%s%s", UPLOAD_DIR, id, ext ? ext : ""); - - FILE* f = fopen(temp_path, "wb"); - if (!f) { - free(id); - 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 save file\"}", arena); - return resp; - } - - fwrite(file_start, 1, file_size, f); - fclose(f); - - // Enqueue for processing - enqueue_job(temp_path, id, type); - - // Return success with ID - char response_body[256]; - snprintf(response_body, sizeof(response_body), - "{\"id\":\"%s\",\"type\":\"%s\",\"status\":\"processing\"}", - id, type == MEDIA_TYPE_IMAGE ? "image" : "video"); - - char* body_copy = Dowa_Arena_Allocate(arena, strlen(response_body) + 1); - strcpy(body_copy, response_body); - - Dowa_HashMap_Push_Arena(resp, "status", "202", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", body_copy, arena); - - free(id); - return resp; -} - -// GET /api/media/:id - Get media info/status -Seobeo_Request_Entry* handle_media_info(Seobeo_Request_Entry* req, Dowa_Arena* arena) { - const char* path = Dowa_HashMap_Get(req, "path"); - - Seobeo_Request_Entry* resp = NULL; - - // Extract ID from path (e.g., /api/media/123456) - const char* id = path + strlen("/api/media/"); - if (!id || strlen(id) == 0) { - Dowa_HashMap_Push_Arena(resp, "status", "400", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Missing media ID\"}", arena); - return resp; - } - - // Check if processed directory exists - char media_dir[512]; - snprintf(media_dir, sizeof(media_dir), "%s/%s", PROCESSED_DIR, id); - - struct stat st; - if (stat(media_dir, &st) != 0) { - // Check if still processing - char upload_pattern[512]; - snprintf(upload_pattern, sizeof(upload_pattern), "%s/%s", UPLOAD_DIR, id); - - DIR* dir = opendir(UPLOAD_DIR); - boolean found = FALSE; - if (dir) { - struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - if (strncmp(entry->d_name, id, strlen(id)) == 0) { - found = TRUE; - break; - } - } - closedir(dir); - } - - if (found) { - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"status\":\"processing\"}", arena); - } else { - Dowa_HashMap_Push_Arena(resp, "status", "404", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"error\":\"Media not found\"}", arena); - } - return resp; - } - - // Check what files exist - char webp_path[512], hls_path[512], thumb_path[512]; - snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir); - snprintf(hls_path, sizeof(hls_path), "%s/video.m3u8", media_dir); - snprintf(thumb_path, sizeof(thumb_path), "%s/video_thumb.jpg", media_dir); - - boolean is_image = (stat(webp_path, &st) == 0); - boolean is_video = (stat(hls_path, &st) == 0); - - char response[1024]; - if (is_image) { - snprintf(response, sizeof(response), - "{\"id\":\"%s\",\"type\":\"image\",\"status\":\"completed\"," - "\"webp\":\"/media/%s/image.webp\"," - "\"original\":\"/media/%s/original\"}", - id, id, id); - } else if (is_video) { - snprintf(response, sizeof(response), - "{\"id\":\"%s\",\"type\":\"video\",\"status\":\"completed\"," - "\"hls\":\"/media/%s/video.m3u8\"," - "\"thumbnail\":\"/media/%s/video_thumb.jpg\"," - "\"original\":\"/media/%s/original\"}", - id, id, id, id); - } else { - snprintf(response, sizeof(response), "{\"id\":\"%s\",\"status\":\"processing\"}", id); - } - - char* body = Dowa_Arena_Allocate(arena, strlen(response) + 1); - strcpy(body, 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", body, arena); - - return resp; -} - -// GET /api/media - List all media -Seobeo_Request_Entry* handle_media_list(Seobeo_Request_Entry* req, Dowa_Arena* arena) { - (void)req; - - Seobeo_Request_Entry* resp = NULL; - - DIR* dir = opendir(PROCESSED_DIR); - if (!dir) { - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", "{\"items\":[]}", arena); - return resp; - } - - // Build JSON array - char json[16384] = "{\"items\":["; - size_t json_len = strlen(json); - boolean first = TRUE; - - struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - if (entry->d_name[0] == '.') continue; - - char media_dir[512]; - snprintf(media_dir, sizeof(media_dir), "%s/%s", PROCESSED_DIR, entry->d_name); - - struct stat st; - if (stat(media_dir, &st) != 0 || !S_ISDIR(st.st_mode)) continue; - - // Check type - char webp_path[512], hls_path[512]; - snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir); - snprintf(hls_path, sizeof(hls_path), "%s/video.m3u8", media_dir); - - const char* type = "unknown"; - if (stat(webp_path, &st) == 0) type = "image"; - else if (stat(hls_path, &st) == 0) type = "video"; - - char item[256]; - snprintf(item, sizeof(item), "%s{\"id\":\"%s\",\"type\":\"%s\"}", - first ? "" : ",", entry->d_name, type); - - size_t item_len = strlen(item); - if (json_len + item_len + 10 < sizeof(json)) { - strcpy(json + json_len, item); - json_len += item_len; - first = FALSE; - } - } - - closedir(dir); - - strcpy(json + json_len, "]}"); - - char* body = Dowa_Arena_Allocate(arena, strlen(json) + 1); - strcpy(body, json); - - Dowa_HashMap_Push_Arena(resp, "status", "200", arena); - Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); - Dowa_HashMap_Push_Arena(resp, "Body", body, arena); - - return resp; -} - -// ============================================================================ -// Main -// ============================================================================ - -int main(int argc, char** argv) { - const char* port = "8080"; - - printf("===========================================\n"); - printf(" Medi - Media Upload & Processing Server\n"); - printf("===========================================\n"); - printf("Starting on port %s...\n\n", port); - - // Create directories - ensure_directories(); - - // Start background processing worker - pthread_t worker_thread; - pthread_create(&worker_thread, NULL, processing_worker, NULL); - - // Initialize router - Seobeo_Router_Init(); - - // Register routes - Seobeo_Router_Register("GET", "/", handle_index); - Seobeo_Router_Register("POST", "/api/upload", handle_upload); - Seobeo_Router_Register("GET", "/api/media", handle_media_list); - Seobeo_Router_Register("GET", "/api/media/*", handle_media_info); - - // Start server (serves static files from ./media for /media/* paths) - Seobeo_Web_Server_Start("medi/src", port, SEOBEO_MODE_EDGE, 4); - - return 0; -}
--- a/medi/src/app.tsx Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,232 +0,0 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; - -interface MediaItem { - id: string; - type: 'image' | 'video'; - status: 'processing' | 'completed' | 'error'; - webp?: string; - hls?: string; - thumbnail?: string; - original?: string; - error?: string; -} - -const UploadIcon = () => ( - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> - <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> - <polyline points="17 8 12 3 7 8" /> - <line x1="12" y1="3" x2="12" y2="15" /> - </svg> -); - -export function App() { - const [media, setMedia] = useState<MediaItem[]>([]); - const [uploading, setUploading] = useState(false); - const [dragover, setDragover] = useState(false); - const fileInputRef = useRef<HTMLInputElement>(null); - - // Fetch media list on mount - useEffect(() => { - fetchMediaList(); - }, []); - - // Poll for processing status - useEffect(() => { - const processingItems = media.filter(m => m.status === 'processing'); - if (processingItems.length === 0) return; - - const interval = setInterval(() => { - processingItems.forEach(item => { - checkMediaStatus(item.id); - }); - }, 2000); - - return () => clearInterval(interval); - }, [media]); - - const fetchMediaList = async () => { - try { - const res = await fetch('/api/media'); - const data = await res.json(); - - // Fetch details for each item - const items = await Promise.all( - data.items.map(async (item: { id: string; type: string }) => { - const infoRes = await fetch(`/api/media/${item.id}`); - return infoRes.json(); - }) - ); - - setMedia(items); - } catch (err) { - console.error('Failed to fetch media list:', err); - } - }; - - const checkMediaStatus = async (id: string) => { - try { - const res = await fetch(`/api/media/${id}`); - const data = await res.json(); - - setMedia(prev => prev.map(item => - item.id === id ? { ...item, ...data } : item - )); - } catch (err) { - console.error('Failed to check status:', err); - } - }; - - const uploadFile = async (file: File) => { - const formData = new FormData(); - formData.append('file', file); - - setUploading(true); - - try { - const res = await fetch('/api/upload', { - method: 'POST', - body: formData, - }); - - const data = await res.json(); - - if (data.error) { - throw new Error(data.error); - } - - // Add to list with processing status - setMedia(prev => [{ - id: data.id, - type: data.type, - status: 'processing', - }, ...prev]); - - } catch (err: any) { - console.error('Upload failed:', err); - alert('Upload failed: ' + err.message); - } finally { - setUploading(false); - } - }; - - const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { - const files = e.target.files; - if (files && files.length > 0) { - Array.from(files).forEach(uploadFile); - } - e.target.value = ''; - }; - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setDragover(false); - - const files = e.dataTransfer.files; - if (files && files.length > 0) { - Array.from(files).forEach(uploadFile); - } - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setDragover(true); - }, []); - - const handleDragLeave = useCallback(() => { - setDragover(false); - }, []); - - return ( - <> - <h1>Medi</h1> - <p className="subtitle">Upload images and videos for processing</p> - - <div - className={`upload-area ${dragover ? 'dragover' : ''}`} - onClick={() => fileInputRef.current?.click()} - onDrop={handleDrop} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - > - <input - ref={fileInputRef} - type="file" - accept="image/*,video/*" - multiple - onChange={handleFileSelect} - /> - {uploading ? ( - <> - <div className="loading-spinner" /> - <h3>Uploading...</h3> - </> - ) : ( - <> - <UploadIcon /> - <h3>Drop files here or click to upload</h3> - <p>Supports images (JPG, PNG, GIF, WebP) and videos (MP4, MOV, WebM)</p> - </> - )} - </div> - - {media.length === 0 ? ( - <div className="empty-state"> - <p>No media uploaded yet</p> - </div> - ) : ( - <div className="media-grid"> - {media.map(item => ( - <MediaCard key={item.id} item={item} /> - ))} - </div> - )} - </> - ); -} - -function MediaCard({ item }: { item: MediaItem }) { - const [videoError, setVideoError] = useState(false); - - return ( - <div className="media-card"> - <div className="media-preview"> - {item.status === 'processing' ? ( - <div className="loading-spinner" /> - ) : item.type === 'image' && item.webp ? ( - <img src={item.webp} alt={item.id} /> - ) : item.type === 'video' && item.thumbnail ? ( - <img src={item.thumbnail} alt={item.id} /> - ) : ( - <span style={{ color: '#8b949e' }}>No preview</span> - )} - </div> - - <div className="media-info"> - <h4>{item.id}</h4> - <span className="type">{item.type}</span> - <div> - <span className={`status ${item.status}`}> - {item.status === 'processing' ? 'Processing...' : - item.status === 'completed' ? 'Ready' : 'Error'} - </span> - </div> - </div> - - {item.status === 'completed' && ( - <div className="media-links"> - {item.type === 'image' ? ( - <> - <a href={item.webp} target="_blank" rel="noreferrer">WebP</a> - <a href={item.original} target="_blank" rel="noreferrer">Original</a> - </> - ) : ( - <> - <a href={item.hls} target="_blank" rel="noreferrer">HLS Stream</a> - <a href={item.thumbnail} target="_blank" rel="noreferrer">Thumbnail</a> - </> - )} - </div> - )} - </div> - ); -}
--- a/medi/src/index.html Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,191 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Medi - Media Upload</title> - <style> - :root { - --bg: #0d1117; - --bg-secondary: #161b22; - --border: #30363d; - --text: #e6edf3; - --text-secondary: #8b949e; - --accent: #58a6ff; - --success: #3fb950; - --error: #f85149; - } - - * { - box-sizing: border-box; - margin: 0; - padding: 0; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); - color: var(--text); - min-height: 100vh; - } - - #root { - max-width: 1200px; - margin: 0 auto; - padding: 40px 20px; - } - - h1 { - font-size: 2rem; - margin-bottom: 8px; - } - - .subtitle { - color: var(--text-secondary); - margin-bottom: 32px; - } - - .upload-area { - border: 2px dashed var(--border); - border-radius: 12px; - padding: 60px 40px; - text-align: center; - cursor: pointer; - transition: all 0.2s; - background: var(--bg-secondary); - margin-bottom: 32px; - } - - .upload-area:hover, .upload-area.dragover { - border-color: var(--accent); - background: rgba(88, 166, 255, 0.1); - } - - .upload-area svg { - width: 64px; - height: 64px; - margin-bottom: 16px; - color: var(--text-secondary); - } - - .upload-area h3 { - margin-bottom: 8px; - } - - .upload-area p { - color: var(--text-secondary); - font-size: 14px; - } - - .upload-area input { - display: none; - } - - .media-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; - } - - .media-card { - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: 12px; - overflow: hidden; - } - - .media-preview { - aspect-ratio: 16/9; - background: #000; - display: flex; - align-items: center; - justify-content: center; - position: relative; - } - - .media-preview img, .media-preview video { - max-width: 100%; - max-height: 100%; - object-fit: contain; - } - - .media-info { - padding: 16px; - } - - .media-info h4 { - font-size: 14px; - margin-bottom: 4px; - word-break: break-all; - } - - .media-info .type { - font-size: 12px; - color: var(--text-secondary); - text-transform: uppercase; - } - - .media-info .status { - font-size: 12px; - margin-top: 8px; - padding: 4px 8px; - border-radius: 4px; - display: inline-block; - } - - .status.processing { - background: rgba(88, 166, 255, 0.2); - color: var(--accent); - } - - .status.completed { - background: rgba(63, 185, 80, 0.2); - color: var(--success); - } - - .status.error { - background: rgba(248, 81, 73, 0.2); - color: var(--error); - } - - .media-links { - padding: 0 16px 16px; - display: flex; - gap: 8px; - } - - .media-links a { - font-size: 12px; - color: var(--accent); - text-decoration: none; - } - - .media-links a:hover { - text-decoration: underline; - } - - .loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 1s linear infinite; - } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - .empty-state { - text-align: center; - padding: 60px 20px; - color: var(--text-secondary); - } - </style> -</head> -<body> - <div id="root"></div> - <script src="/medi_bundle.js"></script> -</body> -</html>
--- a/medi/src/main.tsx Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,6 +0,0 @@ -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { App } from './app'; - -const root = createRoot(document.getElementById('root')!); -root.render(<App />);
--- a/media_processor/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -load("@rules_cc//cc:cc_library.bzl", "cc_library") -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_library( - name = "media_processor", - hdrs = ["media_processor.h"], - srcs = ["media_processor.c"], - deps = [ - "//third_party/ffmpeg:ffmpeg_cli", - "//dowa:dowa", - ], - visibility = ["//visibility:public"], -) - -cc_test( - name = "media_processor_test", - srcs = ["media_processor_test.c"], - deps = [":media_processor"], -)
--- a/media_processor/README.md Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,85 +0,0 @@ -# media_processor - -Reusable media processing library for images and videos. - -## Features - -- **Image Processing**: Convert images to WebP format with quality/size options -- **Video Processing**: Convert videos to HLS format for streaming -- **Auto-detection**: Automatically detects media type from file extension -- **Thumbnail Generation**: Creates thumbnails for videos -- **Original Preservation**: Keeps original files alongside processed versions - -## Usage - -```c -#include "media_processor/media_processor.h" - -// Process any media file (auto-detects type) -MediaProcessorOpts opts = media_processor_opts_default("./output"); -opts.image_quality = 85; -opts.video_max_width = 1920; - -char* id = media_generate_id(); -MediaResult* result = media_process_file("upload.mp4", id, &opts); - -if (result->status == MEDIA_STATUS_COMPLETED) { - printf("HLS playlist: %s\n", result->hls_playlist); - printf("Thumbnail: %s\n", result->thumbnail_path); -} - -media_result_free(result); -free(id); -``` - -## Output Structure - -For images: -``` -output/ -└── {id}/ - ├── original.jpg # Original file - └── image.webp # Converted WebP -``` - -For videos: -``` -output/ -└── {id}/ - ├── original.mp4 # Original file - ├── video.m3u8 # HLS playlist - ├── video_000.ts # HLS segments - ├── video_001.ts - └── video_thumb.jpg # Thumbnail -``` - -## Options - -### Image Options - -| Option | Default | Description | -|--------|---------|-------------| -| `image_quality` | 80 | WebP quality (0-100) | -| `image_max_width` | 0 | Max width (0 = no limit) | -| `image_max_height` | 0 | Max height (0 = no limit) | - -### Video Options - -| Option | Default | Description | -|--------|---------|-------------| -| `video_segment_duration` | 6 | HLS segment length in seconds | -| `video_bitrate` | 0 | Video bitrate in kbps (0 = auto) | -| `video_max_width` | 0 | Max width (0 = no limit) | -| `video_max_height` | 0 | Max height (0 = no limit) | -| `generate_thumbnail` | true | Generate video thumbnail | - -## Building - -```bash -bazel build //media_processor:media_processor -``` - -## Dependencies - -- `//third_party/ffmpeg:ffmpeg_cli` - FFmpeg CLI wrapper -- `//dowa:dowa` - Memory and string utilities
--- a/media_processor/media_processor.c Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,316 +0,0 @@ -#include "media_processor.h" -#include "third_party/ffmpeg/ffmpeg_cli.h" -#include "dowa/dowa.h" - -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <stdarg.h> -#include <time.h> -#include <unistd.h> -#include <sys/stat.h> -#include <errno.h> - -// File extension mappings -static const struct { - const char* ext; - MediaType type; -} EXTENSION_MAP[] = { - // Images - {".jpg", MEDIA_TYPE_IMAGE}, - {".jpeg", MEDIA_TYPE_IMAGE}, - {".png", MEDIA_TYPE_IMAGE}, - {".gif", MEDIA_TYPE_IMAGE}, - {".webp", MEDIA_TYPE_IMAGE}, - {".bmp", MEDIA_TYPE_IMAGE}, - {".tiff", MEDIA_TYPE_IMAGE}, - {".tif", MEDIA_TYPE_IMAGE}, - {".heic", MEDIA_TYPE_IMAGE}, - {".heif", MEDIA_TYPE_IMAGE}, - {".avif", MEDIA_TYPE_IMAGE}, - - // Videos - {".mp4", MEDIA_TYPE_VIDEO}, - {".mov", MEDIA_TYPE_VIDEO}, - {".avi", MEDIA_TYPE_VIDEO}, - {".mkv", MEDIA_TYPE_VIDEO}, - {".webm", MEDIA_TYPE_VIDEO}, - {".flv", MEDIA_TYPE_VIDEO}, - {".wmv", MEDIA_TYPE_VIDEO}, - {".m4v", MEDIA_TYPE_VIDEO}, - {".3gp", MEDIA_TYPE_VIDEO}, - - // Audio - {".mp3", MEDIA_TYPE_AUDIO}, - {".wav", MEDIA_TYPE_AUDIO}, - {".flac", MEDIA_TYPE_AUDIO}, - {".aac", MEDIA_TYPE_AUDIO}, - {".ogg", MEDIA_TYPE_AUDIO}, - {".m4a", MEDIA_TYPE_AUDIO}, - {".wma", MEDIA_TYPE_AUDIO}, - - {NULL, MEDIA_TYPE_UNKNOWN} -}; - -MediaProcessorOpts media_processor_opts_default(const char* output_dir) { - return (MediaProcessorOpts){ - .output_dir = output_dir, - .image_quality = 80, - .image_max_width = 0, - .image_max_height = 0, - .video_segment_duration = 6, - .video_bitrate = 0, - .video_max_width = 0, - .video_max_height = 0, - .generate_thumbnail = true, - }; -} - -const char* media_get_extension(const char* filename) { - if (!filename) return NULL; - const char* dot = strrchr(filename, '.'); - return dot ? dot : NULL; -} - -MediaType media_detect_type(const char* filename) { - const char* ext = media_get_extension(filename); - if (!ext) return MEDIA_TYPE_UNKNOWN; - - // Convert to lowercase for comparison - char ext_lower[16] = {0}; - size_t len = strlen(ext); - if (len >= sizeof(ext_lower)) return MEDIA_TYPE_UNKNOWN; - - for (size_t i = 0; i < len; i++) { - ext_lower[i] = (ext[i] >= 'A' && ext[i] <= 'Z') ? ext[i] + 32 : ext[i]; - } - - for (int i = 0; EXTENSION_MAP[i].ext != NULL; i++) { - if (strcmp(ext_lower, EXTENSION_MAP[i].ext) == 0) { - return EXTENSION_MAP[i].type; - } - } - - return MEDIA_TYPE_UNKNOWN; -} - -const char* media_type_to_mime(MediaType type) { - switch (type) { - case MEDIA_TYPE_IMAGE: return "image/*"; - case MEDIA_TYPE_VIDEO: return "video/*"; - case MEDIA_TYPE_AUDIO: return "audio/*"; - default: return "application/octet-stream"; - } -} - -static MediaResult* create_result(void) { - MediaResult* result = calloc(1, sizeof(MediaResult)); - result->status = MEDIA_STATUS_PENDING; - return result; -} - -static void set_result_error(MediaResult* result, const char* fmt, ...) { - result->status = MEDIA_STATUS_FAILED; - - va_list args; - va_start(args, fmt); - - char buf[512]; - vsnprintf(buf, sizeof(buf), fmt, args); - result->error_message = strdup(buf); - - va_end(args); -} - -void media_result_free(MediaResult* result) { - if (!result) return; - free(result->error_message); - free(result->original_path); - free(result->webp_path); - free(result->hls_playlist); - free(result->thumbnail_path); - free(result); -} - -char* media_generate_id(void) { - static int counter = 0; - char buf[64]; - snprintf(buf, sizeof(buf), "%ld_%d_%d", - (long)time(NULL), getpid(), counter++); - return strdup(buf); -} - -bool media_path_is_safe(const char* path) { - if (!path) return false; - // Check for directory traversal - if (strstr(path, "..") != NULL) return false; - // Check for absolute paths (in uploaded filenames) - if (path[0] == '/') return false; - return true; -} - -static bool ensure_directory(const char* path) { - struct stat st; - if (stat(path, &st) == 0) { - return S_ISDIR(st.st_mode); - } - return mkdir(path, 0755) == 0; -} - -bool media_copy_file(const char* src, const char* dst) { - FILE* in = fopen(src, "rb"); - if (!in) return false; - - FILE* out = fopen(dst, "wb"); - if (!out) { - fclose(in); - return false; - } - - char buf[8192]; - size_t n; - while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { - if (fwrite(buf, 1, n, out) != n) { - fclose(in); - fclose(out); - return false; - } - } - - fclose(in); - fclose(out); - return true; -} - -MediaResult* media_process_image(const char* input_path, const char* id, MediaProcessorOpts* opts) { - MediaResult* result = create_result(); - result->status = MEDIA_STATUS_PROCESSING; - - if (!opts || !opts->output_dir) { - set_result_error(result, "Output directory not specified"); - return result; - } - - if (!ensure_directory(opts->output_dir)) { - set_result_error(result, "Failed to create output directory: %s", opts->output_dir); - return result; - } - - // Create subdirectory for this media - char media_dir[1024]; - snprintf(media_dir, sizeof(media_dir), "%s/%s", opts->output_dir, id); - if (!ensure_directory(media_dir)) { - set_result_error(result, "Failed to create media directory: %s", media_dir); - return result; - } - - // Copy original - const char* ext = media_get_extension(input_path); - char original_path[1024]; - snprintf(original_path, sizeof(original_path), "%s/original%s", media_dir, ext ? ext : ""); - - if (!media_copy_file(input_path, original_path)) { - set_result_error(result, "Failed to copy original file"); - return result; - } - result->original_path = strdup(original_path); - - // Convert to WebP - char webp_path[1024]; - snprintf(webp_path, sizeof(webp_path), "%s/image.webp", media_dir); - - ImageConvertOpts img_opts = ffmpeg_image_opts_default(); - img_opts.quality = opts->image_quality; - img_opts.max_width = opts->image_max_width; - img_opts.max_height = opts->image_max_height; - - FfmpegResult ffmpeg_result = ffmpeg_image_to_webp(input_path, webp_path, &img_opts); - if (ffmpeg_result != FFMPEG_OK) { - set_result_error(result, "FFmpeg conversion failed: %s", ffmpeg_last_error()); - return result; - } - - result->webp_path = strdup(webp_path); - result->status = MEDIA_STATUS_COMPLETED; - return result; -} - -MediaResult* media_process_video(const char* input_path, const char* id, MediaProcessorOpts* opts) { - MediaResult* result = create_result(); - result->status = MEDIA_STATUS_PROCESSING; - - if (!opts || !opts->output_dir) { - set_result_error(result, "Output directory not specified"); - return result; - } - - if (!ensure_directory(opts->output_dir)) { - set_result_error(result, "Failed to create output directory: %s", opts->output_dir); - return result; - } - - // Create subdirectory for this media - char media_dir[1024]; - snprintf(media_dir, sizeof(media_dir), "%s/%s", opts->output_dir, id); - if (!ensure_directory(media_dir)) { - set_result_error(result, "Failed to create media directory: %s", media_dir); - return result; - } - - // Copy original - const char* ext = media_get_extension(input_path); - char original_path[1024]; - snprintf(original_path, sizeof(original_path), "%s/original%s", media_dir, ext ? ext : ""); - - if (!media_copy_file(input_path, original_path)) { - set_result_error(result, "Failed to copy original file"); - return result; - } - result->original_path = strdup(original_path); - - // Get duration - ffmpeg_get_duration(input_path, &result->duration); - - // Convert to HLS - HlsConvertOpts hls_opts = ffmpeg_hls_opts_default(); - hls_opts.segment_duration = opts->video_segment_duration; - hls_opts.video_bitrate = opts->video_bitrate; - hls_opts.max_width = opts->video_max_width; - hls_opts.max_height = opts->video_max_height; - hls_opts.generate_thumbnail = opts->generate_thumbnail; - - FfmpegResult ffmpeg_result = ffmpeg_video_to_hls(input_path, media_dir, "video", &hls_opts); - if (ffmpeg_result != FFMPEG_OK) { - set_result_error(result, "FFmpeg HLS conversion failed: %s", ffmpeg_last_error()); - return result; - } - - char playlist_path[1024]; - snprintf(playlist_path, sizeof(playlist_path), "%s/video.m3u8", media_dir); - result->hls_playlist = strdup(playlist_path); - - if (opts->generate_thumbnail) { - char thumb_path[1024]; - snprintf(thumb_path, sizeof(thumb_path), "%s/video_thumb.jpg", media_dir); - result->thumbnail_path = strdup(thumb_path); - } - - result->status = MEDIA_STATUS_COMPLETED; - return result; -} - -MediaResult* media_process_file(const char* input_path, const char* id, MediaProcessorOpts* opts) { - MediaType type = media_detect_type(input_path); - - switch (type) { - case MEDIA_TYPE_IMAGE: - return media_process_image(input_path, id, opts); - case MEDIA_TYPE_VIDEO: - return media_process_video(input_path, id, opts); - default: { - MediaResult* result = create_result(); - set_result_error(result, "Unsupported media type"); - return result; - } - } -}
--- a/media_processor/media_processor.h Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -#ifndef MEDIA_PROCESSOR_H -#define MEDIA_PROCESSOR_H - -#include <stdbool.h> -#include <stddef.h> - -// Media types -typedef enum { - MEDIA_TYPE_UNKNOWN = 0, - MEDIA_TYPE_IMAGE, - MEDIA_TYPE_VIDEO, - MEDIA_TYPE_AUDIO, -} MediaType; - -// Processing status -typedef enum { - MEDIA_STATUS_PENDING = 0, - MEDIA_STATUS_PROCESSING, - MEDIA_STATUS_COMPLETED, - MEDIA_STATUS_FAILED, -} MediaStatus; - -// Result of a processing operation -typedef struct { - MediaStatus status; - char* error_message; - - // For images - char* original_path; // Path to original file - char* webp_path; // Path to WebP version - - // For videos - char* hls_playlist; // Path to .m3u8 file - char* thumbnail_path; // Path to thumbnail - double duration; // Duration in seconds -} MediaResult; - -// Processing options -typedef struct { - // Output directory (required) - const char* output_dir; - - // Image options - int image_quality; // 0-100, default 80 - int image_max_width; // 0 = no limit - int image_max_height; // 0 = no limit - - // Video options - int video_segment_duration; // HLS segment duration, default 6 - int video_bitrate; // kbps, 0 = auto - int video_max_width; // 0 = no limit - int video_max_height; // 0 = no limit - bool generate_thumbnail; // default true -} MediaProcessorOpts; - -// Initialize processor options with defaults -MediaProcessorOpts media_processor_opts_default(const char* output_dir); - -// Detect media type from file extension -MediaType media_detect_type(const char* filename); - -// Get MIME type string -const char* media_type_to_mime(MediaType type); - -// Process a single file (auto-detects type) -// Returns a MediaResult that must be freed with media_result_free() -MediaResult* media_process_file(const char* input_path, const char* id, MediaProcessorOpts* opts); - -// Process image specifically -MediaResult* media_process_image(const char* input_path, const char* id, MediaProcessorOpts* opts); - -// Process video specifically -MediaResult* media_process_video(const char* input_path, const char* id, MediaProcessorOpts* opts); - -// Free a MediaResult -void media_result_free(MediaResult* result); - -// Generate a unique ID for a media file -char* media_generate_id(void); - -// Utility: Copy file to destination -bool media_copy_file(const char* src, const char* dst); - -// Utility: Get file extension (returns pointer into filename, do not free) -const char* media_get_extension(const char* filename); - -// Utility: Check if path is safe (no directory traversal) -bool media_path_is_safe(const char* path); - -#endif // MEDIA_PROCESSOR_H
--- a/media_processor/media_processor_test.c Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,109 +0,0 @@ -#include "media_processor.h" -#include <stdio.h> -#include <assert.h> -#include <string.h> - -void test_detect_type() { - printf("Testing media_detect_type...\n"); - - // Images - assert(media_detect_type("photo.jpg") == MEDIA_TYPE_IMAGE); - assert(media_detect_type("photo.JPEG") == MEDIA_TYPE_IMAGE); - assert(media_detect_type("image.png") == MEDIA_TYPE_IMAGE); - assert(media_detect_type("animation.gif") == MEDIA_TYPE_IMAGE); - assert(media_detect_type("modern.webp") == MEDIA_TYPE_IMAGE); - assert(media_detect_type("apple.heic") == MEDIA_TYPE_IMAGE); - - // Videos - assert(media_detect_type("video.mp4") == MEDIA_TYPE_VIDEO); - assert(media_detect_type("movie.mov") == MEDIA_TYPE_VIDEO); - assert(media_detect_type("clip.avi") == MEDIA_TYPE_VIDEO); - assert(media_detect_type("stream.mkv") == MEDIA_TYPE_VIDEO); - assert(media_detect_type("web.webm") == MEDIA_TYPE_VIDEO); - - // Audio - assert(media_detect_type("song.mp3") == MEDIA_TYPE_AUDIO); - assert(media_detect_type("audio.wav") == MEDIA_TYPE_AUDIO); - assert(media_detect_type("music.flac") == MEDIA_TYPE_AUDIO); - - // Unknown - assert(media_detect_type("document.pdf") == MEDIA_TYPE_UNKNOWN); - assert(media_detect_type("archive.zip") == MEDIA_TYPE_UNKNOWN); - assert(media_detect_type("noextension") == MEDIA_TYPE_UNKNOWN); - - printf(" PASSED\n"); -} - -void test_get_extension() { - printf("Testing media_get_extension...\n"); - - assert(strcmp(media_get_extension("file.jpg"), ".jpg") == 0); - assert(strcmp(media_get_extension("path/to/file.mp4"), ".mp4") == 0); - assert(strcmp(media_get_extension("multiple.dots.png"), ".png") == 0); - assert(media_get_extension("noextension") == NULL); - assert(media_get_extension(NULL) == NULL); - - printf(" PASSED\n"); -} - -void test_path_safety() { - printf("Testing media_path_is_safe...\n"); - - // Safe paths - assert(media_path_is_safe("file.jpg") == true); - assert(media_path_is_safe("folder/file.jpg") == true); - assert(media_path_is_safe("a/b/c/file.jpg") == true); - - // Unsafe paths - assert(media_path_is_safe("../file.jpg") == false); - assert(media_path_is_safe("folder/../file.jpg") == false); - assert(media_path_is_safe("/absolute/path.jpg") == false); - assert(media_path_is_safe(NULL) == false); - - printf(" PASSED\n"); -} - -void test_generate_id() { - printf("Testing media_generate_id...\n"); - - char* id1 = media_generate_id(); - char* id2 = media_generate_id(); - - assert(id1 != NULL); - assert(id2 != NULL); - assert(strlen(id1) > 0); - assert(strlen(id2) > 0); - assert(strcmp(id1, id2) != 0); // Should be unique - - free(id1); - free(id2); - - printf(" PASSED\n"); -} - -void test_default_opts() { - printf("Testing media_processor_opts_default...\n"); - - MediaProcessorOpts opts = media_processor_opts_default("./output"); - - assert(opts.output_dir != NULL); - assert(strcmp(opts.output_dir, "./output") == 0); - assert(opts.image_quality == 80); - assert(opts.video_segment_duration == 6); - assert(opts.generate_thumbnail == true); - - printf(" PASSED\n"); -} - -int main() { - printf("\n=== Media Processor Tests ===\n\n"); - - test_detect_type(); - test_get_extension(); - test_path_safety(); - test_generate_id(); - test_default_opts(); - - printf("\n=== All tests passed! ===\n\n"); - return 0; -}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/.config.development Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,12 @@ +# MrJuneJune Server Configuration + +# Auth token for S3 upload API +# Client must send this in Authorization header: Bearer <token> +UPLOAD_AUTH_TOKEN=THIS + +# S3 Configuration +S3_REGION=THIS +S3_BUCKET=THIS + +# Presigned URL expiration (seconds) +S3_URL_EXPIRES=SECONDS
--- a/mrjunejune/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -28,15 +28,37 @@ dest = "src/public/highlight", ) +move_files_into_dir( + name = "rich_editor_js", + srcs = [ + "//rich_editor:rich_editor", + ], + dest = "src/public/js", +) + +move_files_into_dir( + name = "icons", + srcs = [ + "//assets:icons", + ], + dest = "src/public/icons", +) + filegroup( name = "public_files", - srcs = glob(["src/public/**"]), + srcs = glob(["src/public/*"]), + visibility = ["//visibility:public"], +) + +filegroup( + name = "public_fonts_files", + srcs = glob(["src/public/fonts/*"]), visibility = ["//visibility:public"], ) filegroup( name = "src_files", - srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", "shared_js_file"], + srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js", ":icons"], visibility = ["//mrjunejune/test:__pkg__"], ) @@ -45,10 +67,18 @@ name = "mrjunejune_server", srcs = ["main.c"], deps = [ - "//seobeo:seobeo_tcp_server_ws", + "//seobeo:seobeo", "//markdown_converter:markdown_to_html_c", + "//s3:s3", + "//deita:deita", ], - data = [":src_files"], + copts = ["-D_GNU_SOURCE"], + linkopts = ["-lpthread"], + data = [ + ":src_files", + ":config_file", + "//:env_file", + ], visibility = ["//mrjunejune/test:__pkg__"], ) @@ -57,10 +87,30 @@ name = "mrjunejune_server_debug", srcs = ["main.c"], deps = [ - "//seobeo:seobeo_tcp_server_ws_debug", - "//markdown_converter:markdown_to_html_c" + "//seobeo:seobeo_debug", + "//markdown_converter:markdown_to_html_c", + "//s3:s3", + "//deita:deita", + ], + copts = ["-D_GNU_SOURCE"], + linkopts = ["-lpthread"], + data = [ + ":src_files", + ":config_file", + "//:env_file", ], - data = [":src_files"], +) + +filegroup( + name = "config_file", + srcs = [".config"], + visibility = ["//visibility:public"], +) + +filegroup( + name = "data_dir", + srcs = glob(["data/*"]), + visibility = ["//visibility:public"], ) # Run this to create html files
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/PWA_SETUP.md Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,126 @@ +# PWA Setup Guide + +Your site is now configured as a Progressive Web App! 🎉 + +## What's Been Added + +1. **manifest.json** - App configuration +2. **sw.js** - Service worker for caching and offline support +3. **pwa-register.js** - Service worker registration +4. **offline.html** - Offline fallback page +5. **Updated base_head.html** - Links to manifest and PWA script + +## Missing: App Icons + +You need to create PNG icons from your SVG. Run these commands: + +### Using ImageMagick (recommended): + +```bash +cd src/public + +# 192x192 icon +convert epi_all_colors.svg -resize 192x192 -background none icon-192.png + +# 512x512 icon +convert epi_all_colors.svg -resize 512x512 -background none icon-512.png + +# Maskable icon (512x512 with safe zone) +convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png +``` + +### Or using Inkscape: + +```bash +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-192.png -w 192 -h 192 +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-512.png -w 512 -h 512 +inkscape epi_all_colors.svg --export-type=png --export-filename=icon-maskable-512.png -w 512 -h 512 +``` + +### Or use an online tool: +- https://realfavicongenerator.net/ +- https://www.pwabuilder.com/ + +## Optional: Screenshots (for better app install experience) + +Create screenshots of your site: + +```bash +# Mobile screenshot (540x720) +screenshot-mobile.png + +# Desktop screenshot (1280x720) +screenshot-desktop.png +``` + +You can use browser DevTools to capture these, or remove the `screenshots` section from manifest.json if you don't want them. + +## Testing Your PWA + +1. **Serve over HTTPS** - PWAs require HTTPS (localhost works for testing) +2. **Open Chrome DevTools** → Application tab → Manifest +3. **Check Service Worker** → Application tab → Service Workers +4. **Lighthouse Audit** → Run PWA audit to see score + +## Install Prompt + +When users visit your site, they'll see an "Install App" button in the bottom-right corner for 10 seconds. They can: +- Click it to install immediately +- Use browser menu: "Install MrJuneJune" or "Add to Home Screen" + +## Features + +✅ **Offline Support** - Caches pages, CSS, JS, fonts, images +✅ **App Shortcuts** - Quick access to Blog and Notes +✅ **Install Prompt** - Automatic install button +✅ **Auto-updates** - Service worker updates on reload +✅ **Fast Loading** - Cached resources load instantly + +## Customization + +Edit `manifest.json` to change: +- `theme_color` - App theme color +- `background_color` - Splash screen color +- `display` - `standalone`, `fullscreen`, `minimal-ui`, or `browser` +- `shortcuts` - App shortcut menu items + +Edit `sw.js` to change: +- `CACHE_VERSION` - Increment to force cache refresh +- `STATIC_CACHE` - Files to cache immediately +- Caching strategy (currently: cache-first with network fallback) + +## Testing on Mobile + +### Android: +1. Open Chrome +2. Visit your site +3. Tap menu → "Install app" or "Add to Home Screen" + +### iOS (Safari): +1. Open Safari +2. Tap Share button +3. Tap "Add to Home Screen" + +Note: iOS has limited PWA support (no install prompt, limited background features) + +## Debugging + +Check console for: +- `[PWA]` - Registration events +- `[SW]` - Service worker caching events + +Clear cache: +```js +navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_CACHE' }); +``` + +## Next Steps + +1. Generate the PNG icons (see commands above) +2. Test on HTTPS +3. Run Lighthouse audit +4. Deploy and test on mobile device +5. Consider adding: + - Push notifications + - Background sync + - Share target API
--- a/mrjunejune/deploy.sh Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/deploy.sh Sun Aug 02 16:50:48 2026 -0700 @@ -5,6 +5,9 @@ sudo mkdir -p /opt/mrjunejune_server sudo cp -a bazel-bin/mrjunejune/mrjunejune_server_bundle /opt/mrjunejune_server_bundle_new sudo chown -R mrjunejune_server:zenbu_team /opt/mrjunejune_server_bundle_new +# sqlite3 db +sudo mkdir -p /opt/data/mrjunejune/ +sudo chown -R mrjunejune_server:zenbu_team /opt/data/mrjunejune/ # Swap sudo rm -rf /opt/mrjunejune_server_bundle_active
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/generate-icons.sh Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,42 @@ +#!/bin/bash + +# Generate PWA icons from SVG +# Requires ImageMagick: apt-get install imagemagick + +cd "$(dirname "$0")/src/public" + +if ! command -v convert &> /dev/null; then + echo "❌ ImageMagick not found. Please install it:" + echo " Ubuntu/Debian: sudo apt-get install imagemagick" + echo " macOS: brew install imagemagick" + echo "" + echo "Or use an online tool: https://realfavicongenerator.net/" + exit 1 +fi + +echo "🎨 Generating PWA icons from epi_all_colors.svg..." + +# 192x192 icon +echo " → Creating icon-192.png..." +convert epi_all_colors.svg -resize 192x192 -background none icon-192.png + +# 512x512 icon +echo " → Creating icon-512.png..." +convert epi_all_colors.svg -resize 512x512 -background none icon-512.png + +# Maskable icon (512x512 with safe zone - 80% of size) +echo " → Creating icon-maskable-512.png..." +convert epi_all_colors.svg -resize 409x409 -background none -gravity center -extent 512x512 icon-maskable-512.png + +echo "" +echo "✅ Icons generated successfully!" +echo "" +echo "Generated files:" +echo " - icon-192.png" +echo " - icon-512.png" +echo " - icon-maskable-512.png" +echo "" +echo "Next steps:" +echo " 1. Verify icons look good: ls -lh src/public/icon-*.png" +echo " 2. Test PWA: Open site in Chrome → DevTools → Application → Manifest" +echo " 3. Run Lighthouse audit to check PWA score"
--- a/mrjunejune/main.c Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/main.c Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,11 @@ #include "seobeo/seobeo.h" #include "markdown_converter/markdown_to_html.h" +#include "s3/s3_uploader.h" +#include "deita/deita.h" #include <time.h> +#include <sys/stat.h> +#include <stdarg.h> +#include <pthread.h> // UUID + /tmp/ + format (max 4) #define TMP_FILE_LENGTH 47 @@ -9,6 +14,171 @@ volatile sig_atomic_t stop_server = 0; static _Atomic uint32_t counter = 0; +// Media Processing Context for background threads +typedef struct { + int64 media_id; + char s3_key_original[512]; + char s3_key_processed[512]; + char content_type[128]; + char access_token[256]; + char db_path[256]; + S3_Config s3_config; +} Media_Processing_Context; + +// Server configuration (loaded from .config) +static char g_upload_auth_token[256] = {0}; +static char g_s3_region[64] = "us-west-2"; +static char g_s3_bucket[128] = "mrjunejune"; +static char g_s3_cloudfront_url[256] = {0}; +static char g_db_path[256] = "mrjunejune/data/mrjunejune.db"; +static int g_s3_url_expires = 3600; +static S3_Config g_s3_config = {0}; +static Deita_Connection *g_db_connection = NULL; + +static void load_config(const char *config_path) +{ + FILE *f = fopen(config_path, "r"); + if (!f) + { + printf("[CONFIG] Warning: Could not open %s, using defaults\n", config_path); + return; + } + + char line[512]; + while (fgets(line, sizeof(line), f)) + { + // Skip comments and empty lines + if (line[0] == '#' || line[0] == '\n' || line[0] == '\r') continue; + + char *eq = strchr(line, '='); + if (!eq) continue; + + *eq = '\0'; + char *key = line; + char *value = eq + 1; + + // Trim newline from value + size_t vlen = strlen(value); + while (vlen > 0 && (value[vlen-1] == '\n' || value[vlen-1] == '\r')) + value[--vlen] = '\0'; + + if (strcmp(key, "UPLOAD_AUTH_TOKEN") == 0) + { + strncpy(g_upload_auth_token, value, sizeof(g_upload_auth_token) - 1); + } + else if (strcmp(key, "S3_REGION") == 0) + { + strncpy(g_s3_region, value, sizeof(g_s3_region) - 1); + } + else if (strcmp(key, "S3_BUCKET") == 0) + { + strncpy(g_s3_bucket, value, sizeof(g_s3_bucket) - 1); + } + else if (strcmp(key, "S3_URL_EXPIRES") == 0) + { + g_s3_url_expires = atoi(value); + } + else if (strcmp(key, "S3_CLOUDFRONT_URL") == 0) + { + strncpy(g_s3_cloudfront_url, value, sizeof(g_s3_cloudfront_url) - 1); + } + else if (strcmp(key, "DB_PATH") == 0) + { + strncpy(g_db_path, value, sizeof(g_db_path) - 1); + } + } + fclose(f); + + printf("[CONFIG] Loaded: token=%s..., region=%s, bucket=%s, expires=%d, cloudfront=%s, db=%s\n", + g_upload_auth_token[0] ? "***" : "(empty)", + g_s3_region, g_s3_bucket, g_s3_url_expires, + g_s3_cloudfront_url[0] ? g_s3_cloudfront_url : "(none)", + g_db_path); +} + +static void init_database(void) +{ + // Create data directory if needed + char *last_slash = strrchr(g_db_path, '/'); + if (last_slash) + { + char dir_path[256]; + size_t dir_len = last_slash - g_db_path; + strncpy(dir_path, g_db_path, dir_len); + dir_path[dir_len] = '\0'; + mkdir(dir_path, 0755); + } + + g_db_connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, g_db_path); + if (!g_db_connection || !Deita_Connection_Is_Open(g_db_connection)) + { + printf("[DB] ERROR: Failed to open database at %s\n", g_db_path); + return; + } + + // Create editor_content table + const char *create_table = + "CREATE TABLE IF NOT EXISTS editor_content (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " access_token TEXT NOT NULL," + " doc_id TEXT NOT NULL," + " content TEXT," + " created_at INTEGER DEFAULT (strftime('%s', 'now'))," + " updated_at INTEGER DEFAULT (strftime('%s', 'now'))," + " UNIQUE(access_token, doc_id)" + ")"; + + int32 result = Deita_Query_Execute_Update(g_db_connection, create_table); + if (result < 0) + { + printf("[DB] ERROR: Failed to create editor_content table\n"); + } + + // Create media_uploads table + const char *create_media_uploads = + "CREATE TABLE IF NOT EXISTS media_uploads (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " access_token TEXT NOT NULL," + " original_filename TEXT NOT NULL," + " content_type TEXT NOT NULL," + " s3_key_original TEXT NOT NULL," + " s3_key_processed TEXT," + " file_size INTEGER," + " status TEXT NOT NULL DEFAULT 'pending'," + " error_message TEXT," + " created_at INTEGER DEFAULT (strftime('%s', 'now'))," + " updated_at INTEGER DEFAULT (strftime('%s', 'now'))" + ")"; + + result = Deita_Query_Execute_Update(g_db_connection, create_media_uploads); + if (result < 0) + { + printf("[DB] ERROR: Failed to create media_uploads table\n"); + } + + // Create indices for media_uploads + const char *create_status_idx = + "CREATE INDEX IF NOT EXISTS idx_media_uploads_status ON media_uploads(status)"; + result = Deita_Query_Execute_Update(g_db_connection, create_status_idx); + if (result < 0) + { + printf("[DB] ERROR: Failed to create status index\n"); + } + + const char *create_token_status_idx = + "CREATE INDEX IF NOT EXISTS idx_media_uploads_token_status " + "ON media_uploads(access_token, status)"; + result = Deita_Query_Execute_Update(g_db_connection, create_token_status_idx); + if (result < 0) + { + printf("[DB] ERROR: Failed to create token_status index\n"); + } + else + { + printf("[DB] Initialized: %s\n", g_db_path); + } +} + void handle_sigint(int sig) { printf("Failed\n"); @@ -34,6 +204,8 @@ char *end_tag = strstr(start_tag, "}}"); if (!end_tag) break; + Seobeo_Log(SEOBEO_INFO, "[Curr] Life\n"); + size_t leading_len = start_tag - cursor; memcpy(final_body + current_offset, cursor, leading_len); current_offset += leading_len; @@ -45,7 +217,8 @@ size_t sub_file_size = 0; char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size); - Seobeo_Log(SEOBEO_DEBUG, "[Curr] Sub content: %s\n", sub_content); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading include: '%s' -> %s (size=%zu)\n", + include_name, sub_content ? "OK" : "FAILED", sub_file_size); if (sub_content) { memcpy(final_body + current_offset, sub_content, sub_file_size); @@ -58,15 +231,15 @@ strcpy(final_body + current_offset, cursor); } - void Seobeo_Render_Html_FilePath( char *final_body, char *path, Dowa_Arena *arena ) { - Seobeo_Log(SEOBEO_DEBUG, "[Curr] %s\n", path); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading main template: '%s'\n", path); size_t html_size = 0; char *template = Seobeo_Web_LoadFile(path, &html_size); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Main template loaded: %s (size=%zu)\n", template ? "OK" : "FAILED", html_size); if (!template) return; Seobeo_Render_Html(final_body, template, arena); } @@ -123,7 +296,7 @@ if (!req) { - printf("ERROR: Request is NULL\n"); + Seobeo_Log(SEOBEO_ERROR, "Request is NULL\n"); char *error_msg = "Internal error: no request data"; Dowa_HashMap_Push_Arena(resp, "status", "500", arena); Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); @@ -136,7 +309,7 @@ for (size_t i = 0; i < req_length; i++) { - printf(" Key[%zu]: '%s'\n", i, req[i].key); + Seobeo_Log(SEOBEO_INFO, " Key[%zu]: '%s'\n", i, req[i].key); } void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); @@ -496,15 +669,1143 @@ return resp; } +Seobeo_Request_Entry *GetNotesLogin(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, "/notes/login.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +Seobeo_Request_Entry *GetNotes(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, "/notes/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +Seobeo_Request_Entry *GetNoteById(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + // Same template - JavaScript handles the note_id from URL + Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + CREATE_REDIRECT_HANDLER(HomePage, "/") CREATE_REDIRECT_HANDLER(Resume, "/resume") CREATE_REDIRECT_HANDLER(Tools, "/tools") CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html") CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter") CREATE_REDIRECT_HANDLER(Talk, "/talk") +CREATE_REDIRECT_HANDLER(Editor, "/editor") + +// S3 Upload URL API +// POST /api/s3/upload-url +// Headers: Authorization: Bearer <token>, Content-Type: application/json +// Body: {"filename": "photo.png", "content_type": "image/png"} +// Returns: {"upload_url": "https://...", "key": "uploads/..."} +Seobeo_Request_Entry *GetS3UploadUrl(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + + // Expect "Bearer <token>" + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format, use Bearer token\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + // Parse request body for filename and content_type + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + if (!body_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena); + return resp; + } + + const char *body = ((Seobeo_Request_Entry*)body_kv)->value; + + // Simple JSON parsing for filename and content_type + char filename[256] = {0}; + char content_type[128] = "application/octet-stream"; + + // Find "filename":"value" + const char *fn_key = strstr(body, "\"filename\""); + if (fn_key) + { + const char *fn_start = strchr(fn_key + 10, '"'); + if (fn_start) + { + fn_start++; + const char *fn_end = strchr(fn_start, '"'); + if (fn_end && (size_t)(fn_end - fn_start) < sizeof(filename)) + { + memcpy(filename, fn_start, fn_end - fn_start); + filename[fn_end - fn_start] = '\0'; + } + } + } + + // Find "content_type":"value" + const char *ct_key = strstr(body, "\"content_type\""); + if (ct_key) + { + const char *ct_start = strchr(ct_key + 14, '"'); + if (ct_start) + { + ct_start++; + const char *ct_end = strchr(ct_start, '"'); + if (ct_end && (size_t)(ct_end - ct_start) < sizeof(content_type)) + { + memcpy(content_type, ct_start, ct_end - ct_start); + content_type[ct_end - ct_start] = '\0'; + } + } + } + + if (strlen(filename) == 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing filename in request body\"}", arena); + return resp; + } + + // Generate unique S3 key with timestamp + char s3_key[512]; + char *uuid = Dowa_Arena_Allocate(arena, UUID_LEN); + uint32 seed = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + Dowa_String_UUID(seed, uuid); + snprintf(s3_key, sizeof(s3_key), "uploads/%s/%s", uuid, filename); + + // Generate presigned URL + S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key, content_type, g_s3_url_expires); + + if (!presigned.success) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + char *error_body = Dowa_Arena_Allocate(arena, 256); + snprintf(error_body, 256, "{\"error\":\"Failed to generate upload URL: %s\"}", + presigned.error_message ? presigned.error_message : "unknown"); + Dowa_HashMap_Push_Arena(resp, "body", error_body, arena); + S3_Presigned_URL_Destroy(&presigned); + return resp; + } + + // Build public URL using CloudFront + char public_url[512]; + if (g_s3_cloudfront_url[0]) + { + snprintf(public_url, sizeof(public_url), "%s/%s", g_s3_cloudfront_url, s3_key); + } + else + { + snprintf(public_url, sizeof(public_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key); + } + + // Build response + char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url)); + snprintf(response_body, 4096 + strlen(presigned.url), + "{\"upload_url\":\"%s\",\"public_url\":\"%s\",\"key\":\"%s\",\"expires\":%d}", + presigned.url, public_url, s3_key, g_s3_url_expires); + + S3_Presigned_URL_Destroy(&presigned); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + printf("[S3] Generated upload URL for: %s\n", s3_key); + + return resp; +} + +// Editor Content Save API +// POST /api/editor/save +// Headers: Authorization: Bearer <token> +// Body: {"doc_id": "my-doc", "content": "<html content>"} +Seobeo_Request_Entry *EditorSave(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + 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\":\"Database not available\"}", arena); + return resp; + } + + // Parse request body + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + if (!body_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena); + return resp; + } + + const char *body = ((Seobeo_Request_Entry*)body_kv)->value; + + // Parse doc_id and content from JSON + char doc_id[256] = "default"; + char *content = NULL; + size_t content_len = 0; + + // Find "doc_id":"value" + const char *doc_key = strstr(body, "\"doc_id\""); + if (doc_key) + { + const char *doc_start = strchr(doc_key + 8, '"'); + if (doc_start) + { + doc_start++; + const char *doc_end = strchr(doc_start, '"'); + if (doc_end && (size_t)(doc_end - doc_start) < sizeof(doc_id)) + { + memcpy(doc_id, doc_start, doc_end - doc_start); + doc_id[doc_end - doc_start] = '\0'; + } + } + } + + // Find "content":"value" - content can be large and contain escaped characters + const char *content_key = strstr(body, "\"content\""); + if (content_key) + { + const char *content_start = strchr(content_key + 9, '"'); + if (content_start) + { + content_start++; + // Find closing quote (accounting for escaped quotes) + const char *p = content_start; + while (*p) + { + if (*p == '\\' && *(p+1)) + { + p += 2; + continue; + } + if (*p == '"') break; + p++; + } + content_len = p - content_start; + content = Dowa_Arena_Allocate(arena, content_len + 1); + memcpy(content, content_start, content_len); + content[content_len] = '\0'; + } + } + + if (!content) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing content\"}", arena); + return resp; + } + + // Upsert content + const char *upsert_query = + "INSERT INTO editor_content (access_token, doc_id, content, updated_at) " + "VALUES (?, ?, ?, strftime('%s', 'now')) " + "ON CONFLICT(access_token, doc_id) DO UPDATE SET " + "content = excluded.content, updated_at = strftime('%s', 'now')"; + + const char *params[] = { token, doc_id, content }; + int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, upsert_query, 3, params); + + if (result < 0) + { + 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 save\"}", arena); + return resp; + } + + printf("[EDITOR] Saved doc_id=%s, content_len=%zu\n", doc_id, content_len); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true}", arena); + return resp; +} + +// Editor Content Load API +// GET /api/editor/load/:doc_id +// Headers: Authorization: Bearer <token> +Seobeo_Request_Entry *EditorLoad(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + 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\":\"Database not available\"}", arena); + return resp; + } + + // Get doc_id from URL parameter + void *doc_id_kv = Dowa_HashMap_Get_Ptr(req, ":doc_id"); + const char *doc_id = "default"; + if (doc_id_kv) + { + doc_id = ((Seobeo_Request_Entry*)doc_id_kv)->value; + } + + // Query content + const char *select_query = + "SELECT content, updated_at FROM editor_content WHERE access_token = ? AND doc_id = ?"; + const char *params[] = { token, doc_id }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, params, arena); + + if (p_result && Deita_Result_Set_Next(p_result)) + { + const char *content = Deita_Result_Set_Get_Text(p_result, 0); + int64 updated_at = Deita_Result_Set_Get_Integer(p_result, 1); + + // Build JSON response - escape content + size_t content_len = content ? strlen(content) : 0; + char *response_body = Dowa_Arena_Allocate(arena, content_len + 256); + snprintf(response_body, content_len + 256, + "{\"doc_id\":\"%s\",\"content\":\"%s\",\"updated_at\":%lld}", + doc_id, content ? content : "", (long long)updated_at); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + printf("[EDITOR] Loaded doc_id=%s\n", doc_id); + } + else + { + // No content found, return empty + char *response_body = Dowa_Arena_Allocate(arena, 128); + snprintf(response_body, 128, "{\"doc_id\":\"%s\",\"content\":\"\",\"updated_at\":0}", doc_id); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + } + + if (p_result) Deita_Result_Set_Free(p_result); + return resp; +} + +// Media Upload API - Create media record +// POST /api/media/create +// Headers: Authorization: Bearer <token>, Content-Type: application/json +// Body: {"filename": "photo.jpg", "content_type": "image/jpeg"} +// Returns: {"media_id": 123, "upload_url": "https://...", "expires": 3600} +Seobeo_Request_Entry *MediaCreate(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + 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\":\"Database not available\"}", arena); + return resp; + } + + // Parse request body + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + if (!body_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena); + return resp; + } + + const char *body = ((Seobeo_Request_Entry*)body_kv)->value; + + // Parse filename and content_type + char filename[256] = {0}; + char content_type[128] = "application/octet-stream"; + + // Find "filename":"value" + const char *fn_key = strstr(body, "\"filename\""); + if (fn_key) + { + const char *fn_start = strchr(fn_key + 10, '"'); + if (fn_start) + { + fn_start++; + const char *fn_end = strchr(fn_start, '"'); + if (fn_end && (size_t)(fn_end - fn_start) < sizeof(filename)) + { + memcpy(filename, fn_start, fn_end - fn_start); + filename[fn_end - fn_start] = '\0'; + } + } + } + + // Find "content_type":"value" + const char *ct_key = strstr(body, "\"content_type\""); + if (ct_key) + { + const char *ct_start = strchr(ct_key + 14, '"'); + if (ct_start) + { + ct_start++; + const char *ct_end = strchr(ct_start, '"'); + if (ct_end && (size_t)(ct_end - ct_start) < sizeof(content_type)) + { + memcpy(content_type, ct_start, ct_end - ct_start); + content_type[ct_end - ct_start] = '\0'; + } + } + } + + if (strlen(filename) == 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing filename\"}", arena); + return resp; + } + + // Generate UUID for this upload + char *uuid = Dowa_Arena_Allocate(arena, UUID_LEN); + uint32 seed = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + Dowa_String_UUID(seed, uuid); + + // Generate S3 keys + char s3_key_original[512]; + char s3_key_processed[512]; + snprintf(s3_key_original, sizeof(s3_key_original), "uploads/%s/%s", uuid, filename); + + // Only use .webp for images + int is_image = (strncmp(content_type, "image/", 6) == 0); + if (is_image) + { + snprintf(s3_key_processed, sizeof(s3_key_processed), "uploads/%s/processed.webp", uuid); + } + else + { + s3_key_processed[0] = '\0'; // No processed version for non-images + } + + // 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')"; + + 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) + { + 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); + + // Generate presigned PUT URL + S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key_original, content_type, g_s3_url_expires); + + if (!presigned.success) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + char *error_body = Dowa_Arena_Allocate(arena, 256); + snprintf(error_body, 256, "{\"error\":\"Failed to generate upload URL: %s\"}", + presigned.error_message ? presigned.error_message : "unknown"); + Dowa_HashMap_Push_Arena(resp, "body", error_body, arena); + S3_Presigned_URL_Destroy(&presigned); + return resp; + } + + // Build public URL using CloudFront or S3 + char public_url[512]; + if (g_s3_cloudfront_url[0]) + { + snprintf(public_url, sizeof(public_url), "%s/%s", g_s3_cloudfront_url, s3_key_original); + } + else + { + snprintf(public_url, sizeof(public_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key_original); + } + + // Build response + char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url) + strlen(public_url)); + snprintf(response_body, 4096 + strlen(presigned.url) + strlen(public_url), + "{\"media_id\":%lld,\"upload_url\":\"%s\",\"public_url\":\"%s\",\"expires\":%d}", + (long long)media_id, presigned.url, public_url, g_s3_url_expires); + + S3_Presigned_URL_Destroy(&presigned); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + printf("[MEDIA] Created media_id=%lld, file=%s\n", (long long)media_id, filename); + + return resp; +} + +// Background thread function for media processing +void *Media_Process_Background(void *arg) +{ + Media_Processing_Context *ctx = (Media_Processing_Context *)arg; + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Background thread started for media_id=%lld\n", (long long)ctx->media_id); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] S3 key original: %s\n", ctx->s3_key_original); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] S3 key processed: %s\n", ctx->s3_key_processed); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] DB path: %s\n", ctx->db_path); + + // Open thread-local DB connection + Deita_Connection *db_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, ctx->db_path); + if (!db_conn || !Deita_Connection_Is_Open(db_conn)) + { + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] Thread ERROR: Failed to open database for media_id=%lld\n", (long long)ctx->media_id); + free(ctx); + return NULL; + } + + // Update status to 'processing' + const char *update_processing = + "UPDATE media_uploads SET status='processing', updated_at=strftime('%s','now') WHERE id=?"; + char media_id_str[32]; + snprintf(media_id_str, sizeof(media_id_str), "%lld", (long long)ctx->media_id); + const char *params[] = { media_id_str }; + int32 update_result = Deita_Query_Execute_Update_Prepared(db_conn, update_processing, 1, params); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Updated status to 'processing' for media_id=%lld (result=%d)\n", (long long)ctx->media_id, update_result); + + // Generate presigned GET URL for download (10 min expiry) + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Generating presigned GET URL for media_id=%lld\n", (long long)ctx->media_id); + S3_Presigned_URL download_url = S3_Presign_Get(&ctx->s3_config, ctx->s3_key_original, 600); + if (!download_url.success) + { + const char *error_msg = download_url.error_message ? download_url.error_message : "Failed to generate download URL"; + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to generate download URL for media_id=%lld: %s\n", + (long long)ctx->media_id, error_msg); + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { error_msg, media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + S3_Presigned_URL_Destroy(&download_url); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Generated presigned URL: %.100s...\n", download_url.url); + + // Generate temp file paths + char tmp_input[256]; + char tmp_output[256]; + char *uuid_input = malloc(UUID_LEN); + char *uuid_output = malloc(UUID_LEN); + uint32 seed1 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + uint32 seed2 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + Dowa_String_UUID(seed1, uuid_input); + Dowa_String_UUID(seed2, uuid_output); + snprintf(tmp_input, sizeof(tmp_input), "/tmp/%s", uuid_input); + snprintf(tmp_output, sizeof(tmp_output), "/tmp/%s.webp", uuid_output); + free(uuid_input); + free(uuid_output); + + // Download from S3 + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Downloading from S3 to %s for media_id=%lld\n", tmp_input, (long long)ctx->media_id); + Seobeo_Client_Request *download_req = Seobeo_Client_Request_Create(download_url.url); + Seobeo_Client_Request_Set_Download_Path(download_req, tmp_input); + Seobeo_Client_Response *download_resp = Seobeo_Client_Request_Execute(download_req); + + S3_Presigned_URL_Destroy(&download_url); + + if (!download_resp || download_resp->status_code != 200) + { + int status = download_resp ? download_resp->status_code : 0; + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to download from S3 for media_id=%lld (status=%d)\n", + (long long)ctx->media_id, status); + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Failed to download from S3", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + if (download_req) Seobeo_Client_Request_Destroy(download_req); + if (download_resp) Seobeo_Client_Response_Destroy(download_resp); + unlink(tmp_input); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully downloaded file to %s\n", tmp_input); + Seobeo_Client_Request_Destroy(download_req); + Seobeo_Client_Response_Destroy(download_resp); + + // Convert to webp using FFmpeg + char cmd[1024]; + char log_file[256]; + snprintf(log_file, sizeof(log_file), "/tmp/ffmpeg_%lld.log", (long long)ctx->media_id); + snprintf(cmd, sizeof(cmd), "ffmpeg -y -i %s -quality 80 %s 2>%s", + tmp_input, tmp_output, log_file); + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Running FFmpeg: %s\n", cmd); + int ffmpeg_result = system(cmd); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] FFmpeg result: %d for media_id=%lld\n", ffmpeg_result, (long long)ctx->media_id); + + if (ffmpeg_result != 0) + { + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: FFmpeg conversion failed for media_id=%lld (exit code %d). Check log: %s\n", + (long long)ctx->media_id, ffmpeg_result, log_file); + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Image conversion failed", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully converted to webp: %s\n", tmp_output); + + // Upload processed file to S3 + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Uploading processed file to S3: %s -> %s\n", tmp_output, ctx->s3_key_processed); + S3_Result upload_result = S3_Upload_File_With_Content_Type( + &ctx->s3_config, tmp_output, ctx->s3_key_processed, "image/webp"); + + if (!upload_result.success) + { + const char *error_msg = upload_result.error_message ? upload_result.error_message : "Failed to upload processed file"; + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to upload processed file for media_id=%lld: %s (HTTP status: %d)\n", + (long long)ctx->media_id, error_msg, upload_result.status_code); + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { error_msg, media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully uploaded processed file to S3\n"); + + // Update status to 'finished' + const char *update_finished = + "UPDATE media_uploads SET status='finished', updated_at=strftime('%s','now') WHERE id=?"; + Deita_Query_Execute_Update_Prepared(db_conn, update_finished, 1, params); + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully processed media_id=%lld - COMPLETE\n", (long long)ctx->media_id); + + // Cleanup + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + + return NULL; +} + +// Media Upload API - Mark uploaded +// POST /api/media/:id/uploaded +// Headers: Authorization: Bearer <token> +Seobeo_Request_Entry *MediaUploaded(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + 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\":\"Database not available\"}", arena); + return resp; + } + + // Extract media_id from URL params + void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id"); + if (!id_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena); + return resp; + } + + const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value; + int64 media_id = atoll(media_id_str); + + // Verify access_token matches and get content_type + const char *select_query = + "SELECT content_type, s3_key_original, s3_key_processed FROM media_uploads WHERE id = ? AND access_token = ?"; + const char *select_params[] = { media_id_str, token }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena); + + if (!p_result || !Deita_Result_Set_Next(p_result)) + { + if (p_result) Deita_Result_Set_Free(p_result); + Dowa_HashMap_Push_Arena(resp, "status", "404", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found or access denied\"}", arena); + return resp; + } + + const char *content_type = Deita_Result_Set_Get_Text(p_result, 0); + const char *s3_key_original = Deita_Result_Set_Get_Text(p_result, 1); + const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 2); + + // Copy values before freeing result set + char content_type_copy[128]; + char s3_key_original_copy[512]; + char s3_key_processed_copy[512]; + strncpy(content_type_copy, content_type, sizeof(content_type_copy) - 1); + strncpy(s3_key_original_copy, s3_key_original, sizeof(s3_key_original_copy) - 1); + strncpy(s3_key_processed_copy, s3_key_processed, sizeof(s3_key_processed_copy) - 1); + content_type_copy[sizeof(content_type_copy) - 1] = '\0'; + s3_key_original_copy[sizeof(s3_key_original_copy) - 1] = '\0'; + s3_key_processed_copy[sizeof(s3_key_processed_copy) - 1] = '\0'; + + Deita_Result_Set_Free(p_result); + + // Update status to 'uploaded' + const char *update_query = + "UPDATE media_uploads SET status='uploaded', updated_at=strftime('%s','now') WHERE id=?"; + const char *update_params[] = { media_id_str }; + int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, update_query, 1, update_params); + + if (result < 0) + { + 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 update status\"}", arena); + return resp; + } + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Content type for media_id=%lld: '%s'\n", (long long)media_id, content_type_copy); + + // If content_type starts with "image/", spawn background processing thread + if (strncmp(content_type_copy, "image/", 6) == 0) + { + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Detected image type, preparing to spawn background thread for media_id=%lld\n", (long long)media_id); + + // Create context for background thread (heap allocated) + Media_Processing_Context *ctx = malloc(sizeof(Media_Processing_Context)); + ctx->media_id = media_id; + strncpy(ctx->s3_key_original, s3_key_original_copy, sizeof(ctx->s3_key_original) - 1); + strncpy(ctx->s3_key_processed, s3_key_processed_copy, sizeof(ctx->s3_key_processed) - 1); + strncpy(ctx->content_type, content_type_copy, sizeof(ctx->content_type) - 1); + strncpy(ctx->access_token, token, sizeof(ctx->access_token) - 1); + strncpy(ctx->db_path, g_db_path, sizeof(ctx->db_path) - 1); + ctx->s3_key_original[sizeof(ctx->s3_key_original) - 1] = '\0'; + ctx->s3_key_processed[sizeof(ctx->s3_key_processed) - 1] = '\0'; + ctx->content_type[sizeof(ctx->content_type) - 1] = '\0'; + ctx->access_token[sizeof(ctx->access_token) - 1] = '\0'; + ctx->db_path[sizeof(ctx->db_path) - 1] = '\0'; + ctx->s3_config = g_s3_config; + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Creating pthread for media_id=%lld\n", (long long)media_id); + + // Spawn detached thread + pthread_t thread_id; + int thread_result = pthread_create(&thread_id, NULL, Media_Process_Background, ctx); + + if (thread_result != 0) + { + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: pthread_create failed with result=%d for media_id=%lld\n", thread_result, (long long)media_id); + free(ctx); + } + else + { + // Detach thread so it cleans up automatically when done + pthread_detach(thread_id); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Successfully spawned and detached thread for media_id=%lld\n", (long long)media_id); + } + } + else + { + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Non-image file, skipping background processing for media_id=%lld\n", (long long)media_id); + } + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true,\"status\":\"uploaded\"}", arena); + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Marked uploaded media_id=%lld\n", (long long)media_id); + + return resp; +} + +// Media Upload API - Get status +// GET /api/media/:id/status +// Headers: Authorization: Bearer <token> +// Returns: {"id": 123, "status": "finished", "processed_url": "https://...", "error_message": null} +Seobeo_Request_Entry *MediaStatus(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + 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\":\"Database not available\"}", arena); + return resp; + } + + // Extract media_id from URL params + void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id"); + if (!id_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena); + return resp; + } + + const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value; + + // Query media status + const char *select_query = + "SELECT id, status, s3_key_original, s3_key_processed, error_message FROM media_uploads WHERE id = ? AND access_token = ?"; + const char *select_params[] = { media_id_str, token }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena); + + if (!p_result || !Deita_Result_Set_Next(p_result)) + { + if (p_result) Deita_Result_Set_Free(p_result); + Dowa_HashMap_Push_Arena(resp, "status", "404", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found\"}", arena); + return resp; + } + + int64 id = Deita_Result_Set_Get_Integer(p_result, 0); + const char *status = Deita_Result_Set_Get_Text(p_result, 1); + const char *s3_key_original = Deita_Result_Set_Get_Text(p_result, 2); + const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 3); + const char *error_message = Deita_Result_Set_Get_Text(p_result, 4); + + // Build CloudFront URL for processed file if status is 'finished' + char processed_url[1024] = {0}; + if (strcmp(status, "finished") == 0 && s3_key_processed && strlen(s3_key_processed) > 0) + { + if (g_s3_cloudfront_url[0]) + { + snprintf(processed_url, sizeof(processed_url), "%s/%s", g_s3_cloudfront_url, s3_key_processed); + } + else + { + snprintf(processed_url, sizeof(processed_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key_processed); + } + } + + // Build CloudFront URL for original file (for non-images or before processing completes) + char original_url[1024] = {0}; + if (s3_key_original && strlen(s3_key_original) > 0) + { + if (g_s3_cloudfront_url[0]) + { + snprintf(original_url, sizeof(original_url), "%s/%s", g_s3_cloudfront_url, s3_key_original); + } + else + { + snprintf(original_url, sizeof(original_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key_original); + } + } + + // Build JSON response with both processed_url and original_url + char *response_body = Dowa_Arena_Allocate(arena, 3072); + + // Build the base response + int offset = snprintf(response_body, 3072, + "{\"id\":%lld,\"status\":\"%s\",", + (long long)id, status); + + // Add processed_url + if (strlen(processed_url) > 0) + { + offset += snprintf(response_body + offset, 3072 - offset, + "\"processed_url\":\"%s\",", processed_url); + } + else + { + offset += snprintf(response_body + offset, 3072 - offset, + "\"processed_url\":null,"); + } + + // Add original_url + if (strlen(original_url) > 0) + { + offset += snprintf(response_body + offset, 3072 - offset, + "\"original_url\":\"%s\",", original_url); + } + else + { + offset += snprintf(response_body + offset, 3072 - offset, + "\"original_url\":null,"); + } + + // Add error_message + if (error_message && strlen(error_message) > 0) + { + snprintf(response_body + offset, 3072 - offset, + "\"error_message\":\"%s\"}", error_message); + } + else + { + snprintf(response_body + offset, 3072 - offset, + "\"error_message\":null}"); + } + + Deita_Result_Set_Free(p_result); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + return resp; +} int main(void) { + // Load server config + load_config("mrjunejune/.config"); + + // Load S3 credentials from .env + FILE *env_file = fopen(".env", "r"); + static char s3_access_key[128] = {0}; + static char s3_secret_key[128] = {0}; + + if (env_file) + { + char line[512]; + while (fgets(line, sizeof(line), env_file)) + { + if (strncmp(line, "AWS_MRJUNEJUNE_ACCESS_KEY=", 26) == 0) + { + char *val = line + 26; + size_t len = strlen(val); + while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0'; + strncpy(s3_access_key, val, sizeof(s3_access_key) - 1); + } + else if (strncmp(line, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=", 33) == 0) + { + char *val = line + 33; + size_t len = strlen(val); + while (len > 0 && (val[len-1] == '\n' || val[len-1] == '\r')) val[--len] = '\0'; + strncpy(s3_secret_key, val, sizeof(s3_secret_key) - 1); + } + } + fclose(env_file); + } + + // Initialize S3 config + g_s3_config.access_key_id = s3_access_key; + g_s3_config.secret_access_key = s3_secret_key; + g_s3_config.region = g_s3_region; + g_s3_config.bucket = g_s3_bucket; + g_s3_config.endpoint = NULL; + g_s3_config.use_path_style = FALSE; + + printf("[S3] Configured: region=%s, bucket=%s, key=%s...\n", + g_s3_region, g_s3_bucket, s3_access_key[0] ? "***" : "(missing)"); + + // Show current working directory + char cwd[1024]; + if (getcwd(cwd, sizeof(cwd)) != NULL) + { + printf("[STARTUP] Current working directory: %s\n", cwd); + printf("[STARTUP] Database path (relative): %s\n", g_db_path); + } + + // Initialize database + init_database(); + Seobeo_Router_Init(); Seobeo_Router_Register("GET", "/", GetHomePage); @@ -527,6 +1828,18 @@ Seobeo_Router_Register("POST", "/api/convert/video-to-mp4", ConvertVideoToMP4); Seobeo_Router_Register("GET", "/api/download/:filename", DownloadConvertedFile); + // -- S3 Upload --/ + Seobeo_Router_Register("POST", "/api/s3/upload-url", GetS3UploadUrl); + + // -- Media Upload --/ + Seobeo_Router_Register("POST", "/api/media/create", MediaCreate); + Seobeo_Router_Register("POST", "/api/media/:id/uploaded", MediaUploaded); + Seobeo_Router_Register("GET", "/api/media/:id/status", MediaStatus); + + // -- Editor --/ + Seobeo_Router_Register("POST", "/api/editor/save", EditorSave); + Seobeo_Router_Register("GET", "/api/editor/load/:doc_id", EditorLoad); + // -- Blog --/ Seobeo_Router_Register("GET", "/blog", RenderBlogList); Seobeo_Router_Register("GET", "/blog/:blog_id", RenderBlog); @@ -535,8 +1848,17 @@ Seobeo_Router_Register("GET", "/talk", GetTalk); Seobeo_Router_Register("GET", "/talk/index.html", GetRedirectTalk); + // -- Notes --/ + Seobeo_Router_Register("GET", "/notes", GetNotes); + Seobeo_Router_Register("GET", "/notes/", GetNotes); + Seobeo_Router_Register("GET", "/notes/index.html", GetNotes); + Seobeo_Router_Register("GET", "/notes/login", GetNotesLogin); + Seobeo_Router_Register("GET", "/notes/login/", GetNotesLogin); + Seobeo_Router_Register("GET", "/notes/:note_id", GetNoteById); + Seobeo_WebSocket_Server_Init(); Seobeo_WebSocket_Server_Register("/chat", Chat_Handler, NULL); - Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 3); + Seobeo_Log(SEOBEO_INFO, "WTF is going on\n"); + Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 1); }
--- a/mrjunejune/src/base.css Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/base.css Sun Aug 02 16:50:48 2026 -0700 @@ -359,3 +359,40 @@ } +/* pwa icon */ +#pwa-install-btn { + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 24px 12px 36px; + background: #000000; + color: #eeffee; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: bold; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + z-index: 1000; + font-family: inherit; +} + +#pwa-install-btn.with-icon::before { + content: ""; + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + + /* Masked icon */ + -webkit-mask: url('/public/paw.svg') no-repeat center; + mask: url('/public/paw.svg') no-repeat center; + mask-size: contain; + mask-repeat: no-repeat; + + /* Icon color = button text color */ + background-color: currentColor; +} +
--- a/mrjunejune/src/index.html Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -29,6 +29,41 @@ margin-bottom: 0.75em; } } + + #background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; + } + + #game { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; + cursor: default; + } + + #game.active { + pointer-events: auto; + z-index: 100; + } + + #gameUI { + position: fixed; + top: 10px; + right: 10px; + pointer-events: auto; + z-index: 101; + cursor: pointer; + } </style> </head> <body> @@ -53,10 +88,14 @@ <li><a href="/resume">Resume</a> - My professional experiences </li> <li><a href="/tools">Tools</a> - Things I made for myself </li> </ul> + <canvas id="background"></canvas> + <canvas id="game"></canvas> + <canvas id="gameUI"></canvas> </main> {{/parts/footer.html}} </body> <script> + // Epi image let arr = Array.from({ length: 18 }, (_, i) => i+1); function setRandomImages() { const randomIndex = Math.floor(Math.random() * arr.length); @@ -69,4 +108,5 @@ } setRandomImages(); </script> + <script src="/public/dog-game.js"></script> </html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/notes/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,162 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + {{/parts/base_head.html}} + <title>Notes</title> + <style> + .notes-page { + min-width: 70%; + margin: 0 auto; + padding: 20px; + } + .notes-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; + } + .notes-header h1 { + margin: 0; + display: flex; + align-items: center; + gap: 12px; + } + .note-id-display { + font-size: 14px; + color: #666; + background: #f0f0f0; + padding: 4px 12px; + border-radius: 4px; + font-weight: normal; + } + .notes-actions { + display: flex; + gap: 8px; + align-items: center; + } + .notes-actions button, .notes-actions a { + padding: 8px 16px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + text-decoration: none; + color: #333; + font-size: 14px; + } + .notes-actions button:hover, .notes-actions a:hover { + background: #f5f5f5; + } + .notes-actions .btn-primary { + background: #0078ff; + color: white; + border-color: #0078ff; + } + .notes-actions .btn-primary:hover { + background: #0066dd; + } + .notes-actions .btn-danger { + color: #d32f2f; + border-color: #d32f2f; + } + .new-note-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 100; + } + .new-note-dialog.show { + display: flex; + } + .new-note-form { + background: white; + padding: 24px; + border-radius: 8px; + width: 400px; + max-width: 90%; + } + .new-note-form h2 { + margin: 0 0 16px 0; + } + .new-note-form input { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; + box-sizing: border-box; + margin-bottom: 16px; + } + .new-note-form .form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + .new-note-form button { + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + .new-note-form .btn-cancel { + background: #f5f5f5; + border: 1px solid #ccc; + } + .new-note-form .btn-create { + background: #0078ff; + color: white; + border: none; + } + .note-hint { + font-size: 12px; + color: #666; + margin-top: -12px; + margin-bottom: 16px; + } + </style> +</head> +<body> + {{/parts/header.html}} + + <main class="notes-page"> + <div class="notes-header"> + <h1> + Notes + <span class="note-id-display" id="note-id-display">index</span> + </h1> + <div class="notes-actions"> + <button onclick="goHome()"> Home </button> + <button onclick="logout()" class="btn-danger">Logout</button> + </div> + </div> + + <div id="editor-container"></div> + </main> + + <!-- New Note Dialog --> + <div class="new-note-dialog" id="new-note-dialog"> + <div class="new-note-form"> + <h2>Create New Note</h2> + <input type="text" id="new-note-id" placeholder="note-id (e.g., my-ideas)"> + <p class="note-hint">Use lowercase letters, numbers, and hyphens only</p> + <div class="form-actions"> + <button class="btn-cancel" onclick="hideNewNoteDialog()">Cancel</button> + <button class="btn-create" onclick="createNewNote()">Create & Open</button> + </div> + </div> + </div> + + {{/parts/footer.html}} + + <script src="/public/js/rich_editor.js"></script> + <script src="/public/editor.js"></script> +</body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/notes/login.html Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,113 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + {{/parts/base_head.html}} + <title>Login | Notes</title> + <style> + .login-page { + max-width: 400px; + margin: 100px auto; + padding: 20px; + } + .login-box { + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + padding: 32px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + .login-box h1 { + margin: 0 0 24px 0; + font-size: 24px; + text-align: center; + } + .login-box label { + display: block; + margin-bottom: 8px; + font-weight: 500; + } + .login-box input { + width: 100%; + padding: 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; + box-sizing: border-box; + } + .login-box input:focus { + outline: none; + border-color: #0078ff; + } + .login-box button { + width: 100%; + padding: 12px; + margin-top: 16px; + background: #0078ff; + color: white; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + } + .login-box button:hover { + background: #0066dd; + } + .error-msg { + color: #d32f2f; + margin-top: 12px; + text-align: center; + display: none; + } + </style> +</head> +<body> + {{/parts/header.html}} + + <main class="login-page"> + <div class="login-box"> + <h1>Notes Login</h1> + <form id="login-form"> + <label for="token">Access Token</label> + <input type="password" id="token" placeholder="Enter your access token" required> + <button type="submit">Login</button> + <p class="error-msg" id="error-msg">Invalid token</p> + </form> + </div> + </main> + + {{/parts/footer.html}} + + <script> + // Check if already logged in + const savedToken = localStorage.getItem('notes-auth-token'); + if (savedToken) { + window.location.href = '/notes/'; + } + + document.getElementById('login-form').addEventListener('submit', async function(e) { + e.preventDefault(); + + const token = document.getElementById('token').value.trim(); + if (!token) return; + + // Verify token by trying to load a document + try { + const response = await fetch('/api/editor/load/index', { + headers: { 'Authorization': 'Bearer ' + token } + }); + + if (response.ok) { + localStorage.setItem('notes-auth-token', token); + // Redirect to originally requested page or /notes/ + const returnUrl = new URLSearchParams(window.location.search).get('return') || '/notes/'; + window.location.href = returnUrl; + } else { + document.getElementById('error-msg').style.display = 'block'; + } + } catch (err) { + document.getElementById('error-msg').style.display = 'block'; + } + }); + </script> +</body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/offline.html Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,61 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Offline - MrJuneJune</title> + <style> + body { + font-family: system-ui, -apple-system, sans-serif; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + margin: 0; + padding: 20px; + background: linear-gradient(135deg, #544e43 0%, #3a3630 100%); + color: white; + text-align: center; + } + + h1 { + font-size: 3em; + margin: 0 0 0.5em 0; + } + + p { + font-size: 1.2em; + margin: 0 0 2em 0; + opacity: 0.9; + } + + button { + padding: 12px 32px; + font-size: 1em; + background: white; + color: #544e43; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: bold; + transition: transform 0.2s; + } + + button:hover { + transform: scale(1.05); + } + + .icon { + font-size: 5em; + margin-bottom: 0.3em; + } + </style> + </head> + <body> + <div class="icon">📡</div> + <h1>You're Offline</h1> + <p>It looks like you've lost your internet connection.<br>Don't worry, some cached pages might still work!</p> + <button onclick="window.location.reload()">Try Again</button> + </body> +</html>
--- a/mrjunejune/src/parts/base_head.html Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/parts/base_head.html Sun Aug 02 16:50:48 2026 -0700 @@ -1,6 +1,9 @@ <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> @@ -16,3 +19,5 @@ <link rel="preload" href="/base.css" as="style" /> <link rel="stylesheet" href="/base.css" /> +<script src="/public/pwa-register.js" defer></script> +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/dog-game.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,295 @@ +// Dog frames +const imagesSrc = [ + "/public/sprite_shiba0.png", + "/public/sprite_shiba1.png", + "/public/sprite_shiba2.png", + "/public/sprite_shiba3.png", +]; +const images =imagesSrc.map((src, index) => { + const img = new Image(); + img.src = src; + img.onload = () => { + if (index == imagesSrc.length-1) + startAnimation(); + }; + return img +}); + +// Load treat image +const treatImage = new Image(); +treatImage.src = "/public/dog-treat.png"; + +// Load star images for background +let starImages = [ + { src: "/public/start-large.png", img: new Image() }, + { src: "/public/start-small-1.png", img: new Image() }, + { src: "/public/start-small-2.png", img: new Image() }, + { src: "/public/start-small-3.png", img: new Image() } +]; +starImages.forEach(star => star.img.src = star.src); + +const background = document.getElementById('background'); +const bgCtx = background.getContext("2d"); +const ctx = game.getContext("2d"); +const uiCtx = gameUI.getContext("2d"); + + +// Dog initial position and movement +const initialDogVx = 2; +let x = Math.random() * (game.width); +let y = Math.random() * (game.height); +let vx = initialDogVx; +let vy = 0; +const gravity = 0.5; +const jumpForce = -12; +let isFlipped = false; + +// Random jump interval +// TODO: let's do it by frame? +let nextJump = Math.random() * 2000 + 1000; + +// Treat system +const originalTreatSize = 30; +let treatSize = originalTreatSize; +const treatMargin = 20; +const reachDistance = 50; +let treatRainActive = false; +let treats = []; +let lastTreatSpawn = 0; +const treatFallSpeed = 0.25; +const treatGravity = 0.13; + +// Background stars +let stars = []; +function createStars() { + stars = []; + const starCount = window.innerWidth * 0.05; + + for (let i = 0; i < starCount; i++) { + const starType = starImages[Math.floor(Math.random() * starImages.length)]; + const scale = 0.5 + Math.random() * 0.8; // Random scale + + stars.push({ + x: Math.random() * background.width, + y: Math.random() * background.height, + vx: (Math.random() - 0.5) * 0.5, + vy: (Math.random() - 0.5) * 0.5, + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.02, + image: starType.img, + size: starType.img.width, + scale: scale, + opacity: 0.3 + Math.random() * 0.4 // Random opacity + }); + } +} + +function animateBackground() { + bgCtx.clearRect(0, 0, background.width, background.height); + + stars.forEach(star => { + // Update position + star.x += star.vx; + star.y += star.vy; + star.rotation += star.rotationSpeed; + + // Wrap around edges + if (star.x < -100) star.x = background.width + 100; + if (star.x > background.width + 100) star.x = -100; + if (star.y < -100) star.y = background.height + 100; + if (star.y > background.height + 100) star.y = -100; + + // Draw star + bgCtx.save(); + bgCtx.globalAlpha = star.opacity; + bgCtx.translate(star.x, star.y); + bgCtx.rotate(star.rotation); + + const size = star.size * star.scale; + bgCtx.drawImage(star.image, -size / 2, -size / 2, size, size); + + bgCtx.restore(); + }); + + requestAnimationFrame(animateBackground); +} + +function getMousePosition(event) { + const rect = game.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + return { x: x, y: y }; +} + +// UI canvas click - toggle treat rain +gameUI.addEventListener('click', (e) => { + treatRainActive = !treatRainActive; + if (treatRainActive) + game.classList.add('active'); + else + game.classList.remove('active'); +}); + +game.addEventListener('click', (e) => { + if (treatRainActive) { + const mousePos = getMousePosition(e); + treats.push({ x: mousePos.x, y: mousePos.y, vy: treatFallSpeed }); + } +}); + +function startAnimation() { + requestAnimationFrame(animate); +} + + +let index = 0; +let last = 0; +const frameDuration = 120; // ms per frame +function animate(ts) { + if (ts - last > frameDuration) { + index = (index + 1) % images.length; + last = ts; + } + + // Get image dimensions + const imgWidth = images[0].width; + const imgHeight = images[0].height; + + // Update falling treats + treats.forEach(treat => { + treat.vy += treatGravity; + treat.y += treat.vy; + }); + treats = treats.filter(treat => treat.y < game.height + treatSize); + + // Find closest treat for dog to chase + let targetTreat = null; + let closestDist = Infinity; + treats.forEach(treat => { + const dx = treat.x + treatSize / 2 - (x + imgWidth / 2); + const dy = treat.y + treatSize / 2 - (y + imgHeight / 2); + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < closestDist) { + closestDist = dist; + targetTreat = treat; + } + }); + + // Dog behavior + if (targetTreat) { + const dx = targetTreat.x + treatSize / 2 - (x + imgWidth / 2); + const dy = targetTreat.y + treatSize / 2 - (y + imgHeight / 2); + const distance = Math.sqrt(dx * dx + dy * dy); + + // Dog eating + if (distance < reachDistance) + { + treats = treats.filter(t => t !== targetTreat); + vx = initialDogVx; + } + // Looking for it + else + { + const speed = initialDogVx * 1.5; + vx = dx > 0 ? speed : -speed; + + // Update flip direction + if ((dx > 0 && isFlipped) || (dx < 0 && !isFlipped)) + { + isFlipped = !isFlipped; + } + + // Jump if treat is above and dog is on ground + const onGround = y + imgHeight >= game.height - 5; + if (dy < -50 && onGround && Math.abs(vy) < 1) + { + vy = jumpForce; + } + } + } + + // --- Entity updates --- // + vy += gravity; + x += vx; + y += vy; + + // Bounce off walls + if (x <= 0 || x + imgWidth >= game.width) + { + if (!targetTreat) + { + vx = -vx; + isFlipped = !isFlipped; + } + x = x <= 0 ? 0 : game.width - imgWidth; + } + + // Bounce off floor + if (y + imgHeight >= game.height) + { + y = game.height - imgHeight; + vy = -vy * 0.7; + if (Math.abs(vy) < 2) vy = 0; + } + + // Random jumps (only when not chasing treat) + if (!targetTreat && Date.now() > nextJump && Math.abs(vy) < 1) + { + vy = jumpForce; + nextJump = Date.now() + 1000 + Math.random() * 3000; + } + + // --- Draw entity --- // + ctx.clearRect(0, 0, game.width, game.height); + ctx.save(); + + if (isFlipped) { + ctx.translate(x + imgWidth, y); + ctx.scale(-1, 1); + ctx.drawImage(images[index], 0, 0); + } else { + ctx.drawImage(images[index], x, y); + } + + ctx.restore(); + treats.forEach(treat => { + ctx.drawImage(treatImage, treat.x, treat.y, treatSize, treatSize); + }); + + drawUI(); + // repeat + requestAnimationFrame(animate); +} + +// Draw UI elements (treat toggle button) +function drawUI() { + uiCtx.drawImage(treatImage, 0, 0, treatSize, treatSize); +} + +// --- Window functions --- // +function resizeCanvas() { + background.width = window.innerWidth; + background.height = window.innerHeight; + game.width = window.innerWidth; + game.height = window.innerHeight; + + // resize treats + treatSize = window.innerWidth < 700 ? originalTreatSize * 0.6 : originalTreatSize; + + // Size UI canvas to fit treat button + text + gameUI.width = treatSize + 10; + gameUI.height = treatSize + 25; + + createStars(); // Recreate stars on resize + drawUI(); // Redraw UI after resize +} +resizeCanvas(); +window.addEventListener('resize', resizeCanvas); + +window.onload = () => { + resizeCanvas(); // ensure canvas has real size + createStars(); // stars need correct canvas size + animateBackground(); // safe to start background animation +}; +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/editor.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,218 @@ +let editor = null; +let currentNoteId = 'index'; + +function getAuthToken() { + return localStorage.getItem('notes-auth-token'); +} + +function requireAuth() { + if (!getAuthToken()) { + const returnUrl = encodeURIComponent(window.location.pathname); + window.location.href = '/notes/login?return=' + returnUrl; + return false; + } + return true; +} + +function goHome() { + window.location.href = '/notes'; +} + +function logout() { + localStorage.removeItem('notes-auth-token'); + window.location.href = '/notes/login'; +} + +function getNoteIdFromPath() { + const path = window.location.pathname; + const match = path.match(/^\/notes\/(.+)$/); + if (match && match[1] && match[1] !== 'login') { + return decodeURIComponent(match[1]); + } + return 'index'; +} + +function showNewNoteDialog() { + document.getElementById('new-note-dialog').classList.add('show'); + document.getElementById('new-note-id').focus(); +} + +function hideNewNoteDialog() { + document.getElementById('new-note-dialog').classList.remove('show'); + document.getElementById('new-note-id').value = ''; +} + +function createNewNote() { + let noteId = document.getElementById('new-note-id').value.trim(); + if (!noteId) return; + + // Sanitize note ID + noteId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + + hideNewNoteDialog(); + window.location.href = '/notes/' + encodeURIComponent(noteId); +} + +// Handle Enter key in new note dialog +document.getElementById('new-note-id').addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + createNewNote(); + } + if (e.key === 'Escape') { + hideNewNoteDialog(); + } +}); + +// Close dialog on backdrop click +document.getElementById('new-note-dialog').addEventListener('click', function(e) { + if (e.target === this) { + hideNewNoteDialog(); + } +}); + +async function uploadFile(file) { + const token = getAuthToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + // 1. Create media record + const createResponse = await fetch('/api/media/create', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + filename: file.name, + content_type: file.type + }) + }); + + if (!createResponse.ok) { + const error = await createResponse.json().catch(() => ({})); + throw new Error(error.error || 'Failed to create media record'); + } + + const data = await createResponse.json(); + + // 2. Upload file directly to S3 + const uploadResponse = await fetch(data.upload_url, { + method: 'PUT', + headers: { + 'Content-Type': file.type + }, + body: file + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to upload file to S3'); + } + + // 3. Mark as uploaded (triggers processing for images) + await fetch(`/api/media/${data.media_id}/uploaded`, { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token + } + }); + + // 4. Poll for images, return immediately for non-images + if (file.type.startsWith('image/')) { + return await pollForProcessedImage(data.media_id, token); + } else { + // For non-images, use the public URL from the server + return { url: data.public_url }; + } +} + +async function pollForProcessedImage(mediaId, token) { + const maxAttempts = 60; // 2 minutes max (60 * 2 seconds) + + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second interval + + const statusResponse = await fetch(`/api/media/${mediaId}/status`, { + headers: { + 'Authorization': 'Bearer ' + token + } + }); + + if (!statusResponse.ok) { + console.warn('Status check failed, retrying...'); + continue; + } + + const statusData = await statusResponse.json(); + + if (statusData.status === 'finished') { + return { url: statusData.processed_url }; + } else if (statusData.status === 'error') { + throw new Error(statusData.error_message || 'Processing failed'); + } + // Status is 'uploaded' or 'processing', continue polling + } + + throw new Error('Processing timeout after 2 minutes'); +} + +async function saveContent(content) { + const token = getAuthToken(); + if (!token) return; + + const response = await fetch('/api/editor/save', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + doc_id: currentNoteId, + content: content + }) + }); + + if (!response.ok) { + throw new Error('Failed to save'); + } +} + +async function loadNote(noteId) { + const token = getAuthToken(); + if (!token) return; + + try { + const response = await fetch('/api/editor/load/' + encodeURIComponent(noteId), { + headers: { 'Authorization': 'Bearer ' + token } + }); + + if (response.ok) { + const data = await response.json(); + editor.setContent(data.content || ''); + } + } catch (error) { + console.error('Failed to load note:', error); + } +} + +// Initialize +document.addEventListener('DOMContentLoaded', function() { + if (!requireAuth()) return; + + currentNoteId = getNoteIdFromPath(); + document.getElementById('note-id-display').textContent = currentNoteId; + + // Update page title + document.title = currentNoteId + ' | Notes'; + + editor = RichEditor.init('editor-container', { + uploadCallback: uploadFile, + saveCallback: saveContent, + debounceMs: 1500, + placeholder: 'Start writing... (paste images, drag files, or use /upload)\n\nTip: Click "+ New Note" to create linked notes.' + }); + + loadNote(currentNoteId); +}); +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/manifest.json Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,67 @@ +{ + "name": "MrJuneJune", + "short_name": "MrJuneJune", + "description": "Personal website and blog of Juntae (June)", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#544e43", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/public/epi_all_colors.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/public/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/public/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/public/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [ + { + "src": "/public/screenshot-mobile.png", + "sizes": "540x720", + "type": "image/png", + "form_factor": "narrow" + }, + { + "src": "/public/screenshot-desktop.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide" + } + ], + "categories": ["lifestyle", "productivity"], + "shortcuts": [ + { + "name": "Blog", + "short_name": "Blog", + "description": "Read latest blog posts", + "url": "/blog", + "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }] + }, + { + "name": "Notes", + "short_name": "Notes", + "description": "Access your notes", + "url": "/notes", + "icons": [{ "src": "/public/epi_all_colors.svg", "sizes": "192x192" }] + } + ] +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/pwa-register.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,88 @@ +// PWA Service Worker Registration +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/public/sw.js') + .then((registration) => { + // console.log('[PWA] Service Worker registered:', registration.scope); + + // Check for updates periodically + setInterval(() => { + registration.update(); + }, 60000); // Check every minute + + // Handle updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New content available, show update notification + if (confirm('New version available! Reload to update?')) { + newWorker.postMessage({ type: 'SKIP_WAITING' }); + window.location.reload(); + } + } + }); + }); + }) + .catch((error) => { + console.error('[PWA] Service Worker registration failed:', error); + }); + + // Handle service worker updates + let refreshing = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!refreshing) { + refreshing = true; + window.location.reload(); + } + }); + }); +} + +// Add install prompt handler +let deferredPrompt; + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredPrompt = e; + + showInstallPromotion(); +}); + +function showInstallPromotion() { + // Create an install button if it doesn't exist + if (document.getElementById('pwa-install-btn')) return; + + const installBtn = document.createElement('button'); + installBtn.classList.add('with-icon'); + installBtn.id = 'pwa-install-btn'; + installBtn.textContent = 'Install as App'; + + installBtn.addEventListener('click', async () => { + if (!deferredPrompt) return; + + deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + console.log(`[PWA] User response: ${outcome}`); + + deferredPrompt = null; + installBtn.remove(); + }); + + document.body.appendChild(installBtn); + + setTimeout(() => { + installBtn.style.opacity = '0'; + installBtn.style.transition = 'opacity 0.3s'; + setTimeout(() => installBtn.remove(), 300); + }, 5000); +} + +window.addEventListener('appinstalled', () => { + console.log('[PWA] App installed successfully!'); + deferredPrompt = null; + + const installBtn = document.getElementById('pwa-install-btn'); + if (installBtn) installBtn.remove(); +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/public/sw.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,138 @@ +// Service Worker for MrJuneJune PWA +const CACHE_VERSION = 'v1'; +const CACHE_NAME = `mrjunejune-${CACHE_VERSION}`; + +// Files to cache immediately on install +const STATIC_CACHE = [ + '/', + '/base.css', + '/public/epi_all_colors.svg', + '/public/fonts/Roboto-Regular.ttf', + '/public/fonts/Roboto-Thin.ttf', + '/public/fonts/more-sugar.regular.otf', + '/public/fonts/more-sugar.thin.otf', +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_CACHE); + }).then(() => { + console.log('[SW] Skip waiting'); + return self.skipWaiting(); + }) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + console.log('[SW] Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }).then(() => { + console.log('[SW] Claiming clients'); + return self.clients.claim(); + }) + ); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) { + return; + } + + // Skip API calls and media uploads (always go to network) + if (url.pathname.startsWith('/api/')) { + return; + } + + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + console.log('[SW] Serving from cache:', url.pathname); + return cachedResponse; + } + + // Not in cache, fetch from network + return fetch(request).then((networkResponse) => { + // Only cache successful responses + if (!networkResponse || networkResponse.status !== 200 || networkResponse.type === 'error') { + return networkResponse; + } + + // Cache specific file types + const shouldCache = + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.webp') || + url.pathname.endsWith('.woff') || + url.pathname.endsWith('.woff2') || + url.pathname.endsWith('.ttf') || + url.pathname.endsWith('.otf') || + url.pathname.startsWith('/blog/') || + url.pathname.startsWith('/notes/') || + url.pathname === '/'; + + if (shouldCache) { + const responseToCache = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + console.log('[SW] Caching new resource:', url.pathname); + cache.put(request, responseToCache); + }); + } + + return networkResponse; + }).catch((error) => { + console.log('[SW] Fetch failed:', error); + + // Return offline page for HTML requests + if (request.headers.get('Accept').includes('text/html')) { + return caches.match('/offline.html'); + } + }); + }) + ); +}); + +// Handle messages from the client +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'CLEAR_CACHE') { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => caches.delete(cacheName)) + ); + }) + ); + } +});
--- a/mrjunejune/src/resume/index.html Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/resume/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -27,7 +27,7 @@ <a href="mailto:[email protected]">[email protected]</a> </div> <div> - <a href="https://www.linkedin.com/in/junepark"><svg width="12" height="12" fill="currentColor" class="bi bi-linkedin" viewBox="0 0 16 16"> + <a href="https://www.linkedin.com/in/june-park-61721215b"><svg width="12" height="12" fill="currentColor" class="bi bi-linkedin" viewBox="0 0 16 16"> <path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"/> </svg> June Park </a>
--- a/mrjunejune/src/tools/index.html Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/tools/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -11,9 +11,9 @@ <ul class="nav-list"> <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="/notes">Personal Notes</a></li> </ul> <h3> TODOs </h3> - <p> Probably should add this... </p> <ul class="nav-list"> <li>- Simple online LaTex editor.</li> <li>- Online HLS player.</li>
--- a/mrjunejune/src/tools/markdown_to_html/index.html Sun Jan 25 10:44:04 2026 -0800 +++ b/mrjunejune/src/tools/markdown_to_html/index.html Sun Aug 02 16:50:48 2026 -0700 @@ -54,7 +54,7 @@ ### Images - + **Try editing this text!**</textarea> </div> @@ -68,10 +68,16 @@ </div> </div> {{/parts/footer.html}} - <script src="/markdown_to_html_bin.js"></script> - <script> - // Wait for WASM module to be ready - Module.onRuntimeInitialized = () => { + <script type="module"> + import createModule from '/markdown_to_html_bin.js'; + + // Get DOM elements + const input = document.getElementById('input'); + const output = document.getElementById('output'); + const copy = document.getElementById('copy'); + + // Modern Emscripten exports a factory function that returns a Promise + createModule().then(Module => { // Get raw pointer so we can free it after use const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); const markdownFree = Module.cwrap('markdown_free', null, ['number']); @@ -108,7 +114,7 @@ console.error('Failed to copy: ', err); }); }); - }; + }); </script> </body> </html>
--- a/react_games/public/base.css Sun Jan 25 10:44:04 2026 -0800 +++ b/react_games/public/base.css Sun Aug 02 16:50:48 2026 -0700 @@ -5,7 +5,6 @@ } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 10%, #764ba2 100%); background-attachment: fixed; background-repeat: no-repeat;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,5 @@ +filegroup( + name = "rich_editor", + srcs = ["rich_editor.js"], + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/rich_editor.js Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,1062 @@ +/** + * Rich Editor + * ----------- + * A vanilla JavaScript rich text editor with: + * - Text formatting (h1-h6, paragraphs, lists) + * - Image/file upload via paste, drop, click, or /upload command + * - Debounced auto-save + * - Generic callback functions for uploads and saves + * + * Usage: + * const editor = RichEditor.init('editor-container', { + * uploadCallback: async (file) => { return { url: '...', key: '...' }; }, + * saveCallback: async (content) => { console.log('Saved:', content); }, + * debounceMs: 1000, + * placeholder: 'Start writing...' + * }); + */ + +const RichEditor = (function() { + 'use strict'; + + const DEFAULT_OPTIONS = { + uploadCallback: null, + saveCallback: null, + debounceMs: 1000, + placeholder: 'Start writing... (Use /upload to insert files)', + cloudFrontUrl: '' + }; + + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + function createToolbar(editor) { + const toolbar = document.createElement('div'); + toolbar.className = 'rich-editor-toolbar'; + + const buttons = [ + { cmd: 'h1', label: '', title: 'Heading 1', icons: "/public/icons/h1.png" }, + { cmd: 'h2', label: '', title: 'Heading 2', icons: "/public/icons/h2.png" }, + { cmd: 'h3', label: '', title: 'Heading 3', icons: "/public/icons/h3.png" }, + { cmd: 'p', label: '¶', title: 'Paragraph' }, + { cmd: 'ul', label: '•', title: 'Bullet List' }, + { cmd: 'ol', label: '1.', title: 'Numbered List' }, + { cmd: 'bold', label: '', title: 'Bold', icons: "/public/icons/bold.png" }, + { cmd: 'separator' }, + { cmd: 'link', label: '', title: 'Insert Link', icons: "/public/icons/link.png" }, + { cmd: 'notelink', label: '', title: 'Link to Note', icons: "/public/icons/binder.png" }, + { cmd: 'upload', label: '', title: 'Upload File', icons: "/public/icons/clipy.png" }, + { cmd: 'separator' }, + { cmd: 'readonly', label: '', title: 'readonly', icons: "/public/icons/book.png" }, + ]; + + buttons.forEach(btn => { + if (btn.cmd === 'separator') { + const sep = document.createElement('span'); + sep.className = 'rich-editor-separator'; + toolbar.appendChild(sep); + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'rich-editor-btn'; + if (btn.icons) + button.style.backgroundImage = `url("${btn.icons}")`; + button.textContent = btn.label; + button.title = btn.title; + button.dataset.cmd = btn.cmd; + + button.addEventListener('click', (e) => { + e.preventDefault(); + editor.execCommand(btn.cmd); + }); + + toolbar.appendChild(button); + }); + + return toolbar; + } + + function ensureViewportMeta() { + // Check if viewport meta tag exists + let viewport = document.querySelector('meta[name="viewport"]'); + if (!viewport) { + viewport = document.createElement('meta'); + viewport.name = 'viewport'; + viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=5.0'; + document.head.appendChild(viewport); + } + } + + function createStyles() { + if (document.getElementById('rich-editor-styles')) return; + + ensureViewportMeta(); + + const style = document.createElement('style'); + style.id = 'rich-editor-styles'; + style.textContent = ` + .rich-editor-container { + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; + } + + .rich-editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + background: #f5f5f5; + border-bottom: 1px solid #ccc; + } + + .rich-editor-btn { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; + min-width: 32px; + background-repeat: no-repeat; + background-position: center; + background-size: 24px 24px; + border: none; + } + + .rich-editor-btn:hover { + background: #e9e9e9; + background-repeat: no-repeat; + background-position: center; + background-size: 24px 24px; + } + + .rich-editor-btn:active { + background: #ddd; + background-repeat: no-repeat; + background-position: center; + background-size: 24px 24px; + } + + .rich-editor-separator { + width: 1px; + background: #ccc; + margin: 0 4px; + } + + .rich-editor-content { + min-height: 300px; + padding: 16px; + outline: none; + overflow-y: auto; + } + + .rich-editor-content:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; + } + + .rich-editor-content img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; + cursor: pointer; + } + + .rich-editor-content img.selected { + outline: 2px solid #0078ff; + outline-offset: 2px; + } + + .rich-editor-resize-wrapper { + position: relative; + display: inline-block; + margin: 8px 0; + } + + .rich-editor-resize-handle { + position: absolute; + width: 12px; + height: 12px; + background: #0078ff; + border: 2px solid white; + border-radius: 50%; + cursor: nwse-resize; + z-index: 10; + } + + .rich-editor-resize-handle.nw { + top: -6px; + left: -6px; + cursor: nwse-resize; + } + + .rich-editor-resize-handle.ne { + top: -6px; + right: -6px; + cursor: nesw-resize; + } + + .rich-editor-resize-handle.sw { + bottom: -6px; + left: -6px; + cursor: nesw-resize; + } + + .rich-editor-resize-handle.se { + bottom: -6px; + right: -6px; + cursor: nwse-resize; + } + + .rich-editor-size-input { + position: absolute; + top: -35px; + left: 50%; + transform: translateX(-50%); + background: white; + border: 1px solid #ccc; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + display: flex; + gap: 8px; + align-items: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + z-index: 11; + } + + .rich-editor-size-input input { + width: 60px; + padding: 2px 4px; + border: 1px solid #ccc; + border-radius: 2px; + font-size: 12px; + } + + .rich-editor-size-input label { + font-size: 11px; + color: #666; + } + + .rich-editor-content a { + color: #0078ff; + text-decoration: none; + } + + .rich-editor-content a:hover { + text-decoration: underline; + } + + .rich-editor-content a.note-link { + background: #e8f4ff; + padding: 2px 6px; + border-radius: 3px; + } + + .rich-editor-content a.note-link:hover { + background: #d0e8ff; + } + + .rich-editor-content .upload-placeholder { + display: inline-block; + padding: 8px 16px; + background: #f0f0f0; + border-radius: 4px; + color: #666; + font-style: italic; + } + + .rich-editor-content .upload-placeholder.loading { + padding: 8px 16px; + background: #fffacd; + border-radius: 4px; + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + .rich-editor-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 120, 255, 0.1); + border: 2px dashed #0078ff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #0078ff; + pointer-events: none; + z-index: 10; + padding: 20px; + text-align: center; + } + + .rich-editor-status { + padding: 4px 8px; + font-size: 12px; + color: #666; + background: #f9f9f9; + border-top: 1px solid #eee; + } + + .rich-editor-file-input { + display: none; + } + + ol,ul { + padding: 16px; + } + + /* Mobile optimizations */ + @media (max-width: 720px) { + .rich-editor-toolbar { + padding: 6px; + gap: 3px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .rich-editor-btn { + min-width: 44px; + min-height: 44px; + padding: 8px; + flex-shrink: 0; + } + + .rich-editor-content { + min-height: 200px; + padding: 12px; + font-size: 16px; /* Prevents iOS zoom */ + line-height: 1.6; + } + + .rich-editor-content h1 { + font-size: 1.75em; + } + + .rich-editor-content h2 { + font-size: 1.5em; + } + + .rich-editor-content h3 { + font-size: 1.25em; + } + + .rich-editor-status { + font-size: 11px; + padding: 6px 8px; + } + + /* Mobile-friendly image resizing */ + .rich-editor-resize-handle { + width: 20px; + height: 20px; + border-width: 3px; + } + + .rich-editor-resize-handle.nw { + top: -10px; + left: -10px; + } + + .rich-editor-resize-handle.ne { + top: -10px; + right: -10px; + } + + .rich-editor-resize-handle.sw { + bottom: -10px; + left: -10px; + } + + .rich-editor-resize-handle.se { + bottom: -10px; + right: -10px; + } + + .rich-editor-size-input { + top: -40px; + padding: 6px 10px; + } + + .rich-editor-size-input input { + width: 70px; + padding: 4px 6px; + font-size: 16px; /* Prevents iOS zoom */ + } + + /* Better touch targets for images */ + .rich-editor-content img { + margin: 12px 0; + } + + /* Hide drop overlay text on small screens */ + .rich-editor-upload-overlay { + font-size: 16px; + } + + /* Make link/note dialogs mobile-friendly */ + .rich-editor-content a { + padding: 2px 0; + } + } + + /* Extra small devices */ + @media (max-width: 480px) { + .rich-editor-toolbar { + padding: 4px; + gap: 2px; + } + + .rich-editor-btn { + min-width: 40px; + min-height: 40px; + padding: 6px; + } + + .rich-editor-content { + padding: 10px; + font-size: 16px; + } + + .rich-editor-size-input { + font-size: 11px; + } + + .rich-editor-size-input input { + width: 60px; + font-size: 14px; + } + } + `; + document.head.appendChild(style); + } + + class Editor { + constructor(elementId, options) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.container = document.getElementById(elementId); + this.state = { + readOnly: false, + selectedImage: null, + resizing: false + }; + + if (!this.container) { + throw new Error(`Element with id "${elementId}" not found`); + } + + this.init(); + } + + init() { + createStyles(); + + // Create wrapper + this.wrapper = document.createElement('div'); + this.wrapper.className = 'rich-editor-container'; + this.wrapper.style.position = 'relative'; + + // Create toolbar + this.toolbar = createToolbar(this); + this.wrapper.appendChild(this.toolbar); + + // Create content area + this.content = document.createElement('div'); + this.content.className = 'rich-editor-content'; + this.content.contentEditable = true; + this.content.dataset.placeholder = this.options.placeholder; + this.wrapper.appendChild(this.content); + + // Create status bar + this.status = document.createElement('div'); + this.status.className = 'rich-editor-status'; + this.status.textContent = 'Ready'; + this.wrapper.appendChild(this.status); + + // Create hidden file input + this.fileInput = document.createElement('input'); + this.fileInput.type = 'file'; + this.fileInput.className = 'rich-editor-file-input'; + this.fileInput.multiple = true; + this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt'; + this.wrapper.appendChild(this.fileInput); + + // Add to container + this.container.appendChild(this.wrapper); + + // Setup event listeners + this.setupEvents(); + + // Setup debounced save + if (this.options.saveCallback) { + this.debouncedSave = debounce(() => { + this.save(); + }, this.options.debounceMs); + } + } + + setupEvents() { + // Input events for auto-save + this.content.addEventListener('input', () => { + if (this.debouncedSave) { + this.setStatus('Editing...'); + this.debouncedSave(); + } + }); + + // Handle paste + this.content.addEventListener('paste', (e) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith('image/') || item.kind === 'file') { + e.preventDefault(); + const file = item.getAsFile(); + if (file) this.uploadFile(file); + return; + } + } + }); + + // Handle drop + this.content.addEventListener('dragover', (e) => { + e.preventDefault(); + this.showDropOverlay(); + }); + + this.content.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + }); + + this.content.addEventListener('drop', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + }); + + // Handle /upload command + this.content.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const selection = window.getSelection(); + const node = selection.anchorNode; + if (node && node.textContent) { + const text = node.textContent; + if (text.trim() === '/upload') { + e.preventDefault(); + node.textContent = ''; + this.triggerFileUpload(); + } + } + } + }); + + // File input change + this.fileInput.addEventListener('change', (e) => { + const files = e.target.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + this.fileInput.value = ''; + }); + + // Handle keyboard shortcuts + this.content.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + this.execCommand('bold'); + break; + case 'i': + e.preventDefault(); + this.execCommand('italic'); + break; + case 's': + e.preventDefault(); + this.save(); + break; + } + } + }); + + // Handle image clicks for resizing + this.content.addEventListener('click', (e) => { + if (e.target.tagName === 'IMG') { + e.preventDefault(); + this.selectImage(e.target); + } else if (!e.target.closest('.rich-editor-resize-wrapper')) { + this.deselectImage(); + } + }); + + // Deselect image when clicking outside + document.addEventListener('click', (e) => { + if (!this.content.contains(e.target)) { + this.deselectImage(); + } + }); + } + + showDropOverlay() { + if (this.dropOverlay) return; + + this.dropOverlay = document.createElement('div'); + this.dropOverlay.className = 'rich-editor-upload-overlay'; + this.dropOverlay.textContent = 'Drop files here to upload'; + this.wrapper.appendChild(this.dropOverlay); + } + + hideDropOverlay() { + if (this.dropOverlay) { + this.dropOverlay.remove(); + this.dropOverlay = null; + } + } + + triggerFileUpload() { + this.fileInput.click(); + } + + readOnly() { + this.state.readOnly = !this.state.readOnly; + + if (this.state.readOnly) + { + this.content.contentEditable = false; + this.setStatus('Read-only mode'); + const buttons = this.toolbar.querySelectorAll('.rich-editor-btn'); + buttons.forEach(btn => { + if (btn.dataset.cmd !== 'readonly') { + btn.disabled = true; + } + }); + } + else + { + // Enable editing + this.content.contentEditable = true; + this.content.style.backgroundColor = ''; + this.setStatus('Ready'); + + // Enable toolbar buttons + const buttons = this.toolbar.querySelectorAll('.rich-editor-btn'); + buttons.forEach(btn => { + btn.disabled = false; + }); + } + } + + async uploadFile(file) { + if (!this.options.uploadCallback) { + console.warn('No upload callback configured'); + return; + } + + // Insert loading placeholder + const placeholder = document.createElement('div'); + placeholder.className = 'upload-placeholder loading'; + placeholder.textContent = file.type.startsWith('image/') + ? `Processing ${file.name}... ⏳` + : `Uploading ${file.name}...`; + + this.insertAtCursor(placeholder); + + this.setStatus(`Uploading ${file.name}...`); + + try { + const result = await this.options.uploadCallback(file); + + if (result && result.url) { + // Replace placeholder with actual content + if (file.type.startsWith('image/')) { + const img = document.createElement('img'); + img.src = result.url; + img.alt = file.name; + + // Store original dimensions when image loads + img.onload = () => { + img.dataset.originalWidth = img.naturalWidth; + img.dataset.originalHeight = img.naturalHeight; + }; + + placeholder.replaceWith(img); + } else { + const link = document.createElement('a'); + link.href = result.url; + link.textContent = file.name; + link.target = '_blank'; + placeholder.replaceWith(link); + } + + this.setStatus(`Uploaded ${file.name}`); + if (this.debouncedSave) this.debouncedSave(); + } else { + placeholder.textContent = `Failed to upload ${file.name}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload failed'); + } + } catch (error) { + console.error('Upload error:', error); + placeholder.textContent = `Error: ${error.message}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload error'); + } + } + + insertAtCursor(element) { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(element); + range.setStartAfter(element); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + this.content.appendChild(element); + } + this.content.focus(); + } + + execCommand(cmd) { + this.content.focus(); + + switch (cmd) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + document.execCommand('formatBlock', false, cmd); + break; + case 'p': + document.execCommand('formatBlock', false, 'p'); + break; + case 'ul': + document.execCommand('insertUnorderedList', false, null); + break; + case 'ol': + document.execCommand('insertOrderedList', false, null); + break; + case 'bold': + document.execCommand('bold', false, null); + break; + case 'italic': + document.execCommand('italic', false, null); + break; + case 'link': + this.insertLink(); + break; + case 'notelink': + this.insertNoteLink(); + break; + case 'upload': + this.triggerFileUpload(); + break; + case 'readonly': + this.readOnly(); + break; + } + + if (this.debouncedSave) this.debouncedSave(); + } + + insertLink() { + const url = prompt('Enter URL:'); + if (!url) return; + + const selection = window.getSelection(); + const selectedText = selection.toString() || url; + + const link = document.createElement('a'); + link.href = url; + link.textContent = selectedText; + link.target = '_blank'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + insertNoteLink() { + const noteId = prompt('Enter note ID (e.g., my-ideas):'); + if (!noteId) return; + + const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + const selection = window.getSelection(); + const selectedText = selection.toString() || sanitizedId; + + const link = document.createElement('a'); + link.href = '/notes/' + encodeURIComponent(sanitizedId); + link.textContent = selectedText; + link.className = 'note-link'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + setStatus(text) { + this.status.textContent = text; + } + + async save() { + if (!this.options.saveCallback) return; + + this.setStatus('Saving...'); + + try { + await this.options.saveCallback(this.getContent()); + this.setStatus('Saved'); + } catch (error) { + console.error('Save error:', error); + this.setStatus('Save failed'); + } + } + + getContent() { + return this.content.innerHTML; + } + + setContent(html) { + this.content.innerHTML = html; + } + + getText() { + return this.content.textContent; + } + + clear() { + this.content.innerHTML = ''; + } + + focus() { + this.content.focus(); + } + + selectImage(img) { + if (this.state.readOnly) return; + + // Deselect previous image + this.deselectImage(); + + this.state.selectedImage = img; + img.classList.add('selected'); + + // Wrap image in resize container if not already wrapped + if (!img.parentElement.classList.contains('rich-editor-resize-wrapper')) { + const wrapper = document.createElement('div'); + wrapper.className = 'rich-editor-resize-wrapper'; + wrapper.contentEditable = false; + + // Store original dimensions if not set + if (!img.dataset.originalWidth) { + img.dataset.originalWidth = img.naturalWidth; + img.dataset.originalHeight = img.naturalHeight; + } + + img.parentNode.insertBefore(wrapper, img); + wrapper.appendChild(img); + } + + const wrapper = img.parentElement; + + // Add resize handles + const corners = ['nw', 'ne', 'sw', 'se']; + corners.forEach(corner => { + const handle = document.createElement('div'); + handle.className = `rich-editor-resize-handle ${corner}`; + handle.dataset.corner = corner; + + // Mouse events + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.startResize(e, img, corner); + }); + + // Touch events for mobile + handle.addEventListener('touchstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + const touch = e.touches[0]; + this.startResize(touch, img, corner, true); + }); + + wrapper.appendChild(handle); + }); + + // Add size input + const sizeInput = document.createElement('div'); + sizeInput.className = 'rich-editor-size-input'; + sizeInput.innerHTML = ` + <label>Width:</label> + <input type="number" class="width-input" value="${img.width}" min="50" max="2000"> + <label>px</label> + `; + + const widthInput = sizeInput.querySelector('.width-input'); + widthInput.addEventListener('input', (e) => { + const newWidth = parseInt(e.target.value); + if (newWidth && newWidth > 0) { + this.resizeImage(img, newWidth); + } + }); + + wrapper.appendChild(sizeInput); + } + + deselectImage() { + if (!this.state.selectedImage) return; + + const img = this.state.selectedImage; + img.classList.remove('selected'); + + const wrapper = img.parentElement; + if (wrapper && wrapper.classList.contains('rich-editor-resize-wrapper')) { + // Remove resize handles and size input + wrapper.querySelectorAll('.rich-editor-resize-handle, .rich-editor-size-input').forEach(el => el.remove()); + + // Unwrap image + wrapper.parentNode.insertBefore(img, wrapper); + wrapper.remove(); + } + + this.state.selectedImage = null; + } + + startResize(e, img, corner, isTouch = false) { + this.state.resizing = true; + + const startX = e.clientX; + const startY = e.clientY; + const startWidth = img.width; + const startHeight = img.height; + const aspectRatio = startWidth / startHeight; + + const onMove = (e) => { + if (!this.state.resizing) return; + + const clientX = isTouch ? e.touches[0].clientX : e.clientX; + const clientY = isTouch ? e.touches[0].clientY : e.clientY; + + let deltaX = clientX - startX; + let deltaY = clientY - startY; + + // Adjust delta based on corner + if (corner === 'nw' || corner === 'sw') { + deltaX = -deltaX; + } + if (corner === 'nw' || corner === 'ne') { + deltaY = -deltaY; + } + + // Use the larger delta to maintain aspect ratio + const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY; + const newWidth = Math.max(50, startWidth + delta); + + this.resizeImage(img, newWidth, aspectRatio); + }; + + const onEnd = () => { + this.state.resizing = false; + + if (isTouch) { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + } else { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onEnd); + } + + // Update width input + const wrapper = img.parentElement; + const widthInput = wrapper.querySelector('.width-input'); + if (widthInput) { + widthInput.value = img.width; + } + + if (this.debouncedSave) this.debouncedSave(); + }; + + if (isTouch) { + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + } else { + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onEnd); + } + } + + resizeImage(img, newWidth, aspectRatio = null) { + if (!aspectRatio) { + const originalWidth = parseInt(img.dataset.originalWidth) || img.naturalWidth; + const originalHeight = parseInt(img.dataset.originalHeight) || img.naturalHeight; + aspectRatio = originalWidth / originalHeight; + } + + const newHeight = newWidth / aspectRatio; + + img.width = newWidth; + img.height = newHeight; + img.style.width = newWidth + 'px'; + img.style.height = newHeight + 'px'; + } + } + + return { + init: function(elementId, options) { + return new Editor(elementId, options); + } + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = RichEditor; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,56 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") + +# File group +filegroup( + name = "s3_hdrs", + srcs = ["s3_uploader.h"], + visibility = ["//visibility:public"], +) + +# Main S3 uploader library (platform-aware) +alias( + name = "s3", + actual = select({ + "//config:macos": ":s3_macos", + "//config:linux": ":s3_linux", + "//conditions:default": ":s3_linux", + }), + visibility = ["//visibility:public"], +) + +cc_library( + name = "s3_macos", + srcs = ["s3_uploader.c"], + hdrs = [":s3_hdrs"], + deps = [ + "//dowa:dowa", + "//seobeo:seobeo", + "@openssl//:ssl", + ], + target_compatible_with = [ + "@platforms//os:osx", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "s3_linux", + srcs = ["s3_uploader.c"], + hdrs = [":s3_hdrs"], + deps = [ + "//dowa:dowa", + "//seobeo:seobeo", + "@openssl//:ssl", + ], + target_compatible_with = [ + "@platforms//os:linux", + ], + visibility = ["//visibility:public"], +) + +# Alias for convenience +alias( + name = "s3_uploader", + actual = ":s3", + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/s3_uploader.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,787 @@ +#include "s3_uploader.h" +#include "seobeo/seobeo.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> +#include <ctype.h> + +#include <openssl/hmac.h> +#include <openssl/sha.h> + +#define S3_ARENA_SIZE (10 * ONE_MEGA_BYTE) +#define S3_RESULT_ARENA_SIZE (64 * 1024) +#define S3_SERVICE_NAME "s3" +#define S3_AWS4_REQUEST "aws4_request" +#define S3_ALGORITHM "AWS4-HMAC-SHA256" + +// --- Internal Structures --- // + +typedef struct { + char date[9]; // YYYYMMDD + char datetime[17]; // YYYYMMDDTHHMMSSZ +} S3_Timestamp; + +// --- Forward Declarations --- // + +static void s3__get_timestamp(S3_Timestamp *p_ts); +static void s3__sha256_hex(const uint8 *data, size_t len, char *out); +static void s3__hmac_sha256(const uint8 *key, size_t key_len, + const uint8 *data, size_t data_len, + uint8 *out); +static void s3__hex_encode(const uint8 *data, size_t len, char *out); +static char *s3__uri_encode(const char *str, Dowa_Arena *p_arena); +static char *s3__build_canonical_request(const char *method, + const char *uri, + const char *query, + const char *headers, + const char *signed_headers, + const char *payload_hash, + Dowa_Arena *p_arena); +static char *s3__build_string_to_sign(const char *datetime, + const char *date, + const char *region, + const char *canonical_request, + Dowa_Arena *p_arena); +static void s3__calculate_signing_key(const char *secret_key, + const char *date, + const char *region, + uint8 *out); +static char *s3__build_authorization_header(const char *access_key, + const char *date, + const char *region, + const char *signed_headers, + const uint8 *signing_key, + const char *string_to_sign, + Dowa_Arena *p_arena); +static uint8 *s3__load_file(const char *path, size_t *p_size); + +// --- Public API Implementation --- // + +S3_Result S3_Upload_File(const S3_Config *p_config, + const char *local_path, + const char *s3_key) +{ + const char *content_type = S3_Guess_Content_Type(local_path); + return S3_Upload_File_With_Content_Type(p_config, local_path, s3_key, content_type); +} + +S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config, + const char *local_path, + const char *s3_key, + const char *content_type) +{ + S3_Result result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + size_t file_size = 0; + uint8 *file_data = s3__load_file(local_path, &file_size); + if (!file_data) + { + result.success = FALSE; + result.status_code = 0; + result.error_message = Dowa_String_Copy_Arena("Failed to read file", result.p_arena); + return result; + } + + result = S3_Upload_Data(p_config, file_data, file_size, s3_key, content_type); + free(file_data); + + return result; +} + +S3_Result S3_Upload_Data(const S3_Config *p_config, + const uint8 *data, + size_t data_length, + const char *s3_key, + const char *content_type) +{ + S3_Result result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + if (!p_config || !data || !s3_key) + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena); + return result; + } + + Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE); + + // Get timestamp + S3_Timestamp ts; + s3__get_timestamp(&ts); + + // Calculate payload hash + char payload_hash[65]; + s3__sha256_hex(data, data_length, payload_hash); + + // Build host + char *host; + if (p_config->endpoint) + { + host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena); + } + else if (p_config->use_path_style) + { + size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region); + } + else + { + size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region); + } + + // Build URI path + char *uri_path; + char *encoded_key = s3__uri_encode(s3_key, p_arena); + if (p_config->use_path_style) + { + size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key); + } + else + { + size_t uri_len = strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s", encoded_key); + } + + // Build canonical headers (must be sorted alphabetically) + // Note: Content-Length is NOT signed for S3 PUT requests (it's sent but not in signature) + size_t headers_len = 512 + strlen(host) + strlen(content_type); + char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len); + snprintf(canonical_headers, headers_len, + "content-type:%s\n" + "host:%s\n" + "x-amz-content-sha256:%s\n" + "x-amz-date:%s\n", + content_type, + host, + payload_hash, + ts.datetime); + + const char *signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"; + + // Build canonical request + char *canonical_request = s3__build_canonical_request("PUT", + uri_path, + "", // No query string + canonical_headers, + signed_headers, + payload_hash, + p_arena); + + // Build string to sign + char *string_to_sign = s3__build_string_to_sign(ts.datetime, + ts.date, + p_config->region, + canonical_request, + p_arena); + + // Calculate signing key + uint8 signing_key[32]; + s3__calculate_signing_key(p_config->secret_access_key, + ts.date, + p_config->region, + signing_key); + + // Build authorization header + char *auth_header = s3__build_authorization_header(p_config->access_key_id, + ts.date, + p_config->region, + signed_headers, + signing_key, + string_to_sign, + p_arena); + + // Build URL + size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1; + char *url = Dowa_Arena_Allocate(p_arena, url_len); + snprintf(url, url_len, "https://%s%s", host, uri_path); + + // Execute request using seobeo + Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Method(p_req, "PUT"); + Seobeo_Client_Request_Set_Body(p_req, (const char *)data, data_length); + Seobeo_Client_Request_Add_Header_Map(p_req, "Connection", "keep-alive"); + Seobeo_Client_Request_Add_Header_Map(p_req, "Content-Type", content_type); + Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-date", ts.datetime); + Seobeo_Client_Request_Add_Header_Map(p_req, "x-amz-content-sha256", payload_hash); + Seobeo_Client_Request_Add_Header_Map(p_req, "Authorization", auth_header); + + Seobeo_Log(SEOBEO_DEBUG, "[S3] Uploading %zu bytes to %s\n", data_length, url); + Seobeo_Log(SEOBEO_DEBUG, "[S3] Content-Type: %s\n", content_type); + Seobeo_Log(SEOBEO_DEBUG, "[S3] x-amz-date: %s\n", ts.datetime); + Seobeo_Log(SEOBEO_DEBUG, "[S3] x-amz-content-sha256: %s\n", payload_hash); + Seobeo_Log(SEOBEO_DEBUG, "[S3] Authorization: %.80s...\n", auth_header); + + Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req); + + if (p_resp) + { + result.status_code = p_resp->status_code; + if (p_resp->status_code >= 200 && p_resp->status_code < 300) + { + result.success = TRUE; + + // Extract ETag from response headers + if (p_resp->headers) + { + char *etag = Dowa_HashMap_Get(p_resp->headers, "etag"); + if (!etag) etag = Dowa_HashMap_Get(p_resp->headers, "ETag"); + if (etag) + { + result.etag = Dowa_String_Copy_Arena(etag, result.p_arena); + } + } + } + else + { + result.success = FALSE; + if (p_resp->body && p_resp->body_length > 0) + { + result.error_message = Dowa_String_Copy_Arena(p_resp->body, result.p_arena); + } + else + { + result.error_message = Dowa_String_Copy_Arena("Upload failed", result.p_arena); + } + } + Seobeo_Client_Response_Destroy(p_resp); + } + else + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Failed to execute request", result.p_arena); + } + + Seobeo_Client_Request_Destroy(p_req); + Dowa_Arena_Free(p_arena); + + return result; +} + +void S3_Result_Destroy(S3_Result *p_result) +{ + if (p_result && p_result->p_arena) + { + Dowa_Arena_Free(p_result->p_arena); + p_result->p_arena = NULL; + p_result->error_message = NULL; + p_result->etag = NULL; + } +} + +// --- Presigned URL Implementation --- // + +static char *s3__uri_encode_strict(const char *str, Dowa_Arena *p_arena) +{ + // Stricter encoding for query string values (no forward slash allowed) + if (!str) return NULL; + + size_t len = strlen(str); + size_t max_len = len * 3 + 1; + char *out = Dowa_Arena_Allocate(p_arena, max_len); + char *p = out; + + for (size_t i = 0; i < len; i++) + { + char c = str[i]; + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~') + { + *p++ = c; + } + else + { + sprintf(p, "%%%02X", (unsigned char)c); + p += 3; + } + } + *p = '\0'; + return out; +} + +static S3_Presigned_URL s3__presign_url(const S3_Config *p_config, + const char *s3_key, + const char *method, + const char *content_type, + int32 expires_seconds) +{ + S3_Presigned_URL result = {0}; + result.p_arena = Dowa_Arena_Create(S3_RESULT_ARENA_SIZE); + + if (!p_config || !s3_key) + { + result.success = FALSE; + result.error_message = Dowa_String_Copy_Arena("Invalid parameters", result.p_arena); + return result; + } + + Dowa_Arena *p_arena = Dowa_Arena_Create(S3_ARENA_SIZE); + + // Get timestamp + S3_Timestamp ts; + s3__get_timestamp(&ts); + + // Build host + char *host; + if (p_config->endpoint) + { + host = Dowa_String_Copy_Arena((char *)p_config->endpoint, p_arena); + } + else if (p_config->use_path_style) + { + size_t host_len = strlen("s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "s3.%s.amazonaws.com", p_config->region); + } + else + { + size_t host_len = strlen(p_config->bucket) + strlen(".s3.") + strlen(p_config->region) + strlen(".amazonaws.com") + 1; + host = Dowa_Arena_Allocate(p_arena, host_len); + snprintf(host, host_len, "%s.s3.%s.amazonaws.com", p_config->bucket, p_config->region); + } + + // Build URI path + char *uri_path; + char *encoded_key = s3__uri_encode(s3_key, p_arena); + if (p_config->use_path_style) + { + size_t uri_len = strlen("/") + strlen(p_config->bucket) + strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s/%s", p_config->bucket, encoded_key); + } + else + { + size_t uri_len = strlen("/") + strlen(encoded_key) + 1; + uri_path = Dowa_Arena_Allocate(p_arena, uri_len); + snprintf(uri_path, uri_len, "/%s", encoded_key); + } + + // Build credential scope + size_t scope_len = strlen(p_config->access_key_id) + strlen(ts.date) + strlen(p_config->region) + + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10; + char *credential = Dowa_Arena_Allocate(p_arena, scope_len); + snprintf(credential, scope_len, "%s/%s/%s/%s/%s", + p_config->access_key_id, ts.date, p_config->region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + char *encoded_credential = s3__uri_encode_strict(credential, p_arena); + + // Build signed headers (host is required, content-type for PUT) + const char *signed_headers; + if (content_type && strcmp(method, "PUT") == 0) + { + signed_headers = "content-type;host"; + } + else + { + signed_headers = "host"; + } + + // Build canonical query string (must be sorted alphabetically) + char expires_str[16]; + snprintf(expires_str, sizeof(expires_str), "%d", expires_seconds); + + // URL-encode signed headers (semicolon becomes %3B) + char *encoded_signed_headers = s3__uri_encode_strict(signed_headers, p_arena); + + size_t query_len = 1024 + strlen(encoded_credential); + char *canonical_query = Dowa_Arena_Allocate(p_arena, query_len); + + snprintf(canonical_query, query_len, + "X-Amz-Algorithm=%s" + "&X-Amz-Credential=%s" + "&X-Amz-Date=%s" + "&X-Amz-Expires=%s" + "&X-Amz-SignedHeaders=%s", + S3_ALGORITHM, + encoded_credential, + ts.datetime, + expires_str, + encoded_signed_headers); + + // Build canonical headers + size_t headers_len = 256 + strlen(host) + (content_type ? strlen(content_type) : 0); + char *canonical_headers = Dowa_Arena_Allocate(p_arena, headers_len); + + if (content_type && strcmp(method, "PUT") == 0) + { + snprintf(canonical_headers, headers_len, + "content-type:%s\nhost:%s\n", + content_type, host); + } + else + { + snprintf(canonical_headers, headers_len, "host:%s\n", host); + } + + // For presigned URLs, payload is UNSIGNED-PAYLOAD + const char *payload_hash = "UNSIGNED-PAYLOAD"; + + // Build canonical request + char *canonical_request = s3__build_canonical_request(method, + uri_path, + canonical_query, + canonical_headers, + signed_headers, + payload_hash, + p_arena); + + // Build string to sign + char *string_to_sign = s3__build_string_to_sign(ts.datetime, + ts.date, + p_config->region, + canonical_request, + p_arena); + + // Calculate signing key + uint8 signing_key[32]; + s3__calculate_signing_key(p_config->secret_access_key, + ts.date, + p_config->region, + signing_key); + + // Calculate signature + uint8 signature[32]; + s3__hmac_sha256(signing_key, 32, + (const uint8 *)string_to_sign, strlen(string_to_sign), + signature); + + char signature_hex[65]; + s3__hex_encode(signature, 32, signature_hex); + + // Build final presigned URL + size_t url_len = strlen("https://") + strlen(host) + strlen(uri_path) + 1 + + strlen(canonical_query) + strlen("&X-Amz-Signature=") + 64 + 1; + char *url = Dowa_Arena_Allocate(result.p_arena, url_len); + snprintf(url, url_len, "https://%s%s?%s&X-Amz-Signature=%s", + host, uri_path, canonical_query, signature_hex); + + result.success = TRUE; + result.url = url; + + Dowa_Arena_Free(p_arena); + return result; +} + +S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config, + const char *s3_key, + const char *content_type, + int32 expires_seconds) +{ + return s3__presign_url(p_config, s3_key, "PUT", content_type, expires_seconds); +} + +S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config, + const char *s3_key, + int32 expires_seconds) +{ + return s3__presign_url(p_config, s3_key, "GET", NULL, expires_seconds); +} + +void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url) +{ + if (p_url && p_url->p_arena) + { + Dowa_Arena_Free(p_url->p_arena); + p_url->p_arena = NULL; + p_url->url = NULL; + p_url->error_message = NULL; + } +} + +const char *S3_Guess_Content_Type(const char *filename) +{ + if (!filename) return "application/octet-stream"; + + const char *dot = strrchr(filename, '.'); + if (!dot) return "application/octet-stream"; + + dot++; // Skip the dot + + // Common content types + if (strcasecmp(dot, "html") == 0 || strcasecmp(dot, "htm") == 0) + return "text/html"; + if (strcasecmp(dot, "css") == 0) + return "text/css"; + if (strcasecmp(dot, "js") == 0) + return "application/javascript"; + if (strcasecmp(dot, "json") == 0) + return "application/json"; + if (strcasecmp(dot, "xml") == 0) + return "application/xml"; + if (strcasecmp(dot, "txt") == 0) + return "text/plain"; + if (strcasecmp(dot, "csv") == 0) + return "text/csv"; + + // Images + if (strcasecmp(dot, "png") == 0) + return "image/png"; + if (strcasecmp(dot, "jpg") == 0 || strcasecmp(dot, "jpeg") == 0) + return "image/jpeg"; + if (strcasecmp(dot, "gif") == 0) + return "image/gif"; + if (strcasecmp(dot, "svg") == 0) + return "image/svg+xml"; + if (strcasecmp(dot, "webp") == 0) + return "image/webp"; + if (strcasecmp(dot, "ico") == 0) + return "image/x-icon"; + + // Audio/Video + if (strcasecmp(dot, "mp3") == 0) + return "audio/mpeg"; + if (strcasecmp(dot, "mp4") == 0) + return "video/mp4"; + if (strcasecmp(dot, "webm") == 0) + return "video/webm"; + if (strcasecmp(dot, "ogg") == 0) + return "audio/ogg"; + if (strcasecmp(dot, "wav") == 0) + return "audio/wav"; + + // Documents + if (strcasecmp(dot, "pdf") == 0) + return "application/pdf"; + if (strcasecmp(dot, "zip") == 0) + return "application/zip"; + if (strcasecmp(dot, "gz") == 0 || strcasecmp(dot, "gzip") == 0) + return "application/gzip"; + if (strcasecmp(dot, "tar") == 0) + return "application/x-tar"; + + // Fonts + if (strcasecmp(dot, "woff") == 0) + return "font/woff"; + if (strcasecmp(dot, "woff2") == 0) + return "font/woff2"; + if (strcasecmp(dot, "ttf") == 0) + return "font/ttf"; + if (strcasecmp(dot, "otf") == 0) + return "font/otf"; + + return "application/octet-stream"; +} + +// --- Internal Implementation --- // + +static void s3__get_timestamp(S3_Timestamp *p_ts) +{ + time_t now = time(NULL); + struct tm *utc = gmtime(&now); + + strftime(p_ts->date, sizeof(p_ts->date), "%Y%m%d", utc); + strftime(p_ts->datetime, sizeof(p_ts->datetime), "%Y%m%dT%H%M%SZ", utc); +} + +static void s3__hex_encode(const uint8 *data, size_t len, char *out) +{ + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) + { + out[i * 2] = hex[(data[i] >> 4) & 0x0F]; + out[i * 2 + 1] = hex[data[i] & 0x0F]; + } + out[len * 2] = '\0'; +} + +static void s3__sha256_hex(const uint8 *data, size_t len, char *out) +{ + uint8 hash[SHA256_DIGEST_LENGTH]; + SHA256(data, len, hash); + s3__hex_encode(hash, SHA256_DIGEST_LENGTH, out); +} + +static void s3__hmac_sha256(const uint8 *key, size_t key_len, + const uint8 *data, size_t data_len, + uint8 *out) +{ + unsigned int out_len = 0; + HMAC(EVP_sha256(), key, (int)key_len, data, data_len, out, &out_len); +} + +static char *s3__uri_encode(const char *str, Dowa_Arena *p_arena) +{ + if (!str) return NULL; + + size_t len = strlen(str); + // Worst case: every char becomes %XX (3 chars) + size_t max_len = len * 3 + 1; + char *out = Dowa_Arena_Allocate(p_arena, max_len); + char *p = out; + + for (size_t i = 0; i < len; i++) + { + char c = str[i]; + // Unreserved characters per RFC 3986 + if ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.' || c == '~' || c == '/') + { + *p++ = c; + } + else + { + sprintf(p, "%%%02X", (unsigned char)c); + p += 3; + } + } + *p = '\0'; + return out; +} + +static char *s3__build_canonical_request(const char *method, + const char *uri, + const char *query, + const char *headers, + const char *signed_headers, + const char *payload_hash, + Dowa_Arena *p_arena) +{ + size_t len = strlen(method) + strlen(uri) + strlen(query) + + strlen(headers) + strlen(signed_headers) + strlen(payload_hash) + 10; + char *out = Dowa_Arena_Allocate(p_arena, len); + snprintf(out, len, "%s\n%s\n%s\n%s\n%s\n%s", + method, uri, query, headers, signed_headers, payload_hash); + return out; +} + +static char *s3__build_string_to_sign(const char *datetime, + const char *date, + const char *region, + const char *canonical_request, + Dowa_Arena *p_arena) +{ + // Hash the canonical request + char canonical_hash[65]; + s3__sha256_hex((const uint8 *)canonical_request, strlen(canonical_request), canonical_hash); + + // Build credential scope + size_t scope_len = strlen(date) + strlen(region) + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 4; + char *scope = Dowa_Arena_Allocate(p_arena, scope_len); + snprintf(scope, scope_len, "%s/%s/%s/%s", date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + // Build string to sign + size_t len = strlen(S3_ALGORITHM) + strlen(datetime) + strlen(scope) + strlen(canonical_hash) + 10; + char *out = Dowa_Arena_Allocate(p_arena, len); + snprintf(out, len, "%s\n%s\n%s\n%s", S3_ALGORITHM, datetime, scope, canonical_hash); + + return out; +} + +static void s3__calculate_signing_key(const char *secret_key, + const char *date, + const char *region, + uint8 *out) +{ + // AWS4 + SecretAccessKey + size_t key_len = 4 + strlen(secret_key) + 1; + char *k_secret = malloc(key_len); + snprintf(k_secret, key_len, "AWS4%s", secret_key); + + uint8 k_date[32]; + uint8 k_region[32]; + uint8 k_service[32]; + + s3__hmac_sha256((const uint8 *)k_secret, strlen(k_secret), + (const uint8 *)date, strlen(date), k_date); + + s3__hmac_sha256(k_date, 32, + (const uint8 *)region, strlen(region), k_region); + + s3__hmac_sha256(k_region, 32, + (const uint8 *)S3_SERVICE_NAME, strlen(S3_SERVICE_NAME), k_service); + + s3__hmac_sha256(k_service, 32, + (const uint8 *)S3_AWS4_REQUEST, strlen(S3_AWS4_REQUEST), out); + + free(k_secret); +} + +static char *s3__build_authorization_header(const char *access_key, + const char *date, + const char *region, + const char *signed_headers, + const uint8 *signing_key, + const char *string_to_sign, + Dowa_Arena *p_arena) +{ + // Calculate signature + uint8 signature[32]; + s3__hmac_sha256(signing_key, 32, + (const uint8 *)string_to_sign, strlen(string_to_sign), + signature); + + char signature_hex[65]; + s3__hex_encode(signature, 32, signature_hex); + + // Build credential + size_t cred_len = strlen(access_key) + strlen(date) + strlen(region) + + strlen(S3_SERVICE_NAME) + strlen(S3_AWS4_REQUEST) + 10; + char *credential = Dowa_Arena_Allocate(p_arena, cred_len); + snprintf(credential, cred_len, "%s/%s/%s/%s/%s", + access_key, date, region, S3_SERVICE_NAME, S3_AWS4_REQUEST); + + // Build full authorization header + size_t auth_len = strlen(S3_ALGORITHM) + strlen(credential) + + strlen(signed_headers) + strlen(signature_hex) + 64; + char *auth = Dowa_Arena_Allocate(p_arena, auth_len); + snprintf(auth, auth_len, + "%s Credential=%s, SignedHeaders=%s, Signature=%s", + S3_ALGORITHM, credential, signed_headers, signature_hex); + + return auth; +} + +static uint8 *s3__load_file(const char *path, size_t *p_size) +{ + FILE *f = fopen(path, "rb"); + if (!f) + { + *p_size = 0; + return NULL; + } + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size <= 0) + { + fclose(f); + *p_size = 0; + return NULL; + } + + uint8 *data = malloc((size_t)size); + if (!data) + { + fclose(f); + *p_size = 0; + return NULL; + } + + size_t read = fread(data, 1, (size_t)size, f); + fclose(f); + + if (read != (size_t)size) + { + free(data); + *p_size = 0; + return NULL; + } + + *p_size = (size_t)size; + return data; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/s3_uploader.h Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,150 @@ +#ifndef S3_UPLOADER_H +#define S3_UPLOADER_H + +/** + * S3 Uploader + * ----------- + * + * Generic S3 bucket uploader using AWS Signature Version 4. + * Built on top of seobeo HTTP client. + * + * ## Examples + * + * ### 1. Upload a file from disk + * + * ```c + * S3_Config config = { + * .access_key_id = "AKIAIOSFODNN7EXAMPLE", + * .secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + * .region = "us-east-1", + * .bucket = "my-bucket" + * }; + * + * S3_Result result = S3_Upload_File(&config, "/path/to/local/file.txt", "uploads/file.txt"); + * if (result.success) + * { + * printf("Uploaded successfully!\n"); + * } + * else + * { + * printf("Error: %s\n", result.error_message); + * } + * S3_Result_Destroy(&result); + * ``` + * + * ### 2. Upload raw data from memory + * + * ```c + * const char *data = "Hello, S3!"; + * S3_Result result = S3_Upload_Data(&config, (const uint8 *)data, strlen(data), + * "hello.txt", "text/plain"); + * S3_Result_Destroy(&result); + * ``` + * + * ### 3. Upload with custom content type + * + * ```c + * S3_Result result = S3_Upload_File_With_Content_Type(&config, + * "/path/to/image.png", + * "images/photo.png", + * "image/png"); + * S3_Result_Destroy(&result); + * ``` + */ + +#include <stddef.h> + +#include "dowa/dowa.h" + +// S3 configuration +typedef struct { + const char *access_key_id; // AWS access key ID + const char *secret_access_key; // AWS secret access key + const char *region; // AWS region (e.g., "us-east-1") + const char *bucket; // S3 bucket name + const char *endpoint; // Optional custom endpoint (NULL for default AWS) + boolean use_path_style; // Use path-style URLs (bucket in path, not subdomain) +} S3_Config; + +// Upload result +typedef struct { + boolean success; // TRUE if upload succeeded + int32 status_code; // HTTP status code + char *error_message; // Error message (NULL on success) + char *etag; // ETag of uploaded object (on success) + Dowa_Arena *p_arena; // Internal arena (do not modify) +} S3_Result; + +// --- Core API --- // + +/* Upload file from disk to S3. Content-Type is auto-detected. */ +extern S3_Result S3_Upload_File(const S3_Config *p_config, + const char *local_path, + const char *s3_key); + +/* Upload file with explicit content type. */ +extern S3_Result S3_Upload_File_With_Content_Type(const S3_Config *p_config, + const char *local_path, + const char *s3_key, + const char *content_type); + +/* Upload raw data from memory to S3. */ +extern S3_Result S3_Upload_Data(const S3_Config *p_config, + const uint8 *data, + size_t data_length, + const char *s3_key, + const char *content_type); + +/* Free resources associated with result. */ +extern void S3_Result_Destroy(S3_Result *p_result); + +// --- Presigned URLs --- // + +// Presigned URL result +typedef struct { + boolean success; // TRUE if generation succeeded + char *url; // The presigned URL (on success) + char *error_message; // Error message (on failure) + Dowa_Arena *p_arena; // Internal arena (do not modify) +} S3_Presigned_URL; + +/* Generate a presigned URL for uploading (PUT) a file. + * + * The client can use this URL to upload directly to S3 without credentials. + * + * Example: + * S3_Presigned_URL url = S3_Presign_Put(&config, "uploads/file.txt", "image/png", 3600); + * if (url.success) { + * printf("Upload URL: %s\n", url.url); + * // Client does: curl -X PUT -H "Content-Type: image/png" --data-binary @file.png "$URL" + * } + * S3_Presigned_URL_Destroy(&url); + */ +extern S3_Presigned_URL S3_Presign_Put(const S3_Config *p_config, + const char *s3_key, + const char *content_type, + int32 expires_seconds); + +/* Generate a presigned URL for downloading (GET) a file. + * + * Example: + * S3_Presigned_URL url = S3_Presign_Get(&config, "uploads/file.txt", 3600); + * if (url.success) { + * printf("Download URL: %s\n", url.url); + * // Client does: curl "$URL" -o file.txt + * } + * S3_Presigned_URL_Destroy(&url); + */ +extern S3_Presigned_URL S3_Presign_Get(const S3_Config *p_config, + const char *s3_key, + int32 expires_seconds); + +/* Free resources associated with presigned URL result. */ +extern void S3_Presigned_URL_Destroy(S3_Presigned_URL *p_url); + +// --- Utility Functions --- // + +/* Guess content type from file extension. Returns "application/octet-stream" if unknown. */ +extern const char *S3_Guess_Content_Type(const char *filename); + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/tests/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,23 @@ +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_test( + name = "s3_uploader_test", + srcs = ["s3_uploader_test.c"], + deps = [ + "//s3:s3", + ], + data = [ + "//:env_file", + ], + visibility = ["//visibility:public"], +) + +cc_binary( + name = "s3_uploader_bin", + srcs = ["s3_uploader_test.c"], + deps = [ + "//s3:s3", + ], + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/s3/tests/s3_uploader_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,192 @@ +#include "s3/s3_uploader.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +// Simple .env parser +static char *parse_env_value(const char *env_content, const char *key) +{ + const char *pos = strstr(env_content, key); + if (!pos) return NULL; + + pos += strlen(key); + if (*pos != '=') return NULL; + pos++; + + const char *end = pos; + while (*end && *end != '\n' && *end != '\r') end++; + + size_t len = end - pos; + char *value = malloc(len + 1); + memcpy(value, pos, len); + value[len] = '\0'; + return value; +} + +static char *load_file(const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return NULL; + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + char *content = malloc(size + 1); + fread(content, 1, size, f); + content[size] = '\0'; + fclose(f); + return content; +} + +int main(int argc, char **argv) +{ + // Try to load .env file - check multiple locations for bazel runfiles + char *env_content = load_file(".env"); + if (!env_content) + { + env_content = load_file("zenbu/.env"); // bazel runfiles path + } + if (!env_content) + { + env_content = load_file("../.env"); + } + if (!env_content) + { + env_content = load_file("../../.env"); + } + + char *access_key = NULL; + char *secret_key = NULL; + + if (env_content) + { + access_key = parse_env_value(env_content, "AWS_MRJUNEJUNE_ACCESS_KEY"); + secret_key = parse_env_value(env_content, "AWS_MRJUNEJUNE_SECRET_ACCESS_KEY"); + free(env_content); + } + + // Fall back to environment variables + if (!access_key) access_key = getenv("AWS_ACCESS_KEY_ID") ? strdup(getenv("AWS_ACCESS_KEY_ID")) : NULL; + if (!secret_key) secret_key = getenv("AWS_SECRET_ACCESS_KEY") ? strdup(getenv("AWS_SECRET_ACCESS_KEY")) : NULL; + + if (!access_key || !secret_key) + { + printf("Missing credentials. Set in .env file or environment:\n"); + printf(" AWS_MRJUNEJUNE_ACCESS_KEY / AWS_ACCESS_KEY_ID\n"); + printf(" AWS_MRJUNEJUNE_SECRET_ACCESS_KEY / AWS_SECRET_ACCESS_KEY\n"); + return 1; + } + + S3_Config config = { + .access_key_id = access_key, + .secret_access_key = secret_key, + .region = "us-west-2", + .bucket = "mrjunejune", + .endpoint = NULL, + .use_path_style = FALSE + }; + + printf("S3 Uploader Test\n"); + printf("================\n"); + printf("Region: %s\n", config.region); + printf("Bucket: %s\n", config.bucket); + printf("\n"); + + // Test 1: Upload string data + printf("Test 1: Uploading string data...\n"); + const char *test_data = "Hello from S3 uploader test!"; + S3_Result result = S3_Upload_Data(&config, + (const uint8 *)test_data, + strlen(test_data), + "test/hello.txt", + "text/plain"); + if (result.success) + { + printf(" SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)"); + } + else + { + printf(" FAILED! Status: %d, Error: %s\n", + result.status_code, + result.error_message ? result.error_message : "(unknown)"); + } + S3_Result_Destroy(&result); + + // Test 2: Content type detection + printf("\nTest 2: Content type detection...\n"); + printf(" .html -> %s\n", S3_Guess_Content_Type("page.html")); + printf(" .png -> %s\n", S3_Guess_Content_Type("image.png")); + printf(" .json -> %s\n", S3_Guess_Content_Type("data.json")); + printf(" .xyz -> %s\n", S3_Guess_Content_Type("unknown.xyz")); + + // Test 3: Generate presigned PUT URL + printf("\nTest 3: Generating presigned PUT URL...\n"); + S3_Presigned_URL put_url = S3_Presign_Put(&config, + "uploads/client-upload.png", + "image/png", + 3600); // 1 hour + if (put_url.success) + { + printf(" SUCCESS!\n"); + printf(" URL: %s\n", put_url.url); + printf("\n Client can upload with:\n"); + printf(" curl -X PUT -H \"Content-Type: image/png\" --data-binary @file.png \"$URL\"\n"); + } + else + { + printf(" FAILED! Error: %s\n", put_url.error_message ? put_url.error_message : "(unknown)"); + } + S3_Presigned_URL_Destroy(&put_url); + + // Test 4: Generate presigned GET URL + printf("\nTest 4: Generating presigned GET URL...\n"); + S3_Presigned_URL get_url = S3_Presign_Get(&config, + "uploads/client-upload.png", + 3600); // 1 hour + if (get_url.success) + { + printf(" SUCCESS!\n"); + printf(" URL: %s\n", get_url.url); + printf("\n Client can download with:\n"); + printf(" curl \"$URL\" -o file.png\n"); + } + else + { + printf(" FAILED! Error: %s\n", get_url.error_message ? get_url.error_message : "(unknown)"); + } + S3_Presigned_URL_Destroy(&get_url); + + // Test 5: Upload file (if provided as argument) + if (argc >= 3) + { + printf("\nTest 5: Uploading file...\n"); + printf(" Local: %s\n", argv[1]); + printf(" S3 Key: %s\n", argv[2]); + + result = S3_Upload_File(&config, argv[1], argv[2]); + if (result.success) + { + printf(" SUCCESS! ETag: %s\n", result.etag ? result.etag : "(none)"); + } + else + { + printf(" FAILED! Status: %d, Error: %s\n", + result.status_code, + result.error_message ? result.error_message : "(unknown)"); + } + S3_Result_Destroy(&result); + } + else + { + printf("\nTest 5: Skipped (no file path provided)\n"); + printf(" Usage: %s <local_file> <s3_key>\n", argv[0]); + } + + printf("\nDone!\n"); + + free(access_key); + free(secret_key); + return 0; +}
--- a/seobeo/s_http_client.c Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/s_http_client.c Sun Aug 02 16:50:48 2026 -0700 @@ -1,5 +1,46 @@ #include "seobeo/seobeo.h" #include <ctype.h> +#include <time.h> + +static int64_t Seobeo_Client_Monotonic_Milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static boolean Seobeo_Client_Read_Timed_Out(int32 timeout_ms, int64_t last_progress_ms) +{ + return timeout_ms > 0 && + Seobeo_Client_Monotonic_Milliseconds() - last_progress_ms >= timeout_ms; +} + +static size_t Seobeo_Client_Find_Header_Length(const uint8 *buffer, size_t length) +{ + if (!buffer || length < 4) + return 0; + for (size_t i = 0; i + 3 < length; i++) + { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') + return i + 4; + } + return 0; +} + +static const char *Seobeo_Client_Header_Value( + Seobeo_Request_Entry *headers, + const char *name) +{ + if (!headers || !name) + return NULL; + for (size_t i = 0; i < Dowa_Array_Length(headers); i++) + { + if (headers[i].key && strcasecmp(headers[i].key, name) == 0) + return headers[i].value; + } + return NULL; +} static void Seobeo_Client_Parse_Url(const char *url, char **p_host, char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena) @@ -87,6 +128,7 @@ p_req->follow_redirects = FALSE; p_req->max_redirects = 10; + p_req->timeout_ms = 0; return p_req; } @@ -149,6 +191,13 @@ p_req->max_redirects = max_redirects > 0 ? max_redirects : 10; } +void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms) +{ + if (!p_req) + return; + p_req->timeout_ms = timeout_ms > 0 ? timeout_ms : 0; +} + void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path) { if (!p_req || !path) @@ -223,7 +272,10 @@ return offset; } -static Seobeo_Client_Response *Seobeo_Client_Parse_Response(Seobeo_Handle *p_handle, const char *download_path) +static Seobeo_Client_Response *Seobeo_Client_Parse_Response( + Seobeo_Handle *p_handle, + const char *download_path, + int32 timeout_ms) { Seobeo_Client_Response *p_resp = malloc(sizeof(Seobeo_Client_Response)); if (!p_resp) @@ -238,27 +290,49 @@ return NULL; } + int64_t last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); while (TRUE) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0) - return p_resp; if (r == -2) break; + if (r < 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); - if (p_handle->read_buffer_len >= 4 && strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL) + if (Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len) > 0) break; if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response header timed out\n"); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } - char *buf = (char*)p_handle->read_buffer; - char *hdr_end = strstr(buf, "\r\n\r\n"); - if (!hdr_end) - return p_resp; + size_t hdr_len = Seobeo_Client_Find_Header_Length( + p_handle->read_buffer, p_handle->read_buffer_len); + if (hdr_len == 0) + { + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } - size_t hdr_len = hdr_end - buf + 4; + char *buf = Dowa_Arena_Allocate(p_resp->p_arena, hdr_len + 1); + memcpy(buf, p_handle->read_buffer, hdr_len); + buf[hdr_len] = '\0'; + char *hdr_end = buf + hdr_len - 4; char version[16]; int status_code; @@ -328,12 +402,12 @@ Seobeo_Handle_Consume(p_handle, (uint32)hdr_len); - void *p_cl_kv = Dowa_HashMap_Get_Ptr(p_resp->headers, "Content-Length"); size_t body_len = 0; - if (p_cl_kv) + const char *content_length = Seobeo_Client_Header_Value( + p_resp->headers, "Content-Length"); + if (content_length) { - const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value; - body_len = atoi(content_length_str); + body_len = (size_t)strtoull(content_length, NULL, 10); } FILE *p_file = NULL; @@ -366,15 +440,32 @@ total_read += to_copy; Seobeo_Handle_Consume(p_handle, (uint32)to_copy); + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); } if (total_read < body_len) { int r = Seobeo_Handle_Read(p_handle); - if (r < 0 || r == -2) - break; + if (r == -2 || r < 0) + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + if (r > 0) + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (r == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } } } @@ -397,9 +488,12 @@ while (1) { - int n = Seobeo_Handle_Read(p_handle); + int n = p_handle->read_buffer_len > 0 + ? (int)p_handle->read_buffer_len + : Seobeo_Handle_Read(p_handle); if (n > 0) { + last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); if (download_path) { fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file); @@ -420,9 +514,23 @@ else if (n == -2) break; else if (n == 0) + { + if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms)) + { + Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n"); + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + usleep(1000); continue; + } else - break; + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } } if (!download_path) @@ -468,7 +576,8 @@ return NULL; } - Seobeo_Client_Response *p_resp = Seobeo_Client_Parse_Response(p_handle, p_req->download_path); + Seobeo_Client_Response *p_resp = + Seobeo_Client_Parse_Response(p_handle, p_req->download_path, p_req->timeout_ms); Seobeo_Handle_Destroy(p_handle);
--- a/seobeo/s_network.c Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/s_network.c Sun Aug 02 16:50:48 2026 -0700 @@ -1,8 +1,37 @@ #include "seobeo/seobeo.h" +static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT; + +static void Seobeo_Process_Ignore_Sigpipe(void) +{ + signal(SIGPIPE, SIG_IGN); +} + +static void Seobeo_Socket_Disable_Sigpipe(int socket_fd) +{ +#ifdef SO_NOSIGPIPE + int enabled = 1; + setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#else + (void)socket_fd; +#endif +} + +static ssize_t Seobeo_Socket_Write( + int socket_fd, + const void *buffer, + size_t length) +{ +#ifdef MSG_NOSIGNAL + return send(socket_fd, buffer, length, MSG_NOSIGNAL); +#else + return send(socket_fd, buffer, length, 0); +#endif +} Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host, const char* port) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; struct addrinfo hints, *server_infos, *free_server_info; int32 socket_fd, yes = 1; // Need this for setsockopt @@ -26,6 +55,7 @@ if((socket_fd = socket(free_server_info->ai_family, free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1) { perror("socket"); continue; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) { perror("setsockopt SO_REUSEADDR"); continue; } @@ -81,6 +111,7 @@ Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host, const char* port, boolean use_tls) { + pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; p_handle = malloc(sizeof(*p_handle)); @@ -98,6 +129,7 @@ if((socket_fd = socket(server_infos->ai_family, server_infos->ai_socktype, server_infos->ai_protocol)) == -1) { perror("socket"); return NULL; } + Seobeo_Socket_Disable_Sigpipe(socket_fd); if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0) { perror("connect"); return NULL; } @@ -154,6 +186,7 @@ Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr), client_inet_addr, sizeof client_inet_addr); if (client_fd == -1) return NULL; + Seobeo_Socket_Disable_Sigpipe(client_fd); // Set non blocking... int flags = fcntl(client_fd, F_GETFL, 0); @@ -236,7 +269,7 @@ }else { Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket); - ssize_t n = write( + ssize_t n = Seobeo_Socket_Write( p_handle->socket, p_handle->write_buffer + sent, total - sent @@ -268,9 +301,19 @@ uint32 offset = 0; while (offset < data_size) { - ssize_t n = write(p_handle->socket, - data + offset, - data_size - offset); + ssize_t n; + if (p_handle->ssl) + { + n = Seobeo_SSL_Write(p_handle, data + offset, data_size - offset); + } + else + { + n = Seobeo_Socket_Write( + p_handle->socket, + data + offset, + data_size - offset); + } + if (n==0) { // DEBUG
--- a/seobeo/s_web.c Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/s_web.c Sun Aug 02 16:50:48 2026 -0700 @@ -51,6 +51,7 @@ { case HTTP_OK: status_text = "OK"; break; case HTTP_CREATED: status_text = "Created"; break; + case HTTP_NO_CONTENT: status_text = "No Content"; break; case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break; case HTTP_FOUND: status_text = "Found"; break; case HTTP_BAD_REQUEST: status_text = "Bad Request"; break; @@ -58,11 +59,13 @@ case HTTP_FORBIDDEN: status_text = "Forbidden"; break; case HTTP_NOT_FOUND: status_text = "Not Found"; break; case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break; + case 502: status_text = "Bad Gateway"; break; + case 504: status_text = "Gateway Timeout"; break; default: status_text = "Unknown"; break; } - sprintf( - buffer, + snprintf( + (char*)buffer, 1024, "HTTP/1.1 %d %s\r\n" "Content-Type: %s\r\n" "Content-Length: %d\r\n" @@ -774,6 +777,11 @@ Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE); } +static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value) +{ + return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL; +} + void Seobeo_Router_Send_Response_KeepAlive( Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, @@ -819,26 +827,63 @@ else body_length = strlen(body); - char *header = Dowa_Arena_Allocate(p_arena, 4096); - Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_capacity = 1024; for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; if ( - strstr(p_response_map[i].key, "status") || - strstr(p_response_map[i].key, "body") || - strstr(p_response_map[i].key, "content-type") || - strstr(p_response_map[i].key, "content-length") + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 + ) + continue; + if (Seobeo_Response_Header_Value_Is_Safe(key) && + Seobeo_Response_Header_Value_Is_Safe(value)) + header_capacity += strlen(key) + strlen(value) + 4; + } + + char *header = Dowa_Arena_Allocate(p_arena, header_capacity); + Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive); + size_t header_length = strlen(header); + if (header_length < 2) + return; + header_length -= 2; + + for (int i = 0; i < Dowa_Array_Length(p_response_map); i++) + { + const char *key = p_response_map[i].key; + const char *value = p_response_map[i].value; + if ( + strcasecmp(key, "status") == 0 || + strcasecmp(key, "body") == 0 || + strcasecmp(key, "content-type") == 0 || + strcasecmp(key, "content-length") == 0 ) continue; - int32 current_header_len = strlen(header); - char *temp = malloc(sizeof(char) * 1024); - sprintf(temp, "%s: %s\r\n\r\n", p_response_map[i].key, p_response_map[i].value); - memcpy(&header[current_header_len - 2 /* \r\n */], temp, strlen(temp)); - free(temp); + if (!Seobeo_Response_Header_Value_Is_Safe(key) || + !Seobeo_Response_Header_Value_Is_Safe(value)) + { + Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n"); + continue; + } + + int written = snprintf( + header + header_length, + header_capacity - header_length, + "%s: %s\r\n", + key, + value); + if (written < 0 || (size_t)written >= header_capacity - header_length) + { + Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n"); + return; + } + header_length += (size_t)written; } - - printf("hEADER %s\n", header); + memcpy(header + header_length, "\r\n", 3); Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header)); Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
--- a/seobeo/seobeo.h Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/seobeo.h Sun Aug 02 16:50:48 2026 -0700 @@ -30,6 +30,7 @@ // HTTP STATUS CODE #define HTTP_OK 200 #define HTTP_CREATED 201 +#define HTTP_NO_CONTENT 204 #define HTTP_MOVED_PERMANENTLY 301 #define HTTP_FOUND 302 #define HTTP_BAD_REQUEST 400 @@ -82,6 +83,8 @@ extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); /* Enable/disable following redirects with max redirect count. Default is FALSE with 10 max. */ extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +/* Set the maximum idle time for HTTP response reads. Zero disables the timeout. */ +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); /* Set download path to save response body to file instead of memory. */ extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); /* Execute the HTTP request and return response. */
--- a/seobeo/seobeo_internal.h Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/seobeo_internal.h Sun Aug 02 16:50:48 2026 -0700 @@ -131,6 +131,7 @@ boolean follow_redirects; int32 max_redirects; + int32 timeout_ms; char *download_path; @@ -158,6 +159,7 @@ extern void Seobeo_Client_Request_Add_Header_Array(Seobeo_Client_Request *p_req, const char *header); extern void Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length); extern void Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects); +extern void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms); extern void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path); extern Seobeo_Client_Response *Seobeo_Client_Request_Execute(Seobeo_Client_Request *p_req); extern void Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req);
--- a/seobeo/tests/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ b/seobeo/tests/BUILD Sun Aug 02 16:50:48 2026 -0700 @@ -28,3 +28,48 @@ args = ["$(location //seobeo/examples:websocket_server_example)"], visibility = ["//visibility:public"], ) + +cc_test( + name = "seobeo_response_test", + srcs = ["seobeo_response_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_sigpipe_test", + srcs = ["seobeo_sigpipe_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_timeout_test", + srcs = ["seobeo_http_timeout_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_framing_test", + srcs = ["seobeo_http_framing_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +) + +cc_test( + name = "seobeo_http_content_length_test", + srcs = ["seobeo_http_content_length_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_content_length_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,91 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Content_Length_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_lowercase_content_length(void *argument) +{ + Content_Length_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "content-length: 4\r\n" + "Connection: keep-alive\r\n" + "\r\n" + "done"; + (void)write(client, response, sizeof(response) - 1); + usleep(500000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Content_Length_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_lowercase_content_length, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = !response || + response->body_length != 4 || + memcmp(response->body, "done", 4) != 0 || + elapsed > 300; + if (failed) + fprintf(stderr, "Case-insensitive Content-Length was not honored (%lldms)\n", + (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_framing_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,79 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> + +typedef struct { + int listener; +} Framing_Server; + +static void *serve_close_delimited_response(void *argument) +{ + Framing_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + const char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Connection: close\r\n" + "\r\n" + "buffered-body"; + (void)write(client, response, sizeof(response) - 1); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Framing_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_close_delimited_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + + int failed = !response || + response->status_code != 200 || + response->body_length != strlen("buffered-body") || + memcmp(response->body, "buffered-body", strlen("buffered-body")) != 0; + if (failed) + fprintf(stderr, "Close-delimited response body was not preserved\n"); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_http_timeout_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,82 @@ +#include "seobeo/seobeo.h" + +#include <arpa/inet.h> +#include <pthread.h> +#include <stdio.h> +#include <string.h> +#include <time.h> + +typedef struct { + int listener; +} Timeout_Server; + +static int64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static void *serve_stalled_response(void *argument) +{ + Timeout_Server *server = argument; + int client = accept(server->listener, NULL, NULL); + if (client >= 0) { + char request[1024]; + (void)read(client, request, sizeof(request)); + usleep(250000); + close(client); + } + return NULL; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) + return 1; + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 || + listen(listener, 1) != 0) { + close(listener); + return 1; + } + + socklen_t address_length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) { + close(listener); + return 1; + } + + Timeout_Server server = {.listener = listener}; + pthread_t thread; + if (pthread_create(&thread, NULL, serve_stalled_response, &server) != 0) { + close(listener); + return 1; + } + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 50); + + int64_t started = monotonic_milliseconds(); + Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); + int64_t elapsed = monotonic_milliseconds() - started; + + int failed = response != NULL || elapsed < 40 || elapsed > 2000; + if (failed) + fprintf(stderr, "Expected a bounded timeout; response=%p elapsed=%lldms\n", + (void *)response, (long long)elapsed); + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); + pthread_join(thread, NULL); + close(listener); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_response_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,79 @@ +#include "seobeo/seobeo.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> + +int main(void) +{ + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + + Dowa_Arena *arena = Dowa_Arena_Create(4096); + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "hello", arena); + Dowa_HashMap_Push_Arena(response, "X-Test", "present", arena); + Dowa_HashMap_Push_Arena(response, "X-Unsafe", "bad\r\nInjected: yes", arena); + + Seobeo_Router_Send_Response(&handle, response, arena); + + char received[4096] = {0}; + ssize_t length = read(sockets[1], received, sizeof(received) - 1); + int failed = 0; + if (length <= 0) + failed = 1; + if (!strstr(received, "Content-Length: 5\r\n")) + failed = 1; + if (!strstr(received, "X-Test: present\r\n\r\nhello")) + failed = 1; + if (strstr(received, "Injected: yes")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + memset(&handle, 0, sizeof(handle)); + handle.socket = sockets[0]; + handle.write_buffer_capacity = 4096; + handle.write_buffer = malloc(handle.write_buffer_capacity); + arena = Dowa_Arena_Create(4096); + response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "204", arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "", arena); + Seobeo_Router_Send_Response(&handle, response, arena); + + memset(received, 0, sizeof(received)); + length = read(sockets[1], received, sizeof(received) - 1); + if (length <= 0 || + !strstr(received, "HTTP/1.1 204 No Content\r\n") || + !strstr(received, "Content-Length: 0\r\n") || + !strstr(received, "\r\n\r\n")) + failed = 1; + + if (failed) + fprintf(stderr, "Unexpected no-content response:\n%s\n", received); + + close(sockets[0]); + close(sockets[1]); + free(handle.write_buffer); + Dowa_Arena_Free(arena); + return failed; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_sigpipe_test.c Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,36 @@ +#include "seobeo/seobeo.h" + +#include <signal.h> +#include <stdlib.h> +#include <sys/socket.h> + +int main(void) +{ + Seobeo_Handle *server = + Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0"); + if (!server) + return 1; + + raise(SIGPIPE); + Seobeo_Handle_Destroy(server); + + int sockets[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) + return 1; + + Seobeo_Handle handle = {0}; + handle.socket = sockets[0]; + handle.write_buffer_capacity = 64; + handle.write_buffer = malloc(handle.write_buffer_capacity); + if (!handle.write_buffer) + return 1; + + close(sockets[1]); + if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0) + return 1; + + int result = Seobeo_Handle_Flush(&handle); + close(sockets[0]); + free(handle.write_buffer); + return result < 0 ? 0 : 1; +}
--- a/third_party/README.md Sun Jan 25 10:44:04 2026 -0800 +++ b/third_party/README.md Sun Aug 02 16:50:48 2026 -0700 @@ -8,7 +8,6 @@ |-----------|-------------| | `bun/` | Bun JavaScript runtime | | `emsdk/` | Emscripten SDK for WASM compilation | -| `ffmpeg/` | FFmpeg CLI wrapper for media processing | | `highlight/` | highlight.js for syntax highlighting | | `libuv/` | Async I/O library | | `luajit/` | LuaJIT interpreter | @@ -24,5 +23,4 @@ deps = ["//third_party/sqlite3:sqlite3"] deps = ["//third_party/raylib:raylib"] deps = ["//third_party/libuv:libuv"] -deps = ["//third_party/ffmpeg:ffmpeg_cli"] ```
--- a/third_party/ffmpeg/BUILD Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,40 +0,0 @@ -# Assumes ffmpeg is installed on the system (apt install ffmpeg / brew install ffmpeg) -load("@rules_cc//cc:cc_library.bzl", "cc_library") -load("@rules_cc//cc:cc_test.bzl", "cc_test") - -cc_library( - name = "ffmpeg", - hdrs = ["ffmpeg.h"], - srcs = ["ffmpeg_wrapper.c"], - linkopts = select({ - "@platforms//os:linux": [ - "-lavcodec", - "-lavformat", - "-lavutil", - "-lswscale", - "-lswresample", - ], - "@platforms//os:macos": [ - "-lavcodec", - "-lavformat", - "-lavutil", - "-lswscale", - "-lswresample", - ], - "//conditions:default": [], - }), - copts = select({ - "@platforms//os:linux": ["-I/usr/include/ffmpeg"], - "@platforms//os:macos": ["-I/opt/homebrew/include", "-I/usr/local/include"], - "//conditions:default": [], - }), - visibility = ["//visibility:public"], -) - -# For projects that just need to shell out to ffmpeg CLI -cc_library( - name = "ffmpeg_cli", - hdrs = ["ffmpeg_cli.h"], - srcs = ["ffmpeg_cli.c"], - visibility = ["//visibility:public"], -)
--- a/third_party/ffmpeg/README.md Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,75 +0,0 @@ -# ffmpeg - -FFmpeg wrapper for Bazel projects. - -## Prerequisites - -FFmpeg must be installed on the system: - -```bash -# Ubuntu/Debian -sudo apt install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev - -# macOS -brew install ffmpeg - -# Arch Linux -sudo pacman -S ffmpeg -``` - -## Libraries - -### ffmpeg_cli - -Shells out to the `ffmpeg` and `ffprobe` command-line tools. This is the recommended approach for most projects as it's simpler and doesn't require linking against FFmpeg libraries. - -```starlark -deps = ["//third_party/ffmpeg:ffmpeg_cli"] -``` - -```c -#include "third_party/ffmpeg/ffmpeg_cli.h" - -// Convert image to WebP -ImageConvertOpts opts = ffmpeg_image_opts_default(); -opts.quality = 85; -ffmpeg_image_to_webp("input.jpg", "output.webp", &opts); - -// Convert video to HLS -HlsConvertOpts hls_opts = ffmpeg_hls_opts_default(); -ffmpeg_video_to_hls("input.mp4", "./output", "video", &hls_opts); -``` - -### ffmpeg (library) - -Links against FFmpeg libraries directly. Use this when you need low-level access to FFmpeg's encoding/decoding APIs. - -```starlark -deps = ["//third_party/ffmpeg:ffmpeg"] -``` - -## API - -### Image Conversion - -```c -FfmpegResult ffmpeg_image_to_webp(const char* input, const char* output, ImageConvertOpts* opts); -``` - -### Video to HLS - -```c -FfmpegResult ffmpeg_video_to_hls(const char* input, const char* output_dir, const char* name, HlsConvertOpts* opts); -``` - -### Video Thumbnail - -```c -FfmpegResult ffmpeg_video_thumbnail(const char* input, const char* output, int timestamp_seconds); -``` - -### Duration - -```c -FfmpegResult ffmpeg_get_duration(const char* input, double* duration_out); -```
--- a/third_party/ffmpeg/ffmpeg.h Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,18 +0,0 @@ -// FFmpeg library wrapper header -// This is a placeholder for projects that want to use libavcodec/libavformat directly -// Most projects should use ffmpeg_cli.h instead which shells out to the ffmpeg binary - -#ifndef FFMPEG_WRAPPER_H -#define FFMPEG_WRAPPER_H - -// Include FFmpeg headers when using the library directly -// Uncomment these when building with FFmpeg libraries linked -/* -#include <libavcodec/avcodec.h> -#include <libavformat/avformat.h> -#include <libavutil/avutil.h> -#include <libswscale/swscale.h> -#include <libswresample/swresample.h> -*/ - -#endif // FFMPEG_WRAPPER_H
--- a/third_party/ffmpeg/ffmpeg_cli.c Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,260 +0,0 @@ -#include "ffmpeg_cli.h" -#include <stdio.h> -#include <stdlib.h> -#include <string.h> -#include <stdarg.h> -#include <sys/stat.h> -#include <sys/wait.h> -#include <errno.h> - -#define MAX_CMD_LEN 4096 -#define MAX_ERROR_LEN 512 - -static char last_error[MAX_ERROR_LEN] = {0}; - -static void set_error(const char* fmt, ...) { - va_list args; - va_start(args, fmt); - vsnprintf(last_error, MAX_ERROR_LEN, fmt, args); - va_end(args); -} - -static bool file_exists(const char* path) { - struct stat st; - return stat(path, &st) == 0; -} - -static bool ensure_dir(const char* path) { - struct stat st; - if (stat(path, &st) == 0) { - return S_ISDIR(st.st_mode); - } - return mkdir(path, 0755) == 0; -} - -static int run_command(const char* cmd) { - int ret = system(cmd); - if (ret == -1) { - set_error("Failed to execute command: %s", strerror(errno)); - return -1; - } - return WEXITSTATUS(ret); -} - -ImageConvertOpts ffmpeg_image_opts_default(void) { - return (ImageConvertOpts){ - .quality = 80, - .max_width = 0, - .max_height = 0, - .preserve_aspect = true, - }; -} - -HlsConvertOpts ffmpeg_hls_opts_default(void) { - return (HlsConvertOpts){ - .segment_duration = 6, - .video_bitrate = 0, - .audio_bitrate = 128, - .max_width = 0, - .max_height = 0, - .generate_thumbnail = true, - }; -} - -bool ffmpeg_is_available(void) { - return system("ffmpeg -version > /dev/null 2>&1") == 0; -} - -const char* ffmpeg_last_error(void) { - return last_error; -} - -FfmpegResult ffmpeg_image_to_webp(const char* input_path, const char* output_path, ImageConvertOpts* opts) { - if (!input_path || !output_path) { - set_error("Invalid arguments: input_path and output_path required"); - return FFMPEG_ERR_INVALID_ARGS; - } - - if (!file_exists(input_path)) { - set_error("Input file not found: %s", input_path); - return FFMPEG_ERR_INPUT_NOT_FOUND; - } - - ImageConvertOpts default_opts = ffmpeg_image_opts_default(); - if (!opts) opts = &default_opts; - - char cmd[MAX_CMD_LEN]; - char scale_filter[256] = ""; - - // Build scale filter if resizing - if (opts->max_width > 0 || opts->max_height > 0) { - int w = opts->max_width > 0 ? opts->max_width : -1; - int h = opts->max_height > 0 ? opts->max_height : -1; - - if (opts->preserve_aspect) { - if (w > 0 && h > 0) { - snprintf(scale_filter, sizeof(scale_filter), - "-vf \"scale='min(%d,iw)':min'(%d,ih)':force_original_aspect_ratio=decrease\"", w, h); - } else if (w > 0) { - snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale='min(%d,iw)':-1\"", w); - } else { - snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=-1:'min(%d,ih)'\"", h); - } - } else { - snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=%d:%d\"", w, h); - } - } - - snprintf(cmd, sizeof(cmd), - "ffmpeg -y -i \"%s\" %s -quality %d \"%s\" 2>/dev/null", - input_path, scale_filter, opts->quality, output_path); - - int ret = run_command(cmd); - if (ret != 0) { - set_error("FFmpeg conversion failed with code %d", ret); - return FFMPEG_ERR_PROCESS_FAILED; - } - - if (!file_exists(output_path)) { - set_error("Output file was not created"); - return FFMPEG_ERR_OUTPUT_FAILED; - } - - return FFMPEG_OK; -} - -FfmpegResult ffmpeg_video_to_hls(const char* input_path, const char* output_dir, const char* name, HlsConvertOpts* opts) { - if (!input_path || !output_dir || !name) { - set_error("Invalid arguments: input_path, output_dir, and name required"); - return FFMPEG_ERR_INVALID_ARGS; - } - - if (!file_exists(input_path)) { - set_error("Input file not found: %s", input_path); - return FFMPEG_ERR_INPUT_NOT_FOUND; - } - - if (!ensure_dir(output_dir)) { - set_error("Failed to create output directory: %s", output_dir); - return FFMPEG_ERR_OUTPUT_FAILED; - } - - HlsConvertOpts default_opts = ffmpeg_hls_opts_default(); - if (!opts) opts = &default_opts; - - char cmd[MAX_CMD_LEN]; - char scale_filter[256] = ""; - char video_bitrate[64] = ""; - char audio_bitrate[64] = ""; - - // Build scale filter if resizing - if (opts->max_width > 0 || opts->max_height > 0) { - int w = opts->max_width > 0 ? opts->max_width : -2; - int h = opts->max_height > 0 ? opts->max_height : -2; - snprintf(scale_filter, sizeof(scale_filter), "-vf \"scale=%d:%d\"", w, h); - } - - // Video bitrate - if (opts->video_bitrate > 0) { - snprintf(video_bitrate, sizeof(video_bitrate), "-b:v %dk", opts->video_bitrate); - } - - // Audio bitrate - snprintf(audio_bitrate, sizeof(audio_bitrate), "-b:a %dk", - opts->audio_bitrate > 0 ? opts->audio_bitrate : 128); - - char playlist_path[1024]; - char segment_pattern[1024]; - snprintf(playlist_path, sizeof(playlist_path), "%s/%s.m3u8", output_dir, name); - snprintf(segment_pattern, sizeof(segment_pattern), "%s/%s_%%03d.ts", output_dir, name); - - // Main HLS conversion command - snprintf(cmd, sizeof(cmd), - "ffmpeg -y -i \"%s\" " - "-c:v libx264 -preset fast -crf 22 %s %s " - "-c:a aac %s " - "-f hls " - "-hls_time %d " - "-hls_list_size 0 " - "-hls_segment_filename \"%s\" " - "\"%s\" 2>/dev/null", - input_path, - scale_filter, video_bitrate, - audio_bitrate, - opts->segment_duration, - segment_pattern, - playlist_path); - - int ret = run_command(cmd); - if (ret != 0) { - set_error("FFmpeg HLS conversion failed with code %d", ret); - return FFMPEG_ERR_PROCESS_FAILED; - } - - // Generate thumbnail if requested - if (opts->generate_thumbnail) { - char thumb_path[1024]; - snprintf(thumb_path, sizeof(thumb_path), "%s/%s_thumb.jpg", output_dir, name); - ffmpeg_video_thumbnail(input_path, thumb_path, 1); - } - - return FFMPEG_OK; -} - -FfmpegResult ffmpeg_video_thumbnail(const char* input_path, const char* output_path, int timestamp_seconds) { - if (!input_path || !output_path) { - set_error("Invalid arguments: input_path and output_path required"); - return FFMPEG_ERR_INVALID_ARGS; - } - - if (!file_exists(input_path)) { - set_error("Input file not found: %s", input_path); - return FFMPEG_ERR_INPUT_NOT_FOUND; - } - - char cmd[MAX_CMD_LEN]; - snprintf(cmd, sizeof(cmd), - "ffmpeg -y -i \"%s\" -ss %d -vframes 1 -q:v 2 \"%s\" 2>/dev/null", - input_path, timestamp_seconds, output_path); - - int ret = run_command(cmd); - if (ret != 0) { - set_error("FFmpeg thumbnail generation failed with code %d", ret); - return FFMPEG_ERR_PROCESS_FAILED; - } - - return FFMPEG_OK; -} - -FfmpegResult ffmpeg_get_duration(const char* input_path, double* duration_out) { - if (!input_path || !duration_out) { - set_error("Invalid arguments"); - return FFMPEG_ERR_INVALID_ARGS; - } - - if (!file_exists(input_path)) { - set_error("Input file not found: %s", input_path); - return FFMPEG_ERR_INPUT_NOT_FOUND; - } - - char cmd[MAX_CMD_LEN]; - snprintf(cmd, sizeof(cmd), - "ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"%s\" 2>/dev/null", - input_path); - - FILE* fp = popen(cmd, "r"); - if (!fp) { - set_error("Failed to run ffprobe"); - return FFMPEG_ERR_PROCESS_FAILED; - } - - char output[64]; - if (fgets(output, sizeof(output), fp) != NULL) { - *duration_out = atof(output); - } else { - *duration_out = 0; - } - - pclose(fp); - return FFMPEG_OK; -}
--- a/third_party/ffmpeg/ffmpeg_cli.h Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,57 +0,0 @@ -#ifndef FFMPEG_CLI_H -#define FFMPEG_CLI_H - -#include <stdbool.h> - -// Result codes -typedef enum { - FFMPEG_OK = 0, - FFMPEG_ERR_INPUT_NOT_FOUND = 1, - FFMPEG_ERR_OUTPUT_FAILED = 2, - FFMPEG_ERR_INVALID_ARGS = 3, - FFMPEG_ERR_PROCESS_FAILED = 4, -} FfmpegResult; - -// Image conversion options -typedef struct { - int quality; // 0-100, default 80 - int max_width; // 0 = no resize - int max_height; // 0 = no resize - bool preserve_aspect; -} ImageConvertOpts; - -// Video conversion options for HLS -typedef struct { - int segment_duration; // seconds, default 6 - int video_bitrate; // kbps, 0 = auto - int audio_bitrate; // kbps, 0 = 128 - int max_width; // 0 = no resize - int max_height; // 0 = no resize - bool generate_thumbnail; -} HlsConvertOpts; - -// Initialize default options -ImageConvertOpts ffmpeg_image_opts_default(void); -HlsConvertOpts ffmpeg_hls_opts_default(void); - -// Convert image to WebP format -// output_path should end with .webp -FfmpegResult ffmpeg_image_to_webp(const char* input_path, const char* output_path, ImageConvertOpts* opts); - -// Convert video to HLS format -// output_dir is the directory where .m3u8 and .ts files will be created -FfmpegResult ffmpeg_video_to_hls(const char* input_path, const char* output_dir, const char* name, HlsConvertOpts* opts); - -// Generate video thumbnail -FfmpegResult ffmpeg_video_thumbnail(const char* input_path, const char* output_path, int timestamp_seconds); - -// Get media duration in seconds (works for both audio and video) -FfmpegResult ffmpeg_get_duration(const char* input_path, double* duration_out); - -// Check if ffmpeg is available on the system -bool ffmpeg_is_available(void); - -// Get last error message -const char* ffmpeg_last_error(void); - -#endif // FFMPEG_CLI_H
--- a/third_party/ffmpeg/ffmpeg_wrapper.c Sun Jan 25 10:44:04 2026 -0800 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,7 +0,0 @@ -// FFmpeg library wrapper -// Placeholder implementation for direct FFmpeg library usage -// Most projects should use ffmpeg_cli.c instead - -#include "ffmpeg.h" - -// Add FFmpeg library wrapper functions here when needed