From 9ed25e2d5f716753a2499ebaba23d8e893e0c238 Mon Sep 17 00:00:00 2001 From: TheFozid Date: Mon, 3 Aug 2026 09:42:13 +0100 Subject: [PATCH] first build --- .env.example | 38 + .gitignore | 24 + Cargo.lock | 2772 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 56 + DESIGN.md | 428 ++++++ README.md | 114 ++ frontend/app.js | 777 +++++++++++ frontend/index.html | 215 +++ frontend/style.css | 463 +++++++ mapserver.service | 39 + migrations/0001_init.sql | 36 + nginx-snippet.conf | 36 + postgres.container | 38 + rust_push.sh | 275 ++++ src/auth.rs | 428 ++++++ src/config.rs | 137 ++ src/error.rs | 59 + src/gpx.rs | 265 ++++ src/mail.rs | 92 ++ src/main.rs | 167 +++ src/markers.rs | 235 ++++ src/ratelimit.rs | 144 ++ src/web.rs | 110 ++ 23 files changed, 6948 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DESIGN.md create mode 100644 README.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css create mode 100644 mapserver.service create mode 100644 migrations/0001_init.sql create mode 100644 nginx-snippet.conf create mode 100644 postgres.container create mode 100755 rust_push.sh create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/error.rs create mode 100644 src/gpx.rs create mode 100644 src/mail.rs create mode 100644 src/main.rs create mode 100644 src/markers.rs create mode 100644 src/ratelimit.rs create mode 100644 src/web.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..03b6842 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# .env.example +# ── core ────────────────────────────────────────────────────────────── +DATABASE_URL=postgres://mapserver:CHANGEME@127.0.0.1:5432/mapserver +BIND_ADDR=127.0.0.1:8080 +# Subpath nginx serves this under. nginx does NOT strip it. +# Leave empty (or "/") to serve from the domain root. +BASE_PATH=/maps +# Origin only, no trailing slash. BASE_PATH is appended for reset links. +PUBLIC_URL=https://your-domain.example + +# ── auth ────────────────────────────────────────────────────────────── +# Shared secret required at registration. There is no other admin lever. +REG_TOKEN=CHANGEME-long-random-string +SESSION_DURATION_DAYS=30 + +# ── map ─────────────────────────────────────────────────────────────── +# Served to the browser via GET /api/config, so switching providers (or to +# OpenTopoMap for hiking) is an edit and a restart, not a recompile. +# Use the apex host: the {s}.tile.openstreetmap.org sharded form is deprecated. +TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png + +# ── storage ─────────────────────────────────────────────────────────── +GPX_DIR=/home/mapserver/gpx + +# ── smtp ────────────────────────────────────────────────────────────── +# Leave SMTP_HOST empty to use the stub transport: reset links get logged +# at info level instead of emailed. The whole flow is testable that way. +SMTP_HOST= +SMTP_PORT=587 +# starttls (587) | implicit (465) | none (localhost/mailpit only) +SMTP_TLS=starttls +SMTP_USERNAME= +SMTP_PASSWORD= +# MUST be an address this SMTP account is allowed to send as, or the +# provider returns 550/553 at send time and it looks like an auth failure. +SMTP_FROM=mapserver@your-domain.example + +RUST_LOG=rs_maps=info,tower_http=warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d321c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# .gitignore + +# Build output. Several GB after a release build — never commit this. +/target/ + +# Secrets: REG_TOKEN, database password, SMTP credentials. +# .env.example is the committed template; .env itself never goes in. +.env +.env.local +.env.* +!.env.example + +# Release artifacts produced by rust_push.sh +*.tar.gz + +# Local GPX scratch, if you keep any in-tree while testing +/gpx/ + +# Editor / OS noise +*.swp +*~ +.DS_Store +.idea/ +.vscode/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7425c54 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2772 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.0", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", + "serde", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rs_maps" +version = "0.0.1" +dependencies = [ + "anyhow", + "argon2", + "axum", + "dotenvy", + "hex", + "lettre", + "mime_guess", + "password-hash", + "rand", + "rust-embed", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx", + "subtle", + "thiserror 2.0.19", + "time", + "tokio", + "tower-http", + "tower-sessions", + "tower-sessions-sqlx-store", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.19", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.19", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower-sessions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand", + "serde", + "serde_json", + "thiserror 2.0.19", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[package]] +name = "tower-sessions-sqlx-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3" +dependencies = [ + "async-trait", + "rmp-serde", + "sqlx", + "thiserror 1.0.69", + "time", + "tower-sessions-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9316221 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,56 @@ +# Cargo.toml +[package] +name = "rs_maps" +version = "0.0.1" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["multipart", "macros"] } +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["trace"] } + +# "tls-rustls" is an alias that selects aws-lc-rs. Naming ring explicitly keeps +# sqlx and lettre on one crypto provider rather than compiling both into the +# binary — which also avoids the ambiguity that makes rustls' install_default() +# panic when two providers are present. +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", "tls-rustls-ring", "postgres", "uuid", "time", "macros", "migrate", +] } + +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } + +argon2 = "0.5" +password-hash = { version = "0.5", features = ["std"] } +rand = "0.8" +sha2 = "0.10" +subtle = "2" +hex = "0.4" + +# 0.11.22 split rustls support three ways — transport, crypto provider and +# certificate roots must each be named or the build stops at a compile_error!. +# ring: no cmake/NASM build dependency, unlike aws-lc-rs. +# webpki-roots: bundled Mozilla roots, so SMTP TLS doesn't depend on the host +# trust store being populated. Swap to rustls-native-certs if the SMTP provider +# uses a private CA. +lettre = { version = "0.11", default-features = false, features = [ + "tokio1-rustls", "ring", "webpki-roots", "smtp-transport", "builder", "pool", +] } + +rust-embed = { version = "8", features = ["debug-embed"] } +mime_guess = "2" + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +time = { version = "0.3", features = ["serde", "formatting"] } +dotenvy = "0.15" +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..ca5f0e7 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,428 @@ + + +# Self-Hosted Mapping Server — Design Document + +*Revision 2. Incorporates design-review decisions on TLS/subpath deployment, rootless systemd, +upload limits, auth surface, and GPX collision handling.* + +## 1. Purpose + +A self-hosted replacement for Google Maps' "save places / plan routes" functionality, built as a +single Rust binary. It shows an OpenStreetMap-based map in the browser, lets multiple logged-in +users tag locations (privately or shared), shows live GPS position, and lets users view/import GPX +routes from a shared folder on the server. Route *creation* with road/trail snapping is an +explicitly deferred, later addition. + +Scale is two users (myself and my wife). Several decisions below are deliberately sized to that +fact and are called out where they'd need revisiting at larger scale. + +## 2. Non-goals / explicitly deferred + +- **GPX route creation with road/trail snapping.** Out of scope for v1. Will be added later as a + separate backend integration with a self-hosted router (BRouter is the leading candidate — light + weight, single Java process, good for hiking/cycling profiles). The API/DB design below should not + need rework to accommodate this later; it will likely add a `POST /api/routes/calculate` endpoint + that proxies to BRouter and writes the result into the shared GPX folder. + *Note:* BRouter is not storage-free — it requires pre-generated `.rd5` segment files at roughly + 100–200 MB per 5°×5° tile, so a country-sized area is a few GB. Small compared to the rejected + Nominatim/tile options, but budget for it. +- **Live turn-by-turn navigation.** Out of scope. The live-location feature is "show my current + position on the map," not routing guidance. +- **User/role administration.** No admin UI, no roles, no user deactivation flow. The only + "admin" lever is the registration token in `.env`. +- **Self-hosted tiles/geocoding.** Explicitly rejected earlier due to storage cost (full planet + Nominatim ≈1TB, tiles ≈120GB+). Tiles and search go live to OSM's own tile servers and Nominatim + from the *browser*, not proxied through the backend. +- **Session revocation on password change.** Decided against. Changing a password affects + subsequent logins only; existing sessions on other devices remain valid. See §8. +- **GPX file metadata (uploader, upload date).** Decided against. The folder is shared between two + trusted users; provenance has no value here. Filesystem only, no table. + +## 3. Architecture overview + +Three moving parts: + +1. **Rust binary** (built with `axum`), running as a **rootless systemd user unit**. Serves: + - The API (auth, account management, markers, GPX folder listing/serving/upload) + - The frontend static assets, embedded into the binary at compile time via `rust-embed` (no + separate web server / no separate static file deployment step) +2. **PostgreSQL**, running as a **rootless podman quadlet** container under the same user. Stores + users, sessions, and markers. +3. **A shared GPX folder** on the host filesystem, owned by the same user. Plain files, no database + involved. Shared across all users — anyone logged in can list, read, and upload into it. + +An **existing nginx reverse proxy** terminates TLS and forwards to the binary on +`127.0.0.1:8080`. The app is served under a **subpath** (e.g. `/maps`), configured via `BASE_PATH`. +See §9 for the prefix-handling contract, which is the fiddliest part of the deployment. + +The **browser** talks to three different things directly: +- OSM tile servers (map imagery) — direct, no backend involvement +- Nominatim (`nominatim.openstreetmap.org`) (POI/address search) — direct, no backend involvement +- The Rust backend, via nginx (auth, markers, GPX) — the only thing that's actually "yours" + +``` +┌────────────────────────────────────────────────────────┐ +│ OSM infrastructure (external) │ +│ Tile servers Nominatim │ +└───────────▲──────────────────▲───────────────────────────┘ + │ tile requests │ search requests + │ (direct) │ (direct, submit-on-enter) + ┌─────┴──────────────────┴─────┐ + │ Browser │ + │ Leaflet map · live-location │ + │ toggle · marker/tag UI · │ + │ GPX list & device file picker │ + └───────────────┬─────────────────┘ + │ HTTPS + ┌────────────────▼───────────────────┐ + │ nginx (existing, TLS terminated) │ + │ location /maps → 127.0.0.1:8080 │ + │ prefix NOT stripped │ + └────────────────┬───────────────────┘ + │ HTTP, loopback only + ┌────────────────▼───────────────────┐ + │ Rust binary (axum, systemd --user) │ + │ - auth (register/login/logout/ │ + │ reset) + account management │ + │ - markers API │ + │ - GPX folder list/read/upload │ + │ - serves embedded frontend assets │ + └───────┬───────────────────┬──────────┘ + │ │ + ┌──────────▼─────────┐ ┌─────▼──────────────┐ + │ Postgres (rootless │ │ Shared GPX folder │ + │ podman quadlet) │ │ (plain filesystem, │ + │ 127.0.0.1:5432 │ │ no DB, shared) │ + │ users, sessions, │ │ │ + │ markers │ │ │ + └───────────────────────┘ └──────────────────────┘ +``` + +## 4. Tech stack + +| Concern | Choice | Notes | +|---|---|---| +| Web framework | `axum` | async, tokio/hyper-based, mature ecosystem | +| DB access | `sqlx` (postgres) | features are `runtime-tokio` + `tls-rustls` (two separate features in 0.8, not one combined one). rustls keeps a musl static build painless | +| Sessions | `tower-sessions` + Postgres store | opaque server-side session IDs; store crate owns its own migration | +| Password hashing | `argon2` | current best practice | +| Rate limiting | `tower-governor` | applied to auth + account endpoints (§8) | +| Email (password reset) | `lettre` (SMTP transport) | stub/logging transport acceptable for the build session | +| Frontend asset embedding | `rust-embed` | note: reads from disk in debug builds unless `debug-embed` is enabled | +| Frontend | Plain Leaflet + vanilla JS | talks to OSM tiles/Nominatim directly, and to the API for everything else | +| Config | `.env` via `dotenvy` | see §7 | +| Migrations | `sqlx migrate` (SQL files) | run against Postgres on startup or via CLI | + +Pin actual crate versions at build time (`cargo add`, let it resolve) rather than trusting anything +written here. + +## 5. Database schema + +```sql +-- Required if the Postgres image predates 13; gen_random_uuid() is core from 13 onward. +-- CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + email TEXT, -- nullable: recovery channel only, see §8 + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reset_token_hash TEXT, -- SHA-256 of the emailed token, never the token itself + reset_token_expires TIMESTAMPTZ +); + +-- Case-insensitive uniqueness; multiple NULL emails permitted. +CREATE UNIQUE INDEX users_username_key ON users (lower(username)); +CREATE UNIQUE INDEX users_email_key ON users (lower(email)) WHERE email IS NOT NULL; + +-- markers (the "tags/flags" overlay) +CREATE TABLE markers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + category TEXT, -- free text in DB; fixed picker + "other" in the UI + color TEXT, -- optional marker color/icon hint + is_shared BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX markers_owner_idx ON markers (owner_id); +CREATE INDEX markers_shared_idx ON markers (id) WHERE is_shared; + +-- updated_at: DEFAULT now() fires on INSERT only, so it needs a trigger (or an explicit +-- assignment in every UPDATE statement — trigger is harder to forget). +CREATE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER markers_updated_at BEFORE UPDATE ON markers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- sessions table: created/managed by the tower-sessions Postgres store crate. +-- Do not hand-roll; let the crate's migration handle it. +``` + +GPX files: **no table**, by decision. The backend lists/reads/writes files in `GPX_DIR`. + +## 6. API surface + +All paths below are relative to `BASE_PATH` (§9). + +**Auth** +- `POST /api/register` — `{ username, email?, password, reg_token }`. `reg_token` compared against + `REG_TOKEN` in **constant time**. Password hashed with argon2. Email optional. +- `POST /api/login` — `{ username_or_email, password }`. Sets a long-lived session cookie. When the + user isn't found, still verify against a dummy argon2 hash so both paths take comparable time. +- `POST /api/logout` — destroys the session server-side, clears the cookie. +- `GET /api/me` — current user info if authenticated, 401 otherwise. Frontend uses this to decide + between login page and map. +- `POST /api/password-reset/request` — `{ email }`. Generates a high-entropy token, stores its + SHA-256 hash plus expiry, emails the link. Always returns success regardless of whether the + address exists. +- `POST /api/password-reset/confirm` — `{ token, new_password }`. Hashes the supplied token, + looks it up, checks expiry, updates the password hash, clears the token fields. + +**Account management** (require auth; both require the current password so a borrowed unlocked +device can't silently take over the account) +- `POST /api/me/password` — `{ current_password, new_password }`. Verify, rehash, update. Then call + `session.cycle_id()` to rotate the current session ID. No other sessions are touched (§8). +- `POST /api/me/email` — `{ current_password, new_email }`. Verify, update. `409` on unique + violation. + +**Config** +- `GET /api/config` — returns `{ tile_url, base_path }` so the frontend doesn't hardcode them. + May be folded into `GET /api/me` if preferred. + +**Markers** (all require auth) +- `GET /api/markers` — the current user's own markers plus all `is_shared = true` markers from any + user. Accepts an optional `?bbox=minlon,minlat,maxlon,maxlat` filter — worth building now, since + adding it later is a breaking change. +- `POST /api/markers` — create. +- `PUT /api/markers/:id` — update (only if `owner_id` matches). +- `DELETE /api/markers/:id` — delete (only if `owner_id` matches). + +**GPX** (all require auth) +- `GET /api/gpx` — list files in `GPX_DIR` (name, size, modified time). +- `GET /api/gpx/:filename` — serve raw file content. See §10 for mandatory filename validation. +- `POST /api/gpx/upload` — multipart. Writes into `GPX_DIR` with collision avoidance (§10). + Returns the final stored filename, which may differ from the one submitted. + +The frontend also handles **client-side-only GPX viewing** — a file opened from the user's own +device via `` is parsed and rendered without touching the API, unless the user +chooses to save it to the shared folder. + +## 7. Configuration (`.env`) + +``` +DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mapserver +REG_TOKEN= +SESSION_SECRET= # VERIFY AT BUILD TIME — see note below +SESSION_DURATION_DAYS=30 # "remember me" is the default behaviour, no checkbox +GPX_DIR=/home/mapserver/data/gpx +BIND_ADDR=127.0.0.1:8080 # loopback only; nginx is the only client +BASE_PATH=/maps # subpath the app is served under +PUBLIC_URL=https://your-domain.example # scheme+host only; reset links are PUBLIC_URL + BASE_PATH +TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png +UPLOAD_MAX_BYTES=26214400 # 25 MiB +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=... +SMTP_PASSWORD=... +SMTP_FROM=mapserver@your-domain.example +``` + +Notes: + +- **`SESSION_SECRET` may be vestigial.** `tower-sessions` backed by a Postgres store issues a random + opaque session ID and keeps all state server-side — there may be nothing to sign. Confirm at build + time whether the chosen store consumes a key; if it doesn't, delete this line rather than shipping + config that implies a security property that isn't there. +- **`TILE_URL`** is config rather than hardcoded so a switch to a paid provider or a different layer + (e.g. OpenTopoMap for hiking) is an edit and a restart, not a recompile. Use + `tile.openstreetmap.org` directly — the old `{s}.tile.` subdomain-sharded form is deprecated. +- `.env` lives at `~/.config/mapserver/.env`, mode `0600`. + +## 8. Auth and account behaviour + +- **Registration**: username + password + registration token required; email optional. Token checked + against `REG_TOKEN` in constant time. +- **Email is a recovery channel only.** It has no other function. A user who supplies a bad address, + or none at all, is only removing their own ability to self-serve a password reset — no other + capability depends on it. Accordingly there is no verification flow and the column is nullable. +- **Login**: sets a session cookie. Sessions live in Postgres so restarts don't log anyone out. + Expiry per `SESSION_DURATION_DAYS`. +- **Cookie attributes**: `Secure` (TLS is terminated at nginx), `HttpOnly`, `SameSite=Strict`, and + `Path` set to `BASE_PATH` so the cookie doesn't leak to other apps on the same domain. +- **CSRF**: `SameSite=Strict` is the primary defence, sufficient for a single-origin app. Also check + the `Origin` header on mutating requests as a cheap second layer. +- **Logout**: destroys the session server-side and clears the cookie. +- **Password reset**: request → email with a time-limited, single-use token link → confirm screen. + Only the token's hash is stored. Requires working SMTP; `lettre`'s stub transport logging the URL + to stdout is fine for the build session, so provider signup doesn't block starting. +- **Password change does not revoke sessions.** *Deliberate.* Changing a password affects subsequent + logins only; sessions already established on other devices stay valid. The current session's ID is + rotated via `cycle_id()`, which invalidates a leaked ID for the device performing the change but + touches nothing else. The consequence, accepted knowingly: if a device with a live session is + lost, changing the password will not lock it out — that requires clearing session rows directly + (`DELETE FROM tower_sessions;` via psql). At two users this is an acceptable manual recovery path; + it would not be at larger scale. +- **Rate limiting** via `tower-governor` on `/api/login`, `/api/register`, + `/api/password-reset/request`, `/api/me/password`, and `/api/me/email`. A single shared + registration secret with unlimited attempts is guessable given enough time. + +## 9. Deployment + +### Subpath handling (the part most likely to go wrong) + +The contract is: **nginx does not strip the prefix.** Paths are then identical on both sides of the +proxy and there is nothing to keep in sync. + +```nginx +location /maps { + proxy_pass http://127.0.0.1:8080; # NO trailing slash, no rewrite + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} + +location /maps/api/gpx/upload { + client_max_body_size 25m; # nginx default is 1m and would reject first + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +On the axum side: `Router::new().nest(&base_path, app)`. + +Four things must carry the prefix: + +1. **Session cookie `Path`** = `BASE_PATH`. +2. **Frontend asset URLs** — inject `` into `index.html` at serve time. Every + relative URL in the JS (`fetch("api/markers")`) then resolves correctly with no other changes. + Two gotchas: no leading slashes anywhere in the frontend, and the trailing slash on the `href` is + mandatory. +3. **Reset links** — derive as `PUBLIC_URL + BASE_PATH + "/reset?token=…"` rather than storing a + second full URL that can drift out of sync. +4. **SPA fallback** — must live *inside* the nest, so `/maps/reset?token=…` serves `index.html` + rather than 404ing. + +### Upload size limits + +Two limits sit in series and both must be raised, on the upload route only: + +- **nginx**: `client_max_body_size 25m;` (default 1m). +- **axum**: `DefaultBodyLimit` defaults to **2 MB** and applies to the `Multipart` extractor, + producing a `413` before the handler runs. Apply + `.layer(DefaultBodyLimit::max(UPLOAD_MAX_BYTES))` to the upload route specifically — a 25 MB body + allowance on `/api/login` would be free memory exhaustion. +- In the handler, **stream** the field to disk (`while let Some(chunk) = field.chunk().await?`) + rather than `field.bytes().await`, which buffers the entire file in RAM. + +25 MB is sized for a long GPS track with per-point elevation and timestamps (~20k points lands in +the 5–15 MB range). + +### Rootless systemd + +Both the binary and Postgres run as the same non-root user, as **user units** — which means +ordering between them works normally within the single user manager. + +- Quadlet: `~/.config/containers/systemd/postgres.container` +- App unit: `~/.config/systemd/user/mapserver.service` + +```ini +[Unit] +Description=mapserver +After=network-online.target postgres.service + +[Service] +EnvironmentFile=%h/.config/mapserver/.env +ExecStart=%h/.local/bin/mapserver +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +- `WantedBy=default.target`, **not** `multi-user.target` (user units). +- **`loginctl enable-linger `** — without it, user units don't start at boot and are killed on + logout. (Already in place on this server; noted for completeness.) +- Postgres quadlet: **named volume** for `PGDATA`, not a host bind mount — rootless podman maps + container UIDs into the subuid range and bind mounts hit permission errors that named volumes + avoid. Publish as `127.0.0.1:5432:5432`. +- `After=` does **not** guarantee Postgres is accepting connections. The binary must retry its + initial pool connection with backoff rather than exiting. +- `GPX_DIR` under a directory the same user owns outright. + +## 10. GPX file handling + +**Filename validation is mandatory** on both read and write, since filenames are user-supplied and +appear in a path parameter. Without it, `GET /api/gpx/../../etc/passwd` reads anything the service +user can read. + +- Reject any name containing a path separator, or not matching `^[A-Za-z0-9 ._-]{1,120}$`. +- Join to `GPX_DIR`, then canonicalize and assert the result is still under `GPX_DIR`. +- Do not attempt to sanitize by string-replacing `..`. + +**Collision avoidance** uses `create_new(true)`, which is atomic — it fails if the path exists, so +there's no check-then-write race: + +```rust +fn reserve(dir: &Path, stem: &str, ext: &str) -> io::Result<(File, String)> { + for n in 0..1000 { + let name = if n == 0 { format!("{stem}.{ext}") } else { format!("{stem}-{n}.{ext}") }; + match OpenOptions::new().write(true).create_new(true).open(dir.join(&name)) { + Ok(f) => return Ok((f, name)), + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::other("too many collisions")) +} +``` + +`track.gpx` becomes `track-1.gpx`, then `track-2.gpx`. The final name is returned in the upload +response so the UI can show what was actually saved. On a failed or aborted upload, delete the +reserved file rather than leaving a zero-byte stub. + +## 11. Frontend feature list (v1) + +- Login / register / forgot-password / reset-password pages +- Account page: change password, change email (both requiring current password) +- Map view: Leaflet, tile layer from `TILE_URL`, Nominatim-backed search box + - **Search must be submit-on-enter**, not search-as-you-type. Nominatim's usage policy prohibits + autocomplete-style querying. Send an identifiable `User-Agent`/`Referer`. +- Live location toggle: `watchPosition()` (not one-shot), on/off, shows a dot at current position + while enabled. Requires a secure context — works because nginx terminates TLS. +- Marker/tag management: click map (or use current location) to drop a marker, set + name/category/color/description, toggle "shared with others". Own markers plus all shared markers + are shown. Category is a fixed picker plus a free-text "other", stored as free text. +- GPX panel: + - List files from the shared server folder (`GET /api/gpx`), select one to render on the map + - "Open from this device" — local file picker, parses and renders client-side immediately + - Optional "save to server" on a device-opened file, uploading into the shared folder. Surface the + returned filename if it was renamed for collision avoidance. + +## 12. Open items for the build session + +Design-level review is complete; everything in §§3–11 is settled. What remains are things that can +only be resolved against a compiler: + +- Pin actual crate versions (`cargo add`). +- Confirm whether the chosen `tower-sessions` Postgres store consumes `SESSION_SECRET`; delete it + from `.env` if not (§7). +- Confirm `sqlx` feature-flag names resolve as expected for the pinned version. +- Decide whether `rust-embed`'s `debug-embed` feature is wanted, so debug and release builds behave + identically. +- Obtain an SMTP provider, or ship with the stub transport and wire it later. +- Verify the Postgres image version supports `gen_random_uuid()` natively; add the `pgcrypto` + extension to the first migration if not. diff --git a/README.md b/README.md new file mode 100644 index 0000000..76bcea3 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ + + +# Waymark — build and deploy + +Single Rust binary + one rootless Postgres container. See `DESIGN.md` for the reasoning. + +> **This code has not been compiled.** It was written without a Rust toolchain available, +> so treat the first `cargo build` as the real review. Expect to fix a handful of import +> paths and trait-bound details, not the structure. + +## 1. Refresh dependency versions + +`Cargo.toml` has plausible versions, but pin them properly: + +```bash +cargo add axum -F multipart,macros +cargo add tokio -F full +cargo add sqlx --no-default-features -F runtime-tokio,tls-rustls,postgres,uuid,time,macros,migrate +cargo add tower-sessions +cargo add tower-sessions-sqlx-store -F postgres +cargo add lettre --no-default-features -F tokio1-rustls,smtp-transport,builder,pool +cargo add rust-embed -F debug-embed +cargo build +``` + +Two version-sensitive spots to check first if it doesn't compile: + +- **`tower-sessions` / `tower-sessions-sqlx-store` must be a matching pair.** The store crate + tracks the main crate's version and they break in lockstep. Check the store's own README for + which `tower-sessions` it expects. +- **axum 0.8 changed path params from `:id` to `{id}`.** This code uses `{id}`. If you end up on + 0.7 for some reason, they all need changing back. + +## 2. Database + +```bash +podman secret create mapserver-db-password - # type the password, then Ctrl-D +mkdir -p ~/.config/containers/systemd +cp deploy/postgres.container ~/.config/containers/systemd/ +systemctl --user daemon-reload +systemctl --user start postgres +``` + +Migrations run automatically on startup — both `./migrations` and the session store's own. + +## 3. Config + +```bash +mkdir -p ~/.config/mapserver ~/gpx +cp .env.example ~/.config/mapserver/.env +chmod 600 ~/.config/mapserver/.env +$EDITOR ~/.config/mapserver/.env +``` + +Must change: `DATABASE_URL`, `REG_TOKEN`, `PUBLIC_URL`, `BASE_PATH`, `GPX_DIR`. + +Leave `SMTP_HOST` empty for now — reset links get logged instead of emailed, and the whole flow +is testable without a provider. + +## 4. Run + +```bash +cargo build --release +mkdir -p ~/.local/bin && cp target/release/mapserver ~/.local/bin/ +cp deploy/mapserver.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now mapserver +loginctl enable-linger "$USER" # or nothing starts at boot +journalctl --user -u mapserver -f +``` + +## 5. nginx + +Merge `deploy/nginx-snippet.conf` into your existing TLS server block, then +`nginx -t && systemctl reload nginx`. + +## 6. First account + +Visit `https://your-domain/maps/`, choose "Create an account", and enter the `REG_TOKEN` from +`.env`. + +--- + +## Testing the reset flow with real mail + +`mailpit` gives you a local SMTP server and a web inbox with no signup: + +``` +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 +SMTP_TLS=none +SMTP_USERNAME= +SMTP_PASSWORD= +``` + +When you move to a real provider: use an app-specific password (any account with 2FA will reject +the normal one over SMTP), check SMTP isn't disabled by default on the account, and make sure +`SMTP_FROM` is an address that account may send as. + +## Things worth checking by hand after the first deploy + +- `curl -i https://your-domain/maps/api/gpx/file/../../etc/passwd` → 400 or 404, never a file +- Live location works (needs HTTPS; it silently fails on plain HTTP) +- Session survives `systemctl --user restart mapserver` +- Upload a >2 MB GPX — catches both the axum and nginx body limits at once +- Upload the same filename twice — second one should come back as `name-1.gpx` +- Reboot the server, confirm both units come back (this is what `enable-linger` is for) + +## Unit tests + + cargo test + +No database needed — every test is pure: base-path normalisation, bbox parsing, +GPX filename safety, and the rate-limiter window. diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..c81edbc --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,777 @@ +// frontend/app.js +/* Waymark — all URLs relative, resolved against the injected . + Never use a leading slash: it would escape BASE_PATH. */ + +'use strict'; + +// Swap this for a paid provider without rebuilding the binary. +// Tile source comes from GET /api/config (TILE_URL in .env) so it can be +// changed without rebuilding. These are only the fallback if that call fails. +const TILE_FALLBACK = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +const TILE_ATTRIB = '© OpenStreetMap contributors'; +let tileUrl = TILE_FALLBACK; + +async function loadClientConfig() { + try { + const res = await fetch('api/config', { headers: { Accept: 'application/json' } }); + if (res.ok) { + const cfg = await res.json(); + if (cfg.tile_url) tileUrl = cfg.tile_url; + } + } catch (err) { + console.warn('using fallback tile source', err); + } +} +const NOMINATIM = 'https://nominatim.openstreetmap.org/search'; + +const COLOURS = ['#4E9C6B', '#D2467F', '#E2A93C', '#4E8FC9', '#B07BD4', '#D3574B']; +const DEFAULT_COLOUR = COLOURS[0]; + +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + +const state = { + me: null, + markers: [], + layers: new Map(), // marker id -> leaflet layer + map: null, + watchId: null, + hereLayer: null, + gpxLayer: null, + localGpx: null, // { name, text } awaiting an optional upload + editing: null, // marker being edited, or null for a new one + draftLatLng: null, + colour: DEFAULT_COLOUR, +}; + +/* ───────────────────────────── api ───────────────────────────── */ + +async function api(path, options = {}) { + const res = await fetch(path, { + credentials: 'same-origin', + headers: options.body instanceof FormData + ? {} + : { 'Content-Type': 'application/json' }, + ...options, + }); + + if (res.status === 204) return null; + + let payload = null; + try { payload = await res.json(); } catch { /* empty body */ } + + if (!res.ok) { + const err = new Error((payload && payload.error) || 'Something went wrong.'); + err.status = res.status; + throw err; + } + return payload; +} + +const json = (body) => ({ body: JSON.stringify(body) }); + +/* ───────────────────────── small helpers ───────────────────────── */ + +function note(form, message, kind) { + const el = $('[data-note]', form); + if (!el) return; + el.textContent = message || ''; + el.className = 'note' + (kind ? ' ' + kind : ''); +} + +let toastTimer; +function toast(message, bad) { + const el = $('#toast'); + el.textContent = message; + el.className = 'toast' + (bad ? ' bad' : ''); + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.hidden = true; }, 3600); +} + +async function submitting(form, fn) { + const button = $('button[type=submit]', form); + if (button) button.disabled = true; + try { + await fn(); + } finally { + if (button) button.disabled = false; + } +} + +const fmtCoord = (lat, lon) => + `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + +function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(); +} + +/* ───────────────────────────── auth ───────────────────────────── */ + +function showPane(name) { + $$('.pane').forEach((p) => { p.hidden = p.dataset.pane !== name; }); + const subs = { + login: 'Places worth going back to.', + register: 'You will need the registration token.', + forgot: 'We will email a link if that address is registered.', + reset: 'Choose a new password.', + }; + $('#auth-sub').textContent = subs[name] || ''; +} + +$$('[data-goto]').forEach((link) => { + link.addEventListener('click', (e) => { + e.preventDefault(); + showPane(link.dataset.goto); + }); +}); + +$('#form-login').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + submitting(form, async () => { + const data = Object.fromEntries(new FormData(form)); + try { + state.me = await api('api/login', { method: 'POST', ...json(data) }); + form.reset(); + note(form, ''); + enterApp(); + } catch (err) { + note(form, err.status === 401 + ? 'That username or password is not right.' + : err.message, 'bad'); + } + }); +}); + +$('#form-register').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + submitting(form, async () => { + const data = Object.fromEntries(new FormData(form)); + if (!data.email) data.email = null; + try { + await api('api/register', { method: 'POST', ...json(data) }); + form.reset(); + showPane('login'); + note($('#form-login'), 'Account created. Log in below.', 'good'); + } catch (err) { + note(form, err.status === 401 + ? 'That registration token is not right.' + : err.message, 'bad'); + } + }); +}); + +$('#form-forgot').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + submitting(form, async () => { + const data = Object.fromEntries(new FormData(form)); + await api('api/password-reset/request', { method: 'POST', ...json(data) }) + .catch(() => {}); + // Deliberately the same message either way — the server does not reveal + // whether the address is registered, and neither does this. + note(form, 'If that address is registered, a reset link is on its way.', 'good'); + form.reset(); + }); +}); + +$('#form-reset').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + + if (data.new_password !== data.confirm) { + note(form, 'Those two passwords do not match.', 'bad'); + return; + } + + submitting(form, async () => { + try { + await api('api/password-reset/confirm', { + method: 'POST', + ...json({ token: resetToken(), new_password: data.new_password }), + }); + form.reset(); + history.replaceState(null, '', location.pathname); + showPane('login'); + note($('#form-login'), 'Password updated. Log in with it now.', 'good'); + } catch (err) { + note(form, err.message, 'bad'); + } + }); +}); + +const resetToken = () => new URLSearchParams(location.search).get('token'); + +/* ───────────────────────────── map ───────────────────────────── */ + +function initMap() { + if (state.map) return; + + state.map = L.map('map', { zoomControl: true }).setView([54.5, -3.0], 6); + L.tileLayer(tileUrl, { maxZoom: 19, attribution: TILE_ATTRIB }).addTo(state.map); + + state.map.on('click', (e) => openMarkerSheet(null, e.latlng)); + state.map.on('move zoom', updateReadout); + updateReadout(); +} + +function updateReadout() { + if (!state.map) return; + const c = state.map.getCenter(); + $('#readout-latlon').textContent = fmtCoord(c.lat, c.lng); + $('#readout-zoom').textContent = String(state.map.getZoom()); +} + +function pinIcon(colour) { + return L.divIcon({ + className: '', + html: `
`, + iconSize: [15, 15], + iconAnchor: [7, 14], + }); +} + +/* ─────────────────────────── markers ─────────────────────────── */ + +async function loadMarkers() { + state.markers = await api('api/markers'); + renderMarkers(); +} + +function renderMarkers() { + state.layers.forEach((layer) => state.map.removeLayer(layer)); + state.layers.clear(); + + const list = $('#marker-list'); + list.innerHTML = ''; + $('#marker-empty').hidden = state.markers.length > 0; + + state.markers.forEach((m) => { + const mine = m.owner_id === state.me.id; + const layer = L.marker([m.lat, m.lon], { icon: pinIcon(m.color) }).addTo(state.map); + + layer.bindPopup( + `${escapeHtml(m.name)}` + + (m.category ? `${escapeHtml(m.category)}
` : '') + + (m.description ? `${escapeHtml(m.description)}
` : '') + + `${fmtCoord(m.lat, m.lon)}` + + (mine ? '' : '
shared with you') + ); + + if (mine) layer.on('dblclick', () => openMarkerSheet(m)); + state.layers.set(m.id, layer); + + const li = document.createElement('li'); + li.innerHTML = + `` + + `` + + `${fmtCoord(m.lat, m.lon)}`; + $('.name', li).textContent = m.name; + + if (m.is_shared) { + const badge = document.createElement('span'); + badge.className = 'badge'; + badge.textContent = mine ? 'shared' : 'theirs'; + li.appendChild(badge); + } + + const go = document.createElement('button'); + go.className = 'link'; + go.textContent = mine ? 'Edit' : 'Show'; + go.addEventListener('click', () => { + state.map.setView([m.lat, m.lon], Math.max(state.map.getZoom(), 14)); + state.layers.get(m.id).openPopup(); + if (mine) openMarkerSheet(m); + }); + li.appendChild(go); + + list.appendChild(li); + }); +} + +function escapeHtml(s) { + const div = document.createElement('div'); + div.textContent = s == null ? '' : s; + return div.innerHTML; +} + +function buildSwatches() { + const wrap = $('#swatches'); + wrap.innerHTML = ''; + COLOURS.forEach((c) => { + const b = document.createElement('button'); + b.type = 'button'; + b.style.background = c; + b.setAttribute('aria-label', 'Colour ' + c); + b.setAttribute('aria-pressed', String(c === state.colour)); + b.addEventListener('click', () => { + state.colour = c; + $$('#swatches button').forEach((x) => + x.setAttribute('aria-pressed', String(x.style.background === b.style.background))); + }); + wrap.appendChild(b); + }); +} + +function openSheet(id) { + $('#scrim').hidden = false; + $(id).hidden = false; +} + +function closeSheets() { + $('#scrim').hidden = true; + $('#marker-sheet').hidden = true; + $('#account-sheet').hidden = true; + state.editing = null; + state.draftLatLng = null; +} + +$('#scrim').addEventListener('click', closeSheets); +$$('[data-close-sheet]').forEach((b) => b.addEventListener('click', closeSheets)); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheets(); }); + +function openMarkerSheet(marker, latlng) { + const form = $('#marker-form'); + form.reset(); + note(form, ''); + + state.editing = marker || null; + state.draftLatLng = marker ? { lat: marker.lat, lng: marker.lon } : latlng; + state.colour = (marker && marker.color) || DEFAULT_COLOUR; + + $('#sheet-title').textContent = marker ? 'Edit place' : 'New place'; + $('#marker-coords').textContent = + fmtCoord(state.draftLatLng.lat, state.draftLatLng.lng); + $('#marker-delete').hidden = !marker; + + if (marker) { + form.name.value = marker.name; + form.description.value = marker.description || ''; + form.is_shared.checked = marker.is_shared; + + const known = Array.from(form.category.options).some((o) => o.value === marker.category); + if (marker.category && !known) { + form.category.value = '__other'; + form.category_other.value = marker.category; + } else { + form.category.value = marker.category || ''; + } + } + + $('#category-other-wrap').hidden = form.category.value !== '__other'; + buildSwatches(); + openSheet('#marker-sheet'); + form.name.focus(); +} + +$('#marker-form').category.addEventListener('change', (e) => { + $('#category-other-wrap').hidden = e.target.value !== '__other'; +}); + +$('#marker-form').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + + submitting(form, async () => { + const data = Object.fromEntries(new FormData(form)); + const category = data.category === '__other' + ? (data.category_other || '').trim() + : data.category; + + const body = { + name: data.name, + description: data.description || null, + lat: state.draftLatLng.lat, + lon: state.draftLatLng.lng, + category: category || null, + color: state.colour, + is_shared: form.is_shared.checked, + }; + + try { + if (state.editing) { + await api('api/markers/' + state.editing.id, { method: 'PUT', ...json(body) }); + } else { + await api('api/markers', { method: 'POST', ...json(body) }); + } + closeSheets(); + await loadMarkers(); + toast('Place saved'); + } catch (err) { + note(form, err.message, 'bad'); + } + }); +}); + +$('#marker-delete').addEventListener('click', async () => { + if (!state.editing) return; + if (!confirm(`Delete "${state.editing.name}"?`)) return; + try { + await api('api/markers/' + state.editing.id, { method: 'DELETE' }); + closeSheets(); + await loadMarkers(); + toast('Place deleted'); + } catch (err) { + toast(err.message, true); + } +}); + +/* ─────────────────────────── search ─────────────────────────── */ + +// Submit-on-enter only. Nominatim's usage policy prohibits autocomplete-style +// search-as-you-type, so there is deliberately no keystroke handler here. +$('#search').addEventListener('submit', async (e) => { + e.preventDefault(); + const q = e.target.q.value.trim(); + if (!q) return; + + try { + const url = `${NOMINATIM}?format=jsonv2&limit=1&q=${encodeURIComponent(q)}`; + const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); + const hits = await res.json(); + + if (!hits.length) { toast('Nothing found for that', true); return; } + const hit = hits[0]; + state.map.setView([parseFloat(hit.lat), parseFloat(hit.lon)], 14); + toast(hit.display_name.split(',').slice(0, 2).join(',')); + } catch { + toast('Search is unavailable right now', true); + } +}); + +/* ────────────────────── live location ────────────────────── */ + +$('#locate').addEventListener('click', () => { + const button = $('#locate'); + + if (state.watchId !== null) { + navigator.geolocation.clearWatch(state.watchId); + state.watchId = null; + if (state.hereLayer) { state.map.removeLayer(state.hereLayer); state.hereLayer = null; } + button.setAttribute('aria-pressed', 'false'); + return; + } + + if (!navigator.geolocation) { + toast('This browser has no location support', true); + return; + } + + // watchPosition, not getCurrentPosition — the dot should follow you. + // Requires a secure context, so it will not run over plain HTTP. + state.watchId = navigator.geolocation.watchPosition( + (pos) => { + const { latitude, longitude } = pos.coords; + if (!state.hereLayer) { + state.hereLayer = L.marker([latitude, longitude], { + icon: L.divIcon({ className: '', html: '
', iconSize: [15, 15] }), + interactive: false, + }).addTo(state.map); + state.map.setView([latitude, longitude], Math.max(state.map.getZoom(), 15)); + } else { + state.hereLayer.setLatLng([latitude, longitude]); + } + }, + (err) => { + toast(err.code === err.PERMISSION_DENIED + ? 'Location permission was refused' + : 'Could not get a location fix', true); + button.setAttribute('aria-pressed', 'false'); + state.watchId = null; + }, + { enableHighAccuracy: true, maximumAge: 5000, timeout: 20000 } + ); + + button.setAttribute('aria-pressed', 'true'); +}); + +/* ─────────────────────────── gpx ─────────────────────────── */ + +function parseGpx(text) { + const doc = new DOMParser().parseFromString(text, 'application/xml'); + if (doc.querySelector('parsererror')) throw new Error('That file is not valid GPX.'); + + const tracks = []; + doc.querySelectorAll('trkseg').forEach((seg) => { + const pts = Array.from(seg.querySelectorAll('trkpt')) + .map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))]) + .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); + if (pts.length) tracks.push(pts); + }); + + const routePts = Array.from(doc.querySelectorAll('rte > rtept')) + .map((p) => [parseFloat(p.getAttribute('lat')), parseFloat(p.getAttribute('lon'))]) + .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b)); + if (routePts.length) tracks.push(routePts); + + const waypoints = Array.from(doc.querySelectorAll('gpx > wpt')).map((p) => ({ + lat: parseFloat(p.getAttribute('lat')), + lon: parseFloat(p.getAttribute('lon')), + name: (p.querySelector('name') || {}).textContent || '', + })); + + if (!tracks.length && !waypoints.length) throw new Error('That file has no track in it.'); + return { tracks, waypoints }; +} + +function drawGpx(text, label) { + const { tracks, waypoints } = parseGpx(text); + + if (state.gpxLayer) state.map.removeLayer(state.gpxLayer); + const group = L.layerGroup(); + + tracks.forEach((pts) => { + L.polyline(pts, { color: '#D2467F', weight: 4, opacity: 0.9 }).addTo(group); + }); + + waypoints.forEach((w) => { + L.circleMarker([w.lat, w.lon], { + radius: 4, color: '#E2A93C', fillColor: '#E2A93C', fillOpacity: 1, + }).bindPopup(escapeHtml(w.name || 'Waypoint')).addTo(group); + }); + + group.addTo(state.map); + state.gpxLayer = group; + + const bounds = L.latLngBounds([]); + tracks.forEach((pts) => pts.forEach((p) => bounds.extend(p))); + waypoints.forEach((w) => bounds.extend([w.lat, w.lon])); + if (bounds.isValid()) state.map.fitBounds(bounds, { padding: [40, 40] }); + + toast('Showing ' + label); +} + +async function loadGpxList() { + const files = await api('api/gpx'); + const list = $('#gpx-list'); + list.innerHTML = ''; + $('#gpx-empty').hidden = files.length > 0; + + files.forEach((f) => { + const li = document.createElement('li'); + li.innerHTML = + '' + + `${fmtSize(f.size)} · ${fmtDate(f.modified)}`; + $('.name', li).textContent = f.name; + + const show = document.createElement('button'); + show.className = 'link'; + show.textContent = 'Show'; + show.addEventListener('click', async () => { + try { + const res = await fetch('api/gpx/file/' + encodeURIComponent(f.name), { + credentials: 'same-origin', + }); + if (!res.ok) throw new Error('Could not read that file.'); + drawGpx(await res.text(), f.name); + } catch (err) { + toast(err.message, true); + } + }); + li.appendChild(show); + list.appendChild(li); + }); +} + +$('#gpx-local').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + drawGpx(text, file.name); + state.localGpx = { name: file.name, text }; + $('#local-name').textContent = file.name; + $('#local-actions').hidden = false; + } catch (err) { + toast(err.message, true); + state.localGpx = null; + $('#local-actions').hidden = true; + } + e.target.value = ''; +}); + +$('#gpx-save').addEventListener('click', async () => { + if (!state.localGpx) return; + const button = $('#gpx-save'); + button.disabled = true; + + try { + const body = new FormData(); + body.append('file', new Blob([state.localGpx.text], { type: 'application/gpx+xml' }), + state.localGpx.name); + + const result = await api('api/gpx/upload', { method: 'POST', body }); + // The server may have renamed it to avoid a collision, so report what it used. + toast(result.name === state.localGpx.name + ? `Saved as ${result.name}` + : `Saved as ${result.name} — that name was taken`); + state.localGpx = null; + $('#local-actions').hidden = true; + await loadGpxList(); + } catch (err) { + toast(err.status === 413 ? 'That file is too large to upload.' : err.message, true); + } finally { + button.disabled = false; + } +}); + +$('#gpx-clear').addEventListener('click', () => { + if (state.gpxLayer) { state.map.removeLayer(state.gpxLayer); state.gpxLayer = null; } +}); + +/* ─────────────────────── panels & account ─────────────────────── */ + +$$('.tab').forEach((tab) => { + tab.addEventListener('click', () => { + const target = tab.dataset.panel; + $$('.tab').forEach((t) => t.setAttribute('aria-pressed', String(t === tab))); + $('#panel-places').hidden = target !== 'places'; + $('#panel-routes').hidden = target !== 'routes'; + if (target === 'routes') loadGpxList().catch(() => {}); + }); +}); + +$('#close-places').addEventListener('click', () => { + $('#panel-places').hidden = true; + $$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false')); +}); +$('#close-routes').addEventListener('click', () => { + $('#panel-routes').hidden = true; + $$('.tab').forEach((t) => t.setAttribute('aria-pressed', 'false')); +}); + +const accountBtn = $('#account-btn'); +const accountMenu = $('#account-menu'); + +accountBtn.addEventListener('click', () => { + const open = accountMenu.hidden; + accountMenu.hidden = !open; + accountBtn.setAttribute('aria-expanded', String(open)); +}); + +document.addEventListener('click', (e) => { + if (!accountMenu.hidden && !e.target.closest('.account')) { + accountMenu.hidden = true; + accountBtn.setAttribute('aria-expanded', 'false'); + } +}); + +$$('#account-menu button').forEach((b) => { + b.addEventListener('click', async () => { + accountMenu.hidden = true; + accountBtn.setAttribute('aria-expanded', 'false'); + + if (b.dataset.action === 'logout') { + await api('api/logout', { method: 'POST' }).catch(() => {}); + location.reload(); + return; + } + + $('#current-email').textContent = state.me.email || 'Not set'; + $('#email-form').reset(); + $('#password-form').reset(); + note($('#email-form'), ''); + note($('#password-form'), ''); + $('#email-form').new_email.value = state.me.email || ''; + openSheet('#account-sheet'); + }); +}); + +$('#email-form').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + + submitting(form, async () => { + const data = Object.fromEntries(new FormData(form)); + try { + await api('api/me/email', { + method: 'POST', + ...json({ + current_password: data.current_password, + new_email: data.new_email.trim() || null, + }), + }); + state.me = await api('api/me'); + $('#current-email').textContent = state.me.email || 'Not set'; + form.current_password.value = ''; + note(form, 'Email updated.', 'good'); + } catch (err) { + note(form, err.status === 401 ? 'Incorrect password.' + : err.status === 409 ? 'That email is already in use.' + : err.message, 'bad'); + } + }); +}); + +$('#password-form').addEventListener('submit', (e) => { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form)); + + if (data.new_password !== data.confirm) { + note(form, 'Those two passwords do not match.', 'bad'); + return; + } + + submitting(form, async () => { + try { + await api('api/me/password', { + method: 'POST', + ...json({ + current_password: data.current_password, + new_password: data.new_password, + }), + }); + form.reset(); + // No redirect to login — existing sessions stay valid by design. + note(form, 'Password updated.', 'good'); + } catch (err) { + note(form, err.status === 401 ? 'Incorrect password.' : err.message, 'bad'); + } + }); +}); + +/* ─────────────────────────── boot ─────────────────────────── */ + +async function enterApp() { + $('#auth').hidden = true; + $('#app').hidden = false; + $('#account-name').textContent = state.me.username; + + await loadClientConfig(); + initMap(); + state.map.invalidateSize(); + + await loadMarkers(); + await loadGpxList().catch(() => {}); +} + +async function boot() { + if (resetToken()) { + $('#auth').hidden = false; + showPane('reset'); + return; + } + + try { + state.me = await api('api/me'); + await enterApp(); + } catch { + $('#auth').hidden = false; + showPane('login'); + } +} + +boot(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..95a3164 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,215 @@ + + + + + + + + +Waymark + + + + + + + + + +
+
+ +
+ + + + + + +
+ + +
+ CENTRE + + ZOOM + +
+ + + + +
+
+

Places

+ +
+

Click the map to drop a new place.

+
    + +
    + + + + + + + + + + + + + +
    + + + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..5f47cdd --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,463 @@ +/* frontend/style.css */ +/* ───────────────────────────────────────────────────────────── + Waymark — chrome for a map, not a dashboard. + Palette taken from Ordnance Survey sheet conventions: + rights-of-way green, road-fill magenta, contour bistre. + ───────────────────────────────────────────────────────────── */ + +:root { + --ink: #0F1A17; /* deep bottle-green black */ + --panel: #16231F; + --panel-2: #1E2E29; + --hair: #2C433C; /* hairline rules */ + --paper: #E7EDE7; /* cool pale sage */ + --paper-dim: #9DB0A8; + --moss: #4E9C6B; /* rights of way — primary action */ + --moss-lift: #5FB47D; + --waymark: #D2467F; /* road fill — shared / accent */ + --sun: #E2A93C; /* live location, warnings */ + --alarm: #D3574B; + + --r: 3px; /* map furniture is squared off, not pill-shaped */ + --shadow: 0 8px 28px rgba(0,0,0,.38); + + --sans: ui-sans-serif, "Inter", "Segoe UI", Roboto, system-ui, sans-serif; + --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Consolas", monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; + font-family: var(--sans); + background: var(--ink); + color: var(--paper); + -webkit-text-size-adjust: 100%; +} + +[hidden] { display: none !important; } + +:focus-visible { + outline: 2px solid var(--sun); + outline-offset: 2px; +} + +/* ── trig point: a small pyramid glyph, the app's only ornament ── */ +.trig { + display: block; + width: 34px; height: 30px; + background: var(--moss); + clip-path: polygon(50% 0, 100% 100%, 0 100%); + margin: 0 auto 14px; +} +.trig.small { width: 20px; height: 17px; margin: 0 4px 0 0; flex: none; } + +/* ───────────────────────── auth ───────────────────────── */ + +.auth { + min-height: 100%; + display: grid; + place-items: center; + padding: 24px; + /* faint contour rings, drawn not imported */ + background: + radial-gradient(circle at 22% 30%, transparent 0 78px, rgba(78,156,107,.10) 78px 79px, transparent 79px), + radial-gradient(circle at 22% 30%, transparent 0 128px, rgba(78,156,107,.08) 128px 129px, transparent 129px), + radial-gradient(circle at 22% 30%, transparent 0 186px, rgba(78,156,107,.06) 186px 187px, transparent 187px), + radial-gradient(circle at 80% 74%, transparent 0 96px, rgba(210,70,127,.09) 96px 97px, transparent 97px), + radial-gradient(circle at 80% 74%, transparent 0 154px, rgba(210,70,127,.06) 154px 155px, transparent 155px), + var(--ink); +} + +.auth-card { + width: min(420px, 100%); + background: var(--panel); + border: 1px solid var(--hair); + border-radius: var(--r); + padding: 34px 30px 30px; + box-shadow: var(--shadow); +} + +.auth-head { text-align: center; margin-bottom: 26px; } + +.auth-head h1 { + margin: 0; + font-size: 30px; + font-weight: 600; + letter-spacing: .16em; + text-transform: uppercase; +} + +.auth-sub { + margin: 8px 0 0; + color: var(--paper-dim); + font-size: 13px; +} + +/* ───────────────────────── forms ───────────────────────── */ + +label { + display: block; + margin-bottom: 15px; + font-size: 12px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--paper-dim); +} + +label .opt { + text-transform: none; + letter-spacing: 0; + font-size: 11px; + color: var(--paper-dim); + opacity: .8; +} + +input[type=text], input[type=password], input[type=email], +input[type=search], input:not([type]), select, textarea { + display: block; + width: 100%; + margin-top: 6px; + padding: 10px 11px; + background: var(--ink); + border: 1px solid var(--hair); + border-radius: var(--r); + color: var(--paper); + font: 15px/1.3 var(--sans); + text-transform: none; + letter-spacing: 0; +} + +textarea { resize: vertical; } + +input:focus, select:focus, textarea:focus { + border-color: var(--moss); + outline: none; +} + +label.check { + display: flex; + align-items: center; + gap: 9px; + text-transform: none; + letter-spacing: 0; + font-size: 14px; + color: var(--paper); +} +label.check input { width: auto; margin: 0; } + +button { + font: 500 14px var(--sans); + border-radius: var(--r); + border: 1px solid transparent; + padding: 10px 15px; + cursor: pointer; + color: var(--paper); + background: var(--panel-2); + transition: background .12s ease, border-color .12s ease; +} +button:hover { background: var(--hair); } +button[disabled] { opacity: .55; cursor: progress; } + +button.primary { background: var(--moss); color: #07140D; font-weight: 600; } +button.primary:hover { background: var(--moss-lift); } + +button.danger { background: transparent; border-color: var(--alarm); color: var(--alarm); } +button.danger:hover { background: rgba(211,87,75,.14); } + +button.ghost { background: transparent; border-color: var(--hair); } +button.ghost:hover { background: var(--panel-2); } +button.ghost[aria-pressed=true] { border-color: var(--sun); color: var(--sun); } + +button.small { padding: 7px 11px; font-size: 13px; } + +button.link { + background: none; border: none; padding: 4px; + color: var(--paper-dim); font-size: 13px; +} +button.link:hover { color: var(--paper); background: none; } + +.pane .primary { width: 100%; margin-top: 6px; } + +.switch { + display: flex; + justify-content: space-between; + gap: 12px; + margin: 16px 0 0; + font-size: 13px; +} +.switch a { color: var(--paper-dim); text-decoration: none; } +.switch a:hover { color: var(--moss-lift); text-decoration: underline; } + +.note { margin: 10px 0 0; font-size: 13px; min-height: 1em; color: var(--paper-dim); } +.note.bad { color: var(--alarm); } +.note.good { color: var(--moss-lift); } + +/* ───────────────────────── app shell ───────────────────────── */ + +.app { position: fixed; inset: 0; } +#map { position: absolute; inset: 0; background: var(--ink); } + +.bar { + position: absolute; + top: 0; left: 0; right: 0; + z-index: 500; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: linear-gradient(var(--ink) 55%, rgba(15,26,23,0)); + padding-bottom: 22px; +} + +.search { display: flex; gap: 6px; flex: 1 1 auto; max-width: 420px; } +.search input { margin-top: 0; background: var(--panel); } +.search button { flex: none; background: var(--panel); border-color: var(--hair); } + +.account { position: relative; margin-left: auto; } + +.menu { + position: absolute; + right: 0; top: calc(100% + 6px); + min-width: 180px; + background: var(--panel); + border: 1px solid var(--hair); + border-radius: var(--r); + box-shadow: var(--shadow); + padding: 5px; + display: flex; + flex-direction: column; +} +.menu button { + background: none; border: none; text-align: left; + padding: 9px 10px; font-size: 14px; +} +.menu button:hover { background: var(--panel-2); } + +/* signature: map-margin grid readout */ +.readout { + position: absolute; + left: 12px; bottom: 12px; + z-index: 500; + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 11px; + background: rgba(15,26,23,.86); + border: 1px solid var(--hair); + border-left: 3px solid var(--moss); + border-radius: var(--r); + font-family: var(--mono); + font-size: 12px; + backdrop-filter: blur(3px); +} +.readout-label { color: var(--paper-dim); font-size: 10px; letter-spacing: .14em; } +.readout-val { color: var(--paper); } + +.rail { + position: absolute; + top: 62px; right: 12px; + z-index: 500; + display: flex; + gap: 6px; +} +.tab { + background: var(--panel); + border: 1px solid var(--hair); + font-size: 13px; + padding: 8px 13px; +} +.tab[aria-pressed=true] { border-color: var(--moss); color: var(--moss-lift); } + +/* ───────────────────────── drawers & sheets ───────────────────────── */ + +.drawer { + position: absolute; + top: 104px; right: 12px; bottom: 12px; + width: min(330px, calc(100vw - 24px)); + z-index: 500; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + overflow-y: auto; + background: rgba(22,35,31,.96); + border: 1px solid var(--hair); + border-radius: var(--r); + box-shadow: var(--shadow); + backdrop-filter: blur(6px); +} + +.drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.drawer-head h2 { + margin: 0; font-size: 13px; font-weight: 600; + letter-spacing: .14em; text-transform: uppercase; +} + +.sub { + margin: 6px 0 0; font-size: 11px; font-weight: 600; + letter-spacing: .14em; text-transform: uppercase; color: var(--paper-dim); +} + +.hint, .empty { margin: 0; font-size: 13px; color: var(--paper-dim); } + +.list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } + +.list li { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 4px; + border-bottom: 1px solid var(--hair); + font-size: 14px; +} +.list li:last-child { border-bottom: none; } + +.list .dot { + width: 9px; height: 9px; border-radius: 50%; + background: var(--moss); flex: none; +} + +.list .grow { flex: 1 1 auto; min-width: 0; } +.list .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.list .meta { display: block; font-family: var(--mono); font-size: 11px; color: var(--paper-dim); } + +.badge { + font-size: 10px; letter-spacing: .1em; text-transform: uppercase; + color: var(--waymark); border: 1px solid var(--waymark); + border-radius: var(--r); padding: 1px 5px; flex: none; +} + +.row { display: flex; gap: 8px; flex-wrap: wrap; } + +.filebtn { + margin: 0; padding: 10px 15px; + background: var(--panel-2); border: 1px solid var(--hair); + border-radius: var(--r); cursor: pointer; + font-size: 13px; letter-spacing: 0; text-transform: none; + color: var(--paper); +} +.filebtn:hover { background: var(--hair); } + +.local-actions { + display: flex; align-items: center; gap: 9px; flex-wrap: wrap; + padding: 9px; border: 1px dashed var(--hair); border-radius: var(--r); +} + +.mono { font-family: var(--mono); font-size: 12px; } + +.scrim { + position: absolute; inset: 0; z-index: 900; + background: rgba(8,14,12,.55); +} + +.sheet { + position: absolute; + z-index: 1000; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: min(430px, calc(100vw - 24px)); + max-height: calc(100vh - 48px); + overflow-y: auto; + padding: 18px; + background: var(--panel); + border: 1px solid var(--hair); + border-radius: var(--r); + box-shadow: var(--shadow); +} + +.sheet form { margin-top: 14px; } + +.block + .block { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--hair); } + +.current { margin: 8px 0 14px; font-size: 13px; color: var(--paper-dim); } + +.coords { + margin: 0 0 14px; padding: 7px 9px; + background: var(--ink); border-left: 3px solid var(--moss); + border-radius: var(--r); color: var(--paper-dim); +} + +.actions { display: flex; gap: 9px; margin-top: 6px; } +.actions .primary { flex: 1 1 auto; } + +.swatches { display: flex; gap: 7px; margin-top: 7px; } +.swatches button { + width: 26px; height: 26px; padding: 0; + border-radius: 50%; border: 2px solid transparent; +} +.swatches button[aria-pressed=true] { border-color: var(--paper); } + +.toast { + position: absolute; + left: 50%; bottom: 22px; + transform: translateX(-50%); + z-index: 1100; + padding: 10px 16px; + background: var(--panel); + border: 1px solid var(--hair); + border-left: 3px solid var(--moss); + border-radius: var(--r); + font-size: 14px; + box-shadow: var(--shadow); +} +.toast.bad { border-left-color: var(--alarm); } + +/* ───────────────────────── leaflet overrides ───────────────────────── */ + +.leaflet-container { background: var(--ink); font-family: var(--sans); } + +.leaflet-control-attribution { + background: rgba(15,26,23,.82) !important; + color: var(--paper-dim) !important; + font-size: 10px !important; +} +.leaflet-control-attribution a { color: var(--paper-dim) !important; } + +.leaflet-bar a { + background: var(--panel) !important; + color: var(--paper) !important; + border-bottom-color: var(--hair) !important; +} +.leaflet-bar a:hover { background: var(--panel-2) !important; } + +.leaflet-popup-content-wrapper, .leaflet-popup-tip { + background: var(--panel); + color: var(--paper); + border-radius: var(--r); +} +.leaflet-popup-content { margin: 11px 13px; font-size: 14px; } +.leaflet-popup-content b { display: block; margin-bottom: 3px; } +.leaflet-popup-content .mono { color: var(--paper-dim); } + +.pin { + width: 15px; height: 15px; + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); + border: 2px solid rgba(0,0,0,.42); +} + +.here { + width: 15px; height: 15px; border-radius: 50%; + background: var(--sun); + box-shadow: 0 0 0 5px rgba(226,169,60,.26); +} + +/* ───────────────────────── responsive ───────────────────────── */ + +@media (max-width: 640px) { + .bar { flex-wrap: wrap; } + .search { order: 3; flex-basis: 100%; max-width: none; } + .drawer { top: auto; left: 12px; right: 12px; bottom: 12px; width: auto; max-height: 52vh; } + .readout { display: none; } + .rail { top: 108px; } +} + +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; animation: none !important; } +} diff --git a/mapserver.service b/mapserver.service new file mode 100644 index 0000000..6ecb5b6 --- /dev/null +++ b/mapserver.service @@ -0,0 +1,39 @@ +# deploy/mapserver.service +# ~/.config/systemd/user/mapserver.service +# +# systemctl --user daemon-reload +# systemctl --user enable --now mapserver +# +# Requires: loginctl enable-linger +# Without linger, user units do not start at boot and are killed on logout. + +[Unit] +Description=Waymark mapping server +# Quadlet generates postgres.service from postgres.container. +# Both must be USER units for this ordering to work. +After=postgres.service +Wants=postgres.service + +[Service] +Type=simple +EnvironmentFile=%h/.config/mapserver/.env +WorkingDirectory=%h +ExecStart=%h/.local/bin/rs_maps +Restart=on-failure +RestartSec=5 + +# After= does not mean Postgres is accepting connections; the binary retries +# its initial pool connection with backoff, so this only catches hard failures. + +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=%h/gpx +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=default.target diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..6adb85a --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,36 @@ +-- migrations/0001_init.sql +-- gen_random_uuid() is core since PostgreSQL 13. Kept for older images. +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + email TEXT, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + reset_token_hash TEXT, + reset_token_expires TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_username_key + ON users (lower(username)); + +CREATE UNIQUE INDEX IF NOT EXISTS users_email_key + ON users (lower(email)) WHERE email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS markers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + category TEXT, + color TEXT, + is_shared BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS markers_owner_idx ON markers (owner_id); +CREATE INDEX IF NOT EXISTS markers_shared_idx ON markers (id) WHERE is_shared; diff --git a/nginx-snippet.conf b/nginx-snippet.conf new file mode 100644 index 0000000..da2e20f --- /dev/null +++ b/nginx-snippet.conf @@ -0,0 +1,36 @@ +# deploy/nginx-snippet.conf +# Add inside your existing TLS server block. +# +# The prefix is passed through UNCHANGED: no trailing slash on proxy_pass, +# no rewrite. Paths are then identical on both sides, so there is nothing +# to keep in sync between nginx and BASE_PATH. + +# Without this, https://host/maps (no trailing slash) never matches the block +# below. It would also break , since relative URLs would +# resolve against / instead. +location = /maps { + return 301 /maps/; +} + +location /maps/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +# GPX uploads: nginx defaults to client_max_body_size 1m, which rejects a +# real track before the app ever sees it. Raised here only, not globally. +location /maps/api/gpx/upload { + client_max_body_size 25m; + proxy_request_buffering off; + + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} diff --git a/postgres.container b/postgres.container new file mode 100644 index 0000000..a0740d0 --- /dev/null +++ b/postgres.container @@ -0,0 +1,38 @@ +# deploy/postgres.container +# ~/.config/containers/systemd/postgres.container +# +# Rootless. Generates postgres.service on daemon-reload. +# +# podman secret create mapserver-db-password - +# systemctl --user daemon-reload +# systemctl --user start postgres + +[Unit] +Description=PostgreSQL for mapserver + +[Container] +Image=docker.io/library/postgres:17-alpine +ContainerName=mapserver-db + +# Named volume, NOT a host bind mount: rootless podman maps container UIDs +# into the subuid range, and bind mounts hit permission errors that named +# volumes avoid entirely. +Volume=mapserver-pgdata:/var/lib/postgresql/data + +# Localhost only. The Rust binary is the sole client. +PublishPort=127.0.0.1:5432:5432 + +Environment=POSTGRES_DB=mapserver +Environment=POSTGRES_USER=mapserver +Secret=mapserver-db-password,type=env,target=POSTGRES_PASSWORD + +HealthCmd=pg_isready -U mapserver -d mapserver +HealthInterval=10s +HealthRetries=5 + +[Service] +Restart=always +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/rust_push.sh b/rust_push.sh new file mode 100755 index 0000000..02fc5db --- /dev/null +++ b/rust_push.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# rust_push.sh +# +# Usage: +# ./rust_push.sh "commit message" [patch|minor|major] +# +# Order of operations matters here: everything that can fail locally (validation, +# version bump, build) happens BEFORE anything is committed, tagged or pushed. +# A failed build therefore leaves the repository exactly as it found it. + +set -euo pipefail + +MSG="${1:-}" +BUMP="${2:-}" + +export CROSS_CONTAINER_ENGINE=podman + +TARGET="x86_64-unknown-linux-musl" +FORGE_HOST="radiantfig.fyi" + +# ─────────────────────────────── pre-flight ──────────────────────────────── +# All of it, before the first mutating command. + +if [ -z "$MSG" ] || [ -z "$BUMP" ]; then + echo "Usage: ./rust_push.sh \"Commit message\" [patch|minor|major]" + exit 1 +fi + +case "$BUMP" in + major|minor|patch) ;; + *) echo "❌ Invalid bump: $BUMP (use patch|minor|major)"; exit 1 ;; +esac + +[ -f "Cargo.toml" ] || { echo "❌ Cargo.toml not found."; exit 1; } + +if [ -z "${FORGEJO_TOKEN:-}" ]; then + echo "❌ FORGEJO_TOKEN is not set." + echo " Generate one at ${FORGE_HOST}/forgejo -> Settings -> Applications" + exit 1 +fi + +command -v jq >/dev/null 2>&1 || { echo "❌ 'jq' is required."; exit 1; } +command -v curl >/dev/null 2>&1 || { echo "❌ 'curl' is required."; exit 1; } + +CROSS_PATH="$HOME/.cargo/bin/cross" +[ -x "$CROSS_PATH" ] || { echo "❌ cross not found at $CROSS_PATH (cargo install cross)"; exit 1; } + +# `git add .` is indiscriminate, so verify the ignore rules exist BEFORE relying +# on them. On a fresh repo nothing is tracked yet, which means a check for +# "already tracked" would pass while the very next `git add .` commits the lot. +[ -f ".gitignore" ] || { echo "❌ No .gitignore. Create one before the first release (target/ and .env must be excluded)."; exit 1; } + +for secret in .env .env.local; do + [ -e "$secret" ] || continue + + # Would `git add .` pick this up? + if ! git check-ignore -q "$secret"; then + echo "❌ $secret is NOT ignored by git." + echo " It holds REG_TOKEN, the database password and SMTP credentials." + echo " Add it to .gitignore before releasing." + exit 1 + fi + + # Already committed in a previous run? + if git ls-files --error-unmatch "$secret" >/dev/null 2>&1; then + echo "❌ $secret is tracked by git despite being ignored." + echo " git rm --cached $secret" + exit 1 + fi +done + +if ! git check-ignore -q target 2>/dev/null; then + echo "❌ target/ is not ignored — a release build is several GB." + exit 1 +fi + +# Compile-time embeds: sqlx::migrate! and rust-embed both read these at build +# time, and both fail with an unhelpful message if the directory is absent. +[ -d "migrations" ] || { echo "❌ migrations/ is missing (sqlx::migrate! needs it)"; exit 1; } +[ -d "frontend" ] || { echo "❌ frontend/ is missing (rust-embed needs it)"; exit 1; } + +BINARY_NAME=$(grep -m 1 '^name[[:space:]]*=' Cargo.toml | sed -E 's/name[[:space:]]*=[[:space:]]*"(.*)"/\1/') +[ -n "$BINARY_NAME" ] || { echo "❌ Could not parse 'name' from Cargo.toml."; exit 1; } + +# A bare `git remote get-url` on a fresh repo dies with a raw git error, so +# check first and say what to do about it. +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "❌ Not a git repository. Run: git init" + exit 1 +fi + +if ! git remote get-url origin >/dev/null 2>&1; then + echo "❌ No 'origin' remote configured." + echo " Create the repo on ${FORGE_HOST}/forgejo, then:" + echo " git remote add origin https://${FORGE_HOST}/forgejo//.git" + exit 1 +fi + +# Handles both https://…/forgejo/owner/repo.git and git@host:owner/repo.git +ORIGIN_URL=$(git remote get-url origin) +REPO=$(echo "$ORIGIN_URL" | sed -E " + s|\.git$||; + s|^[^/]*//[^@]*@|https://|; + s|^https?://${FORGE_HOST}/forgejo/||; + s|^[^@]*@${FORGE_HOST}:||; +") +if echo "$REPO" | grep -q '[:/]\{1\}.*[:/]'; then + echo "❌ Could not parse owner/repo from remote: $ORIGIN_URL" + echo " Got: $REPO" + exit 1 +fi + +API_URL="https://${FORGE_HOST}/forgejo/api/v1/repos/${REPO}" + +# Releasing a dirty tree means `git add .` sweeps unrelated work into the tag. +# Skipped on a repo with no commits yet, where everything is legitimately new. +if git rev-parse HEAD >/dev/null 2>&1 && [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "⚠️ Working tree has uncommitted changes; they will be included." + read -r -p " Continue? [y/N] " reply + [ "$reply" = "y" ] || exit 1 +fi + +# ──────────────────────────── version bumping ────────────────────────────── + +# Harmless on a fresh repo with an empty remote; don't let it abort the run. +git fetch --tags --force --quiet 2>/dev/null || true + +LATEST=$(git tag --list 'v*' | sort -V | tail -n 1) +LATEST=${LATEST#v} +[ -n "$LATEST" ] || LATEST="0.0.0" + +MAJOR=$(echo "$LATEST" | cut -d. -f1) +MINOR=$(echo "$LATEST" | cut -d. -f2) +PATCH=$(echo "$LATEST" | cut -d. -f3) + +case "$BUMP" in + major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR+1)); PATCH=0 ;; + patch) PATCH=$((PATCH+1)) ;; +esac + +VERSION="$MAJOR.$MINOR.$PATCH" +TAG="v$VERSION" + +if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "❌ Tag $TAG already exists on remote." + exit 1 +fi +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "❌ Tag $TAG already exists locally (left over from a failed run?)." + echo " Remove it with: git tag -d $TAG" + exit 1 +fi + +echo "📦 Project: $BINARY_NAME" +echo "🔗 Repo: $REPO" +echo "📌 Previous: $LATEST" +echo "✨ New: $TAG" +echo + +# Write the version into Cargo.toml so CARGO_PKG_VERSION matches the tag and the +# artifact name. Only the [package] version — the first `version =` in the file. +sed -i "0,/^version[[:space:]]*=.*/s//version = \"$VERSION\"/" Cargo.toml +# Keeps Cargo.lock in step so it isn't left dirty after the build. +cargo update --workspace --quiet 2>/dev/null || true + +restore_manifest() { + echo "↩️ Restoring Cargo.toml" + git checkout -- Cargo.toml Cargo.lock 2>/dev/null || true +} + +# ──────────────────────────────── build ──────────────────────────────────── +# Before any git mutation: a failure here leaves the repo untouched. + +echo "🚀 Compiling static binary ($TARGET) via cross..." +trap restore_manifest ERR + +"$CROSS_PATH" build --release --target "$TARGET" + +trap - ERR + +BIN_PATH="target/$TARGET/release/$BINARY_NAME" +[ -f "$BIN_PATH" ] || { echo "❌ Binary not found at $BIN_PATH"; restore_manifest; exit 1; } + +# [profile.release] already sets strip = true, so this is belt and braces. +BINARY_INFO=$(file "$BIN_PATH") +if echo "$BINARY_INFO" | grep -qE "statically linked|static-pie linked"; then + echo "✅ Binary is fully static" +else + echo "⚠️ Binary may not be static: $BINARY_INFO" +fi + +ARTIFACT_NAME="${BINARY_NAME}-${VERSION}-linux-amd64-musl.tar.gz" +echo "📦 Packaging: $ARTIFACT_NAME" +tar -czf "$ARTIFACT_NAME" -C "target/$TARGET/release" "$BINARY_NAME" + +# ────────────────────────── git commit / tag / push ──────────────────────── +# Only now, with a working artifact in hand. + +echo "📦 Committing and tagging..." +git add . +git commit -m "$MSG" || echo " (nothing to commit)" +git tag "$TAG" + +echo "🌐 Pushing..." +git push -u origin HEAD +git push origin "$TAG" + +# ────────────────────────────── forgejo release ──────────────────────────── + +echo "🌐 Creating release..." + +curl -fsS -X PATCH "$API_URL" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"has_releases": true}' > /dev/null + +RELEASE_ID=$(curl -fsS "${API_URL}/releases" \ + -H "Authorization: token $FORGEJO_TOKEN" | + jq -r ".[] | select(.tag_name==\"${TAG}\") | .id") + +if [ -n "$RELEASE_ID" ] && [ "$RELEASE_ID" != "null" ]; then + echo "⚠️ Release $TAG already exists (ID: $RELEASE_ID)" +else + HTTP_CODE="" + for attempt in 1 2 3; do + RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "${API_URL}/releases" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"Release ${VERSION}\"}") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + [ "$HTTP_CODE" = "201" ] && { RELEASE_ID=$(echo "$BODY" | jq -r .id); break; } + [ "$attempt" -lt 3 ] && { echo "⚠️ Attempt $attempt failed (HTTP $HTTP_CODE), retrying..."; sleep 2; } + done + + if [ "$HTTP_CODE" != "201" ]; then + echo "❌ Could not create release. Response: ${BODY:-}" + echo " Artifact kept at ./$ARTIFACT_NAME for manual upload." + exit 1 + fi +fi + +# An empty ID here would silently produce /releases//assets and a confusing 404. +if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then + echo "❌ No usable release ID. Artifact kept at ./$ARTIFACT_NAME" + exit 1 +fi + +echo "⬆️ Uploading artifact..." +UPLOAD_URL="${API_URL}/releases/${RELEASE_ID}/assets?name=${ARTIFACT_NAME}" +UPLOAD_HTTP="" + +for attempt in 1 2 3; do + UPLOAD_RESPONSE=$(curl -sS -w "\n%{http_code}" -X POST "$UPLOAD_URL" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -F "attachment=@${ARTIFACT_NAME}") + + UPLOAD_HTTP=$(echo "$UPLOAD_RESPONSE" | tail -n1) + [ "$UPLOAD_HTTP" = "201" ] && break + [ "$attempt" -lt 3 ] && { echo "⚠️ Upload attempt $attempt failed (HTTP $UPLOAD_HTTP), retrying..."; sleep 2; } +done + +if [ "$UPLOAD_HTTP" != "201" ]; then + echo "❌ Upload failed (HTTP $UPLOAD_HTTP). Artifact kept at ./$ARTIFACT_NAME" + exit 1 +fi + +rm -f "$ARTIFACT_NAME" + +echo +echo "✅ Release $TAG complete" +echo " https://${FORGE_HOST}/forgejo/${REPO}/releases/tag/${TAG}" diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..08d349d --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,428 @@ +// src/auth.rs +use std::sync::OnceLock; + +use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString}; +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::extract::{FromRequestParts, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::FromRow; +use subtle::ConstantTimeEq; +use tower_sessions::Session; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::AppState; + +const SESSION_USER_KEY: &str = "user_id"; +const MIN_PASSWORD_LEN: usize = 8; +const RESET_TOKEN_TTL_MINUTES: i64 = 60; + +// ---------------------------------------------------------------- model + +#[derive(Debug, Clone, FromRow)] +pub struct User { + pub id: Uuid, + pub username: String, + pub email: Option, + pub password_hash: String, +} + +#[derive(Debug, Serialize)] +pub struct MeResponse { + pub id: Uuid, + pub username: String, + pub email: Option, +} + +/// Extractor that resolves the logged-in user, or fails with 401. +pub struct CurrentUser(pub User); + +impl FromRequestParts for CurrentUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Unauthorised)?; + + let user_id: Uuid = session + .get(SESSION_USER_KEY) + .await? + .ok_or(AppError::Unauthorised)?; + + let user = load_user_by_id(&state.db, user_id) + .await? + .ok_or(AppError::Unauthorised)?; + + Ok(CurrentUser(user)) + } +} + +// ---------------------------------------------------------------- hashing + +fn hash_password(password: &str) -> AppResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::Other(anyhow::anyhow!("hashing failed: {e}"))) +} + +fn verify_password(password: &str, phc: &str) -> bool { + match PasswordHash::new(phc) { + Ok(parsed) => Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok(), + Err(_) => false, + } +} + +/// A real Argon2 hash of a throwaway value. Verifying against this when the account +/// does not exist keeps the login path's timing the same either way, so response +/// time doesn't reveal whether a username is registered. +fn dummy_hash() -> &'static str { + static DUMMY: OnceLock = OnceLock::new(); + DUMMY.get_or_init(|| { + hash_password("not-a-real-password-just-for-timing") + .expect("dummy hash must be constructible") + }) +} + +fn validate_password(password: &str) -> AppResult<()> { + if password.chars().count() < MIN_PASSWORD_LEN { + return Err(AppError::BadRequest(format!( + "Password must be at least {MIN_PASSWORD_LEN} characters." + ))); + } + Ok(()) +} + +fn normalise_email(raw: Option) -> Option { + raw.map(|e| e.trim().to_string()).filter(|e| !e.is_empty()) +} + +// ---------------------------------------------------------------- queries + +async fn load_user_by_id(db: &sqlx::PgPool, id: Uuid) -> AppResult> { + let user = sqlx::query_as::<_, User>( + "SELECT id, username, email, password_hash FROM users WHERE id = $1", + ) + .bind(id) + .fetch_optional(db) + .await?; + Ok(user) +} + +async fn load_user_by_login(db: &sqlx::PgPool, login: &str) -> AppResult> { + let user = sqlx::query_as::<_, User>( + "SELECT id, username, email, password_hash FROM users \ + WHERE lower(username) = lower($1) OR lower(email) = lower($1)", + ) + .bind(login) + .fetch_optional(db) + .await?; + Ok(user) +} + +fn is_unique_violation(err: &sqlx::Error) -> bool { + matches!(err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23505")) +} + +// ---------------------------------------------------------------- register + +#[derive(Deserialize)] +pub struct RegisterRequest { + pub username: String, + #[serde(default)] + pub email: Option, + pub password: String, + pub reg_token: String, +} + +async fn register( + State(state): State, + Json(body): Json, +) -> AppResult { + // Constant-time so the shared secret can't be recovered by timing. + let matches: bool = body + .reg_token + .as_bytes() + .ct_eq(state.config.reg_token.as_bytes()) + .into(); + if !matches { + return Err(AppError::Unauthorised); + } + + let username = body.username.trim().to_string(); + if username.is_empty() { + return Err(AppError::BadRequest("Enter a username.".into())); + } + validate_password(&body.password)?; + + let email = normalise_email(body.email); + let hash = hash_password(&body.password)?; + + let result = sqlx::query( + "INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)", + ) + .bind(&username) + .bind(&email) + .bind(&hash) + .execute(&state.db) + .await; + + match result { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) if is_unique_violation(&e) => Err(AppError::Conflict( + "That username or email is already taken.".into(), + )), + Err(e) => Err(e.into()), + } +} + +// ---------------------------------------------------------------- login / logout / me + +#[derive(Deserialize)] +pub struct LoginRequest { + pub username_or_email: String, + pub password: String, +} + +async fn login( + State(state): State, + session: Session, + Json(body): Json, +) -> AppResult> { + let user = load_user_by_login(&state.db, body.username_or_email.trim()).await?; + + let Some(user) = user else { + // Spend the same time as a real verification before failing. + verify_password(&body.password, dummy_hash()); + return Err(AppError::Unauthorised); + }; + + if !verify_password(&body.password, &user.password_hash) { + return Err(AppError::Unauthorised); + } + + // Fresh session ID on privilege change. + session.cycle_id().await?; + session.insert(SESSION_USER_KEY, user.id).await?; + + Ok(Json(MeResponse { + id: user.id, + username: user.username, + email: user.email, + })) +} + +async fn logout(session: Session) -> AppResult { + session.flush().await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn me(CurrentUser(user): CurrentUser) -> Json { + Json(MeResponse { + id: user.id, + username: user.username, + email: user.email, + }) +} + +// ---------------------------------------------------------------- account changes + +#[derive(Deserialize)] +pub struct ChangePasswordRequest { + pub current_password: String, + pub new_password: String, +} + +async fn change_password( + State(state): State, + session: Session, + CurrentUser(user): CurrentUser, + Json(body): Json, +) -> AppResult { + if !verify_password(&body.current_password, &user.password_hash) { + return Err(AppError::Unauthorised); + } + validate_password(&body.new_password)?; + + let hash = hash_password(&body.new_password)?; + sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") + .bind(&hash) + .bind(user.id) + .execute(&state.db) + .await?; + + // Rotate this session's ID (closes session fixation). Deliberately does NOT + // invalidate other sessions — a password change affects the next login only. + session.cycle_id().await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct ChangeEmailRequest { + pub current_password: String, + /// null or empty clears the address, giving up password recovery. + #[serde(default)] + pub new_email: Option, +} + +async fn change_email( + State(state): State, + CurrentUser(user): CurrentUser, + Json(body): Json, +) -> AppResult { + if !verify_password(&body.current_password, &user.password_hash) { + return Err(AppError::Unauthorised); + } + + let email = normalise_email(body.new_email); + + let result = sqlx::query("UPDATE users SET email = $1 WHERE id = $2") + .bind(&email) + .bind(user.id) + .execute(&state.db) + .await; + + match result { + Ok(_) => Ok(StatusCode::NO_CONTENT), + Err(e) if is_unique_violation(&e) => { + Err(AppError::Conflict("That email is already in use.".into())) + } + Err(e) => Err(e.into()), + } +} + +// ---------------------------------------------------------------- password reset + +fn generate_reset_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn hash_reset_token(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +#[derive(Deserialize)] +pub struct ResetRequestBody { + pub email: String, +} + +async fn reset_request( + State(state): State, + Json(body): Json, +) -> AppResult { + let email = body.email.trim().to_string(); + + let user = sqlx::query_as::<_, User>( + "SELECT id, username, email, password_hash FROM users WHERE lower(email) = lower($1)", + ) + .bind(&email) + .fetch_optional(&state.db) + .await?; + + if let Some(user) = user { + let token = generate_reset_token(); + let expires = time::OffsetDateTime::now_utc() + + time::Duration::minutes(RESET_TOKEN_TTL_MINUTES); + + sqlx::query( + "UPDATE users SET reset_token_hash = $1, reset_token_expires = $2 WHERE id = $3", + ) + .bind(hash_reset_token(&token)) + .bind(expires) + .bind(user.id) + .execute(&state.db) + .await?; + + let link = state + .config + .public_link(&format!("reset?token={token}")); + + // Spawned so the response never blocks on SMTP. The token is already + // persisted, so a send failure means "no email arrived", not bad state. + let mailer = state.mailer.clone(); + let to = user.email.clone().unwrap_or_default(); + tokio::spawn(async move { + if let Err(e) = mailer.send_password_reset(&to, &link).await { + tracing::error!(error = %e, "password reset email failed"); + } + }); + } + + // Always the same response, so this can't be used to enumerate addresses. + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct ResetConfirmBody { + pub token: String, + pub new_password: String, +} + +#[derive(FromRow)] +struct ResetCandidate { + id: Uuid, +} + +async fn reset_confirm( + State(state): State, + Json(body): Json, +) -> AppResult { + validate_password(&body.new_password)?; + + let token_hash = hash_reset_token(body.token.trim()); + + let candidate = sqlx::query_as::<_, ResetCandidate>( + "SELECT id FROM users WHERE reset_token_hash = $1 AND reset_token_expires > now()", + ) + .bind(&token_hash) + .fetch_optional(&state.db) + .await?; + + let Some(candidate) = candidate else { + return Err(AppError::BadRequest( + "That reset link has expired or already been used.".into(), + )); + }; + + let hash = hash_password(&body.new_password)?; + sqlx::query( + "UPDATE users SET password_hash = $1, reset_token_hash = NULL, \ + reset_token_expires = NULL WHERE id = $2", + ) + .bind(&hash) + .bind(candidate.id) + .execute(&state.db) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +// ---------------------------------------------------------------- routes + +pub fn routes() -> Router { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) + .route("/logout", post(logout)) + .route("/me", get(me)) + .route("/me/password", post(change_password)) + .route("/me/email", post(change_email)) + .route("/password-reset/request", post(reset_request)) + .route("/password-reset/confirm", post(reset_confirm)) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6d46607 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,137 @@ +// src/config.rs +use anyhow::{Context, Result}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub bind_addr: String, + /// Normalised: either "" (root) or "/maps" (leading slash, no trailing slash). + pub base_path: String, + /// Origin only, no trailing slash, e.g. "https://example.com". + pub public_url: String, + pub reg_token: String, + /// Sent to the browser via GET /api/config so swapping tile providers is an + /// edit and a restart, not a recompile. + pub tile_url: String, + pub session_duration_days: i64, + pub gpx_dir: PathBuf, + pub smtp: SmtpConfig, +} + +#[derive(Clone, Debug)] +pub struct SmtpConfig { + /// Empty means "use the stub transport and log reset links". + pub host: String, + pub port: u16, + pub tls: SmtpTls, + pub username: String, + pub password: String, + pub from: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SmtpTls { + StartTls, + Implicit, + None, +} + +fn var(key: &str) -> Result { + std::env::var(key).with_context(|| format!("{key} is not set")) +} + +fn var_or(key: &str, default: &str) -> String { + std::env::var(key) + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +impl Config { + pub fn from_env() -> Result { + let base_path = normalise_base_path(&var_or("BASE_PATH", "")); + + let public_url = var_or("PUBLIC_URL", "http://localhost:8080") + .trim_end_matches('/') + .to_string(); + + let tls = match var_or("SMTP_TLS", "starttls").to_lowercase().as_str() { + "implicit" | "tls" | "smtps" => SmtpTls::Implicit, + "none" | "plain" | "" => SmtpTls::None, + _ => SmtpTls::StartTls, + }; + + Ok(Config { + database_url: var("DATABASE_URL")?, + bind_addr: var_or("BIND_ADDR", "127.0.0.1:8080"), + base_path, + public_url, + reg_token: var("REG_TOKEN")?, + tile_url: var_or( + "TILE_URL", + // Apex host: the {s}.tile.openstreetmap.org sharded form is deprecated. + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + ), + session_duration_days: var_or("SESSION_DURATION_DAYS", "30") + .parse() + .context("SESSION_DURATION_DAYS must be an integer")?, + gpx_dir: PathBuf::from(var("GPX_DIR")?), + smtp: SmtpConfig { + host: var_or("SMTP_HOST", ""), + port: var_or("SMTP_PORT", "587") + .parse() + .context("SMTP_PORT must be a number")?, + tls, + username: var_or("SMTP_USERNAME", ""), + password: var_or("SMTP_PASSWORD", ""), + from: var_or("SMTP_FROM", "mapserver@localhost"), + }, + }) + } + + /// The href used in the injected tag. Always ends with "/". + pub fn base_href(&self) -> String { + format!("{}/", self.base_path) + } + + /// Absolute URL for a path relative to the app root, e.g. "reset?token=…". + pub fn public_link(&self, rel: &str) -> String { + format!("{}{}/{}", self.public_url, self.base_path, rel) + } + + /// Cookie Path attribute. Cookies require a non-empty path. + pub fn cookie_path(&self) -> String { + if self.base_path.is_empty() { + "/".to_string() + } else { + self.base_path.clone() + } + } +} + +/// "" and "/" both mean root. Anything else gets a leading slash and no trailing slash. +fn normalise_base_path(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + String::new() + } else if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } +} + +#[cfg(test)] +mod tests { + use super::normalise_base_path; + + #[test] + fn base_path_normalisation() { + assert_eq!(normalise_base_path(""), ""); + assert_eq!(normalise_base_path("/"), ""); + assert_eq!(normalise_base_path("maps"), "/maps"); + assert_eq!(normalise_base_path("/maps/"), "/maps"); + assert_eq!(normalise_base_path("/maps"), "/maps"); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..cc2b0a8 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +// src/error.rs +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("unauthorised")] + Unauthorised, + + #[error("not found")] + NotFound, + + #[error("{0}")] + BadRequest(String), + + #[error("{0}")] + Conflict(String), + + #[error("payload too large")] + TooLarge, + + #[error(transparent)] + Database(#[from] sqlx::Error), + + #[error(transparent)] + Session(#[from] tower_sessions::session::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::Unauthorised => (StatusCode::UNAUTHORIZED, self.to_string()), + AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), + AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), + AppError::Conflict(m) => (StatusCode::CONFLICT, m.clone()), + AppError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, self.to_string()), + other => { + // Internal detail stays in the log, never in the response body. + tracing::error!(error = %other, "internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "something went wrong".to_string(), + ) + } + }; + + (status, Json(json!({ "error": message }))).into_response() + } +} + +pub type AppResult = Result; diff --git a/src/gpx.rs b/src/gpx.rs new file mode 100644 index 0000000..851f849 --- /dev/null +++ b/src/gpx.rs @@ -0,0 +1,265 @@ +// src/gpx.rs +use std::fs::OpenOptions; +use std::io::ErrorKind; +use std::path::{Path as StdPath, PathBuf}; + +use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Serialize; +use tokio::io::AsyncWriteExt; + +use crate::auth::CurrentUser; +use crate::error::{AppError, AppResult}; +use crate::AppState; + +/// A GPX with ~20k trackpoints carrying elevation and timestamps lands in the +/// 5–15 MB range. Axum's default is 2 MB, so this is raised on the upload route +/// only — a 25 MB limit on /api/login would be free memory exhaustion. +/// nginx needs a matching `client_max_body_size 25m;` on the same location. +const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; + +const MAX_COLLISION_ATTEMPTS: u32 = 1000; + +#[derive(Serialize)] +pub struct GpxFile { + name: String, + size: u64, + /// RFC 3339, UTC. + modified: Option, +} + +// ---------------------------------------------------------------- name safety + +/// Accepts only a plain filename: no separators, no traversal, must be a .gpx. +fn validate_filename(name: &str) -> AppResult<()> { + let bad = |m: &str| Err(AppError::BadRequest(m.to_string())); + + if name.is_empty() || name.len() > 120 { + return bad("File names must be between 1 and 120 characters."); + } + if name.starts_with('.') { + return bad("File names can't start with a dot."); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-')) + { + return bad("File names can use letters, numbers, spaces, dots, underscores and hyphens."); + } + if !name.to_ascii_lowercase().ends_with(".gpx") { + return bad("Only .gpx files are accepted."); + } + Ok(()) +} + +/// Joins under `dir` and confirms the canonical result is still inside it. +/// Belt and braces on top of `validate_filename`. +fn resolve_existing(dir: &StdPath, name: &str) -> AppResult { + validate_filename(name)?; + + let candidate = dir.join(name); + let canonical_dir = dir.canonicalize().map_err(|_| AppError::NotFound)?; + let canonical = candidate.canonicalize().map_err(|_| AppError::NotFound)?; + + if !canonical.starts_with(&canonical_dir) || !canonical.is_file() { + return Err(AppError::NotFound); + } + Ok(canonical) +} + +/// Splits "track.gpx" into ("track", "gpx"). The extension is always gpx by the +/// time this runs, but the original case is preserved. +fn split_name(name: &str) -> (String, String) { + match name.rsplit_once('.') { + Some((stem, ext)) => (stem.to_string(), ext.to_string()), + None => (name.to_string(), "gpx".to_string()), + } +} + +/// Reserves a free filename atomically. `create_new` fails if the path exists, +/// so there's no check-then-write race: track.gpx → track-1.gpx → track-2.gpx. +fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)> { + let (stem, ext) = split_name(name); + + for n in 0..MAX_COLLISION_ATTEMPTS { + let candidate = if n == 0 { + format!("{stem}.{ext}") + } else { + format!("{stem}-{n}.{ext}") + }; + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(dir.join(&candidate)) + { + Ok(file) => return Ok((file, candidate)), + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.into()), + } + } + + Err(AppError::Conflict( + "Too many files with that name already.".into(), + )) +} + +// ---------------------------------------------------------------- handlers + +async fn list( + State(state): State, + CurrentUser(_): CurrentUser, +) -> AppResult>> { + let dir = &state.config.gpx_dir; + let mut entries = tokio::fs::read_dir(dir).await?; + let mut files = Vec::new(); + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name().to_string_lossy().to_string(); + if validate_filename(&name).is_err() { + continue; + } + + let meta = match entry.metadata().await { + Ok(m) if m.is_file() => m, + _ => continue, + }; + + let modified = meta + .modified() + .ok() + .map(time::OffsetDateTime::from) + .and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()); + + files.push(GpxFile { + name, + size: meta.len(), + modified, + }); + } + + files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(Json(files)) +} + +async fn fetch( + State(state): State, + CurrentUser(_): CurrentUser, + Path(name): Path, +) -> AppResult { + let path = resolve_existing(&state.config.gpx_dir, &name)?; + let body = tokio::fs::read(&path).await?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/gpx+xml"), + (header::CACHE_CONTROL, "no-store"), + ], + body, + ) + .into_response()) +} + +#[derive(Serialize)] +struct UploadResponse { + /// The name actually written, which may differ from the one submitted. + name: String, +} + +async fn upload( + State(state): State, + CurrentUser(_): CurrentUser, + mut multipart: Multipart, +) -> AppResult> { + let dir = state.config.gpx_dir.clone(); + + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Upload failed: {e}")))? + { + let Some(raw_name) = field.file_name().map(|s| s.to_string()) else { + continue; + }; + + // Browsers may send a path in some cases; keep only the final component. + let submitted = raw_name + .rsplit(['/', '\\']) + .next() + .unwrap_or(&raw_name) + .trim() + .to_string(); + validate_filename(&submitted)?; + + let (std_file, final_name) = reserve(&dir, &submitted)?; + let mut file = tokio::fs::File::from_std(std_file); + let written_path = dir.join(&final_name); + + let mut total: usize = 0; + loop { + let chunk = match field.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(e) => { + let _ = tokio::fs::remove_file(&written_path).await; + return Err(AppError::BadRequest(format!("Upload failed: {e}"))); + } + }; + + total += chunk.len(); + if total > MAX_UPLOAD_BYTES { + let _ = tokio::fs::remove_file(&written_path).await; + return Err(AppError::TooLarge); + } + + if let Err(e) = file.write_all(&chunk).await { + let _ = tokio::fs::remove_file(&written_path).await; + return Err(e.into()); + } + } + + if let Err(e) = file.flush().await { + let _ = tokio::fs::remove_file(&written_path).await; + return Err(e.into()); + } + + return Ok(Json(UploadResponse { name: final_name })); + } + + Err(AppError::BadRequest("No file was included.".into())) +} + +pub fn routes() -> Router { + Router::new() + .route("/", get(list)) + // Distinct prefix rather than /{filename} so nothing can shadow /upload. + .route("/file/{name}", get(fetch)) + .route( + "/upload", + post(upload).layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES)), + ) +} + +#[cfg(test)] +mod tests { + use super::validate_filename; + + #[test] + fn rejects_traversal_and_junk() { + assert!(validate_filename("../../etc/passwd").is_err()); + assert!(validate_filename("a/b.gpx").is_err()); + assert!(validate_filename("a\\b.gpx").is_err()); + assert!(validate_filename(".hidden.gpx").is_err()); + assert!(validate_filename("notes.txt").is_err()); + assert!(validate_filename("").is_err()); + } + + #[test] + fn accepts_ordinary_names() { + assert!(validate_filename("Snowdon ridge.gpx").is_ok()); + assert!(validate_filename("track_2026-01-02.GPX").is_ok()); + } +} diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..408481e --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,92 @@ +// src/mail.rs +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +use crate::config::{SmtpConfig, SmtpTls}; + +#[derive(Clone)] +pub struct Mailer(Arc); + +enum Inner { + Smtp { + transport: AsyncSmtpTransport, + from: String, + }, + /// No SMTP_HOST configured: log the link instead of sending. The entire reset + /// flow is testable this way with no provider. + Stub, +} + +impl Mailer { + pub fn from_config(cfg: &SmtpConfig) -> Result { + if cfg.host.trim().is_empty() { + tracing::warn!("SMTP_HOST is empty — reset links will be logged, not emailed"); + return Ok(Mailer(Arc::new(Inner::Stub))); + } + + // relay() is implicit TLS (usually 465); starttls_relay() upgrades a + // plaintext connection (usually 587). Both verify certificates. + // builder_dangerous() does not — localhost only. + let builder = match cfg.tls { + SmtpTls::Implicit => AsyncSmtpTransport::::relay(&cfg.host) + .context("building implicit-TLS SMTP transport")?, + SmtpTls::StartTls => AsyncSmtpTransport::::starttls_relay(&cfg.host) + .context("building STARTTLS SMTP transport")?, + SmtpTls::None => AsyncSmtpTransport::::builder_dangerous(&cfg.host), + }; + + let mut builder = builder + .port(cfg.port) + .timeout(Some(Duration::from_secs(10))); + + if !cfg.username.is_empty() { + builder = builder.credentials(Credentials::new( + cfg.username.clone(), + cfg.password.clone(), + )); + } + + Ok(Mailer(Arc::new(Inner::Smtp { + transport: builder.build(), + from: cfg.from.clone(), + }))) + } + + pub async fn send_password_reset(&self, to: &str, link: &str) -> Result<()> { + let body = format!( + "Someone asked to reset the password for your mapserver account.\n\n\ + Open this link within the next hour to choose a new one:\n\n{link}\n\n\ + If this wasn't you, ignore this message. Your password stays as it is.\n" + ); + + match &*self.0 { + Inner::Stub => { + tracing::info!(recipient = %to, %link, "password reset (stub transport)"); + Ok(()) + } + Inner::Smtp { transport, from } => { + if to.trim().is_empty() { + return Ok(()); + } + + // Plain text scores better with spam filters than HTML, and the + // From address must be one this SMTP account may send as. + let message = Message::builder() + .from(from.parse().context("SMTP_FROM is not a valid address")?) + .to(to.parse().context("recipient is not a valid address")?) + .subject("Password reset for mapserver") + .header(ContentType::TEXT_PLAIN) + .body(body) + .context("building reset email")?; + + transport.send(message).await.context("sending reset email")?; + Ok(()) + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c1bb3cd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,167 @@ +// src/main.rs +mod auth; +mod config; +mod error; +mod gpx; +mod mail; +mod markers; +mod ratelimit; +mod web; + +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::{Context, Result}; +use axum::routing::get; +use axum::Router; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use tower_http::trace::TraceLayer; +use tower_sessions::cookie::SameSite; +use tower_sessions::{Expiry, SessionManagerLayer}; +use tower_sessions_sqlx_store::PostgresStore; + +use crate::config::Config; +use crate::mail::Mailer; +use crate::ratelimit::RateLimiter; + +#[derive(Clone)] +pub struct AppState { + pub db: PgPool, + pub config: std::sync::Arc, + pub mailer: Mailer, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "rs_maps=info,tower_http=warn".into()), + ) + .init(); + + let config = Config::from_env()?; + + // GPX_DIR must exist and be writable by this user before anything else. + tokio::fs::create_dir_all(&config.gpx_dir) + .await + .with_context(|| format!("cannot create GPX_DIR at {}", config.gpx_dir.display()))?; + + let db = connect_with_retry(&config.database_url).await?; + + sqlx::migrate!("./migrations") + .run(&db) + .await + .context("running migrations")?; + + // The session store owns its own table; let its migration create it. + let session_store = PostgresStore::new(db.clone()); + session_store + .migrate() + .await + .context("running session store migration")?; + + let mailer = Mailer::from_config(&config.smtp)?; + + let session_layer = SessionManagerLayer::new(session_store) + .with_name("mapserver.sid") + .with_secure(true) + .with_http_only(true) + .with_same_site(SameSite::Strict) + .with_path(config.cookie_path()) + .with_expiry(Expiry::OnInactivity(time::Duration::days( + config.session_duration_days, + ))); + + let bind_addr = config.bind_addr.clone(); + let base_path = config.base_path.clone(); + + let state = AppState { + db, + config: std::sync::Arc::new(config), + mailer, + }; + + // 20 attempts per minute per IP across the auth surface: generous for two + // humans, useless for grinding REG_TOKEN or a password. + let limiter = RateLimiter::new(20, Duration::from_secs(60), true); + + let auth_routes = auth::routes().layer(axum::middleware::from_fn_with_state( + limiter.clone(), + ratelimit::limit, + )); + + let api = Router::new() + .merge(auth_routes) + .route("/config", get(web::client_config)) + .nest("/markers", markers::routes()) + .nest("/gpx", gpx::routes()); + + let app = Router::new() + .nest("/api", api) + .fallback(web::serve_asset) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + web::origin_guard, + )) + .layer(session_layer) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + // nest("/") panics, so root deployment uses the router as-is. + let app = if base_path.is_empty() { + app + } else { + Router::new().nest(&base_path, app) + }; + + let listener = tokio::net::TcpListener::bind(&bind_addr) + .await + .with_context(|| format!("cannot bind {bind_addr}"))?; + + tracing::info!( + "mapserver listening on {bind_addr} under {}", + if base_path.is_empty() { "/" } else { &base_path } + ); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .context("server error")?; + Ok(()) +} + +/// systemd's After= doesn't mean Postgres is accepting connections yet, so retry +/// rather than exiting on the first refusal. +async fn connect_with_retry(url: &str) -> Result { + const ATTEMPTS: u32 = 10; + + for attempt in 1..=ATTEMPTS { + match PgPoolOptions::new() + .max_connections(8) + .acquire_timeout(Duration::from_secs(5)) + .connect(url) + .await + { + Ok(pool) => return Ok(pool), + Err(e) if attempt < ATTEMPTS => { + let wait = Duration::from_millis(500 * u64::from(attempt)); + tracing::warn!( + attempt, + error = %e, + "database not ready, retrying in {:?}", + wait + ); + tokio::time::sleep(wait).await; + } + Err(e) => return Err(e).context("database unreachable after retries"), + } + } + + unreachable!() +} diff --git a/src/markers.rs b/src/markers.rs new file mode 100644 index 0000000..a1d51e6 --- /dev/null +++ b/src/markers.rs @@ -0,0 +1,235 @@ +// src/markers.rs +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, put}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::auth::CurrentUser; +use crate::error::{AppError, AppResult}; +use crate::AppState; + +#[derive(Debug, Serialize, FromRow)] +pub struct Marker { + pub id: Uuid, + pub owner_id: Uuid, + pub name: String, + pub description: Option, + pub lat: f64, + pub lon: f64, + pub category: Option, + pub color: Option, + pub is_shared: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: time::OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: time::OffsetDateTime, +} + +#[derive(Debug, Deserialize)] +pub struct MarkerInput { + pub name: String, + #[serde(default)] + pub description: Option, + pub lat: f64, + pub lon: f64, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub is_shared: bool, +} + +const COLUMNS: &str = "id, owner_id, name, description, lat, lon, category, color, \ + is_shared, created_at, updated_at"; + +fn validate(input: &MarkerInput) -> AppResult<()> { + if input.name.trim().is_empty() { + return Err(AppError::BadRequest("Give the place a name.".into())); + } + if !(-90.0..=90.0).contains(&input.lat) || !(-180.0..=180.0).contains(&input.lon) { + return Err(AppError::BadRequest("Those coordinates are off the map.".into())); + } + Ok(()) +} + +fn clean(value: &Option) -> Option { + value + .as_ref() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// "minlon,minlat,maxlon,maxlat". Optional; omitted means everything. + #[serde(default)] + pub bbox: Option, +} + +/// Parses the bbox parameter. Built in now because adding it later would be a +/// breaking API change; the frontend can start using it whenever it wants to. +fn parse_bbox(raw: &str) -> AppResult<(f64, f64, f64, f64)> { + let bad = || AppError::BadRequest("bbox must be minlon,minlat,maxlon,maxlat".into()); + + let parts: Vec = raw + .split(',') + .map(|p| p.trim().parse::().map_err(|_| bad())) + .collect::>()?; + + let [min_lon, min_lat, max_lon, max_lat] = parts[..] else { + return Err(bad()); + }; + + if min_lon > max_lon || min_lat > max_lat { + return Err(bad()); + } + if !(-90.0..=90.0).contains(&min_lat) || !(-90.0..=90.0).contains(&max_lat) { + return Err(bad()); + } + if !(-180.0..=180.0).contains(&min_lon) || !(-180.0..=180.0).contains(&max_lon) { + return Err(bad()); + } + + Ok((min_lon, min_lat, max_lon, max_lat)) +} + +/// Own markers plus every shared marker, whoever owns it. +async fn list( + State(state): State, + CurrentUser(user): CurrentUser, + Query(query): Query, +) -> AppResult>> { + let rows = match query.bbox.as_deref().filter(|b| !b.trim().is_empty()) { + None => { + sqlx::query_as::<_, Marker>(&format!( + "SELECT {COLUMNS} FROM markers WHERE owner_id = $1 OR is_shared \ + ORDER BY created_at DESC" + )) + .bind(user.id) + .fetch_all(&state.db) + .await? + } + Some(raw) => { + let (min_lon, min_lat, max_lon, max_lat) = parse_bbox(raw)?; + sqlx::query_as::<_, Marker>(&format!( + "SELECT {COLUMNS} FROM markers \ + WHERE (owner_id = $1 OR is_shared) \ + AND lon BETWEEN $2 AND $3 AND lat BETWEEN $4 AND $5 \ + ORDER BY created_at DESC" + )) + .bind(user.id) + .bind(min_lon) + .bind(max_lon) + .bind(min_lat) + .bind(max_lat) + .fetch_all(&state.db) + .await? + } + }; + + Ok(Json(rows)) +} + +async fn create( + State(state): State, + CurrentUser(user): CurrentUser, + Json(input): Json, +) -> AppResult<(StatusCode, Json)> { + validate(&input)?; + + let marker = sqlx::query_as::<_, Marker>(&format!( + "INSERT INTO markers (owner_id, name, description, lat, lon, category, color, is_shared) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLUMNS}" + )) + .bind(user.id) + .bind(input.name.trim()) + .bind(clean(&input.description)) + .bind(input.lat) + .bind(input.lon) + .bind(clean(&input.category)) + .bind(clean(&input.color)) + .bind(input.is_shared) + .fetch_one(&state.db) + .await?; + + Ok((StatusCode::CREATED, Json(marker))) +} + +async fn update( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + validate(&input)?; + + // updated_at is set explicitly; DEFAULT now() only fires on INSERT. + let marker = sqlx::query_as::<_, Marker>(&format!( + "UPDATE markers SET name = $1, description = $2, lat = $3, lon = $4, \ + category = $5, color = $6, is_shared = $7, updated_at = now() \ + WHERE id = $8 AND owner_id = $9 RETURNING {COLUMNS}" + )) + .bind(input.name.trim()) + .bind(clean(&input.description)) + .bind(input.lat) + .bind(input.lon) + .bind(clean(&input.category)) + .bind(clean(&input.color)) + .bind(input.is_shared) + .bind(id) + .bind(user.id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(marker)) +} + +async fn delete( + State(state): State, + CurrentUser(user): CurrentUser, + Path(id): Path, +) -> AppResult { + let result = sqlx::query("DELETE FROM markers WHERE id = $1 AND owner_id = $2") + .bind(id) + .bind(user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + // Same response whether it's someone else's marker or doesn't exist. + return Err(AppError::NotFound); + } + Ok(StatusCode::NO_CONTENT) +} + +pub fn routes() -> Router { + Router::new() + .route("/", get(list).post(create)) + // axum 0.8 uses {id}, not :id + .route("/{id}", put(update).delete(delete)) +} + +#[cfg(test)] +mod tests { + use super::parse_bbox; + + #[test] + fn accepts_a_sane_box() { + let (min_lon, min_lat, max_lon, max_lat) = + parse_bbox("-4.2,53.0,-3.9,53.2").expect("valid bbox"); + assert!(min_lon < max_lon && min_lat < max_lat); + } + + #[test] + fn rejects_junk() { + assert!(parse_bbox("1,2,3").is_err()); + assert!(parse_bbox("a,b,c,d").is_err()); + assert!(parse_bbox("10,0,-10,5").is_err()); + assert!(parse_bbox("0,0,0,999").is_err()); + } +} diff --git a/src/ratelimit.rs b/src/ratelimit.rs new file mode 100644 index 0000000..d7078a9 --- /dev/null +++ b/src/ratelimit.rs @@ -0,0 +1,144 @@ +// src/ratelimit.rs +// +// A fixed-window rate limiter for the auth endpoints, keyed by client IP. +// +// This is deliberately a few dozen lines rather than a dependency. At two users +// the requirement is "a shared registration secret shouldn't be brute-forceable +// and login shouldn't be a password oracle", which a fixed window covers. It is +// per-process and resets on restart; that is fine here and would not be at scale. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::extract::{ConnectInfo, Request}; +use axum::http::{header, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use std::net::SocketAddr; + +#[derive(Clone)] +pub struct RateLimiter { + inner: Arc>, + limit: u32, + window: Duration, + /// True when the app sits behind a reverse proxy we control, in which case + /// X-Forwarded-For is trustworthy. Direct peer address is used otherwise. + trust_forwarded: bool, +} + +struct State { + hits: HashMap, + last_sweep: Instant, +} + +struct Window { + count: u32, + started: Instant, +} + +impl RateLimiter { + pub fn new(limit: u32, window: Duration, trust_forwarded: bool) -> Self { + Self { + inner: Arc::new(Mutex::new(State { + hits: HashMap::new(), + last_sweep: Instant::now(), + })), + limit, + window, + trust_forwarded, + } + } + + /// Records a hit. Returns false when the caller is over the limit. + fn allow(&self, key: String) -> bool { + let now = Instant::now(); + let mut state = self.inner.lock().expect("rate limiter mutex poisoned"); + + // Drop stale windows occasionally so the map can't grow without bound. + if now.duration_since(state.last_sweep) > self.window { + let window = self.window; + state + .hits + .retain(|_, w| now.duration_since(w.started) < window); + state.last_sweep = now; + } + + let entry = state.hits.entry(key).or_insert(Window { + count: 0, + started: now, + }); + + if now.duration_since(entry.started) >= self.window { + entry.count = 0; + entry.started = now; + } + + entry.count += 1; + entry.count <= self.limit + } + + fn client_key(&self, request: &Request) -> String { + if self.trust_forwarded { + if let Some(forwarded) = request + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|v| !v.is_empty() && v.parse::().is_ok()) + { + return forwarded.to_string(); + } + } + + request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.ip().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + } +} + +/// Layer this onto the auth routes with `axum::middleware::from_fn_with_state`. +pub async fn limit( + axum::extract::State(limiter): axum::extract::State, + request: Request, + next: Next, +) -> Response { + let key = limiter.client_key(&request); + + if !limiter.allow(key) { + let retry_after = limiter.window.as_secs().to_string(); + return ( + StatusCode::TOO_MANY_REQUESTS, + [(header::RETRY_AFTER, retry_after)], + "Too many attempts. Wait a minute and try again.", + ) + .into_response(); + } + + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::RateLimiter; + use std::time::Duration; + + #[test] + fn blocks_after_limit_then_recovers() { + let limiter = RateLimiter::new(2, Duration::from_millis(50), false); + + assert!(limiter.allow("a".into())); + assert!(limiter.allow("a".into())); + assert!(!limiter.allow("a".into())); + + // A different client is unaffected. + assert!(limiter.allow("b".into())); + + std::thread::sleep(Duration::from_millis(60)); + assert!(limiter.allow("a".into())); + } +} diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..ed45a30 --- /dev/null +++ b/src/web.rs @@ -0,0 +1,110 @@ +// src/web.rs +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{header, HeaderValue, Method, StatusCode, Uri}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use rust_embed::RustEmbed; +use serde::Serialize; + +use crate::AppState; + +#[derive(RustEmbed)] +#[folder = "frontend/"] +struct Assets; + +const BASE_PLACEHOLDER: &str = "__BASE_HREF__"; + +/// Serves an embedded asset, falling back to index.html so client-side routes +/// like /maps/reset?token=… load the app instead of 404ing. +pub async fn serve_asset(State(state): State, uri: Uri) -> Response { + // Inside a nest, uri.path() is already stripped of BASE_PATH. + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + + if let Some(response) = asset_response(&state, path) { + return response; + } + + // Unknown path: hand back the shell and let the frontend route it. + asset_response(&state, "index.html").unwrap_or_else(|| { + (StatusCode::INTERNAL_SERVER_ERROR, "frontend assets missing").into_response() + }) +} + +fn asset_response(state: &AppState, path: &str) -> Option { + let file = Assets::get(path)?; + let mime = mime_guess::from_path(path).first_or_octet_stream(); + + // index.html carries a tag so every relative URL in the frontend + // resolves under BASE_PATH without the frontend knowing what it is. + let body: Body = if path.ends_with(".html") { + let text = String::from_utf8_lossy(&file.data) + .replace(BASE_PLACEHOLDER, &state.config.base_href()); + Body::from(text) + } else { + Body::from(file.data.into_owned()) + }; + + let cache = if path.ends_with(".html") { + "no-cache" + } else { + "public, max-age=3600" + }; + + Some( + ( + [ + (header::CONTENT_TYPE, mime.as_ref()), + (header::CACHE_CONTROL, cache), + ], + body, + ) + .into_response(), + ) +} + +/// Belt and braces alongside SameSite=Strict: reject mutating requests whose +/// Origin header doesn't match PUBLIC_URL. Absent Origin is allowed, since +/// same-origin GETs and some clients omit it. +pub async fn origin_guard( + State(state): State, + request: Request, + next: Next, +) -> Response { + let mutating = matches!( + *request.method(), + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ); + + if mutating { + if let Some(origin) = request.headers().get(header::ORIGIN) { + let expected = HeaderValue::from_str(&state.config.public_url).ok(); + if Some(origin) != expected.as_ref() { + tracing::warn!(?origin, "rejected cross-origin request"); + return (StatusCode::FORBIDDEN, "cross-origin request rejected") + .into_response(); + } + } + } + + next.run(request).await +} + +#[derive(Serialize)] +pub struct ClientConfig { + tile_url: String, + base_path: String, + attribution: String, +} + +/// Public on purpose: the login screen renders the map behind it, and none of +/// this is secret. +pub async fn client_config(State(state): State) -> Json { + Json(ClientConfig { + tile_url: state.config.tile_url.clone(), + base_path: state.config.base_path.clone(), + attribution: "\u{00a9} OpenStreetMap contributors".to_string(), + }) +}