Mercurial
changeset 219:8c9bb0b0759e hg-web
[hg-web] Merge current Zenbu baseline
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 08:34:54 -0700 |
| parents | 921ca3086879 (current diff) 4c725fde6999 (diff) |
| children | ce7f4400c2de |
| files | |
| diffstat | 76 files changed, 5183 insertions(+), 99 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.env.development Sun Aug 02 08:34:54 2026 -0700 @@ -0,0 +1,2 @@ +AWS_MRJUNEJUNE_ACCESS_KEY=FILL +AWS_MRJUNEJUNE_SECRET_ACCESS_KEY=FILL
--- a/.hgignore Sun Aug 02 08:34:28 2026 -0700 +++ b/.hgignore Sun Aug 02 08:34:54 2026 -0700 @@ -40,3 +40,10 @@ # OS related .DS_Store + +# Environment var. +.env +# Server config +.config +# DB +mrjunejune.db
--- a/BUILD Sun Aug 02 08:34:28 2026 -0700 +++ b/BUILD Sun Aug 02 08:34:54 2026 -0700 @@ -0,0 +1,7 @@ +exports_files([".env"]) + +filegroup( + name = "env_file", + srcs = [".env"], + visibility = ["//visibility:public"], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/assets/BUILD Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/bun-web-standard.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/bun.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/elysia.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/express.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/h3.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/hono.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/nbit.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/nhttp.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/oak.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/bun/wobe.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/c/seobeo.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/express.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/fastify.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/h3.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/hono.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/hyper-express.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/koa.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/ultimate-express.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/node/uws.txt Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/benchmark/bun-http-framework-benchmark/results/results.md Sun Aug 02 08:34:54 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
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/.config.development Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/BUILD Sun Aug 02 08:34:54 2026 -0700 @@ -28,6 +28,22 @@ 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/*"]), @@ -42,7 +58,7 @@ 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__"], ) @@ -51,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__"], ) @@ -63,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 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/deploy.sh Sun Aug 02 08:34:54 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 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/main.c Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/base.css Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/index.html Sun Aug 02 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/parts/base_head.html Sun Aug 02 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/resume/index.html Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/tools/index.html Sun Aug 02 08:34:54 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 Aug 02 08:34:28 2026 -0700 +++ b/mrjunejune/src/tools/markdown_to_html/index.html Sun Aug 02 08:34:54 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>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/BUILD Sun Aug 02 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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 08:34:54 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_network.c Sun Aug 02 08:34:28 2026 -0700 +++ b/seobeo/s_network.c Sun Aug 02 08:34:54 2026 -0700 @@ -268,9 +268,18 @@ 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 = write(p_handle->socket, + data + offset, + data_size - offset); + } + if (n==0) { // DEBUG