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:
Generated
+510
-21
@@ -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
@@ -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"] }
|
||||||
|
|||||||
@@ -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 (10–5000). |
|
||||||
|
|
||||||
`[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
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -874,6 +874,7 @@ type=\"application/epub+zip\" length=\"1700000\"/>"
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+119
-28
@@ -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,8 +185,25 @@ 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 {
|
||||||
|
tracing::warn!(%error, "could not delete expired sessions at startup");
|
||||||
|
}
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(error) = store.delete_expired().await {
|
||||||
|
tracing::warn!(%error, "could not delete expired sessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = router(AppState::new(db, config, config_path));
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
|
)
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
tracing::info!("server stopped");
|
tracing::info!("server stopped");
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(§ion) {
|
||||||
|
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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_issue() -> PublicIssue {
|
||||||
|
PublicIssue {
|
||||||
|
date: "1970-01-01".parse().expect("valid epoch date"),
|
||||||
|
issue_number: 0,
|
||||||
|
display_date: String::new(),
|
||||||
|
article_count: 0,
|
||||||
|
reading_minutes: 0,
|
||||||
|
stats_line: String::new(),
|
||||||
|
sections: Vec::new(),
|
||||||
|
generated_at: jiff::Timestamp::UNIX_EPOCH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_issue_carries_no_generated_text() {
|
||||||
|
let source = crate::epub::fixtures::issue();
|
||||||
|
let public = PublicIssue::from(&source);
|
||||||
|
let html = FeedEntryTemplate { issue: &public }.render().unwrap();
|
||||||
|
assert!(html.contains("The Lead Story"));
|
||||||
|
assert!(html.contains("Hacker News"));
|
||||||
|
for private in [
|
||||||
|
"Two stories today",
|
||||||
|
"What it argues",
|
||||||
|
"systems story",
|
||||||
|
"Body of",
|
||||||
|
"write path",
|
||||||
|
"Agreed",
|
||||||
|
"Something happened",
|
||||||
|
"concise view of the day",
|
||||||
|
] {
|
||||||
|
assert!(!html.contains(private), "leaked {private:?} in {html}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¤t))
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
let problem = if !valid {
|
||||||
|
Some("current password is incorrect".to_string())
|
||||||
|
} else if form.new_password != form.confirm_password {
|
||||||
|
Some("the new passwords do not match".to_string())
|
||||||
|
} else {
|
||||||
|
users::validate_password(&form.new_password)
|
||||||
|
.err()
|
||||||
|
.map(|error| error.to_string())
|
||||||
|
};
|
||||||
|
if let Some(error) = problem {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Html(AccountTemplate {
|
||||||
|
page: Page::new("Account", Some(user.into()), "account"),
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
let password = form.new_password;
|
||||||
|
let password_hash = tokio::task::spawn_blocking(move || users::hash_password(&password))
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(&password_hash)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(state.db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
SqliteSessionStore::new(state.db.pool().clone())
|
||||||
|
.delete_for_user(user.id)
|
||||||
|
.await
|
||||||
|
.map_err(crate::db::DbError::from)?;
|
||||||
|
let mut updated = user;
|
||||||
|
updated.password_hash = password_hash;
|
||||||
|
auth.login(&updated)
|
||||||
|
.await
|
||||||
|
.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
|
Ok(axum::response::Redirect::to("/account").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout_everywhere(
|
||||||
|
State(state): State<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/")), "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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 (_) {}
|
||||||
|
});
|
||||||
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user