Web dashboard step 1: migration, sessions, users CLI, public site

Migration 0004 (users, sessions, config_changes, profile_versions, jobs,
rating_events.user_id, runs.report_json, issues.issue_json), the issue
snapshot writer and loader, the web module skeleton with layout and static
assets, axum-login/tower-sessions over a sqlx session store, password-auth
users with a CLI, the login throttle, the origin check, security headers,
and the public issue pages, archive, Atom feed and robots.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 04:38:22 +00:00
co-authored by Claude Fable 5.1
parent a70de4af06
commit 69cd736455
35 changed files with 3801 additions and 69 deletions
+85 -5
View File
@@ -106,6 +106,49 @@ impl Server {
fn get_auth(&self, path: &str, credentials: &str) -> HttpResponse {
try_get(self.port, path, Some(credentials)).expect("request failed")
}
fn add_admin(&self, username: &str, password: &str) {
let mut child = Command::new(env!("CARGO_BIN_EXE_daily-epub"))
.args(["users", "add", username, "--admin", "--password-stdin"])
.current_dir(self.dir.path())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env(
"DAILY_EPUB_DATABASE_PATH",
self.dir.path().join("daily-epub.db"),
)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("spawning users add");
child
.stdin
.take()
.expect("users add stdin")
.write_all(format!("{password}\n").as_bytes())
.expect("writing users add password");
assert!(child.wait().expect("waiting for users add").success());
}
fn post_form(&self, path: &str, body: &str) -> HttpResponse {
try_request(
self.port,
"POST",
path,
&format!(
"Content-Type: application/x-www-form-urlencoded\r\nSec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n",
body.len()
),
body,
)
.expect("request failed")
}
fn get_cookie(&self, path: &str, cookie: &str) -> HttpResponse {
try_request(self.port, "GET", path, &format!("Cookie: {cookie}\r\n"), "")
.expect("request failed")
}
}
#[derive(Debug)]
@@ -135,15 +178,26 @@ fn free_port() -> u16 {
/// A minimal HTTP/1.1 `GET`; `None` when the connection could not be made.
fn try_get(port: u16, path: &str, credentials: Option<&str>) -> Option<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()?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.ok()?;
let auth = credentials
.map(|c| format!("Authorization: Basic {c}\r\n"))
.unwrap_or_default();
let request =
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n{auth}\r\n");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n{extra_headers}\r\n{body}"
);
stream.write_all(request.as_bytes()).ok()?;
stream.flush().ok()?;
// No half-close here: hyper drops a connection whose peer has shut down its
@@ -171,6 +225,32 @@ fn try_get(port: u16, path: &str, credentials: Option<&str>) -> Option<HttpRespo
fn binary_serves_health_issues_and_rating_endpoints() {
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");
assert_eq!(res.status, 200);
assert_eq!(res.body, "ok");