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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 04:38:22 +00:00
co-authored by Claude Fable 5.1
parent a70de4af06
commit 69cd736455
35 changed files with 3801 additions and 69 deletions
Generated
+510 -21
View File
@@ -138,6 +138,18 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -224,6 +236,17 @@ dependencies = [
"tokio", "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]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@@ -269,7 +292,7 @@ dependencies = [
"num-traits", "num-traits",
"pastey", "pastey",
"rayon", "rayon",
"thiserror", "thiserror 2.0.20",
"v_frame", "v_frame",
"y4m", "y4m",
] ]
@@ -372,6 +395,26 @@ dependencies = [
"tracing", "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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -384,6 +427,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "basic-toml" name = "basic-toml"
version = "0.1.10" version = "0.1.10"
@@ -438,6 +487,15 @@ dependencies = [
"no_std_io2", "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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -654,6 +712,17 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" 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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -820,7 +889,9 @@ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
"askama", "askama",
"async-trait",
"axum", "axum",
"axum-login",
"base64 0.23.1", "base64 0.23.1",
"clap", "clap",
"dom_smoothie", "dom_smoothie",
@@ -832,26 +903,48 @@ dependencies = [
"image", "image",
"jiff", "jiff",
"libc", "libc",
"password-auth",
"rand 0.10.2", "rand 0.10.2",
"reqwest", "reqwest",
"resvg", "resvg",
"roxmltree 0.21.1",
"rpassword",
"scraper", "scraper",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"sqlx", "sqlx",
"tempfile", "tempfile",
"thiserror", "thiserror 2.0.20",
"time",
"tiny-skia", "tiny-skia",
"tokio", "tokio",
"tokio-util", "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-http 0.7.0",
"tower_governor",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"zip", "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]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -886,7 +979,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [ dependencies = [
"thiserror", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -894,6 +987,9 @@ name = "deranged"
version = "0.5.8" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "derive_arbitrary" name = "derive_arbitrary"
@@ -935,6 +1031,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer 0.10.4", "block-buffer 0.10.4",
"crypto-common 0.1.7", "crypto-common 0.1.7",
"subtle",
] ]
[[package]] [[package]]
@@ -990,7 +1087,7 @@ dependencies = [
"once_cell", "once_cell",
"phf", "phf",
"tendril", "tendril",
"thiserror", "thiserror 2.0.20",
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -1056,7 +1153,7 @@ dependencies = [
"log", "log",
"once_cell", "once_cell",
"tempfile", "tempfile",
"thiserror", "thiserror 2.0.20",
"upon", "upon",
"uuid", "uuid",
"zip", "zip",
@@ -1176,7 +1273,7 @@ dependencies = [
"pear", "pear",
"serde", "serde",
"tempfile", "tempfile",
"toml", "toml 0.8.23",
"uncased", "uncased",
"version_check", "version_check",
] ]
@@ -1273,6 +1370,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1361,6 +1468,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.34"
@@ -1417,9 +1530,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 5.3.0", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1458,6 +1573,29 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" 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]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.15"
@@ -1500,6 +1638,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -1669,6 +1813,19 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.20" version = "0.1.20"
@@ -1987,7 +2144,7 @@ dependencies = [
"jni-sys", "jni-sys",
"log", "log",
"simd_cesu8", "simd_cesu8",
"thiserror", "thiserror 2.0.20",
"walkdir", "walkdir",
"windows-link", "windows-link",
] ]
@@ -2282,6 +2439,18 @@ dependencies = [
"memchr", "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]] [[package]]
name = "noop_proc_macro" name = "noop_proc_macro"
version = "0.3.0" version = "0.3.0"
@@ -2410,6 +2579,29 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@@ -2510,6 +2702,26 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2668,6 +2880,21 @@ dependencies = [
"bytemuck", "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]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@@ -2688,7 +2915,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -2711,7 +2938,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.20",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -2783,6 +3010,15 @@ dependencies = [
"rand_core 0.9.5", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.9.5"
@@ -2837,7 +3073,7 @@ dependencies = [
"rand 0.9.5", "rand 0.9.5",
"rand_chacha", "rand_chacha",
"simd_helpers", "simd_helpers",
"thiserror", "thiserror 2.0.20",
"v_frame", "v_frame",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -3026,6 +3262,27 @@ dependencies = [
"memchr", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3292,6 +3549,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -3470,6 +3736,15 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.9.0" version = "0.9.0"
@@ -3511,7 +3786,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2 0.10.9", "sha2 0.10.9",
"smallvec", "smallvec",
"thiserror", "thiserror 2.0.20",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -3553,7 +3828,7 @@ dependencies = [
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite", "sqlx-sqlite",
"syn 2.0.119", "syn 2.0.119",
"thiserror", "thiserror 2.0.20",
"tokio", "tokio",
"url", "url",
] ]
@@ -3580,7 +3855,7 @@ dependencies = [
"sha1", "sha1",
"sha2 0.11.0", "sha2 0.11.0",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror 2.0.20",
"tracing", "tracing",
] ]
@@ -3614,7 +3889,7 @@ dependencies = [
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror 2.0.20",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -3638,7 +3913,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"serde", "serde",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror 2.0.20",
"tracing", "tracing",
"url", "url",
] ]
@@ -3800,13 +4075,33 @@ dependencies = [
"new_debug_unreachable", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ 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]] [[package]]
@@ -3854,6 +4149,7 @@ dependencies = [
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@@ -3862,6 +4158,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" 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]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.12.0" version = "0.12.0"
@@ -3983,9 +4289,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit", "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]] [[package]]
@@ -3997,6 +4318,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.27" version = "0.22.27"
@@ -4005,18 +4335,75 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow 0.7.15", "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]] [[package]]
name = "toml_write" name = "toml_write"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -4025,14 +4412,33 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"indexmap",
"pin-project-lite", "pin-project-lite",
"slab",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "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]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.11" version = "0.6.11"
@@ -4080,6 +4486,7 @@ dependencies = [
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -4094,6 +4501,60 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 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]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -4264,6 +4725,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "usvg" name = "usvg"
version = "0.48.1" version = "0.48.1"
@@ -4495,6 +4962,22 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" 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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -4504,6 +4987,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
+11 -1
View File
@@ -7,7 +7,9 @@ edition = "2024"
ammonia = "4.1.4" ammonia = "4.1.4"
anyhow = "1.0.104" anyhow = "1.0.104"
askama = "0.16.0" askama = "0.16.0"
async-trait = "0.1.92"
axum = "0.8.9" axum = "0.8.9"
axum-login = { git = "https://github.com/maxcountryman/axum-login.git", rev = "151c72d7a1b4646830f86b4332e6bd6e34d719a7", version = "0.18.0" }
base64 = "0.23.1" base64 = "0.23.1"
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
dom_smoothie = "0.18.0" dom_smoothie = "0.18.0"
@@ -19,6 +21,7 @@ hmac = "0.13.0"
image = "0.25.10" image = "0.25.10"
jiff = { version = "0.2.35", features = ["serde"] } jiff = { version = "0.2.35", features = ["serde"] }
libc = "0.2.189" libc = "0.2.189"
password-auth = "1.0.0"
rand = "0.10.2" rand = "0.10.2"
reqwest = { version = "0.13.4", default-features = false, features = [ reqwest = { version = "0.13.4", default-features = false, features = [
"rustls", "rustls",
@@ -30,6 +33,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
"system-proxy", "system-proxy",
] } ] }
resvg = "0.48.1" resvg = "0.48.1"
rpassword = "7.5.4"
scraper = "0.27.0" scraper = "0.27.0"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
@@ -44,15 +48,21 @@ sqlx = { version = "0.9.0", default-features = false, features = [
"tls-rustls-ring", "tls-rustls-ring",
] } ] }
thiserror = "2.0.20" thiserror = "2.0.20"
time = "0.3.55"
tiny-skia = "0.12.0" tiny-skia = "0.12.0"
tokio = { version = "1.53.1", features = ["full"] } tokio = { version = "1.53.1", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["io"] } 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 = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = { version = "2.5.8", features = ["serde"] } url = { version = "2.5.8", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
figment = { version = "0.10.19", features = ["test", "toml", "env"] } figment = { version = "0.10.19", features = ["test", "toml", "env"] }
roxmltree = "0.21.1"
tempfile = "3.27.0" tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
zip = { version = "6", default-features = false, features = ["deflate"] } zip = { version = "6", default-features = false, features = ["deflate"] }
+30 -1
View File
@@ -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 backfill-social [--days 7] # re-poll social scores for recent articles
daily-epub db migrate # run migrations (also automatic on every start) 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 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 `--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 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`. 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 ## Configuration
@@ -293,7 +314,12 @@ prints what resolved.
| `server.bind` | `127.0.0.1:3499` | Listen address. | | `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.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.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 (105000). |
`[curation.ranking]` holds the ranker's tunables. The learned signals are `[curation.ranking]` holds the ranker's tunables. The learned signals are
gated: `knn` (rated-neighbour preference) ramps from `knn_floor` (8) to 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 The rating links baked into every article chapter point at
`server.public_url`, so `daily.hallada.net` must resolve and serve TLS from the `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. 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: With an existing certificate, a minimal nginx site is:
```nginx ```nginx
+1 -1
View File
@@ -1,4 +1,4 @@
[general] [general]
# EPUB chapter templates live next to the builder that renders them # EPUB chapter templates live next to the builder that renders them
# (implementation notes §11). # (implementation notes §11).
dirs = ["src/epub/templates"] dirs = ["src/epub/templates", "src/web/templates"]
+5
View File
@@ -210,6 +210,11 @@ settings = "/etc/daily-epub/xtc-settings.json"
[server] [server]
bind = "127.0.0.1:3499" bind = "127.0.0.1:3499"
public_url = "https://daily.hallada.net" 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) # hmac_secret via DAILY_EPUB_SERVER__HMAC_SECRET env (32+ random bytes)
# Optional Basic auth for the XTC OPDS feed and file downloads: # Optional Basic auth for the XTC OPDS feed and file downloads:
# basic_auth_user = "daily" # basic_auth_user = "daily"
@@ -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.
+58
View File
@@ -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);
+34
View File
@@ -700,6 +700,15 @@ pub struct ServerConfig {
/// Optional Basic auth for `/opds/*` and `/files/*`. /// Optional Basic auth for `/opds/*` and `/files/*`.
pub basic_auth_user: Option<String>, pub basic_auth_user: Option<String>,
pub basic_auth_pass: Option<String>, pub basic_auth_pass: Option<String>,
/// 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 { impl Default for ServerConfig {
@@ -710,6 +719,11 @@ impl Default for ServerConfig {
hmac_secret: None, hmac_secret: None,
basic_auth_user: None, basic_auth_user: None,
basic_auth_pass: 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. /// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
pub fn validate(&self) -> Result<(), ConfigError> { 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 { if self.lookback_hours == 0 {
return Err(ConfigError::Invalid("lookback_hours must be > 0".into())); return Err(ConfigError::Invalid("lookback_hours must be > 0".into()));
} }
+3
View File
@@ -509,6 +509,7 @@ mod tests {
fn prompt_sections_are_ordered_and_verdicts_have_required_labels() { fn prompt_sections_are_ordered_and_verdicts_have_required_labels() {
let rating = RatedArticle { let rating = RatedArticle {
article_id: 1, article_id: 1,
user_id: None,
issue_date: None, issue_date: None,
title: "A title".into(), title: "A title".into(),
feed_title: "A feed".into(), feed_title: "A feed".into(),
@@ -551,6 +552,7 @@ mod tests {
fn rebuild_prompt_carries_summary_facets_note_and_diversity_instruction() { fn rebuild_prompt_carries_summary_facets_note_and_diversity_instruction() {
let rating = RatedArticle { let rating = RatedArticle {
article_id: 1, article_id: 1,
user_id: None,
issue_date: Some("2026-08-15".parse().unwrap()), issue_date: Some("2026-08-15".parse().unwrap()),
title: "Postgres failover".into(), title: "Postgres failover".into(),
feed_title: "Engineering Notes".into(), feed_title: "Engineering Notes".into(),
@@ -647,6 +649,7 @@ mod tests {
.unwrap(); .unwrap();
db.append_rating_event(&RatingEvent { db.append_rating_event(&RatingEvent {
id: 0, id: 0,
user_id: None,
article_id: 1, article_id: 1,
issue_date: None, issue_date: None,
kind: "explicit".into(), kind: "explicit".into(),
+140 -8
View File
@@ -60,6 +60,27 @@ pub struct Db {
pool: SqlitePool, pool: SqlitePool,
} }
#[derive(Debug, Clone)]
pub struct IssueRow {
pub date: Date,
pub issue_number: i64,
pub generated_at: Timestamp,
pub epub_path: Option<String>,
pub x4_path: Option<String>,
pub xtc_path: Option<String>,
pub front_page_html: Option<String>,
pub report_json: Option<String>,
pub issue_json: Option<String>,
}
#[derive(Debug, Clone)]
pub struct IssueListRow {
pub date: Date,
pub issue_number: i64,
pub generated_at: Timestamp,
pub article_count: i64,
}
impl Db { impl Db {
/// Open (creating if needed) the database at `path` with WAL + foreign keys on, /// Open (creating if needed) the database at `path` with WAL + foreign keys on,
/// creating parent directories first. Does **not** run migrations. /// creating parent directories first. Does **not** run migrations.
@@ -442,11 +463,12 @@ impl Db {
xtc_path: Option<&str>, xtc_path: Option<&str>,
front_page_html: Option<&str>, front_page_html: Option<&str>,
report_json: Option<&str>, report_json: Option<&str>,
issue_json: Option<&str>,
) -> Result<()> { ) -> Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path, "INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path,
front_page_html, report_json) front_page_html, report_json, issue_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET ON CONFLICT(date) DO UPDATE SET
issue_number = excluded.issue_number, issue_number = excluded.issue_number,
generated_at = excluded.generated_at, generated_at = excluded.generated_at,
@@ -454,7 +476,8 @@ impl Db {
x4_path = COALESCE(excluded.x4_path, issues.x4_path), x4_path = COALESCE(excluded.x4_path, issues.x4_path),
xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path), xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path),
front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html), 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(date.to_string())
.bind(issue_number) .bind(issue_number)
@@ -464,11 +487,49 @@ impl Db {
.bind(xtc_path) .bind(xtc_path)
.bind(front_page_html) .bind(front_page_html)
.bind(report_json) .bind(report_json)
.bind(issue_json)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(()) Ok(())
} }
/// One stored issue, including its serialized full-issue snapshot.
pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> {
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<i64>) -> Result<Vec<IssueListRow>> {
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<Option<Date>> {
let raw: Option<String> = 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). /// Replace the lineup for a date (regeneration is idempotent, notes §12).
pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> { pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> {
let mut tx = self.pool.begin().await?; let mut tx = self.pool.begin().await?;
@@ -541,8 +602,8 @@ impl Db {
pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> { pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> {
let row = sqlx::query( let row = sqlx::query(
"INSERT INTO rating_events "INSERT INTO rating_events
(article_id, issue_date, kind, source, label, value, note, event_at) (article_id, issue_date, kind, source, label, value, note, event_at, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id", RETURNING id",
) )
.bind(event.article_id) .bind(event.article_id)
@@ -553,6 +614,7 @@ impl Db {
.bind(event.value) .bind(event.value)
.bind(event.note.as_deref()) .bind(event.note.as_deref())
.bind(fmt_ts(event.event_at)) .bind(fmt_ts(event.event_at))
.bind(event.user_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await?; .await?;
Ok(row.get("id")) Ok(row.get("id"))
@@ -611,7 +673,7 @@ impl Db {
FROM rating_events re FROM rating_events re
WHERE re.kind = 'explicit' AND re.event_at >= ? 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(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title, COALESCE(e.feed_title, '') AS feed_title,
(SELECT ia.summary FROM issue_articles ia (SELECT ia.summary FROM issue_articles ia
@@ -655,7 +717,8 @@ impl Db {
sqlx::query( sqlx::query(
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?, "UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?, 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 = ?", WHERE id = ?",
) )
.bind(report.finished_at.map(fmt_ts)) .bind(report.finished_at.map(fmt_ts))
@@ -680,6 +743,7 @@ impl Db {
source, source,
})?, })?,
) )
.bind(report.to_json())
.bind(id) .bind(id)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -828,6 +892,7 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle>
}); });
Ok(RatedArticle { Ok(RatedArticle {
article_id: row.get("article_id"), article_id: row.get("article_id"),
user_id: row.get("user_id"),
issue_date, issue_date,
title: row.get("title"), title: row.get("title"),
feed_title: row.get("feed_title"), feed_title: row.get("feed_title"),
@@ -840,6 +905,29 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle>
}) })
} }
fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<IssueRow> {
Ok(IssueRow {
date: parse_date("issues.date", &row.get::<String, _>("date"))?,
issue_number: row.get("issue_number"),
generated_at: parse_ts("issues.generated_at", &row.get::<String, _>("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<IssueListRow> {
Ok(IssueListRow {
date: parse_date("issues.date", &row.get::<String, _>("date"))?,
issue_number: row.get("issue_number"),
generated_at: parse_ts("issues.generated_at", &row.get::<String, _>("generated_at"))?,
article_count: row.get("article_count"),
})
}
fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<SocialRef> { fn social_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<SocialRef> {
let raw: String = row.get("source"); let raw: String = row.get("source");
let source = SocialSource::parse(&raw).ok_or(DbError::Decode { let source = SocialSource::parse(&raw).ok_or(DbError::Decode {
@@ -908,6 +996,30 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(row.get::<i64, _>(0), 1); assert_eq!(row.get::<i64, _>(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<String> = 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] #[tokio::test]
@@ -1024,6 +1136,13 @@ mod tests {
report.counts.selected = 20; report.counts.selected = 20;
report.finish(ts("2026-08-15T05:36:00Z")); report.finish(ts("2026-08-15T05:36:00Z"));
db.finish_run(run_id, &report).await.unwrap(); db.finish_run(run_id, &report).await.unwrap();
let stored_report: Option<String> =
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( db.upsert_issue(
date, date,
@@ -1034,6 +1153,7 @@ mod tests {
None, None,
None, None,
Some("{}"), Some("{}"),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -1158,6 +1278,7 @@ mod tests {
None, None,
None, None,
None, None,
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -1185,6 +1306,7 @@ mod tests {
.unwrap(); .unwrap();
let event = |label: &str, value: f64, at: &str| RatingEvent { let event = |label: &str, value: f64, at: &str| RatingEvent {
id: 0, id: 0,
user_id: None,
article_id, article_id,
issue_date: Some("2026-08-15".parse().unwrap()), issue_date: Some("2026-08-15".parse().unwrap()),
kind: "explicit".into(), kind: "explicit".into(),
@@ -1268,9 +1390,13 @@ mod tests {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .unwrap();
sqlx::raw_sql(include_str!("../migrations/0004_web.sql"))
.execute(&pool)
.await
.unwrap();
let rows = sqlx::query( 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", FROM rating_events ORDER BY article_id",
) )
.fetch_all(&pool) .fetch_all(&pool)
@@ -1285,6 +1411,7 @@ mod tests {
assert_eq!(row.get::<String, _>("kind"), "explicit"); assert_eq!(row.get::<String, _>("kind"), "explicit");
assert_eq!(row.get::<String, _>("source"), "migration"); assert_eq!(row.get::<String, _>("source"), "migration");
assert_eq!(row.get::<String, _>("issue_date"), "2026-08-15"); assert_eq!(row.get::<String, _>("issue_date"), "2026-08-15");
assert_eq!(row.get::<Option<i64>, _>("user_id"), None);
} }
assert_eq!(rows[0].get::<String, _>("event_at"), "2026-08-15T12:00:00Z"); assert_eq!(rows[0].get::<String, _>("event_at"), "2026-08-15T12:00:00Z");
@@ -1302,6 +1429,11 @@ mod tests {
"interest_embeddings", "interest_embeddings",
"article_assessments", "article_assessments",
"candidate_runs", "candidate_runs",
"users",
"sessions",
"config_changes",
"profile_versions",
"jobs",
] { ] {
assert!(tables.iter().any(|table| table == expected), "{expected}"); assert!(tables.iter().any(|table| table == expected), "{expected}");
} }
+1
View File
@@ -31,6 +31,7 @@ pub mod report;
pub mod server; pub mod server;
pub mod social; pub mod social;
pub mod types; pub mod types;
pub mod web;
pub mod world; pub mod world;
/// `CARGO_PKG_VERSION`, printed in the colophon and the OPDS generator tag. /// `CARGO_PKG_VERSION`, printed in the colophon and the OPDS generator tag.
+126 -2
View File
@@ -58,6 +58,43 @@ enum Command {
/// Inspect the resolved configuration. /// Inspect the resolved configuration.
#[command(subcommand)] #[command(subcommand)]
Config(ConfigCommand), 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)] #[derive(Debug, Subcommand)]
@@ -286,7 +323,8 @@ async fn main() -> Result<()> {
} }
Command::Serve => { Command::Serve => {
let db = Db::open_and_migrate(&config.database_path).await?; 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) => { Command::Profile(ProfileCommand::Rebuild) => {
let db = Db::open_and_migrate(&config.database_path).await?; let db = Db::open_and_migrate(&config.database_path).await?;
@@ -326,6 +364,10 @@ async fn main() -> Result<()> {
println!("{line}"); println!("{line}");
} }
} }
Command::Users(command) => {
let db = Db::open_and_migrate(&config.database_path).await?;
cmd_users(&db, command).await?;
}
} }
Ok(()) Ok(())
} }
@@ -345,10 +387,91 @@ fn lock_holder(command: &Command) -> Option<&'static str> {
| Command::Stats(_) | Command::Stats(_)
| Command::Features(FeaturesCommand::Prune) | Command::Features(FeaturesCommand::Prune)
| Command::Db(_) | 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<String> {
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<String> {
rpassword::prompt_password(prompt).context("reading the password from the terminal")
}
/// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging"). /// `RUST_LOG`-driven tracing, defaulting to `info` (crate table "logging").
fn init_tracing() { fn init_tracing() {
let filter = EnvFilter::try_from_default_env() let filter = EnvFilter::try_from_default_env()
@@ -562,6 +685,7 @@ async fn append_cli_event(
}; };
let event = RatingEvent { let event = RatingEvent {
id: 0, id: 0,
user_id: None,
article_id, article_id,
issue_date: db.latest_issue_date_for_article(article_id).await?, issue_date: db.latest_issue_date_for_article(article_id).await?,
kind: "explicit".into(), kind: "explicit".into(),
+10
View File
@@ -70,6 +70,7 @@ pub struct GenerateOptions {
/// What one run produced, for the caller to print (§3.13). /// What one run produced, for the caller to print (§3.13).
#[derive(Debug)] #[derive(Debug)]
pub struct GenerateOutcome { pub struct GenerateOutcome {
pub run_id: i64,
pub report: RunReport, pub report: RunReport,
/// `None` only when the run failed before assembly. /// `None` only when the run failed before assembly.
pub issue: Option<Issue>, pub issue: Option<Issue>,
@@ -271,12 +272,14 @@ pub async fn generate(config: &Config, db: &Db, opts: &GenerateOptions) -> Resul
None, None,
None, None,
Some(&report.to_json()), Some(&report.to_json()),
None,
) )
.await .await
{ {
tracing::warn!(error = %e, "could not attach the run report to the issue"); tracing::warn!(error = %e, "could not attach the run report to the issue");
} }
Ok(GenerateOutcome { Ok(GenerateOutcome {
run_id,
report, report,
issue: stages.issue, issue: stages.issue,
artifacts: stages.artifacts, 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 x4_path = path_for(Edition::X4);
let xtc_path = published.xtc.as_ref().map(|p| p.display().to_string()); 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( db.upsert_issue(
issue.meta.date, issue.meta.date,
issue.meta.issue_number, issue.meta.issue_number,
@@ -1054,6 +1063,7 @@ async fn record_issue(db: &Db, issue: &Issue, published: &Published) -> Result<(
xtc_path.as_deref(), xtc_path.as_deref(),
Some(&issue.editorial.front_page_html), Some(&issue.editorial.front_page_html),
None, None,
Some(&issue_json),
) )
.await?; .await?;
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks) db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
+1
View File
@@ -874,6 +874,7 @@ type=\"application/epub+zip\" length=\"1700000\"/>"
None, None,
None, None,
None, None,
None,
) )
.await .await
.unwrap(); .unwrap();
+121 -30
View File
@@ -22,18 +22,23 @@
//! `server.hmac_secret` is unset carries links this server rejects with 403. //! `server.hmac_secret` is unset carries links this server rejects with 403.
use std::path::{Path as FsPath, PathBuf}; use std::path::{Path as FsPath, PathBuf};
use std::sync::{Arc, RwLock};
use axum::Router; use axum::Router;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::middleware::{from_fn, from_fn_with_state};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::get; use axum::routing::get;
use axum_login::AuthManagerLayerBuilder;
use axum_login::tower_sessions::{Expiry, SessionManagerLayer};
use base64::Engine as _; use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use jiff::Timestamp; use jiff::Timestamp;
use jiff::civil::Date; use jiff::civil::Date;
use serde::Deserialize; use serde::Deserialize;
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use crate::config::Config; use crate::config::Config;
@@ -64,10 +69,44 @@ pub enum ServerError {
} }
/// Shared axum state. /// Shared axum state.
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: Db, pub db: Db,
pub config: Config, pub config: Arc<RwLock<Arc<Config>>>,
pub config_path: Option<PathBuf>,
pub web: Arc<crate::web::WebState>,
}
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<PathBuf>) -> 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<Config> {
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 /// `/files/epub/{name}`, `/files/xtc/{name}`, `/healthz`, `/issues.json`, with
/// `tower-http` tracing (§3.12). /// `tower-http` tracing (§3.12).
pub fn router(state: AppState) -> Router { 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() Router::new()
.route("/r/{date}/{article_id}/{vote}", get(handle_rating)) .route("/r/{date}/{article_id}/{vote}", get(handle_rating))
.route(crate::publish::OPDS_PATH, get(handle_opds)) .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("/files/xtc/{name}", get(handle_xtc_file))
.route("/healthz", get(handle_healthz)) .route("/healthz", get(handle_healthz))
.route("/issues.json", get(handle_issues_json)) .route("/issues.json", get(handle_issues_json))
.merge(crate::web::router(&config))
.layer(PropagateRequestIdLayer::x_request_id())
.layer(TraceLayer::new_for_http()) .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) .with_state(state)
} }
/// `daily-epub serve` — bind, serve, graceful shutdown on SIGTERM (§3.12). /// `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<PathBuf>,
db: Db,
) -> Result<(), ServerError> {
if config.server.hmac_secret.is_none() { if config.server.hmac_secret.is_none() {
// Not fatal for the OPDS routes, but every rating link would 500. // Not fatal for the OPDS routes, but every rating link would 500.
tracing::warn!("server.hmac_secret is unset — rating links will be rejected"); 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); let local = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr);
tracing::info!(bind = %local, public_url = %config.server.public_url, "serving"); tracing::info!(bind = %local, public_url = %config.server.public_url, "serving");
let app = router(AppState { db, config }); let store = crate::web::session::SqliteSessionStore::new(db.pool().clone());
axum::serve(listener, app) if let Err(error) = store.delete_expired().await {
.with_graceful_shutdown(shutdown_signal()) tracing::warn!(%error, "could not delete expired sessions at startup");
.await?; }
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::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
tracing::info!("server stopped"); tracing::info!("server stopped");
Ok(()) Ok(())
} }
@@ -219,7 +304,8 @@ async fn handle_rating(
return page(StatusCode::BAD_REQUEST, "Bad link — invalid vote.", None); 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"); tracing::error!("rating request but server.hmac_secret is unset");
return page( return page(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -251,12 +337,13 @@ async fn handle_rating(
}; };
let event = RatingEvent { let event = RatingEvent {
id: 0, id: 0,
user_id: None,
issue_date: Some(date), issue_date: Some(date),
article_id, article_id,
kind: "explicit".into(), kind: "explicit".into(),
source: "epub".into(), source: "epub".into(),
label: label.into(), label: label.into(),
value: vote.value(&state.config.curation.feedback), value: vote.value(&config.curation.feedback),
note: None, note: None,
event_at: Timestamp::now(), event_at: Timestamp::now(),
}; };
@@ -278,23 +365,17 @@ async fn handle_rating(
Vote::Good => "Recorded: Good — thanks.", Vote::Good => "Recorded: Good — thanks.",
Vote::NotForMe => "Recorded: Not for me — thanks.", Vote::NotForMe => "Recorded: Not for me — thanks.",
}; };
confirmation_page( confirmation_page(StatusCode::OK, message, &config, date, article_id, vote)
StatusCode::OK,
message,
&state.config,
date,
article_id,
vote,
)
} }
/// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first, /// `GET /opds/daily.xml` — both EPUB editions of the last issues, newest first,
/// rendered from the publish directory on each request (§3.11). /// rendered from the publish directory on each request (§3.11).
async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Response { async fn handle_opds(State(state): State<AppState>, 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; return challenge;
} }
match crate::publish::build_opds(&state.db, &state.config).await { match crate::publish::build_opds(&state.db, &config).await {
Ok(feed) => ( Ok(feed) => (
StatusCode::OK, StatusCode::OK,
[ [
@@ -321,9 +402,10 @@ async fn handle_epub_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
headers: HeaderMap, headers: HeaderMap,
auth: crate::web::session::AuthSession,
) -> Response { ) -> Response {
let dir = state.config.publish.epub_dir.clone(); let dir = state.config().publish.epub_dir.clone();
serve_file(&state, &dir, &name, &headers).await serve_file(&state, &dir, &name, &headers, auth.user().await.is_some()).await
} }
/// `GET /files/xtc/{name}` — download one XTC artifact (§3.11). /// `GET /files/xtc/{name}` — download one XTC artifact (§3.11).
@@ -334,14 +416,25 @@ async fn handle_xtc_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
headers: HeaderMap, headers: HeaderMap,
auth: crate::web::session::AuthSession,
) -> Response { ) -> Response {
let dir = state.config.publish.xtc_dir.clone(); let dir = state.config().publish.xtc_dir.clone();
serve_file(&state, &dir, &name, &headers).await 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). /// Stream one file out of `dir`. A signed-in web session (any role) bypasses
async fn serve_file(state: &AppState, dir: &FsPath, name: &str, headers: &HeaderMap) -> Response { /// the OPDS Basic auth; without one the existing Basic auth policy applies
if let Some(challenge) = check_basic_auth(&state.config, headers) { /// 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; return challenge;
} }
let Some(path) = safe_join(dir, name) else { 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 listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
let app = router(AppState { let app = router(AppState::new(db.clone(), config, None));
db: db.clone(),
config,
});
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); });
@@ -848,6 +938,7 @@ mod tests {
None, None,
None, None,
Some(&format!("{{\"selected\":{n}}}")), Some(&format!("{{\"selected\":{n}}}")),
None,
) )
.await .await
.unwrap(); .unwrap();
+2
View File
@@ -705,6 +705,7 @@ impl Vote {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RatingEvent { pub struct RatingEvent {
pub id: i64, pub id: i64,
pub user_id: Option<i64>,
pub article_id: ArticleId, pub article_id: ArticleId,
pub issue_date: Option<Date>, pub issue_date: Option<Date>,
pub kind: String, pub kind: String,
@@ -733,6 +734,7 @@ pub struct Facets {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RatedArticle { pub struct RatedArticle {
pub article_id: ArticleId, pub article_id: ArticleId,
pub user_id: Option<i64>,
pub issue_date: Option<Date>, pub issue_date: Option<Date>,
pub title: String, pub title: String,
pub feed_title: String, pub feed_title: String,
+385
View File
@@ -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<Download>,
pub from_json: bool,
}
pub async fn load(
db: &Db,
config: &crate::config::Config,
date: Date,
) -> anyhow::Result<Option<IssueView>> {
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(&section) {
seen_sections.push(section.clone());
}
let summary: Option<String> = 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<String> = 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<Download> {
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\""));
}
}
+914
View File
@@ -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<UnitStatus, String>;
async fn log(&self, unit: &str, lines: usize) -> Result<String, String>;
}
#[derive(Debug, Clone, Default)]
pub struct UnitStatus {
pub active_state: String,
pub sub_state: String,
pub result: String,
pub exit_status: Option<i32>,
}
#[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<UnitStatus, String> {
Err("jobs are disabled".into())
}
async fn log(&self, _unit: &str, _lines: usize) -> Result<String, String> {
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<Vec<String>>,
}
impl MockRunner {
pub fn calls(&self) -> Vec<String> {
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<UnitStatus, String> {
self.record(format!("status {unit}"));
Ok(UnitStatus::default())
}
async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
self.record(format!("log {unit} {lines}"));
Ok(String::new())
}
}
pub struct WebState {
pub jobs: std::sync::Arc<dyn JobRunner>,
pub started_at: Timestamp,
pub config_mtime: Mutex<Option<SystemTime>>,
}
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<Viewer>,
pub flash: Option<Flash>,
pub active_nav: String,
pub version: &'static str,
}
impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, 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<T: Template>(pub T);
impl<T: Template> IntoResponse for Html<T> {
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<crate::server::AppState> {
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<Response, WebError> {
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<String>,
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<Body> {
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);
}
}
+376
View File
@@ -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<PublicSection>,
pub generated_at: jiff::Timestamp,
}
#[derive(Debug, Clone)]
pub struct PublicSection {
pub name: String,
pub entries: Vec<PublicEntry>,
}
#[derive(Debug, Clone)]
pub struct PublicEntry {
pub title: String,
pub url: String,
pub author: Option<String>,
pub source: String,
pub domain: String,
pub reading_minutes: i64,
pub word_count: i64,
pub comment_links: Vec<CommentLink>,
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<CommentLink> = 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<Download>,
empty: bool,
}
#[derive(Debug, Clone)]
pub struct ArchiveMonth {
pub label: String,
pub issues: Vec<ArchiveIssue>,
}
#[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<ArchiveMonth>,
}
#[derive(Template)]
#[template(path = "feed_entry.html")]
struct FeedEntryTemplate<'a> {
issue: &'a PublicIssue,
}
pub async fn latest(
State(state): State<AppState>,
auth: AuthSession,
headers: HeaderMap,
) -> Result<Response, WebError> {
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<AppState>,
auth: AuthSession,
headers: HeaderMap,
Path(date): Path<Date>,
) -> Result<Response, WebError> {
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<AppState>,
auth: AuthSession,
headers: HeaderMap,
) -> Result<Response, WebError> {
let rows = state.db.issue_dates(None).await?;
let mut months: Vec<ArchiveMonth> = 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<AppState>) -> Result<Response, WebError> {
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!(
"<entry><id>tag:{},{}:issue/{}</id><title>The Daily EPUB — {}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
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!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?><feed xmlns=\"http://www.w3.org/2005/Atom\"><id>{}</id><title>The Daily EPUB</title><updated>{}</updated><link rel=\"self\" href=\"{}/feed.xml\"/>{}</feed>",
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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}");
}
}
}
+522
View File
@@ -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<u64, sqlx::Error> {
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<u64, sqlx::Error> {
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<Option<Record>> {
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::<String, _>("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<String>,
}
#[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<Option<User>, 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<Option<User>, 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<HashSet<Role>, 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<Backend>;
#[derive(Debug, Clone)]
pub struct Viewer {
pub id: i64,
pub username: String,
pub role: Role,
}
impl From<User> 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<AppState>,
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<String>,
}
#[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<LoginQuery>) -> 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<AppState>,
auth: AuthSession,
Form(credentials): Form<Credentials>,
) -> Result<Response, WebError> {
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<Response, WebError> {
auth.logout()
.await
.map_err(|error| WebError::Internal(error.into()))?;
Ok(axum::response::Redirect::to("/").into_response())
}
pub async fn account(auth: AuthSession) -> Result<Response, WebError> {
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<AppState>,
auth: AuthSession,
Form(form): Form<PasswordForm>,
) -> Result<Response, WebError> {
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, &current))
.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<AppState>,
auth: AuthSession,
) -> Result<Response, WebError> {
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<String> {
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<i64>, 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<i64> =
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<i64> =
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/")), "/");
}
}
+35
View File
@@ -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; } }
+11
View File
@@ -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 (_) {}
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="6" fill="#fbfaf6"/><path d="M13 12h38v40H13z" fill="none" stroke="#171713" stroke-width="3"/><path d="M20 21h24M20 29h24M20 37h18M20 45h20" stroke="#8b1e1e" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 275 B

+1
View File
@@ -0,0 +1 @@
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Account</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/account/password"><label>Current password <input type="password" name="current_password" required></label><label>New password <input type="password" name="new_password" minlength="12" required></label><label>Confirm password <input type="password" name="confirm_password" minlength="12" required></label><button>Change password</button></form><form method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button>Sign out everywhere</button></form><form method="post" action="/logout"><button>Sign out</button></form></section>{% endblock %}
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard"><h1>Overview</h1><p>The dashboard foundation is ready. Run and article views land in the next dashboard step.</p></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>Issue archive</h1>{% for month in months %}<h2>{{ month.label }}</h2><ul>{% for issue in month.issues %}<li><a href="/issues/{{ issue.date }}">{{ issue.display_date }}</a> · No. {{ issue.issue_number }} · {{ issue.article_count }} articles</li>{% endfor %}</ul>{% endfor %}</section>{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "layout.html" %}{% block content %}<article class="reading issue">
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size_bytes }} bytes)</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }} · The Daily EPUB</title>
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}">
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg">
</head>
<body>
<header class="masthead"><a href="/">The Daily EPUB</a></header>
<nav class="primary"><a href="/">Latest</a> · <a href="/issues">Archive</a> · <a href="/feed.xml">Feed</a> · {% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin"><a href="/dashboard">Overview</a> · <a href="/dashboard/runs">Runs</a> · <a href="/dashboard/articles">Articles</a> · <a href="/dashboard/ratings">Ratings</a> · <a href="/dashboard/profile">Profile</a> · <a href="/dashboard/stats">Stats</a> · <a href="/dashboard/jobs">Jobs</a> · <a href="/dashboard/settings">Settings</a> · <a href="/dashboard/users">Users</a></nav>{% endif %}
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
<main>{% block content %}{% endblock %}</main>
<footer>daily-epub {{ page.version }}</footer>
<script src="/static/app.js?v={{ page.version }}"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Sign in</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label>Username <input name="username" autocomplete="username" required></label><label>Password <input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section>{% endblock %}
+312
View File
@@ -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<Self, Self::Err> {
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<Timestamp>,
}
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<Option<User>, 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<Option<User>, 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<User> {
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<u64> {
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<u64> {
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<u64> {
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<Vec<UserListRow>> {
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<User, DbError> {
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::<String, _>("created_at"))?,
last_login_at: row
.get::<Option<String>, _>("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();
}
}
+1
View File
@@ -364,6 +364,7 @@ async fn assemble_build_publish(
None, None,
Some(&issue.editorial.front_page_html), Some(&issue.editorial.front_page_html),
Some("{\"status\":\"ok\"}"), Some("{\"status\":\"ok\"}"),
None,
) )
.await .await
.expect("record the issue"); .expect("record the issue");
+85 -5
View File
@@ -106,6 +106,49 @@ impl Server {
fn get_auth(&self, path: &str, credentials: &str) -> HttpResponse { fn get_auth(&self, path: &str, credentials: &str) -> HttpResponse {
try_get(self.port, path, Some(credentials)).expect("request failed") 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)] #[derive(Debug)]
@@ -135,15 +178,26 @@ fn free_port() -> u16 {
/// A minimal HTTP/1.1 `GET`; `None` when the connection could not be made. /// 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<HttpResponse> { fn try_get(port: u16, path: &str, credentials: Option<&str>) -> Option<HttpResponse> {
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<HttpResponse> {
let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?; let mut stream = TcpStream::connect(("127.0.0.1", port)).ok()?;
stream stream
.set_read_timeout(Some(Duration::from_secs(10))) .set_read_timeout(Some(Duration::from_secs(10)))
.ok()?; .ok()?;
let auth = credentials let request = format!(
.map(|c| format!("Authorization: Basic {c}\r\n")) "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n{extra_headers}\r\n{body}"
.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");
stream.write_all(request.as_bytes()).ok()?; stream.write_all(request.as_bytes()).ok()?;
stream.flush().ok()?; stream.flush().ok()?;
// No half-close here: hyper drops a connection whose peer has shut down its // 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<HttpRespo
fn binary_serves_health_issues_and_rating_endpoints() { fn binary_serves_health_issues_and_rating_endpoints() {
let server = Server::start(false); let server = Server::start(false);
for path in ["/", "/issues", "/feed.xml", "/login"] {
assert_eq!(server.get(path).status, 200, "{path}");
}
let dashboard = server.get("/dashboard");
assert_eq!(dashboard.status, 302);
assert_eq!(
dashboard.header("location"),
Some("/login?next=%2Fdashboard")
);
server.add_admin("admin", "correct horse battery");
let login = server.post_form(
"/login",
"username=admin&password=correct+horse+battery&next=%2Faccount",
);
assert_eq!(login.status, 303, "{login:?}");
let cookie = login
.header("set-cookie")
.expect("login cookie")
.split(';')
.next()
.expect("cookie pair");
let account = server.get_cookie("/account", cookie);
assert_eq!(account.status, 200, "{account:?}");
assert!(account.body.contains("admin"));
let res = server.get("/healthz"); let res = server.get("/healthz");
assert_eq!(res.status, 200); assert_eq!(res.status, 200);
assert_eq!(res.body, "ok"); assert_eq!(res.body, "ok");