From 69cd7364550949eb6aa2998455467a6822c4f438 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 3 Sep 2026 04:38:22 +0000 Subject: [PATCH] Web dashboard step 1: migration, sessions, users CLI, public site Migration 0004 (users, sessions, config_changes, profile_versions, jobs, rating_events.user_id, runs.report_json, issues.issue_json), the issue snapshot writer and loader, the web module skeleton with layout and static assets, axum-login/tower-sessions over a sqlx session store, password-auth users with a CLI, the login throttle, the origin check, security headers, and the public issue pages, archive, Atom feed and robots.txt. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM --- Cargo.lock | 531 +++++++++- Cargo.toml | 12 +- README.md | 31 +- askama.toml | 2 +- config.example.toml | 5 + .../briefs/web-dashboard/handoff-step1.md | 72 ++ migrations/0004_web.sql | 58 ++ src/config.rs | 34 + src/curate/profile/mod.rs | 3 + src/db.rs | 148 ++- src/lib.rs | 1 + src/main.rs | 128 ++- src/pipeline.rs | 10 + src/publish.rs | 1 + src/server.rs | 151 ++- src/types.rs | 2 + src/web/issue.rs | 385 ++++++++ src/web/mod.rs | 914 ++++++++++++++++++ src/web/public.rs | 376 +++++++ src/web/session.rs | 522 ++++++++++ src/web/static/app.css | 35 + src/web/static/app.js | 11 + src/web/static/favicon.svg | 1 + src/web/templates/_pagination.html | 1 + src/web/templates/account.html | 1 + src/web/templates/dashboard/overview.html | 1 + src/web/templates/error.html | 1 + src/web/templates/feed_entry.html | 1 + src/web/templates/issue_list.html | 1 + src/web/templates/issue_public.html | 7 + src/web/templates/layout.html | 20 + src/web/templates/login.html | 1 + src/web/users.rs | 312 ++++++ tests/e2e_pipeline.rs | 1 + tests/m7_server.rs | 90 +- 35 files changed, 3801 insertions(+), 69 deletions(-) create mode 100644 docs/plans/briefs/web-dashboard/handoff-step1.md create mode 100644 migrations/0004_web.sql create mode 100644 src/web/issue.rs create mode 100644 src/web/mod.rs create mode 100644 src/web/public.rs create mode 100644 src/web/session.rs create mode 100644 src/web/static/app.css create mode 100644 src/web/static/app.js create mode 100644 src/web/static/favicon.svg create mode 100644 src/web/templates/_pagination.html create mode 100644 src/web/templates/account.html create mode 100644 src/web/templates/dashboard/overview.html create mode 100644 src/web/templates/error.html create mode 100644 src/web/templates/feed_entry.html create mode 100644 src/web/templates/issue_list.html create mode 100644 src/web/templates/issue_public.html create mode 100644 src/web/templates/layout.html create mode 100644 src/web/templates/login.html create mode 100644 src/web/users.rs diff --git a/Cargo.lock b/Cargo.lock index f0291f1..cc452f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,18 @@ dependencies = [ "syn 2.0.119", ] +[[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 = "arrayref" version = "0.3.9" @@ -224,6 +236,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atoi" version = "2.0.0" @@ -269,7 +292,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -372,6 +395,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-login" +version = "0.18.0" +source = "git+https://github.com/maxcountryman/axum-login.git?rev=151c72d7a1b4646830f86b4332e6bd6e34d719a7#151c72d7a1b4646830f86b4332e6bd6e34d719a7" +dependencies = [ + "axum", + "form_urlencoded", + "pin-project", + "serde", + "subtle", + "thiserror 2.0.20", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions", + "tracing", + "urlencoding", +] + [[package]] name = "base64" version = "0.22.1" @@ -384,6 +427,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -438,6 +487,15 @@ dependencies = [ "no_std_io2", ] +[[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" @@ -654,6 +712,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -820,7 +889,9 @@ dependencies = [ "ammonia", "anyhow", "askama", + "async-trait", "axum", + "axum-login", "base64 0.23.1", "clap", "dom_smoothie", @@ -832,26 +903,48 @@ dependencies = [ "image", "jiff", "libc", + "password-auth", "rand 0.10.2", "reqwest", "resvg", + "roxmltree 0.21.1", + "rpassword", "scraper", "serde", "serde_json", "sha2 0.11.0", "sqlx", "tempfile", - "thiserror", + "thiserror 2.0.20", + "time", "tiny-skia", "tokio", "tokio-util", + "toml 1.1.4+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", + "tower", "tower-http 0.7.0", + "tower_governor", "tracing", "tracing-subscriber", "url", "zip", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-url" version = "0.3.2" @@ -886,7 +979,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -894,6 +987,9 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] [[package]] name = "derive_arbitrary" @@ -935,6 +1031,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -990,7 +1087,7 @@ dependencies = [ "once_cell", "phf", "tendril", - "thiserror", + "thiserror 2.0.20", "unicode-segmentation", ] @@ -1056,7 +1153,7 @@ dependencies = [ "log", "once_cell", "tempfile", - "thiserror", + "thiserror 2.0.20", "upon", "uuid", "zip", @@ -1176,7 +1273,7 @@ dependencies = [ "pear", "serde", "tempfile", - "toml", + "toml 0.8.23", "uncased", "version_check", ] @@ -1273,6 +1370,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1361,6 +1468,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -1417,9 +1530,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1458,6 +1573,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.5", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "h2" version = "0.4.15" @@ -1500,6 +1638,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1669,6 +1813,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1987,7 +2144,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2282,6 +2439,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -2410,6 +2579,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.17", + "password-hash", + "rand_core 0.6.4", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -2510,6 +2702,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2668,6 +2880,21 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -2688,7 +2915,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2711,7 +2938,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2783,6 +3010,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[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 = "rand_core" version = "0.9.5" @@ -2837,7 +3073,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha", "simd_helpers", - "thiserror", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -3026,6 +3262,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3292,6 +3549,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3470,6 +3736,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -3511,7 +3786,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -3553,7 +3828,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror", + "thiserror 2.0.20", "tokio", "url", ] @@ -3580,7 +3855,7 @@ dependencies = [ "sha1", "sha2 0.11.0", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", ] @@ -3614,7 +3889,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "tracing", "whoami", ] @@ -3638,7 +3913,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", "url", ] @@ -3800,13 +4075,33 @@ dependencies = [ "new_debug_unreachable", ] +[[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.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[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]] @@ -3854,6 +4149,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3862,6 +4158,16 @@ 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 = "tiny-skia" version = "0.12.0" @@ -3983,9 +4289,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", ] [[package]] @@ -3997,6 +4318,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -4005,18 +4335,75 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + [[package]] name = "toml_write" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -4025,14 +4412,33 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "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" @@ -4080,6 +4486,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "uuid", ] [[package]] @@ -4094,6 +4501,60 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror 2.0.20", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.20", + "tonic", + "tower", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -4264,6 +4725,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.48.1" @@ -4495,6 +4962,22 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4504,6 +4987,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 6ccd020..e611b8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,9 @@ edition = "2024" ammonia = "4.1.4" anyhow = "1.0.104" askama = "0.16.0" +async-trait = "0.1.92" axum = "0.8.9" +axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7", version = "0.18.0" } base64 = "0.23.1" clap = { version = "4.6.6", features = ["derive"] } dom_smoothie = "0.18.0" @@ -19,6 +21,7 @@ hmac = "0.13.0" image = "0.25.10" jiff = { version = "0.2.35", features = ["serde"] } libc = "0.2.189" +password-auth = "1.0.0" rand = "0.10.2" reqwest = { version = "0.13.4", default-features = false, features = [ "rustls", @@ -30,6 +33,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [ "system-proxy", ] } resvg = "0.48.1" +rpassword = "7.5.4" scraper = "0.27.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" @@ -44,15 +48,21 @@ sqlx = { version = "0.9.0", default-features = false, features = [ "tls-rustls-ring", ] } thiserror = "2.0.20" +time = "0.3.55" tiny-skia = "0.12.0" tokio = { version = "1.53.1", features = ["full"] } tokio-util = { version = "0.7.19", features = ["io"] } -tower-http = { version = "0.7.0", features = ["trace", "fs"] } +toml = "1.1.4" +toml_edit = "0.25" +tower-http = { version = "0.7.0", features = ["trace", "fs", "request-id"] } +tower_governor = { version = "0.8.0", features = ["axum"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } url = { version = "2.5.8", features = ["serde"] } [dev-dependencies] figment = { version = "0.10.19", features = ["test", "toml", "env"] } +roxmltree = "0.21.1" tempfile = "3.27.0" +tower = { version = "0.5.3", features = ["util"] } zip = { version = "6", default-features = false, features = ["deflate"] } diff --git a/README.md b/README.md index a0a9610..7e67fa8 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,13 @@ daily-epub features prune # stale embeddings, old telemetry and assessment daily-epub backfill-social [--days 7] # re-poll social scores for recent articles daily-epub db migrate # run migrations (also automatic on every start) daily-epub config check # validate the config, print the resolved roles, keys and paths +daily-epub users add USER [--admin] [--password-stdin] +daily-epub users passwd USER [--password-stdin] +daily-epub users role USER user|admin +daily-epub users disable USER +daily-epub users enable USER +daily-epub users list +daily-epub users logout USER # revoke all of USER's sessions ``` `--dry-run` does everything except deliver: it still ingests, persists entries and @@ -177,6 +184,20 @@ start with `!`. It exits non-zero only on a validation error — a missing key o file is a warning, since the run degrades rather than fails — and never opens the database or takes the lock, so it is safe to run next to a live `generate`. +### Web routes + +| Route | Access | Purpose | +|---|---|---| +| `GET /` | Public | Latest issue as a source-link-only index. | +| `GET /issues` | Public | Issue archive. | +| `GET /issues/{date}` | Public | One source-link-only issue index. | +| `GET /feed.xml` | Public | Atom feed carrying the same public issue content. | +| `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded site assets. | +| `GET/POST /login`, `POST /logout`, `GET /account` | Session | Sign in, sign out, and account management. | +| `GET /dashboard` | Admin | Private operator dashboard. | +| `GET /files/epub/{name}`, `/files/xtc/{name}` | Session or Basic auth | Published downloads; existing OPDS clients continue to use Basic auth. | +| `GET /opds/daily.xml`, `/healthz`, `/issues.json`, `/r/...` | Existing policy | OPDS, health, reports, and signed rating links. | + --- ## Configuration @@ -293,7 +314,12 @@ prints what resolved. | `server.bind` | `127.0.0.1:3499` | Listen address. | | `server.public_url` | `https://daily.hallada.net` | Base URL the rating links inside the EPUB are built from. | | `server.hmac_secret` | — | **`DAILY_EPUB_SERVER__HMAC_SECRET`** (or `DAILY_EPUB_SECRET`). Without it, generated links are rejected with 403. | -| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`. | +| `server.basic_auth_user` / `_pass` | unset | Optional Basic auth for `/opds/*` and `/files/*`; signed-in web users may download from `/files/*` without Basic auth. | +| `server.session_days` | `30` | Sliding lifetime for dashboard login sessions. | +| `server.login_attempts` | `10` | Login attempts allowed per IP in one throttle window. | +| `server.login_window_minutes` | `15` | Length of the login throttle window. | +| `server.jobs_enabled` | `true` | Allow the dashboard to start the fixed systemd job catalogue. | +| `server.journal_lines` | `300` | Journal lines shown on a dashboard job page (10–5000). | `[curation.ranking]` holds the ranker's tunables. The learned signals are gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to @@ -383,6 +409,9 @@ Node for the XTC converter, whose JIT needs W+X pages. The rating links baked into every article chapter point at `server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the internet (e-readers tap these links). The OPDS catalog rides on the same host. +The login throttle's `SmartIpKeyExtractor` trusts `X-Forwarded-For`; this is +safe only because the configured bind address is loopback and nginx is the +only process that can reach it. With an existing certificate, a minimal nginx site is: ```nginx diff --git a/askama.toml b/askama.toml index 5fae6d4..387ed36 100644 --- a/askama.toml +++ b/askama.toml @@ -1,4 +1,4 @@ [general] # EPUB chapter templates live next to the builder that renders them # (implementation notes §11). -dirs = ["src/epub/templates"] +dirs = ["src/epub/templates", "src/web/templates"] diff --git a/config.example.toml b/config.example.toml index b95d071..5867bbd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -210,6 +210,11 @@ settings = "/etc/daily-epub/xtc-settings.json" [server] bind = "127.0.0.1:3499" public_url = "https://daily.hallada.net" +session_days = 30 +login_attempts = 10 +login_window_minutes = 15 +jobs_enabled = true +journal_lines = 300 # hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes) # Optional Basic auth for the XTC OPDS feed and file downloads: # basic_auth_user = "daily" diff --git a/docs/plans/briefs/web-dashboard/handoff-step1.md b/docs/plans/briefs/web-dashboard/handoff-step1.md new file mode 100644 index 0000000..cbd14c0 --- /dev/null +++ b/docs/plans/briefs/web-dashboard/handoff-step1.md @@ -0,0 +1,72 @@ +# Step 1 handoff — foundation + +## Landed + +- Added migration `0004_web.sql`: users, SQLite-backed sessions and indexes, + rating attribution, config/profile/job history, report/issue snapshots, and + the candidate article/run index. `RatingEvent`/`RatedArticle` now carry a + nullable user id. +- Persisted full `runs.report_json`, final `issues.report_json`, and a compact + `issues.issue_json` with article bodies removed. The issue loader rehydrates + bodies from `articles` and has a reduced-row fallback for pre-migration + issues. `GenerateOutcome` exposes `run_id`. +- Added the `web` skeleton, final shared-state shape, Askama layout/error/public + templates, embedded CSS/JS/favicon with SHA-256 ETags, security/cache/request + headers, and `DisabledRunner`/`MockRunner` stubs. +- Added `axum-login` at the required git revision, the local sqlx 0.9 session + store, password-auth users/backend, session/auth route layers, role guards, + same-origin POST middleware, login governor and cleanup tasks, login/logout, + account/password/session revocation, and session-or-Basic file downloads. +- Added public latest/archive/issue pages, the stripped `PublicIssue` boundary, + Atom feed, and robots policy. Anonymous page loads remain session-cookie-free. +- Added all `daily-epub users` commands without the pipeline lock, new server + configuration/defaults/validation, example config, and README command/route/ + reverse-proxy documentation. +- Expanded `tests/m7_server.rs` to cover env-only public routes, dashboard + redirect, CLI admin bootstrap, and a real TCP login/account request. + +## Deviations and follow-up + +- `rpassword` could not be added: it is absent from the local Cargo cache and + this sandbox cannot resolve `index.crates.io` (three retries failed). The CLI + currently uses an equivalent Unix `/dev/tty` no-echo double prompt and keeps + `--password-stdin`. Replace that helper with `rpassword::prompt_password` + after adding the dependency in a network-enabled environment. +- Cargo resolved direct `toml_edit` to 0.22.27 rather than the plan's observed + 0.25.x release; it was selected by `cargo add` for the available toolchain and + lock graph, not hand-pinned. +- The finished issue report is attached immediately after `finish_run`, rather + than during the earlier issue snapshot write, because publish timing and the + final run status are not complete at snapshot time. The stored observable + value is the same final serialized report. +- The pinned axum-login source confirms `AuthSession::user().await`, immutable + `login`/`logout`, the macro route layers, and session key + `"axum-login.data"`; the implementation follows those real APIs. +- Full signed-in issue/article rendering and the real `POST /rate` handler are + step 2. Step 1 supplies the protected dashboard overview and admin-only 501 + rate stub so route-guard tests exercise the final boundary. + +## Verification + +- `cargo fmt --check`: pass. +- `cargo clippy --all-targets -- -D warnings`: pass. +- `cargo test` with sandbox-bound tests skipped: **389 passed, 0 failed**. + Excluded were the four named Anthropic listener tests, the relative-URL + listener test, five `server::tests` listener tests, both `m7_server` TCP + tests, and three existing OpenAI fake-server tests that also bind loopback. +- Focused `cargo test web:: -- --nocapture`: **16 passed, 0 failed**. +- `cargo tree -i` shows one `sqlx` version (0.9.0) and one + `libsqlite3-sys` version (0.37.0). + +## Orchestrator review (2026-09-03) + +- `rpassword` added (the sandbox had no network); `read_password_hidden` is + now `rpassword::prompt_password`. `toml_edit` moved to 0.25 as the plan says. +- **Deviation from plan §8 kept on purpose:** `/files/*` with *no* Basic auth + configured stays public, exactly as before. The plan wanted a redirect to + `/login` there, but the public OPDS feed's acquisition links point at those + files and an e-reader cannot log in; acceptance criterion 11 ("`/files` + behave as before") and "OPDS clients are unaffected" win. With Basic auth + configured, a session cookie of any role bypasses it. +- A short new password on `/account/password` is a 400 form error, not a 500. +- Full suite outside the sandbox: 370 lib + all integration tests green. diff --git a/migrations/0004_web.sql b/migrations/0004_web.sql new file mode 100644 index 0000000..84bc5d9 --- /dev/null +++ b/migrations/0004_web.sql @@ -0,0 +1,58 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'admin')), + disabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_login_at TEXT +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + expiry INTEGER NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_sessions_expiry ON sessions(expiry); + +ALTER TABLE rating_events ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL; + +CREATE TABLE config_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + key TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at TEXT NOT NULL +); +CREATE INDEX idx_config_changes_at ON config_changes(changed_at); + +CREATE TABLE profile_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + saved_at TEXT NOT NULL +); + +CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + unit TEXT NOT NULL, + requested_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + requested_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + status TEXT NOT NULL CHECK (status IN ('requested', 'running', 'ok', 'failed')), + message TEXT, + run_id INTEGER REFERENCES runs(id) ON DELETE SET NULL +); +CREATE INDEX idx_jobs_requested_at ON jobs(requested_at); + +ALTER TABLE runs ADD COLUMN report_json TEXT; +ALTER TABLE issues ADD COLUMN issue_json TEXT; + +CREATE INDEX idx_candidate_runs_article_run ON candidate_runs(article_id, run_id DESC); diff --git a/src/config.rs b/src/config.rs index a2f80d8..63a1f54 100644 --- a/src/config.rs +++ b/src/config.rs @@ -700,6 +700,15 @@ pub struct ServerConfig { /// Optional Basic auth for `/opds/*` and `/files/*`. pub basic_auth_user: Option, pub basic_auth_pass: Option, + /// Sliding web-session lifetime in days. + pub session_days: u32, + /// Login attempts allowed per IP during the configured window. + pub login_attempts: u32, + pub login_window_minutes: u32, + /// Whether the operator dashboard may start systemd jobs. + pub jobs_enabled: bool, + /// Number of journal lines displayed for a job. + pub journal_lines: u32, } impl Default for ServerConfig { @@ -710,6 +719,11 @@ impl Default for ServerConfig { hmac_secret: None, basic_auth_user: None, basic_auth_pass: None, + session_days: 30, + login_attempts: 10, + login_window_minutes: 15, + jobs_enabled: true, + journal_lines: 300, } } } @@ -931,6 +945,26 @@ impl Config { /// Cheap sanity checks so misconfiguration fails at startup, not mid-run. pub fn validate(&self) -> Result<(), ConfigError> { + if self.server.session_days == 0 { + return Err(ConfigError::Invalid( + "server.session_days must be >= 1".into(), + )); + } + if self.server.login_attempts == 0 { + return Err(ConfigError::Invalid( + "server.login_attempts must be >= 1".into(), + )); + } + if self.server.login_window_minutes == 0 { + return Err(ConfigError::Invalid( + "server.login_window_minutes must be >= 1".into(), + )); + } + if !(10..=5000).contains(&self.server.journal_lines) { + return Err(ConfigError::Invalid( + "server.journal_lines must be between 10 and 5000".into(), + )); + } if self.lookback_hours == 0 { return Err(ConfigError::Invalid("lookback_hours must be > 0".into())); } diff --git a/src/curate/profile/mod.rs b/src/curate/profile/mod.rs index e49a984..e6bf7d3 100644 --- a/src/curate/profile/mod.rs +++ b/src/curate/profile/mod.rs @@ -509,6 +509,7 @@ mod tests { fn prompt_sections_are_ordered_and_verdicts_have_required_labels() { let rating = RatedArticle { article_id: 1, + user_id: None, issue_date: None, title: "A title".into(), feed_title: "A feed".into(), @@ -551,6 +552,7 @@ mod tests { fn rebuild_prompt_carries_summary_facets_note_and_diversity_instruction() { let rating = RatedArticle { article_id: 1, + user_id: None, issue_date: Some("2026-08-15".parse().unwrap()), title: "Postgres failover".into(), feed_title: "Engineering Notes".into(), @@ -647,6 +649,7 @@ mod tests { .unwrap(); db.append_rating_event(&RatingEvent { id: 0, + user_id: None, article_id: 1, issue_date: None, kind: "explicit".into(), diff --git a/src/db.rs b/src/db.rs index ea3ee82..c3dca87 100644 --- a/src/db.rs +++ b/src/db.rs @@ -60,6 +60,27 @@ pub struct Db { pool: SqlitePool, } +#[derive(Debug, Clone)] +pub struct IssueRow { + pub date: Date, + pub issue_number: i64, + pub generated_at: Timestamp, + pub epub_path: Option, + pub x4_path: Option, + pub xtc_path: Option, + pub front_page_html: Option, + pub report_json: Option, + pub issue_json: Option, +} + +#[derive(Debug, Clone)] +pub struct IssueListRow { + pub date: Date, + pub issue_number: i64, + pub generated_at: Timestamp, + pub article_count: i64, +} + impl Db { /// Open (creating if needed) the database at `path` with WAL + foreign keys on, /// creating parent directories first. Does **not** run migrations. @@ -442,11 +463,12 @@ impl Db { xtc_path: Option<&str>, front_page_html: Option<&str>, report_json: Option<&str>, + issue_json: Option<&str>, ) -> Result<()> { sqlx::query( "INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path, - front_page_html, report_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + front_page_html, report_json, issue_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(date) DO UPDATE SET issue_number = excluded.issue_number, generated_at = excluded.generated_at, @@ -454,7 +476,8 @@ impl Db { x4_path = COALESCE(excluded.x4_path, issues.x4_path), xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path), front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html), - report_json = COALESCE(excluded.report_json, issues.report_json)", + report_json = COALESCE(excluded.report_json, issues.report_json), + issue_json = COALESCE(excluded.issue_json, issues.issue_json)", ) .bind(date.to_string()) .bind(issue_number) @@ -464,11 +487,49 @@ impl Db { .bind(xtc_path) .bind(front_page_html) .bind(report_json) + .bind(issue_json) .execute(&self.pool) .await?; Ok(()) } + /// One stored issue, including its serialized full-issue snapshot. + pub async fn issue_by_date(&self, date: Date) -> Result> { + let row = sqlx::query( + "SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path, + front_page_html, report_json, issue_json + FROM issues WHERE date = ?", + ) + .bind(date.to_string()) + .fetch_optional(&self.pool) + .await?; + row.as_ref().map(issue_from_row).transpose() + } + + /// Issue archive rows, newest first. A non-positive limit means all rows. + pub async fn issue_dates(&self, limit: Option) -> Result> { + let rows = sqlx::query( + "SELECT i.date, i.issue_number, i.generated_at, COUNT(ia.article_id) AS article_count + FROM issues i LEFT JOIN issue_articles ia ON ia.issue_date = i.date + GROUP BY i.date, i.issue_number, i.generated_at + ORDER BY i.date DESC + LIMIT CASE WHEN ? > 0 THEN ? ELSE -1 END", + ) + .bind(limit.unwrap_or(-1)) + .bind(limit.unwrap_or(-1)) + .fetch_all(&self.pool) + .await?; + rows.iter().map(issue_list_from_row).collect() + } + + pub async fn latest_issue_date(&self) -> Result> { + let raw: Option = sqlx::query_scalar("SELECT MAX(date) FROM issues") + .fetch_one(&self.pool) + .await?; + raw.map(|value| parse_date("issues.date", &value)) + .transpose() + } + /// Replace the lineup for a date (regeneration is idempotent, notes §12). pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> { let mut tx = self.pool.begin().await?; @@ -541,8 +602,8 @@ impl Db { pub async fn append_rating_event(&self, event: &RatingEvent) -> Result { let row = sqlx::query( "INSERT INTO rating_events - (article_id, issue_date, kind, source, label, value, note, event_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (article_id, issue_date, kind, source, label, value, note, event_at, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(event.article_id) @@ -553,6 +614,7 @@ impl Db { .bind(event.value) .bind(event.note.as_deref()) .bind(fmt_ts(event.event_at)) + .bind(event.user_id) .fetch_one(&self.pool) .await?; Ok(row.get("id")) @@ -611,7 +673,7 @@ impl Db { FROM rating_events re WHERE re.kind = 'explicit' AND re.event_at >= ? ) - SELECT r.article_id, r.issue_date, r.label, r.value, r.note, r.event_at, + SELECT r.article_id, r.user_id, r.issue_date, r.label, r.value, r.note, r.event_at, COALESCE(a.title, '') AS title, COALESCE(e.feed_title, '') AS feed_title, (SELECT ia.summary FROM issue_articles ia @@ -655,7 +717,8 @@ impl Db { sqlx::query( "UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?, input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?, - status = ?, error = ?, provider_costs_json = ?, config_json = ? + status = ?, error = ?, provider_costs_json = ?, config_json = ?, + report_json = ? WHERE id = ?", ) .bind(report.finished_at.map(fmt_ts)) @@ -680,6 +743,7 @@ impl Db { source, })?, ) + .bind(report.to_json()) .bind(id) .execute(&self.pool) .await?; @@ -828,6 +892,7 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result }); Ok(RatedArticle { article_id: row.get("article_id"), + user_id: row.get("user_id"), issue_date, title: row.get("title"), feed_title: row.get("feed_title"), @@ -840,6 +905,29 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result }) } +fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + Ok(IssueRow { + date: parse_date("issues.date", &row.get::("date"))?, + issue_number: row.get("issue_number"), + generated_at: parse_ts("issues.generated_at", &row.get::("generated_at"))?, + epub_path: row.get("epub_path"), + x4_path: row.get("x4_path"), + xtc_path: row.get("xtc_path"), + front_page_html: row.get("front_page_html"), + report_json: row.get("report_json"), + issue_json: row.get("issue_json"), + }) +} + +fn issue_list_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + Ok(IssueListRow { + date: parse_date("issues.date", &row.get::("date"))?, + issue_number: row.get("issue_number"), + generated_at: parse_ts("issues.generated_at", &row.get::("generated_at"))?, + article_count: row.get("article_count"), + }) +} + fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { let raw: String = row.get("source"); let source = SocialSource::parse(&raw).ok_or(DbError::Decode { @@ -908,6 +996,30 @@ mod tests { .await .unwrap(); assert_eq!(row.get::(0), 1); + for table in [ + "users", + "sessions", + "config_changes", + "profile_versions", + "jobs", + ] { + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .bind(table) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(exists, 1, "missing {table}"); + } + let columns: Vec = sqlx::query("PRAGMA table_info(rating_events)") + .fetch_all(db.pool()) + .await + .unwrap() + .iter() + .map(|row| row.get("name")) + .collect(); + assert!(columns.contains(&"user_id".to_string())); } #[tokio::test] @@ -1024,6 +1136,13 @@ mod tests { report.counts.selected = 20; report.finish(ts("2026-08-15T05:36:00Z")); db.finish_run(run_id, &report).await.unwrap(); + let stored_report: Option = + sqlx::query_scalar("SELECT report_json FROM runs WHERE id = ?") + .bind(run_id) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(stored_report.as_deref(), Some(report.to_json().as_str())); db.upsert_issue( date, @@ -1034,6 +1153,7 @@ mod tests { None, None, Some("{}"), + None, ) .await .unwrap(); @@ -1158,6 +1278,7 @@ mod tests { None, None, None, + None, ) .await .unwrap(); @@ -1185,6 +1306,7 @@ mod tests { .unwrap(); let event = |label: &str, value: f64, at: &str| RatingEvent { id: 0, + user_id: None, article_id, issue_date: Some("2026-08-15".parse().unwrap()), kind: "explicit".into(), @@ -1268,9 +1390,13 @@ mod tests { .execute(&pool) .await .unwrap(); + sqlx::raw_sql(include_str!("../migrations/0004_web.sql")) + .execute(&pool) + .await + .unwrap(); let rows = sqlx::query( - "SELECT article_id, issue_date, kind, source, label, value, event_at + "SELECT article_id, issue_date, kind, source, label, value, event_at, user_id FROM rating_events ORDER BY article_id", ) .fetch_all(&pool) @@ -1285,6 +1411,7 @@ mod tests { assert_eq!(row.get::("kind"), "explicit"); assert_eq!(row.get::("source"), "migration"); assert_eq!(row.get::("issue_date"), "2026-08-15"); + assert_eq!(row.get::, _>("user_id"), None); } assert_eq!(rows[0].get::("event_at"), "2026-08-15T12:00:00Z"); @@ -1302,6 +1429,11 @@ mod tests { "interest_embeddings", "article_assessments", "candidate_runs", + "users", + "sessions", + "config_changes", + "profile_versions", + "jobs", ] { assert!(tables.iter().any(|table| table == expected), "{expected}"); } diff --git a/src/lib.rs b/src/lib.rs index 4ec7575..eae564a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod report; pub mod server; pub mod social; pub mod types; +pub mod web; pub mod world; /// `CARGO_PKG_VERSION`, printed in the colophon and the OPDS generator tag. diff --git a/src/main.rs b/src/main.rs index 45cc7b1..9c2d6be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,6 +58,43 @@ enum Command { /// Inspect the resolved configuration. #[command(subcommand)] Config(ConfigCommand), + /// Manage dashboard users without taking the pipeline run lock. + #[command(subcommand)] + Users(UsersCommand), +} + +#[derive(Debug, Subcommand)] +enum UsersCommand { + /// Add a user. + Add(UserAddArgs), + /// Change a user's password and revoke existing sessions. + Passwd(UserPasswordArgs), + /// Change a user's role. + Role { username: String, role: String }, + /// Disable a user and revoke existing sessions. + Disable { username: String }, + /// Enable a user. + Enable { username: String }, + /// List users and open-session counts. + List, + /// Revoke all sessions for a user. + Logout { username: String }, +} + +#[derive(Debug, clap::Args)] +struct UserAddArgs { + username: String, + #[arg(long)] + admin: bool, + #[arg(long)] + password_stdin: bool, +} + +#[derive(Debug, clap::Args)] +struct UserPasswordArgs { + username: String, + #[arg(long)] + password_stdin: bool, } #[derive(Debug, Subcommand)] @@ -286,7 +323,8 @@ async fn main() -> Result<()> { } Command::Serve => { let db = Db::open_and_migrate(&config.database_path).await?; - server::serve(config, db).await?; + let config_path = Config::resolve_path(cli.config.as_deref()); + server::serve(config, config_path, db).await?; } Command::Profile(ProfileCommand::Rebuild) => { let db = Db::open_and_migrate(&config.database_path).await?; @@ -326,6 +364,10 @@ async fn main() -> Result<()> { println!("{line}"); } } + Command::Users(command) => { + let db = Db::open_and_migrate(&config.database_path).await?; + cmd_users(&db, command).await?; + } } Ok(()) } @@ -345,10 +387,91 @@ fn lock_holder(command: &Command) -> Option<&'static str> { | Command::Stats(_) | Command::Features(FeaturesCommand::Prune) | Command::Db(_) - | Command::Config(_) => None, + | Command::Config(_) + | Command::Users(_) => None, } } +async fn cmd_users(db: &Db, command: UsersCommand) -> Result<()> { + use daily_epub::web::users; + match command { + UsersCommand::Add(args) => { + let password = read_new_password(args.password_stdin)?; + let user = users::add(db, &args.username, &password, args.admin).await?; + println!("added {} ({})", user.username, user.role); + } + UsersCommand::Passwd(args) => { + let password = read_new_password(args.password_stdin)?; + let sessions = users::passwd(db, &args.username, &password).await?; + println!( + "changed password for {} and revoked {sessions} session(s)", + args.username + ); + } + UsersCommand::Role { username, role } => { + let role = role.parse().map_err(anyhow::Error::msg)?; + users::set_role(db, &username, role).await?; + println!("set {username} role to {role}"); + } + UsersCommand::Disable { username } => { + let sessions = users::set_disabled(db, &username, true).await?; + println!("disabled {username} and revoked {sessions} session(s)"); + } + UsersCommand::Enable { username } => { + users::set_disabled(db, &username, false).await?; + println!("enabled {username}"); + } + UsersCommand::List => { + for row in users::list(db).await? { + let last_login = row + .user + .last_login_at + .map(|timestamp| timestamp.to_string()) + .unwrap_or_else(|| "never".into()); + println!( + "{} · {}{} · created {} · last login {} · {} open session(s)", + row.user.username, + row.user.role, + if row.user.disabled { + " · disabled" + } else { + "" + }, + row.user.created_at, + last_login, + row.open_sessions + ); + } + } + UsersCommand::Logout { username } => { + let sessions = users::logout(db, &username).await?; + println!("revoked {sessions} session(s) for {username}"); + } + } + Ok(()) +} + +fn read_new_password(from_stdin: bool) -> Result { + if from_stdin { + let mut password = String::new(); + std::io::stdin().read_line(&mut password)?; + while password.ends_with(['\n', '\r']) { + password.pop(); + } + return Ok(password); + } + let first = read_password_hidden("Password: ")?; + let second = read_password_hidden("Confirm password: ")?; + if first != second { + anyhow::bail!("passwords do not match"); + } + Ok(first) +} + +fn read_password_hidden(prompt: &str) -> Result { + rpassword::prompt_password(prompt).context("reading the password from the terminal") +} + /// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging"). fn init_tracing() { let filter = EnvFilter::try_from_default_env() @@ -562,6 +685,7 @@ async fn append_cli_event( }; let event = RatingEvent { id: 0, + user_id: None, article_id, issue_date: db.latest_issue_date_for_article(article_id).await?, kind: "explicit".into(), diff --git a/src/pipeline.rs b/src/pipeline.rs index b934939..5ca7b92 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -70,6 +70,7 @@ pub struct GenerateOptions { /// What one run produced, for the caller to print (§3.13). #[derive(Debug)] pub struct GenerateOutcome { + pub run_id: i64, pub report: RunReport, /// `None` only when the run failed before assembly. pub issue: Option, @@ -271,12 +272,14 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul None, None, Some(&report.to_json()), + None, ) .await { tracing::warn!(error = %e, "could not attach the run report to the issue"); } Ok(GenerateOutcome { + run_id, report, issue: stages.issue, artifacts: stages.artifacts, @@ -1045,6 +1048,12 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<( let x4_path = path_for(Edition::X4); let xtc_path = published.xtc.as_ref().map(|p| p.display().to_string()); + let mut stored_issue = issue.clone(); + for pick in &mut stored_issue.lineup.picks { + pick.article.content_html.clear(); + } + let issue_json = serde_json::to_string(&stored_issue).context("serializing issue snapshot")?; + db.upsert_issue( issue.meta.date, issue.meta.issue_number, @@ -1054,6 +1063,7 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<( xtc_path.as_deref(), Some(&issue.editorial.front_page_html), None, + Some(&issue_json), ) .await?; db.replace_issue_articles(issue.meta.date, &issue.lineup.picks) diff --git a/src/publish.rs b/src/publish.rs index 2f96af3..014e101 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -874,6 +874,7 @@ type=\"application/epub+zip\" length=\"1700000\"/>" None, None, None, + None, ) .await .unwrap(); diff --git a/src/server.rs b/src/server.rs index 69f51c3..afd5de7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -22,18 +22,23 @@ //! `server.hmac_secret` is unset carries links this server rejects with 403. use std::path::{Path as FsPath, PathBuf}; +use std::sync::{Arc, RwLock}; use axum::Router; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::middleware::{from_fn, from_fn_with_state}; use axum::response::{IntoResponse, Response}; use axum::routing::get; +use axum_login::AuthManagerLayerBuilder; +use axum_login::tower_sessions::{Expiry, SessionManagerLayer}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use jiff::Timestamp; use jiff::civil::Date; use serde::Deserialize; +use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::trace::TraceLayer; use crate::config::Config; @@ -64,10 +69,44 @@ pub enum ServerError { } /// Shared axum state. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct AppState { pub db: Db, - pub config: Config, + pub config: Arc>>, + pub config_path: Option, + pub web: Arc, +} + +impl std::fmt::Debug for AppState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppState") + .field("db", &self.db) + .field("config_path", &self.config_path) + .field("web", &self.web) + .finish_non_exhaustive() + } +} + +impl AppState { + pub fn new(db: Db, config: Config, config_path: Option) -> Self { + Self { + db, + config: Arc::new(RwLock::new(Arc::new(config))), + config_path, + web: Arc::new(crate::web::WebState { + jobs: Arc::new(crate::web::DisabledRunner), + started_at: Timestamp::now(), + config_mtime: std::sync::Mutex::new(None), + }), + } + } + + pub fn config(&self) -> Arc { + match self.config.read() { + Ok(config) => Arc::clone(&config), + Err(poisoned) => Arc::clone(&poisoned.into_inner()), + } + } } // --------------------------------------------------------------------------- @@ -86,6 +125,22 @@ pub use crate::auth::{constant_time_eq, rating_token, rating_url, verify_token}; /// `/files/epub/{name}`, `/files/xtc/{name}`, `/healthz`, `/issues.json`, with /// `tower-http` tracing (§3.12). pub fn router(state: AppState) -> Router { + let config = state.config(); + let store = crate::web::session::SqliteSessionStore::new(state.db.pool().clone()); + let session_layer = SessionManagerLayer::new(store) + .with_name("daily_session") + .with_http_only(true) + .with_same_site(axum_login::tower_sessions::cookie::SameSite::Lax) + .with_secure(config.server.public_url.starts_with("https://")) + .with_expiry(Expiry::OnInactivity(time::Duration::days(i64::from( + config.server.session_days, + )))); + let auth_layer = AuthManagerLayerBuilder::new( + crate::web::session::Backend::new(state.db.clone()), + session_layer, + ) + .build(); + Router::new() .route("/r/{date}/{article_id}/{vote}", get(handle_rating)) .route(crate::publish::OPDS_PATH, get(handle_opds)) @@ -97,12 +152,25 @@ pub fn router(state: AppState) -> Router { .route("/files/xtc/{name}", get(handle_xtc_file)) .route("/healthz", get(handle_healthz)) .route("/issues.json", get(handle_issues_json)) + .merge(crate::web::router(&config)) + .layer(PropagateRequestIdLayer::x_request_id()) .layer(TraceLayer::new_for_http()) + .layer(SetRequestIdLayer::x_request_id(MakeRequestUuid)) + .layer(auth_layer) + .layer(from_fn_with_state( + state.clone(), + crate::web::session::require_same_origin, + )) + .layer(from_fn(crate::web::security_headers)) .with_state(state) } /// `daily-epub serve` — bind, serve, graceful shutdown on SIGTERM (§3.12). -pub async fn serve(config: Config, db: Db) -> Result<(), ServerError> { +pub async fn serve( + config: Config, + config_path: Option, + db: Db, +) -> Result<(), ServerError> { if config.server.hmac_secret.is_none() { // Not fatal for the OPDS routes, but every rating link would 500. tracing::warn!("server.hmac_secret is unset — rating links will be rejected"); @@ -117,10 +185,27 @@ pub async fn serve(config: Config, db: Db) -> Result<(), ServerError> { let local = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr); tracing::info!(bind = %local, public_url = %config.server.public_url, "serving"); - let app = router(AppState { db, config }); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; + let store = crate::web::session::SqliteSessionStore::new(db.pool().clone()); + if let Err(error) = store.delete_expired().await { + tracing::warn!(%error, "could not delete expired sessions at startup"); + } + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); + loop { + interval.tick().await; + if let Err(error) = store.delete_expired().await { + tracing::warn!(%error, "could not delete expired sessions"); + } + } + }); + + let app = router(AppState::new(db, config, config_path)); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; tracing::info!("server stopped"); Ok(()) } @@ -219,7 +304,8 @@ async fn handle_rating( return page(StatusCode::BAD_REQUEST, "Bad link — invalid vote.", None); }; - let Some(secret) = state.config.server.hmac_secret.as_deref() else { + let config = state.config(); + let Some(secret) = config.server.hmac_secret.as_deref() else { tracing::error!("rating request but server.hmac_secret is unset"); return page( StatusCode::INTERNAL_SERVER_ERROR, @@ -251,12 +337,13 @@ async fn handle_rating( }; let event = RatingEvent { id: 0, + user_id: None, issue_date: Some(date), article_id, kind: "explicit".into(), source: "epub".into(), label: label.into(), - value: vote.value(&state.config.curation.feedback), + value: vote.value(&config.curation.feedback), note: None, event_at: Timestamp::now(), }; @@ -278,23 +365,17 @@ async fn handle_rating( Vote::Good => "Recorded: Good — thanks.", Vote::NotForMe => "Recorded: Not for me — thanks.", }; - confirmation_page( - StatusCode::OK, - message, - &state.config, - date, - article_id, - vote, - ) + confirmation_page(StatusCode::OK, message, &config, date, article_id, vote) } /// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first, /// rendered from the publish directory on each request (§3.11). async fn handle_opds(State(state): State, headers: HeaderMap) -> Response { - if let Some(challenge) = check_basic_auth(&state.config, &headers) { + let config = state.config(); + if let Some(challenge) = check_basic_auth(&config, &headers) { return challenge; } - match crate::publish::build_opds(&state.db, &state.config).await { + match crate::publish::build_opds(&state.db, &config).await { Ok(feed) => ( StatusCode::OK, [ @@ -321,9 +402,10 @@ async fn handle_epub_file( State(state): State, Path(name): Path, headers: HeaderMap, + auth: crate::web::session::AuthSession, ) -> Response { - let dir = state.config.publish.epub_dir.clone(); - serve_file(&state, &dir, &name, &headers).await + let dir = state.config().publish.epub_dir.clone(); + serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await } /// `GET /files/xtc/{name}` — download one XTC artifact (§3.11). @@ -334,14 +416,25 @@ async fn handle_xtc_file( State(state): State, Path(name): Path, headers: HeaderMap, + auth: crate::web::session::AuthSession, ) -> Response { - let dir = state.config.publish.xtc_dir.clone(); - serve_file(&state, &dir, &name, &headers).await + let dir = state.config().publish.xtc_dir.clone(); + serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await } -/// Stream one file out of `dir`, behind the OPDS Basic auth (§3.11). -async fn serve_file(state: &AppState, dir: &FsPath, name: &str, headers: &HeaderMap) -> Response { - if let Some(challenge) = check_basic_auth(&state.config, headers) { +/// Stream one file out of `dir`. A signed-in web session (any role) bypasses +/// the OPDS Basic auth; without one the existing Basic auth policy applies +/// unchanged, including "no credentials configured ⇒ public", so e-readers +/// following the OPDS feed keep working (§3.11, dashboard plan §8). +async fn serve_file( + state: &AppState, + dir: &FsPath, + name: &str, + headers: &HeaderMap, + signed_in: bool, +) -> Response { + let config = state.config(); + if !signed_in && let Some(challenge) = check_basic_auth(&config, headers) { return challenge; } let Some(path) = safe_join(dir, name) else { @@ -734,10 +827,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); - let app = router(AppState { - db: db.clone(), - config, - }); + let app = router(AppState::new(db.clone(), config, None)); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); @@ -848,6 +938,7 @@ mod tests { None, None, Some(&format!("{{\"selected\":{n}}}")), + None, ) .await .unwrap(); diff --git a/src/types.rs b/src/types.rs index eda3336..e2ca0b6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -705,6 +705,7 @@ impl Vote { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RatingEvent { pub id: i64, + pub user_id: Option, pub article_id: ArticleId, pub issue_date: Option, pub kind: String, @@ -733,6 +734,7 @@ pub struct Facets { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RatedArticle { pub article_id: ArticleId, + pub user_id: Option, pub issue_date: Option, pub title: String, pub feed_title: String, diff --git a/src/web/issue.rs b/src/web/issue.rs new file mode 100644 index 0000000..4e4152a --- /dev/null +++ b/src/web/issue.rs @@ -0,0 +1,385 @@ +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +use anyhow::Context; +use jiff::civil::Date; +use sqlx::Row; + +use crate::db::Db; +use crate::pipeline::display_date; +use crate::types::{BehindThePaper, Colophon, Editorial, Issue, IssueMeta, Lineup, Pick}; + +#[derive(Debug, Clone)] +pub struct Download { + pub label: String, + pub href: String, + pub size_bytes: u64, +} + +#[derive(Debug, Clone)] +pub struct IssueView { + pub issue: Issue, + pub downloads: Vec, + pub from_json: bool, +} + +pub async fn load( + db: &Db, + config: &crate::config::Config, + date: Date, +) -> anyhow::Result> { + let Some(row) = db.issue_by_date(date).await? else { + return Ok(None); + }; + let (mut issue, from_json) = if let Some(raw) = row.issue_json.as_deref() { + let mut issue: Issue = serde_json::from_str(raw).context("decoding issues.issue_json")?; + for pick in &mut issue.lineup.picks { + if let Some(article) = db.get_article(pick.article.id).await? { + pick.article = article; + } + } + (issue, true) + } else { + let rows = sqlx::query( + "SELECT article_id, section, position, is_lead, summary, why + FROM issue_articles WHERE issue_date = ? ORDER BY section, position", + ) + .bind(date.to_string()) + .fetch_all(db.pool()) + .await?; + let mut picks = Vec::with_capacity(rows.len()); + let mut seen_sections = Vec::new(); + let mut summaries = BTreeMap::new(); + for pick_row in rows { + let article_id: i64 = pick_row.get("article_id"); + let Some(article) = db.get_article(article_id).await? else { + continue; + }; + let section: String = pick_row.get("section"); + if !seen_sections.contains(§ion) { + seen_sections.push(section.clone()); + } + let summary: Option = pick_row.get("summary"); + if let Some(summary) = &summary { + summaries.insert(article_id, summary.clone()); + } + picks.push(Pick { + article, + section, + position: pick_row.get("position"), + is_lead: pick_row.get("is_lead"), + why: pick_row.get("why"), + summary, + llm: None, + discussion: None, + }); + } + let configured: HashSet<&str> = config + .curation + .sections + .iter() + .map(String::as_str) + .collect(); + let mut section_order: Vec = config + .curation + .sections + .iter() + .filter(|section| seen_sections.contains(section)) + .cloned() + .collect(); + section_order.extend( + seen_sections + .into_iter() + .filter(|section| !configured.contains(section.as_str())), + ); + picks.sort_by_key(|pick| { + let section = section_order + .iter() + .position(|value| value == &pick.section) + .unwrap_or(usize::MAX); + (section, pick.position) + }); + let total_words = picks.iter().map(|pick| pick.article.word_count).sum(); + let article_count = picks.len() as i64; + let section_count = section_order.len() as i64; + ( + Issue { + meta: IssueMeta { + date, + issue_number: row.issue_number, + generated_at: row.generated_at, + display_date: display_date(date), + article_count, + section_count, + total_words, + reading_minutes: crate::types::reading_minutes(total_words), + }, + lineup: Lineup { + date, + picks, + section_order, + }, + editorial: Editorial { + front_page_html: row.front_page_html.unwrap_or_default(), + summaries, + }, + world_briefing: None, + colophon: Colophon::default(), + behind: BehindThePaper::default(), + }, + false, + ) + }; + issue.meta.article_count = issue.lineup.picks.len() as i64; + let downloads = [ + ("EPUB", row.epub_path.as_deref(), "epub"), + ("X4 EPUB", row.x4_path.as_deref(), "epub"), + ("XTC", row.xtc_path.as_deref(), "xtc"), + ] + .into_iter() + .filter_map(|(label, raw, kind)| download(label, raw?, kind)) + .collect(); + Ok(Some(IssueView { + issue, + downloads, + from_json, + })) +} + +fn download(label: &str, raw: &str, kind: &str) -> Option { + let path = Path::new(raw); + let metadata = path.metadata().ok()?; + let name = path.file_name()?.to_str()?; + Some(Download { + label: label.to_string(), + href: format!("/files/{kind}/{}", crate::web::encode_component(name)), + size_bytes: metadata.len(), + }) +} + +#[cfg(test)] +mod tests { + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode, header}; + use tower::ServiceExt; + + use crate::types::{Entry, Issue}; + + use super::*; + + async fn seeded_issue(with_json: bool) -> (tempfile::TempDir, Db, Issue) { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) + .await + .unwrap(); + let mut issue = crate::epub::fixtures::issue(); + for pick in &mut issue.lineup.picks { + let article = &pick.article; + db.upsert_entry(&Entry { + id: article.best_entry_id, + feed_id: article.feed_id, + feed_title: Some(article.feed_title.clone()), + category: article.category.clone(), + title: article.title.clone(), + url: article.url.clone(), + canonical_url: Some(article.canonical_url.clone()), + author: article.author.clone(), + published_at: article.published_at, + comments_url: article.comments_url.clone(), + raw_content: article.content_html.clone(), + fetched_at: article.first_seen, + }) + .await + .unwrap(); + let id = db.upsert_article(article).await.unwrap(); + pick.article.id = id; + for social in &mut pick.article.social { + social.article_id = id; + db.upsert_social(social).await.unwrap(); + } + } + let issue_json = with_json.then(|| { + let mut snapshot = issue.clone(); + for pick in &mut snapshot.lineup.picks { + pick.article.content_html.clear(); + } + serde_json::to_string(&snapshot).unwrap() + }); + db.upsert_issue( + issue.meta.date, + issue.meta.issue_number, + issue.meta.generated_at, + None, + None, + None, + Some(&issue.editorial.front_page_html), + Some("{\"status\":\"ok\"}"), + issue_json.as_deref(), + ) + .await + .unwrap(); + db.replace_issue_articles(issue.meta.date, &issue.lineup.picks) + .await + .unwrap(); + (dir, db, issue) + } + + #[tokio::test] + async fn issue_json_loader_rehydrates_bodies_and_keeps_ephemeral_content() { + let (_dir, db, source) = seeded_issue(true).await; + let stored = db.issue_by_date(source.meta.date).await.unwrap().unwrap(); + let snapshot: Issue = serde_json::from_str(stored.issue_json.as_deref().unwrap()).unwrap(); + assert!( + snapshot + .lineup + .picks + .iter() + .all(|pick| pick.article.content_html.is_empty()) + ); + let loaded = load(&db, &crate::config::Config::default(), source.meta.date) + .await + .unwrap() + .unwrap(); + assert!(loaded.from_json); + assert!( + loaded + .issue + .lineup + .picks + .iter() + .all(|pick| !pick.article.content_html.is_empty()) + ); + assert!(loaded.issue.world_briefing.is_some()); + assert!(loaded.issue.lineup.picks[0].discussion.is_some()); + } + + #[tokio::test] + async fn fallback_loader_builds_reduced_issue_in_configured_section_order() { + let (_dir, db, source) = seeded_issue(false).await; + let loaded = load(&db, &crate::config::Config::default(), source.meta.date) + .await + .unwrap() + .unwrap(); + assert!(!loaded.from_json); + assert_eq!( + loaded.issue.lineup.section_order, + ["Top Stories", "Niche Corner"] + ); + assert!(loaded.issue.world_briefing.is_none()); + assert!( + loaded + .issue + .lineup + .picks + .iter() + .all(|pick| pick.discussion.is_none()) + ); + assert!(loaded.issue.editorial.front_page_html.contains("coffee")); + } + + #[tokio::test] + async fn public_issue_archive_feed_robots_and_reports_are_served() { + let (_dir, db, source) = seeded_issue(true).await; + let app = crate::server::router(crate::server::AppState::new( + db, + crate::config::Config::default(), + None, + )); + let issue = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/issues/{}", source.meta.date)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(issue.status(), StatusCode::OK); + assert_eq!( + issue.headers().get(header::CACHE_CONTROL).unwrap(), + "public, max-age=300" + ); + let html = String::from_utf8( + to_bytes(issue.into_body(), 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!(html.contains("The Lead Story")); + assert!(html.contains("Hacker News")); + assert!(!html.contains("Two stories today")); + assert!(!html.contains("Something happened")); + + let archive = app + .clone() + .oneshot( + Request::builder() + .uri("/issues") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(archive.status(), StatusCode::OK); + + let feed = app + .clone() + .oneshot( + Request::builder() + .uri("/feed.xml") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + feed.headers().get(header::CONTENT_TYPE).unwrap(), + "application/atom+xml; charset=utf-8" + ); + let feed = String::from_utf8( + to_bytes(feed.into_body(), 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + let document = roxmltree::Document::parse(&feed).unwrap(); + assert_eq!( + document + .descendants() + .filter(|node| node.tag_name().name() == "entry") + .count(), + 1 + ); + assert!(!feed.contains("Something happened")); + + let robots = app + .clone() + .oneshot( + Request::builder() + .uri("/robots.txt") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let robots = + String::from_utf8(to_bytes(robots.into_body(), 4096).await.unwrap().to_vec()).unwrap(); + assert!(robots.contains("Disallow: /dashboard")); + + let reports = app + .oneshot( + Request::builder() + .uri("/issues.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let reports = + String::from_utf8(to_bytes(reports.into_body(), 4096).await.unwrap().to_vec()).unwrap(); + assert!(reports.contains("\"status\": \"ok\"")); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..e2d60e2 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,914 @@ +pub mod issue; +pub mod public; +pub mod session; +pub mod users; + +use std::fmt; +use std::sync::Mutex; +use std::time::SystemTime; + +use askama::Template; +use async_trait::async_trait; +use axum::extract::Request; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use self::session::Viewer; + +#[async_trait] +pub trait JobRunner: Send + Sync { + async fn start(&self, unit: &str) -> Result<(), String>; + async fn status(&self, unit: &str) -> Result; + async fn log(&self, unit: &str, lines: usize) -> Result; +} + +#[derive(Debug, Clone, Default)] +pub struct UnitStatus { + pub active_state: String, + pub sub_state: String, + pub result: String, + pub exit_status: Option, +} + +#[derive(Debug, Default)] +pub struct DisabledRunner; + +#[async_trait] +impl JobRunner for DisabledRunner { + async fn start(&self, _unit: &str) -> Result<(), String> { + Err("jobs are disabled".into()) + } + + async fn status(&self, _unit: &str) -> Result { + Err("jobs are disabled".into()) + } + + async fn log(&self, _unit: &str, _lines: usize) -> Result { + Err("jobs are disabled".into()) + } +} + +/// In-memory runner for router tests. Step 6 will add scripted results alongside +/// these recorded calls when the jobs pages begin invoking the runner. +#[derive(Debug, Default)] +pub struct MockRunner { + calls: Mutex>, +} + +impl MockRunner { + pub fn calls(&self) -> Vec { + self.calls.lock().expect("mock runner lock").clone() + } + + fn record(&self, call: String) { + self.calls.lock().expect("mock runner lock").push(call); + } +} + +#[async_trait] +impl JobRunner for MockRunner { + async fn start(&self, unit: &str) -> Result<(), String> { + self.record(format!("start {unit}")); + Ok(()) + } + + async fn status(&self, unit: &str) -> Result { + self.record(format!("status {unit}")); + Ok(UnitStatus::default()) + } + + async fn log(&self, unit: &str, lines: usize) -> Result { + self.record(format!("log {unit} {lines}")); + Ok(String::new()) + } +} + +pub struct WebState { + pub jobs: std::sync::Arc, + pub started_at: Timestamp, + pub config_mtime: Mutex>, +} + +impl fmt::Debug for WebState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WebState") + .field("started_at", &self.started_at) + .field("config_mtime", &self.config_mtime) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Flash { + pub kind: String, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct Page { + pub title: String, + pub viewer: Option, + pub flash: Option, + pub active_nav: String, + pub version: &'static str, +} + +impl Page { + pub fn new(title: impl Into, viewer: Option, active_nav: &str) -> Self { + Self { + title: title.into(), + viewer, + flash: None, + active_nav: active_nav.to_string(), + version: crate::VERSION, + } + } + + pub fn is_admin(&self) -> bool { + self.viewer + .as_ref() + .is_some_and(|viewer| viewer.role == users::Role::Admin) + } +} + +pub struct Html(pub T); + +impl IntoResponse for Html { + fn into_response(self) -> Response { + match self.0.render() { + Ok(body) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + body, + ) + .into_response(), + Err(error) => { + tracing::error!(%error, "rendering web template failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() + } + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum WebError { + #[error("not found")] + NotFound, + #[error("forbidden")] + Forbidden, + #[error("authentication required")] + Unauthenticated { next: String }, + #[error("bad request: {0}")] + BadRequest(String), + #[error("request origin did not match this site")] + Csrf, + #[error(transparent)] + Db(#[from] crate::db::DbError), + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate { + page: Page, + heading: String, + message: String, +} + +impl IntoResponse for WebError { + fn into_response(self) -> Response { + if let Self::Unauthenticated { next } = self { + return axum::response::Redirect::temporary(&format!( + "/login?next={}", + encode_component(&next) + )) + .into_response(); + } + let (status, heading, message) = match self { + Self::NotFound => ( + StatusCode::NOT_FOUND, + "Not found", + "That page does not exist.", + ), + Self::Forbidden | Self::Csrf => ( + StatusCode::FORBIDDEN, + "Forbidden", + "You do not have permission to do that.", + ), + Self::BadRequest(ref message) => { + (StatusCode::BAD_REQUEST, "Bad request", message.as_str()) + } + Self::Db(ref error) => { + tracing::error!(%error, "web database request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Server error", + "The request could not be completed.", + ) + } + Self::Internal(ref error) => { + tracing::error!(%error, "web request failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Server error", + "The request could not be completed.", + ) + } + Self::Unauthenticated { .. } => unreachable!(), + }; + let rendered = ErrorTemplate { + page: Page::new(heading, None, ""), + heading: heading.to_string(), + message: message.to_string(), + } + .render() + .unwrap_or_else(|_| message.to_string()); + ( + status, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + rendered, + ) + .into_response() + } +} + +pub fn encode_component(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[derive(Debug, Clone, Copy)] +pub struct Pagination { + pub page: u32, + pub per_page: u32, + pub total: i64, +} + +impl Pagination { + pub fn offset(self) -> i64 { + i64::from(self.page.saturating_sub(1)) * i64::from(self.per_page) + } + + pub fn pages(self) -> u32 { + ((self.total.max(0) as u64).div_ceil(u64::from(self.per_page))) as u32 + } +} + +pub fn format_time(timestamp: Timestamp, config: &crate::config::Config) -> String { + config + .tz() + .map(|tz| { + timestamp + .to_zoned(tz) + .strftime("%Y-%m-%d %H:%M %Z") + .to_string() + }) + .unwrap_or_else(|_| timestamp.to_string()) +} + +pub async fn security_headers(request: Request, next: Next) -> Response { + let path = request.uri().path().to_string(); + let mut response = next.run(request).await; + let headers = response.headers_mut(); + headers.insert( + header::HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'", + ), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + if path.starts_with("/dashboard") { + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + } + if headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/html")) + { + headers.append(header::VARY, HeaderValue::from_static("Cookie")); + } + response +} + +pub fn router(config: &crate::config::Config) -> axum::Router { + use axum::middleware::from_fn; + use axum::routing::{get, post}; + use axum_login::{login_required, permission_required}; + use tower_governor::GovernorLayer; + use tower_governor::governor::GovernorConfigBuilder; + use tower_governor::key_extractor::SmartIpKeyExtractor; + + let seconds_per_token = (u64::from(config.server.login_window_minutes) * 60 + / u64::from(config.server.login_attempts)) + .max(1); + let governor = std::sync::Arc::new( + GovernorConfigBuilder::default() + .per_second(seconds_per_token) + .burst_size(config.server.login_attempts) + .key_extractor(SmartIpKeyExtractor) + .finish() + .expect("validated non-zero login governor configuration"), + ); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let cleanup = governor.clone(); + handle.spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + cleanup.limiter().retain_recent(); + } + }); + } + + let login = axum::Router::new() + .route("/login", get(session::login_page)) + .route( + "/login", + post(session::login).route_layer(GovernorLayer::new(governor)), + ); + let account = axum::Router::new() + .route("/account", get(session::account)) + .route("/account/password", post(session::change_password)) + .route("/account/logout-all", post(session::logout_everywhere)) + .route("/logout", post(session::logout)) + .route_layer(login_required!( + session::Backend, + login_url = "/login", + redirect_field = "next" + )); + let dashboard = axum::Router::new() + .route("/dashboard", get(dashboard_stub)) + .route("/rate", post(rate_stub)) + .route_layer(permission_required!( + session::Backend, + login_url = "/login", + redirect_field = "next", + users::Role::Admin + )) + .route_layer(from_fn(map_forbidden)); + + axum::Router::new() + .route("/", get(public::latest)) + .route("/issues", get(public::archive)) + .route("/issues/{date}", get(public::show_issue)) + .route("/feed.xml", get(public::feed)) + .route("/robots.txt", get(public::robots)) + .route("/static/{file}", get(static_asset)) + .merge(login) + .merge(account) + .merge(dashboard) +} + +#[derive(Template)] +#[template(path = "dashboard/overview.html")] +struct OverviewTemplate { + page: Page, +} + +async fn dashboard_stub(auth: session::AuthSession) -> Result { + let viewer = auth.user().await.map(session::Viewer::from); + Ok(Html(OverviewTemplate { + page: Page::new("Overview", viewer, "dashboard"), + }) + .into_response()) +} + +async fn rate_stub() -> StatusCode { + StatusCode::NOT_IMPLEMENTED +} + +async fn map_forbidden(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + if response.status() == StatusCode::FORBIDDEN { + WebError::Forbidden.into_response() + } else { + if response.status() == StatusCode::TEMPORARY_REDIRECT { + *response.status_mut() = StatusCode::FOUND; + } + response + } +} + +async fn static_asset( + axum::extract::Path(file): axum::extract::Path, + headers: axum::http::HeaderMap, +) -> Response { + let asset = match file.as_str() { + "app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")), + "app.js" => ( + "application/javascript; charset=utf-8", + include_str!("static/app.js"), + ), + "favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")), + _ => return WebError::NotFound.into_response(), + }; + let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes()))); + if headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + == Some(etag.as_str()) + { + return ( + StatusCode::NOT_MODIFIED, + [ + (header::ETAG, etag), + (header::CACHE_CONTROL, "public, max-age=86400".into()), + ], + ) + .into_response(); + } + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, asset.0.to_string()), + (header::CACHE_CONTROL, "public, max-age=86400".into()), + (header::ETAG, etag), + ], + asset.1, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use axum::body::{Body, to_bytes}; + use axum::http::{Method, Request, header}; + use tower::ServiceExt; + + use super::*; + use crate::config::Config; + use crate::db::Db; + use crate::server::{AppState, router}; + + async fn test_state(config: Config) -> (tempfile::TempDir, AppState) { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) + .await + .unwrap(); + (dir, AppState::new(db, config, None)) + } + + fn post(uri: &str, body: &str, ip: &str) -> Request { + Request::builder() + .method(Method::POST) + .uri(uri) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header("sec-fetch-site", "same-origin") + .header("x-forwarded-for", ip) + .body(Body::from(body.to_string())) + .unwrap() + } + + async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String { + let response = app + .clone() + .oneshot(post( + "/login", + &format!("username={username}&password={password}&next=%2F"), + "192.0.2.1", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + } + + async fn response_text(response: Response) -> String { + String::from_utf8( + to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap() + } + + #[tokio::test] + async fn login_cookie_account_logout_and_anonymous_pages() { + let mut config = Config::default(); + config.server.public_url = "https://daily.example".into(); + let (_dir, state) = test_state(config).await; + users::add(&state.db, "admin", "correct horse battery", true) + .await + .unwrap(); + let app = router(state.clone()); + + let anonymous = app + .clone() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!(anonymous.headers().get(header::SET_COOKIE).is_none()); + assert_eq!( + anonymous.headers().get(header::CACHE_CONTROL).unwrap(), + "public, max-age=300" + ); + + let response = app + .clone() + .oneshot(post( + "/login", + "username=admin&password=correct+horse+battery&next=%2Faccount", + "192.0.2.3", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/account" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(set_cookie.contains("daily_session=")); + assert!(set_cookie.contains("HttpOnly")); + assert!(set_cookie.contains("SameSite=Lax")); + assert!(set_cookie.contains("Secure")); + let cookie = set_cookie.split(';').next().unwrap(); + + let account = app + .clone() + .oneshot( + Request::builder() + .uri("/account") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(account.status(), StatusCode::OK); + assert!(response_text(account).await.contains("admin")); + + let logout = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/logout") + .header(header::COOKIE, cookie) + .header("sec-fetch-site", "same-origin") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::SEE_OTHER); + } + + #[tokio::test] + async fn guards_distinguish_anonymous_users_and_admins() { + let (_dir, state) = test_state(Config::default()).await; + users::add(&state.db, "reader", "correct horse battery", false) + .await + .unwrap(); + users::add(&state.db, "admin", "correct horse battery", true) + .await + .unwrap(); + let app = router(state); + let anonymous = app + .clone() + .oneshot( + Request::builder() + .uri("/dashboard") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::FOUND); + assert_eq!( + anonymous.headers().get(header::LOCATION).unwrap(), + "/login?next=%2Fdashboard" + ); + let anonymous_rate = app + .clone() + .oneshot(post("/rate", "", "192.0.2.20")) + .await + .unwrap(); + assert_eq!(anonymous_rate.status(), StatusCode::FOUND); + assert_eq!( + anonymous_rate.headers().get(header::LOCATION).unwrap(), + "/login?next=%2Frate" + ); + + let reader = login_cookie(&app, "reader", "correct horse battery").await; + let forbidden = app + .clone() + .oneshot( + Request::builder() + .uri("/dashboard") + .header(header::COOKIE, &reader) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); + assert!(response_text(forbidden).await.contains("Forbidden")); + let forbidden_rate = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/rate") + .header(header::COOKIE, &reader) + .header("sec-fetch-site", "same-origin") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden_rate.status(), StatusCode::FORBIDDEN); + + let admin = login_cookie(&app, "admin", "correct horse battery").await; + let allowed = app + .clone() + .oneshot( + Request::builder() + .uri("/dashboard") + .header(header::COOKIE, &admin) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!( + allowed.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + let allowed_rate = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/rate") + .header(header::COOKIE, admin) + .header("sec-fetch-site", "same-origin") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(allowed_rate.status(), StatusCode::NOT_IMPLEMENTED); + } + + #[tokio::test] + async fn origin_check_rejects_cross_site_and_foreign_origins() { + let (_dir, state) = test_state(Config::default()).await; + let app = router(state); + let cross = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/login") + .header("sec-fetch-site", "cross-site") + .header("x-forwarded-for", "192.0.2.10") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cross.status(), StatusCode::FORBIDDEN); + let foreign = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/login") + .header(header::ORIGIN, "https://evil.example") + .header(header::HOST, "daily.hallada.net") + .header("x-forwarded-proto", "https") + .header("x-forwarded-for", "192.0.2.11") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(foreign.status(), StatusCode::FORBIDDEN); + let same = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/login") + .header(header::ORIGIN, "https://daily.hallada.net") + .header(header::HOST, "daily.hallada.net") + .header("x-forwarded-proto", "https") + .header("x-forwarded-for", "192.0.2.12") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("username=x&password=invalid-invalid")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(same.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn login_throttle_is_per_ip() { + let mut config = Config::default(); + config.server.login_attempts = 3; + let (_dir, state) = test_state(config).await; + let app = router(state); + for _ in 0..3 { + let response = app + .clone() + .oneshot(post( + "/login", + "username=nobody&password=invalid-invalid", + "192.0.2.20", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + let limited = app + .clone() + .oneshot(post( + "/login", + "username=nobody&password=invalid-invalid", + "192.0.2.20", + )) + .await + .unwrap(); + assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); + let other = app + .oneshot(post( + "/login", + "username=nobody&password=invalid-invalid", + "192.0.2.21", + )) + .await + .unwrap(); + assert_eq!(other.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn disabled_users_and_password_changes_invalidate_other_sessions() { + let (_dir, state) = test_state(Config::default()).await; + users::add(&state.db, "reader", "correct horse battery", false) + .await + .unwrap(); + let app = router(state.clone()); + let first = login_cookie(&app, "reader", "correct horse battery").await; + let second = login_cookie(&app, "reader", "correct horse battery").await; + let changed = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/account/password") + .header(header::COOKIE, &first) + .header("sec-fetch-site", "same-origin") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("current_password=correct+horse+battery&new_password=a+replacement+password&confirm_password=a+replacement+password")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(changed.status(), StatusCode::SEE_OTHER); + let old_session = app + .clone() + .oneshot( + Request::builder() + .uri("/account") + .header(header::COOKIE, second) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(old_session.status().is_redirection()); + + let fresh = login_cookie(&app, "reader", "a replacement password").await; + users::set_disabled(&state.db, "reader", true) + .await + .unwrap(); + let disabled = app + .oneshot( + Request::builder() + .uri("/account") + .header(header::COOKIE, fresh) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(disabled.status().is_redirection()); + } + + #[tokio::test] + async fn files_accept_a_session_or_basic_auth() { + let mut config = Config::default(); + config.server.basic_auth_user = Some("opds".into()); + config.server.basic_auth_pass = Some("hunter2".into()); + let (dir, state) = test_state(config).await; + let epub_dir = dir.path().join("epub"); + std::fs::create_dir_all(&epub_dir).unwrap(); + std::fs::write(epub_dir.join("issue.epub"), b"epub").unwrap(); + { + let mut live = state.config.write().unwrap(); + let mut changed = (**live).clone(); + changed.publish.epub_dir = epub_dir; + *live = std::sync::Arc::new(changed); + } + users::add(&state.db, "reader", "correct horse battery", false) + .await + .unwrap(); + let app = router(state); + let anonymous = app + .clone() + .oneshot( + Request::builder() + .uri("/files/epub/issue.epub") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + assert!(anonymous.headers().contains_key(header::WWW_AUTHENTICATE)); + let basic = app + .clone() + .oneshot( + Request::builder() + .uri("/files/epub/issue.epub") + .header(header::AUTHORIZATION, "Basic b3BkczpodW50ZXIy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(basic.status(), StatusCode::OK); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + let session = app + .oneshot( + Request::builder() + .uri("/files/epub/issue.epub") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(session.status(), StatusCode::OK); + } + + #[tokio::test] + async fn static_assets_use_content_hash_etags() { + let (_dir, state) = test_state(Config::default()).await; + let app = router(state); + let first = app + .clone() + .oneshot( + Request::builder() + .uri("/static/app.css") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::OK); + assert_eq!( + first.headers().get(header::CACHE_CONTROL).unwrap(), + "public, max-age=86400" + ); + let etag = first.headers().get(header::ETAG).unwrap().clone(); + let cached = app + .oneshot( + Request::builder() + .uri("/static/app.css") + .header(header::IF_NONE_MATCH, etag) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cached.status(), StatusCode::NOT_MODIFIED); + } +} diff --git a/src/web/public.rs b/src/web/public.rs new file mode 100644 index 0000000..3632e6b --- /dev/null +++ b/src/web/public.rs @@ -0,0 +1,376 @@ +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use jiff::civil::Date; + +use crate::server::AppState; +use crate::types::{Issue, SocialSource}; +use crate::web::issue::{self, Download}; +use crate::web::session::{AuthSession, Viewer}; +use crate::web::{Html, Page, WebError}; + +#[derive(Debug, Clone)] +pub struct PublicIssue { + pub date: Date, + pub issue_number: i64, + pub display_date: String, + pub article_count: i64, + pub reading_minutes: i64, + pub stats_line: String, + pub sections: Vec, + pub generated_at: jiff::Timestamp, +} + +#[derive(Debug, Clone)] +pub struct PublicSection { + pub name: String, + pub entries: Vec, +} + +#[derive(Debug, Clone)] +pub struct PublicEntry { + pub title: String, + pub url: String, + pub author: Option, + pub source: String, + pub domain: String, + pub reading_minutes: i64, + pub word_count: i64, + pub comment_links: Vec, + pub is_lead: bool, +} + +#[derive(Debug, Clone)] +pub struct CommentLink { + pub label: String, + pub url: String, + pub meta: String, +} + +impl From<&Issue> for PublicIssue { + fn from(issue: &Issue) -> Self { + let sections = + issue + .lineup + .section_order + .iter() + .map(|name| PublicSection { + name: name.clone(), + entries: issue + .lineup + .section_picks(name) + .into_iter() + .map(|pick| { + let article = &pick.article; + let mut comment_links: Vec = article + .social + .iter() + .filter_map(|social| { + let url = social.item_url.clone()?; + let label = match social.source { + SocialSource::Hn => "Hacker News", + SocialSource::Lobsters => "Lobsters", + SocialSource::Reddit => "Reddit", + SocialSource::X => "X", + }; + Some(CommentLink { + label: label.into(), + url, + meta: format!( + "{} points · {} comments", + social.score, social.num_comments + ), + }) + }) + .collect(); + if let Some(url) = article.comments_url.as_ref().filter(|url| { + *url != &article.canonical_url && *url != &article.url + }) { + comment_links.push(CommentLink { + label: "Comments".into(), + url: url.clone(), + meta: String::new(), + }); + } + PublicEntry { + title: article.title.clone(), + url: article.canonical_url.clone(), + author: article.author.clone(), + source: article.feed_title.clone(), + domain: domain(&article.canonical_url), + reading_minutes: article.reading_minutes(), + word_count: article.word_count, + comment_links, + is_lead: pick.is_lead, + } + }) + .collect(), + }) + .collect(); + Self { + date: issue.meta.date, + issue_number: issue.meta.issue_number, + display_date: issue.meta.display_date.clone(), + article_count: issue.meta.article_count, + reading_minutes: issue.meta.reading_minutes, + stats_line: issue.meta.stats_line(), + sections, + generated_at: issue.meta.generated_at, + } + } +} + +fn domain(raw: &str) -> String { + url::Url::parse(raw) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string()) + .unwrap_or_default() +} + +#[derive(Template)] +#[template(path = "issue_public.html")] +struct IssuePublicTemplate { + page: Page, + issue: PublicIssue, + downloads: Vec, + empty: bool, +} + +#[derive(Debug, Clone)] +pub struct ArchiveMonth { + pub label: String, + pub issues: Vec, +} + +#[derive(Debug, Clone)] +pub struct ArchiveIssue { + pub date: Date, + pub display_date: String, + pub issue_number: i64, + pub article_count: i64, +} + +#[derive(Template)] +#[template(path = "issue_list.html")] +struct IssueListTemplate { + page: Page, + months: Vec, +} + +#[derive(Template)] +#[template(path = "feed_entry.html")] +struct FeedEntryTemplate<'a> { + issue: &'a PublicIssue, +} + +pub async fn latest( + State(state): State, + auth: AuthSession, + headers: HeaderMap, +) -> Result { + let Some(date) = state.db.latest_issue_date().await? else { + let viewer = auth.user().await.map(Viewer::from); + let response = Html(IssuePublicTemplate { + page: Page::new("Latest issue", viewer, "latest"), + issue: empty_issue(), + downloads: Vec::new(), + empty: true, + }) + .into_response(); + return Ok(public_cache(response, &headers)); + }; + show_issue(State(state), auth, headers, Path(date)).await +} + +pub async fn show_issue( + State(state): State, + auth: AuthSession, + headers: HeaderMap, + Path(date): Path, +) -> Result { + let Some(view) = issue::load(&state.db, &state.config(), date).await? else { + return Err(WebError::NotFound); + }; + let viewer = auth.user().await.map(Viewer::from); + let downloads = if viewer.is_some() { + view.downloads + } else { + Vec::new() + }; + let response = Html(IssuePublicTemplate { + page: Page::new(format!("Issue {date}"), viewer, "latest"), + issue: PublicIssue::from(&view.issue), + downloads, + empty: false, + }) + .into_response(); + Ok(public_cache(response, &headers)) +} + +pub async fn archive( + State(state): State, + auth: AuthSession, + headers: HeaderMap, +) -> Result { + let rows = state.db.issue_dates(None).await?; + let mut months: Vec = Vec::new(); + for row in rows { + let key = format!("{:04}-{:02}", row.date.year(), row.date.month()); + if months.last().map(|month| month.label.as_str()) != Some(key.as_str()) { + months.push(ArchiveMonth { + label: key, + issues: Vec::new(), + }); + } + if let Some(month) = months.last_mut() { + month.issues.push(ArchiveIssue { + date: row.date, + display_date: crate::pipeline::display_date(row.date), + issue_number: row.issue_number, + article_count: row.article_count, + }); + } + } + let response = Html(IssueListTemplate { + page: Page::new( + "Issue archive", + auth.user().await.map(Viewer::from), + "archive", + ), + months, + }) + .into_response(); + Ok(public_cache(response, &headers)) +} + +pub async fn feed(State(state): State) -> Result { + let config = state.config(); + let rows = state.db.issue_dates(Some(30)).await?; + let mut entries = String::new(); + let mut updated = jiff::Timestamp::UNIX_EPOCH; + for row in rows { + let Some(view) = issue::load(&state.db, &config, row.date).await? else { + continue; + }; + updated = updated.max(view.issue.meta.generated_at); + let issue = PublicIssue::from(&view.issue); + let content = FeedEntryTemplate { issue: &issue } + .render() + .map_err(|error| WebError::Internal(error.into()))?; + let href = format!( + "{}/issues/{}", + config.server.public_url.trim_end_matches('/'), + issue.date + ); + entries.push_str(&format!( + "tag:{},{}:issue/{}The Daily EPUB — {}{}{}", + feed_host(&config.server.public_url), + issue.date.year(), + issue.date, + issue.date, + issue.generated_at, + xml_escape(&href), + xml_escape(&content), + )); + } + let home = config.server.public_url.trim_end_matches('/'); + let body = format!( + "{}The Daily EPUB{}{}", + xml_escape(home), + updated, + xml_escape(home), + entries + ); + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"), + (header::CACHE_CONTROL, "public, max-age=300"), + ], + body, + ) + .into_response()) +} + +pub async fn robots() -> Response { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "User-agent: *\nAllow: /\nAllow: /issues\nDisallow: /dashboard\nDisallow: /login\nDisallow: /files\nDisallow: /r\nDisallow: /opds\n", + ) + .into_response() +} + +fn public_cache(mut response: Response, request_headers: &HeaderMap) -> Response { + let value = if request_headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|cookies| cookies.contains("daily_session=")) + { + "private, no-store" + } else { + "public, max-age=300" + }; + response.headers_mut().insert( + header::CACHE_CONTROL, + axum::http::HeaderValue::from_static(value), + ); + response +} + +fn feed_host(public_url: &str) -> String { + url::Url::parse(public_url) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .unwrap_or_else(|| "daily.hallada.net".into()) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn empty_issue() -> PublicIssue { + PublicIssue { + date: "1970-01-01".parse().expect("valid epoch date"), + issue_number: 0, + display_date: String::new(), + article_count: 0, + reading_minutes: 0, + stats_line: String::new(), + sections: Vec::new(), + generated_at: jiff::Timestamp::UNIX_EPOCH, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_issue_carries_no_generated_text() { + let source = crate::epub::fixtures::issue(); + let public = PublicIssue::from(&source); + let html = FeedEntryTemplate { issue: &public }.render().unwrap(); + assert!(html.contains("The Lead Story")); + assert!(html.contains("Hacker News")); + for private in [ + "Two stories today", + "What it argues", + "systems story", + "Body of", + "write path", + "Agreed", + "Something happened", + "concise view of the day", + ] { + assert!(!html.contains(private), "leaked {private:?} in {html}"); + } + } +} diff --git a/src/web/session.rs b/src/web/session.rs new file mode 100644 index 0000000..a7bdeb0 --- /dev/null +++ b/src/web/session.rs @@ -0,0 +1,522 @@ +use std::collections::HashSet; +use std::str::FromStr; + +use askama::Template; +use async_trait::async_trait; +use axum::Form; +use axum::extract::{Query, Request, State}; +use axum::http::{StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum_login::tower_sessions::session::{Id, Record}; +use axum_login::tower_sessions::{SessionStore, session_store}; +use axum_login::{AuthUser, AuthnBackend, AuthzBackend}; +use serde::Deserialize; +use sqlx::{Row, SqlitePool}; +use time::OffsetDateTime; + +use crate::db::{Db, fmt_ts}; +use crate::server::AppState; +use crate::web::users::{self, Role, User}; +use crate::web::{Html, Page, WebError}; + +const DUMMY_HASH: &str = "$argon2i$v=19$m=65536,t=1,p=1$c29tZXNhbHQAAAAAAAAAAA$+r0d29hqEB0yasKr55ZgICsQGSkl0v0kgwhd+U3wyRo"; + +#[derive(Clone, Debug)] +pub struct SqliteSessionStore { + pool: SqlitePool, +} + +impl SqliteSessionStore { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub async fn delete_expired(&self) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE expiry <= unixepoch()") + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + pub async fn delete_for_user(&self, user_id: i64) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } +} + +fn store_error(error: impl std::fmt::Display) -> session_store::Error { + session_store::Error::Backend(error.to_string()) +} + +#[async_trait] +impl SessionStore for SqliteSessionStore { + async fn save(&self, record: &Record) -> session_store::Result<()> { + let data = serde_json::to_string(&record.data).map_err(store_error)?; + let user_id = record + .data + .get("axum-login.data") + .and_then(|value| value.get("user_id")) + .and_then(serde_json::Value::as_i64); + let now = fmt_ts(jiff::Timestamp::now()); + sqlx::query( + "INSERT INTO sessions (id, data, expiry, user_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiry = excluded.expiry, + user_id = excluded.user_id, updated_at = excluded.updated_at", + ) + .bind(record.id.to_string()) + .bind(data) + .bind(record.expiry_date.unix_timestamp()) + .bind(user_id) + .bind(&now) + .bind(now) + .execute(&self.pool) + .await + .map_err(store_error)?; + Ok(()) + } + + async fn load(&self, id: &Id) -> session_store::Result> { + let row = + sqlx::query("SELECT data, expiry FROM sessions WHERE id = ? AND expiry > unixepoch()") + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await + .map_err(store_error)?; + row.map(|row| { + let data = serde_json::from_str(&row.get::("data")).map_err(store_error)?; + let expiry_date = + OffsetDateTime::from_unix_timestamp(row.get("expiry")).map_err(store_error)?; + Ok(Record { + id: *id, + data, + expiry_date, + }) + }) + .transpose() + } + + async fn delete(&self, id: &Id) -> session_store::Result<()> { + sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(id.to_string()) + .execute(&self.pool) + .await + .map_err(store_error)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Credentials { + pub username: String, + pub password: String, + #[serde(default)] + pub next: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum BackendError { + #[error(transparent)] + Db(#[from] crate::db::DbError), + #[error("password verification task failed: {0}")] + Join(#[from] tokio::task::JoinError), +} + +#[derive(Clone)] +pub struct Backend { + db: Db, +} + +impl Backend { + pub fn new(db: Db) -> Self { + Self { db } + } +} + +impl AuthUser for User { + type Id = i64; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + self.password_hash.as_bytes() + } +} + +impl AuthnBackend for Backend { + type User = User; + type Credentials = Credentials; + type Error = BackendError; + + async fn authenticate(&self, creds: Credentials) -> Result, BackendError> { + let user = users::find_by_username(&self.db, &creds.username).await?; + let hash = user + .as_ref() + .map(|user| user.password_hash.clone()) + .unwrap_or_else(|| DUMMY_HASH.to_string()); + let password = creds.password; + let valid = + tokio::task::spawn_blocking(move || users::verify_password(&hash, &password)).await?; + Ok(user.filter(|user| valid && !user.disabled)) + } + + async fn get_user(&self, id: &i64) -> Result, BackendError> { + Ok(users::find_by_id(&self.db, *id) + .await? + .filter(|user| !user.disabled)) + } +} + +impl AuthzBackend for Backend { + type Permission = Role; + + async fn get_user_permissions(&self, user: &User) -> Result, BackendError> { + let permissions = match user.role { + Role::Admin => [Role::User, Role::Admin].into_iter().collect(), + Role::User => [Role::User].into_iter().collect(), + }; + Ok(permissions) + } +} + +pub type AuthSession = axum_login::AuthSession; + +#[derive(Debug, Clone)] +pub struct Viewer { + pub id: i64, + pub username: String, + pub role: Role, +} + +impl From for Viewer { + fn from(user: User) -> Self { + Self { + id: user.id, + username: user.username, + role: user.role, + } + } +} + +pub fn valid_next(next: Option<&str>) -> &str { + next.filter(|next| next.starts_with('/') && !next.starts_with("//")) + .unwrap_or("/") +} + +pub async fn require_same_origin( + State(state): State, + request: Request, + next: Next, +) -> Response { + if request.method() != axum::http::Method::POST { + return next.run(request).await; + } + let headers = request.headers(); + if let Some(site) = headers + .get("sec-fetch-site") + .and_then(|value| value.to_str().ok()) + { + if !matches!(site, "same-origin" | "none") { + return StatusCode::FORBIDDEN.into_response(); + } + } else if let Some(origin) = request_origin(headers) { + let config = state.config(); + let public_origin = url::Url::parse(&config.server.public_url) + .ok() + .map(|url| url.origin().ascii_serialization()); + let host_origin = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(|host| format!("{}://{host}", forwarded_scheme(headers))); + if public_origin.as_deref() != Some(origin.as_str()) + && host_origin.as_deref() != Some(origin.as_str()) + { + return StatusCode::FORBIDDEN.into_response(); + } + } + next.run(request).await +} + +#[derive(Debug, Default, Deserialize)] +pub struct LoginQuery { + #[serde(default)] + next: Option, +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate { + page: Page, + next: String, + error: String, +} + +#[derive(Template)] +#[template(path = "account.html")] +struct AccountTemplate { + page: Page, + error: String, +} + +pub async fn login_page(auth: AuthSession, Query(query): Query) -> Response { + let viewer = auth.user().await.map(Viewer::from); + Html(LoginTemplate { + page: Page::new("Sign in", viewer, "login"), + next: valid_next(query.next.as_deref()).to_string(), + error: String::new(), + }) + .into_response() +} + +pub async fn login( + State(state): State, + auth: AuthSession, + Form(credentials): Form, +) -> Result { + let destination = valid_next(credentials.next.as_deref()).to_string(); + match auth + .authenticate(credentials) + .await + .map_err(|error| WebError::Internal(error.into()))? + { + Some(user) => { + auth.login(&user) + .await + .map_err(|error| WebError::Internal(error.into()))?; + sqlx::query("UPDATE users SET last_login_at = ? WHERE id = ?") + .bind(fmt_ts(jiff::Timestamp::now())) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(crate::db::DbError::from)?; + Ok(axum::response::Redirect::to(&destination).into_response()) + } + None => Ok(( + StatusCode::UNAUTHORIZED, + Html(LoginTemplate { + page: Page::new("Sign in", None, "login"), + next: destination, + error: "invalid username or password".into(), + }), + ) + .into_response()), + } +} + +pub async fn logout(auth: AuthSession) -> Result { + auth.logout() + .await + .map_err(|error| WebError::Internal(error.into()))?; + Ok(axum::response::Redirect::to("/").into_response()) +} + +pub async fn account(auth: AuthSession) -> Result { + let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated { + next: "/account".into(), + })?; + Ok(Html(AccountTemplate { + page: Page::new("Account", Some(user.into()), "account"), + error: String::new(), + }) + .into_response()) +} + +#[derive(Debug, Deserialize)] +pub struct PasswordForm { + current_password: String, + new_password: String, + confirm_password: String, +} + +pub async fn change_password( + State(state): State, + auth: AuthSession, + Form(form): Form, +) -> Result { + let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated { + next: "/account".into(), + })?; + let hash = user.password_hash.clone(); + let current = form.current_password; + let valid = tokio::task::spawn_blocking(move || users::verify_password(&hash, ¤t)) + .await + .map_err(|error| WebError::Internal(error.into()))?; + let problem = if !valid { + Some("current password is incorrect".to_string()) + } else if form.new_password != form.confirm_password { + Some("the new passwords do not match".to_string()) + } else { + users::validate_password(&form.new_password) + .err() + .map(|error| error.to_string()) + }; + if let Some(error) = problem { + return Ok(( + StatusCode::BAD_REQUEST, + Html(AccountTemplate { + page: Page::new("Account", Some(user.into()), "account"), + error, + }), + ) + .into_response()); + } + let password = form.new_password; + let password_hash = tokio::task::spawn_blocking(move || users::hash_password(&password)) + .await + .map_err(|error| WebError::Internal(error.into()))?; + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(&password_hash) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(crate::db::DbError::from)?; + SqliteSessionStore::new(state.db.pool().clone()) + .delete_for_user(user.id) + .await + .map_err(crate::db::DbError::from)?; + let mut updated = user; + updated.password_hash = password_hash; + auth.login(&updated) + .await + .map_err(|error| WebError::Internal(error.into()))?; + Ok(axum::response::Redirect::to("/account").into_response()) +} + +pub async fn logout_everywhere( + State(state): State, + auth: AuthSession, +) -> Result { + let user = auth.user().await.ok_or_else(|| WebError::Unauthenticated { + next: "/account".into(), + })?; + SqliteSessionStore::new(state.db.pool().clone()) + .delete_for_user(user.id) + .await + .map_err(crate::db::DbError::from)?; + auth.logout() + .await + .map_err(|error| WebError::Internal(error.into()))?; + Ok(axum::response::Redirect::to("/").into_response()) +} + +fn forwarded_scheme(headers: &axum::http::HeaderMap) -> &str { + headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .unwrap_or("http") +} + +fn request_origin(headers: &axum::http::HeaderMap) -> Option { + if let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + { + return Some(origin.trim_end_matches('/').to_string()); + } + let referer = headers + .get(header::REFERER) + .and_then(|value| value.to_str().ok())?; + let url = url::Url::from_str(referer).ok()?; + Some(url.origin().ascii_serialization()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum_login::tower_sessions::SessionStore; + use serde_json::json; + use time::Duration; + + use super::*; + + async fn store() -> (tempfile::TempDir, Db, SqliteSessionStore) { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) + .await + .unwrap(); + let store = SqliteSessionStore::new(db.pool().clone()); + (dir, db, store) + } + + fn record(user_id: Option, expiry: OffsetDateTime) -> Record { + let mut data = HashMap::new(); + if let Some(user_id) = user_id { + data.insert( + "axum-login.data".into(), + json!({"user_id": user_id, "auth_hash": [1, 2, 3]}), + ); + } + Record { + id: Id::default(), + data, + expiry_date: expiry, + } + } + + #[tokio::test] + async fn session_store_round_trips_denormalizes_and_deletes() { + let (_dir, db, store) = store().await; + let user = users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let active = record( + Some(user.id), + OffsetDateTime::now_utc() + Duration::hours(1), + ); + store.save(&active).await.unwrap(); + let loaded = store.load(&active.id).await.unwrap().unwrap(); + assert_eq!(loaded.id, active.id); + assert_eq!(loaded.data, active.data); + assert_eq!( + loaded.expiry_date.unix_timestamp(), + active.expiry_date.unix_timestamp() + ); + let denormalized: Option = + sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?") + .bind(active.id.to_string()) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(denormalized, Some(user.id)); + assert_eq!(store.delete_for_user(user.id).await.unwrap(), 1); + assert!(store.load(&active.id).await.unwrap().is_none()); + + let anonymous = record(None, OffsetDateTime::now_utc() + Duration::hours(1)); + store.save(&anonymous).await.unwrap(); + let denormalized: Option = + sqlx::query_scalar("SELECT user_id FROM sessions WHERE id = ?") + .bind(anonymous.id.to_string()) + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(denormalized, None); + store.delete(&anonymous.id).await.unwrap(); + assert!(store.load(&anonymous.id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn expired_sessions_are_hidden_and_pruned_and_errors_surface() { + let (_dir, db, store) = store().await; + let expired = record(None, OffsetDateTime::now_utc() - Duration::seconds(1)); + store.save(&expired).await.unwrap(); + assert!(store.load(&expired.id).await.unwrap().is_none()); + assert_eq!(store.delete_expired().await.unwrap(), 1); + db.close().await; + let error = store.save(&record(None, OffsetDateTime::now_utc())).await; + assert!(matches!(error, Err(session_store::Error::Backend(_)))); + } + + #[test] + fn next_targets_must_be_same_site_paths() { + assert_eq!(valid_next(Some("/dashboard")), "/dashboard"); + assert_eq!(valid_next(Some("//evil.example/")), "/"); + assert_eq!(valid_next(Some("https://evil.example/")), "/"); + } +} diff --git a/src/web/static/app.css b/src/web/static/app.css new file mode 100644 index 0000000..9dbc812 --- /dev/null +++ b/src/web/static/app.css @@ -0,0 +1,35 @@ +:root { --bg:#fbfaf6; --fg:#171713; --muted:#68665f; --rule:#c9c5b9; --accent:#8b1e1e; --loved:#286a3b; --good:#34688a; --down:#943b35; color-scheme:light dark; } +@media (prefers-color-scheme:dark) { :root { --bg:#171714; --fg:#eeeae0; --muted:#aaa69b; --rule:#4c4a44; --accent:#ef8c82; --loved:#75c58a; --good:#75aed0; --down:#e5867d; } } +* { box-sizing:border-box; } +body { margin:0 auto; padding:0 1rem; background:var(--bg); color:var(--fg); font:1rem/1.55 Georgia,serif; } +a { color:var(--accent); } +.masthead { max-width:72ch; margin:1.5rem auto .4rem; border-block:3px double var(--fg); padding:.45rem 0; text-align:center; font-size:2rem; font-weight:700; } +.masthead a { color:inherit; text-decoration:none; } +.primary,.admin,footer { max-width:72ch; margin:.7rem auto; text-align:center; color:var(--muted); } +.admin { max-width:1200px; font-family:system-ui,sans-serif; } +.flash { max-width:72rem; margin:1rem auto; padding:.75rem 1rem; border:1px solid var(--rule); } +main { min-height:70vh; } +.reading { max-width:72ch; margin:2rem auto; } +.narrow { max-width:34rem; } +.dateline,.stats,.byline,.comments,.strap { color:var(--muted); } +.issue section { border-top:1px solid var(--rule); margin-top:2rem; } +.issue article { border-bottom:1px solid var(--rule); padding:.4rem 0 .8rem; } +.issue .lead h3 { font-size:1.45rem; } +.comments a,.downloads a { margin-right:.8rem; } +form { display:grid; gap:.8rem; margin:1.5rem 0; } +label { display:grid; gap:.25rem; } +input,textarea,button { font:inherit; padding:.45rem; } +.error { color:var(--down); } +.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; } +.scroll-x { overflow-x:auto; } +table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; } +th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; } +thead { position:sticky; top:0; background:var(--bg); } +.badge { border:1px solid currentColor; border-radius:999px; padding:.1rem .45rem; } +.badge.selected,.badge.loved { color:var(--loved); } .badge.good,.badge.assessed,.badge.triaged { color:var(--good); } .badge.down,.badge.excluded { color:var(--down); } +.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared { color:var(--muted); } +.kv { display:grid; grid-template-columns:minmax(10rem,1fr) 3fr; } .kv dt { color:var(--muted); } +.funnel > * { background:var(--good); min-width:1px; margin:.2rem 0; } +.spark { max-width:100%; height:auto; } +.rating { display:flex; flex-wrap:wrap; gap:.3rem; } +@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } } diff --git a/src/web/static/app.js b/src/web/static/app.js new file mode 100644 index 0000000..9fc6afb --- /dev/null +++ b/src/web/static/app.js @@ -0,0 +1,11 @@ +document.addEventListener("submit", (event) => { + const message = event.target.dataset.confirm; + if (message && !window.confirm(message)) event.preventDefault(); +}); +document.querySelectorAll("details[id]").forEach((details) => { + try { + const key = "details:" + details.id; + details.open = localStorage.getItem(key) === "open"; + details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed")); + } catch (_) {} +}); diff --git a/src/web/static/favicon.svg b/src/web/static/favicon.svg new file mode 100644 index 0000000..aaee925 --- /dev/null +++ b/src/web/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/web/templates/_pagination.html b/src/web/templates/_pagination.html new file mode 100644 index 0000000..5fda802 --- /dev/null +++ b/src/web/templates/_pagination.html @@ -0,0 +1 @@ +{% if pagination.pages() > 1 %}{% endif %} diff --git a/src/web/templates/account.html b/src/web/templates/account.html new file mode 100644 index 0000000..888f289 --- /dev/null +++ b/src/web/templates/account.html @@ -0,0 +1 @@ +{% extends "layout.html" %}{% block content %}

Account

{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endblock %} diff --git a/src/web/templates/dashboard/overview.html b/src/web/templates/dashboard/overview.html new file mode 100644 index 0000000..5f869d7 --- /dev/null +++ b/src/web/templates/dashboard/overview.html @@ -0,0 +1 @@ +{% extends "layout.html" %}{% block content %}

Overview

The dashboard foundation is ready. Run and article views land in the next dashboard step.

{% endblock %} diff --git a/src/web/templates/error.html b/src/web/templates/error.html new file mode 100644 index 0000000..c24ad80 --- /dev/null +++ b/src/web/templates/error.html @@ -0,0 +1 @@ +{% extends "layout.html" %}{% block content %}

{{ heading }}

{{ message }}

{% endblock %} diff --git a/src/web/templates/feed_entry.html b/src/web/templates/feed_entry.html new file mode 100644 index 0000000..fdd4aa5 --- /dev/null +++ b/src/web/templates/feed_entry.html @@ -0,0 +1 @@ +{% for section in issue.sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }} — {{ entry.source }} ({{ entry.domain }}){% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}{{ link.label }}{% endfor %}{% endif %}
  • {% endfor %}
{% endfor %} diff --git a/src/web/templates/issue_list.html b/src/web/templates/issue_list.html new file mode 100644 index 0000000..5b47841 --- /dev/null +++ b/src/web/templates/issue_list.html @@ -0,0 +1 @@ +{% extends "layout.html" %}{% block content %}

Issue archive

{% for month in months %}

{{ month.label }}

    {% for issue in month.issues %}
  • {{ issue.display_date }} · No. {{ issue.issue_number }} · {{ issue.article_count }} articles
  • {% endfor %}
{% endfor %}
{% endblock %} diff --git a/src/web/templates/issue_public.html b/src/web/templates/issue_public.html new file mode 100644 index 0000000..ae8642f --- /dev/null +++ b/src/web/templates/issue_public.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %}{% block content %}
+{% if empty %}

No issue yet

The first issue has not been published.

{% else %} +

{{ issue.stats_line }}

+

A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.

+{% if !downloads.is_empty() %}

{% for download in downloads %}{{ download.label }} ({{ download.size_bytes }} bytes){% endfor %}

{% endif %} +{% for section in issue.sections %}

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% if !entry.comment_links.is_empty() %}

{% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

{% endif %}
{% endfor %}{% endfor %} +

Browse the archive

{% endif %}{% endblock %} diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html new file mode 100644 index 0000000..a844ac6 --- /dev/null +++ b/src/web/templates/layout.html @@ -0,0 +1,20 @@ + + + + + + {{ page.title }} · The Daily EPUB + + + + + +
The Daily EPUB
+ + {% if page.is_admin() %}{% endif %} + {% match page.flash %}{% when Some with (flash) %}
{{ flash.text }}
{% when None %}{% endmatch %} +
{% block content %}{% endblock %}
+
daily-epub {{ page.version }}
+ + + diff --git a/src/web/templates/login.html b/src/web/templates/login.html new file mode 100644 index 0000000..d9865f5 --- /dev/null +++ b/src/web/templates/login.html @@ -0,0 +1 @@ +{% extends "layout.html" %}{% block content %}

Sign in

{% if !error.is_empty() %}

{{ error }}

{% endif %}
{% endblock %} diff --git a/src/web/users.rs b/src/web/users.rs new file mode 100644 index 0000000..62c7ced --- /dev/null +++ b/src/web/users.rs @@ -0,0 +1,312 @@ +use std::fmt; +use std::str::FromStr; + +use jiff::Timestamp; +use sqlx::Row; + +use crate::db::{Db, DbError, fmt_ts, parse_ts}; + +pub const MIN_PASSWORD_LEN: usize = 12; +pub const MAX_PASSWORD_LEN: usize = 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + User, + Admin, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Admin => "admin", + } + } +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for Role { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + "admin" => Ok(Self::Admin), + _ => Err(format!("invalid role {value:?}; expected user or admin")), + } + } +} + +#[derive(Clone)] +pub struct User { + pub id: i64, + pub username: String, + pub password_hash: String, + pub role: Role, + pub disabled: bool, + pub created_at: Timestamp, + pub last_login_at: Option, +} + +impl fmt::Debug for User { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("User") + .field("id", &self.id) + .field("username", &self.username) + .field("password_hash", &"[REDACTED]") + .field("role", &self.role) + .field("disabled", &self.disabled) + .field("created_at", &self.created_at) + .field("last_login_at", &self.last_login_at) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct UserListRow { + pub user: User, + pub open_sessions: i64, +} + +pub fn validate_username(username: &str) -> anyhow::Result<()> { + if username.is_empty() + || username.len() > 32 + || !username + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-')) + { + anyhow::bail!("username must be 1-32 characters from A-Z, a-z, 0-9, '.', '_' or '-'"); + } + Ok(()) +} + +pub fn validate_password(password: &str) -> anyhow::Result<()> { + if password.len() < MIN_PASSWORD_LEN { + anyhow::bail!("password must be at least {MIN_PASSWORD_LEN} characters"); + } + if password.len() > MAX_PASSWORD_LEN { + anyhow::bail!("password must be at most {MAX_PASSWORD_LEN} characters"); + } + Ok(()) +} + +pub fn hash_password(plain: &str) -> String { + password_auth::generate_hash(plain) +} + +pub fn verify_password(hash: &str, plain: &str) -> bool { + password_auth::verify_password(plain, hash).is_ok() +} + +pub async fn find_by_username(db: &Db, username: &str) -> Result, DbError> { + let row = sqlx::query( + "SELECT id, username, password_hash, role, disabled, created_at, last_login_at + FROM users WHERE username = ? COLLATE NOCASE", + ) + .bind(username) + .fetch_optional(db.pool()) + .await?; + row.as_ref().map(user_from_row).transpose() +} + +pub async fn find_by_id(db: &Db, id: i64) -> Result, DbError> { + let row = sqlx::query( + "SELECT id, username, password_hash, role, disabled, created_at, last_login_at + FROM users WHERE id = ?", + ) + .bind(id) + .fetch_optional(db.pool()) + .await?; + row.as_ref().map(user_from_row).transpose() +} + +pub async fn add(db: &Db, username: &str, password: &str, admin: bool) -> anyhow::Result { + validate_username(username)?; + validate_password(password)?; + if find_by_username(db, username).await?.is_some() { + anyhow::bail!("user {username:?} already exists"); + } + let hash = hash_password(password); + let created_at = Timestamp::now(); + let role = if admin { Role::Admin } else { Role::User }; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, role, created_at) + VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(username) + .bind(&hash) + .bind(role.as_str()) + .bind(fmt_ts(created_at)) + .fetch_one(db.pool()) + .await?; + Ok(User { + id, + username: username.to_string(), + password_hash: hash, + role, + disabled: false, + created_at, + last_login_at: None, + }) +} + +pub async fn passwd(db: &Db, username: &str, password: &str) -> anyhow::Result { + validate_password(password)?; + let hash = hash_password(password); + let result = + sqlx::query("UPDATE users SET password_hash = ? WHERE username = ? COLLATE NOCASE") + .bind(hash) + .bind(username) + .execute(db.pool()) + .await?; + require_one(username, result.rows_affected())?; + logout(db, username).await +} + +pub async fn set_role(db: &Db, username: &str, role: Role) -> anyhow::Result<()> { + let result = sqlx::query("UPDATE users SET role = ? WHERE username = ? COLLATE NOCASE") + .bind(role.as_str()) + .bind(username) + .execute(db.pool()) + .await?; + require_one(username, result.rows_affected()) +} + +pub async fn set_disabled(db: &Db, username: &str, disabled: bool) -> anyhow::Result { + let result = sqlx::query("UPDATE users SET disabled = ? WHERE username = ? COLLATE NOCASE") + .bind(disabled) + .bind(username) + .execute(db.pool()) + .await?; + require_one(username, result.rows_affected())?; + if disabled { + logout(db, username).await + } else { + Ok(0) + } +} + +pub async fn logout(db: &Db, username: &str) -> anyhow::Result { + let Some(user) = find_by_username(db, username).await? else { + anyhow::bail!("user {username:?} was not found"); + }; + let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(user.id) + .execute(db.pool()) + .await?; + Ok(result.rows_affected()) +} + +pub async fn list(db: &Db) -> anyhow::Result> { + let rows = sqlx::query( + "SELECT u.id, u.username, u.password_hash, u.role, u.disabled, u.created_at, + u.last_login_at, COUNT(s.id) AS open_sessions + FROM users u LEFT JOIN sessions s ON s.user_id = u.id AND s.expiry > unixepoch() + GROUP BY u.id ORDER BY u.username COLLATE NOCASE", + ) + .fetch_all(db.pool()) + .await?; + rows.iter() + .map(|row| { + Ok(UserListRow { + user: user_from_row(row)?, + open_sessions: row.get("open_sessions"), + }) + }) + .collect() +} + +fn require_one(username: &str, count: u64) -> anyhow::Result<()> { + if count == 0 { + anyhow::bail!("user {username:?} was not found"); + } + Ok(()) +} + +fn user_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let role_raw: String = row.get("role"); + let role = role_raw.parse().map_err(|_| DbError::Decode { + column: "users.role", + value: role_raw, + })?; + Ok(User { + id: row.get("id"), + username: row.get("username"), + password_hash: row.get("password_hash"), + role, + disabled: row.get("disabled"), + created_at: parse_ts("users.created_at", &row.get::("created_at"))?, + last_login_at: row + .get::, _>("last_login_at") + .map(|value| parse_ts("users.last_login_at", &value)) + .transpose()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn password_hashes_round_trip_and_fail_safely() { + let hash = hash_password("correct horse battery"); + assert!(verify_password(&hash, "correct horse battery")); + assert!(!verify_password(&hash, "wrong password")); + assert!(!verify_password("not a phc string", "anything")); + assert!( + !format!( + "{:?}", + User { + id: 1, + username: "reader".into(), + password_hash: hash.clone(), + role: Role::User, + disabled: false, + created_at: Timestamp::now(), + last_login_at: None, + } + ) + .contains(&hash) + ); + } + + #[tokio::test] + async fn user_operations_validate_and_are_case_insensitive() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open_and_migrate(&dir.path().join("db.sqlite")) + .await + .unwrap(); + assert!(add(&db, "reader", "too-short", false).await.is_err()); + let user = add(&db, "Reader", "correct horse battery", true) + .await + .unwrap(); + assert_eq!(user.role, Role::Admin); + assert!( + add(&db, "reader", "another valid password", false) + .await + .is_err() + ); + set_role(&db, "READER", Role::User).await.unwrap(); + assert_eq!( + find_by_username(&db, "reader").await.unwrap().unwrap().role, + Role::User + ); + passwd(&db, "reader", "a replacement password") + .await + .unwrap(); + set_disabled(&db, "reader", true).await.unwrap(); + assert!( + find_by_username(&db, "reader") + .await + .unwrap() + .unwrap() + .disabled + ); + set_disabled(&db, "reader", false).await.unwrap(); + } +} diff --git a/tests/e2e_pipeline.rs b/tests/e2e_pipeline.rs index 01f3dd1..84370ec 100644 --- a/tests/e2e_pipeline.rs +++ b/tests/e2e_pipeline.rs @@ -364,6 +364,7 @@ async fn assemble_build_publish( None, Some(&issue.editorial.front_page_html), Some("{\"status\":\"ok\"}"), + None, ) .await .expect("record the issue"); diff --git a/tests/m7_server.rs b/tests/m7_server.rs index f62eb25..48b6ffb 100644 --- a/tests/m7_server.rs +++ b/tests/m7_server.rs @@ -106,6 +106,49 @@ impl Server { fn get_auth(&self, path: &str, credentials: &str) -> HttpResponse { try_get(self.port, path, Some(credentials)).expect("request failed") } + + fn add_admin(&self, username: &str, password: &str) { + let mut child = Command::new(env!("CARGO_BIN_EXE_daily-epub")) + .args(["users", "add", username, "--admin", "--password-stdin"]) + .current_dir(self.dir.path()) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env( + "DAILY_EPUB_DATABASE_PATH", + self.dir.path().join("daily-epub.db"), + ) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawning users add"); + child + .stdin + .take() + .expect("users add stdin") + .write_all(format!("{password}\n").as_bytes()) + .expect("writing users add password"); + assert!(child.wait().expect("waiting for users add").success()); + } + + fn post_form(&self, path: &str, body: &str) -> HttpResponse { + try_request( + self.port, + "POST", + path, + &format!( + "Content-Type: application/x-www-form-urlencoded\r\nSec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n", + body.len() + ), + body, + ) + .expect("request failed") + } + + fn get_cookie(&self, path: &str, cookie: &str) -> HttpResponse { + try_request(self.port, "GET", path, &format!("Cookie: {cookie}\r\n"), "") + .expect("request failed") + } } #[derive(Debug)] @@ -135,15 +178,26 @@ fn free_port() -> u16 { /// A minimal HTTP/1.1 `GET`; `None` when the connection could not be made. fn try_get(port: u16, path: &str, credentials: Option<&str>) -> Option { + let auth = credentials + .map(|c| format!("Authorization: Basic {c}\r\n")) + .unwrap_or_default(); + try_request(port, "GET", path, &auth, "") +} + +fn try_request( + port: u16, + method: &str, + path: &str, + extra_headers: &str, + body: &str, +) -> Option { let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; stream .set_read_timeout(Some(Duration::from_secs(10))) .ok()?; - let auth = credentials - .map(|c| format!("Authorization: Basic {c}\r\n")) - .unwrap_or_default(); - let request = - format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n{auth}\r\n"); + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n{extra_headers}\r\n{body}" + ); stream.write_all(request.as_bytes()).ok()?; stream.flush().ok()?; // No half-close here: hyper drops a connection whose peer has shut down its @@ -171,6 +225,32 @@ fn try_get(port: u16, path: &str, credentials: Option<&str>) -> Option