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
+140 -8
View File
@@ -60,6 +60,27 @@ pub struct Db {
pool: SqlitePool,
}
#[derive(Debug, Clone)]
pub struct IssueRow {
pub date: Date,
pub issue_number: i64,
pub generated_at: Timestamp,
pub epub_path: Option<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 {
/// Open (creating if needed) the database at `path` with WAL + foreign keys on,
/// creating parent directories first. Does **not** run migrations.
@@ -442,11 +463,12 @@ impl Db {
xtc_path: Option<&str>,
front_page_html: Option<&str>,
report_json: Option<&str>,
issue_json: Option<&str>,
) -> Result<()> {
sqlx::query(
"INSERT INTO issues (date, issue_number, generated_at, epub_path, x4_path, xtc_path,
front_page_html, report_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
front_page_html, report_json, issue_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET
issue_number = excluded.issue_number,
generated_at = excluded.generated_at,
@@ -454,7 +476,8 @@ impl Db {
x4_path = COALESCE(excluded.x4_path, issues.x4_path),
xtc_path = COALESCE(excluded.xtc_path, issues.xtc_path),
front_page_html = COALESCE(excluded.front_page_html, issues.front_page_html),
report_json = COALESCE(excluded.report_json, issues.report_json)",
report_json = COALESCE(excluded.report_json, issues.report_json),
issue_json = COALESCE(excluded.issue_json, issues.issue_json)",
)
.bind(date.to_string())
.bind(issue_number)
@@ -464,11 +487,49 @@ impl Db {
.bind(xtc_path)
.bind(front_page_html)
.bind(report_json)
.bind(issue_json)
.execute(&self.pool)
.await?;
Ok(())
}
/// One stored issue, including its serialized full-issue snapshot.
pub async fn issue_by_date(&self, date: Date) -> Result<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).
pub async fn replace_issue_articles(&self, date: Date, picks: &[Pick]) -> Result<()> {
let mut tx = self.pool.begin().await?;
@@ -541,8 +602,8 @@ impl Db {
pub async fn append_rating_event(&self, event: &RatingEvent) -> Result<i64> {
let row = sqlx::query(
"INSERT INTO rating_events
(article_id, issue_date, kind, source, label, value, note, event_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(article_id, issue_date, kind, source, label, value, note, event_at, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(event.article_id)
@@ -553,6 +614,7 @@ impl Db {
.bind(event.value)
.bind(event.note.as_deref())
.bind(fmt_ts(event.event_at))
.bind(event.user_id)
.fetch_one(&self.pool)
.await?;
Ok(row.get("id"))
@@ -611,7 +673,7 @@ impl Db {
FROM rating_events re
WHERE re.kind = 'explicit' AND re.event_at >= ?
)
SELECT r.article_id, r.issue_date, r.label, r.value, r.note, r.event_at,
SELECT r.article_id, r.user_id, r.issue_date, r.label, r.value, r.note, r.event_at,
COALESCE(a.title, '') AS title,
COALESCE(e.feed_title, '') AS feed_title,
(SELECT ia.summary FROM issue_articles ia
@@ -655,7 +717,8 @@ impl Db {
sqlx::query(
"UPDATE runs SET finished_at = ?, entries_fetched = ?, candidates = ?, selected = ?,
input_tokens = ?, cached_tokens = ?, output_tokens = ?, cost_usd = ?,
status = ?, error = ?, provider_costs_json = ?, config_json = ?
status = ?, error = ?, provider_costs_json = ?, config_json = ?,
report_json = ?
WHERE id = ?",
)
.bind(report.finished_at.map(fmt_ts))
@@ -680,6 +743,7 @@ impl Db {
source,
})?,
)
.bind(report.to_json())
.bind(id)
.execute(&self.pool)
.await?;
@@ -828,6 +892,7 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<RatedArticle>
});
Ok(RatedArticle {
article_id: row.get("article_id"),
user_id: row.get("user_id"),
issue_date,
title: row.get("title"),
feed_title: row.get("feed_title"),
@@ -840,6 +905,29 @@ fn rated_article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<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> {
let raw: String = row.get("source");
let source = SocialSource::parse(&raw).ok_or(DbError::Decode {
@@ -908,6 +996,30 @@ mod tests {
.await
.unwrap();
assert_eq!(row.get::<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]
@@ -1024,6 +1136,13 @@ mod tests {
report.counts.selected = 20;
report.finish(ts("2026-08-15T05:36:00Z"));
db.finish_run(run_id, &report).await.unwrap();
let stored_report: Option<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(
date,
@@ -1034,6 +1153,7 @@ mod tests {
None,
None,
Some("{}"),
None,
)
.await
.unwrap();
@@ -1158,6 +1278,7 @@ mod tests {
None,
None,
None,
None,
)
.await
.unwrap();
@@ -1185,6 +1306,7 @@ mod tests {
.unwrap();
let event = |label: &str, value: f64, at: &str| RatingEvent {
id: 0,
user_id: None,
article_id,
issue_date: Some("2026-08-15".parse().unwrap()),
kind: "explicit".into(),
@@ -1268,9 +1390,13 @@ mod tests {
.execute(&pool)
.await
.unwrap();
sqlx::raw_sql(include_str!("../migrations/0004_web.sql"))
.execute(&pool)
.await
.unwrap();
let rows = sqlx::query(
"SELECT article_id, issue_date, kind, source, label, value, event_at
"SELECT article_id, issue_date, kind, source, label, value, event_at, user_id
FROM rating_events ORDER BY article_id",
)
.fetch_all(&pool)
@@ -1285,6 +1411,7 @@ mod tests {
assert_eq!(row.get::<String, _>("kind"), "explicit");
assert_eq!(row.get::<String, _>("source"), "migration");
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");
@@ -1302,6 +1429,11 @@ mod tests {
"interest_embeddings",
"article_assessments",
"candidate_runs",
"users",
"sessions",
"config_changes",
"profile_versions",
"jobs",
] {
assert!(tables.iter().any(|table| table == expected), "{expected}");
}