web: redirect /<repo>/ to the repo home
Assisted-by: Claude Opus 5 via Claude Code
change qtkwwksoymnnuswmmkynmpktrxzkkwtl
commit de23dfdb65dc728c54ea852a6c74924537bf1a70
author Alpha Chen <alpha@kejadlen.dev>
date
parent ovkwlszp
diff --git a/quire-server/src/quire/web/handlers/mod.rs b/quire-server/src/quire/web/handlers/mod.rs
index d3ee74c..3f71675 100644
--- a/quire-server/src/quire/web/handlers/mod.rs
+++ b/quire-server/src/quire/web/handlers/mod.rs
@@ -11,7 +11,7 @@ mod tree;
 pub use ci::{run_detail, run_list};
 pub use commit::commit_view;
 pub use log_view::log_view;
-pub use repo::repo_home;
+pub use repo::{repo_home, repo_home_slash};
 pub use repo_list::repo_list;
 pub use tree::{tree_view, tree_view_path};
 
@@ -205,6 +205,23 @@ mod tests {
         assert_eq!(resp.status(), StatusCode::OK);
     }
 
+    #[tokio::test]
+    async fn repo_home_redirects_trailing_slash() {
+        let env = TestEnv::new();
+        let resp = env
+            .app()
+            .oneshot(
+                Request::builder()
+                    .uri("/example/")
+                    .body(Body::empty())
+                    .unwrap(),
+            )
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
+        assert_eq!(resp.headers()[axum::http::header::LOCATION], "/example");
+    }
+
     #[tokio::test]
     async fn run_list_returns_ok_for_known_repo() {
         let env = TestEnv::new();
diff --git a/quire-server/src/quire/web/handlers/repo.rs b/quire-server/src/quire/web/handlers/repo.rs
index a7ba2b7..6bfe078 100644
--- a/quire-server/src/quire/web/handlers/repo.rs
+++ b/quire-server/src/quire/web/handlers/repo.rs
@@ -1,7 +1,7 @@
 //! Handler for the repository home page.
 
-use axum::extract::State;
-use axum::response::Response;
+use axum::extract::{Path, State};
+use axum::response::{Redirect, Response};
 
 use super::super::auth::Auth;
 use super::super::db;
@@ -12,6 +12,13 @@ use super::render;
 use crate::Quire;
 use crate::quire::web::paths::RepoPath;
 
+/// Redirect `/{repo}/` to the canonical `/{repo}`.
+///
+/// Nothing links to the trailing-slash form, but people type it and paste it.
+pub async fn repo_home_slash(Path(repo): Path<String>) -> Redirect {
+    Redirect::permanent(&format!("/{repo}"))
+}
+
 pub async fn repo_home(
     RepoPath { repo }: RepoPath,
     State(quire): State<Quire>,
diff --git a/quire-server/src/quire/web/mod.rs b/quire-server/src/quire/web/mod.rs
index 4de6c05..2a409c5 100644
--- a/quire-server/src/quire/web/mod.rs
+++ b/quire-server/src/quire/web/mod.rs
@@ -20,8 +20,8 @@ use axum_extra::routing::RouterExt;
 use crate::{
     Quire,
     quire::web::handlers::{
-        commit_view, config, file_view_script, log_view, repo_home, repo_list, run_detail,
-        run_list, stylesheet, tree_view, tree_view_path,
+        commit_view, config, file_view_script, log_view, repo_home, repo_home_slash, repo_list,
+        run_detail, run_list, stylesheet, tree_view, tree_view_path,
     },
 };
 
@@ -95,6 +95,7 @@ pub fn public_router(quire: Quire) -> Router {
         .route("/style.css", get(stylesheet))
         .route("/file-view.js", get(file_view_script))
         .typed_get(repo_home)
+        .route("/{repo}/", get(repo_home_slash))
         .typed_get(tree_view)
         .typed_get(tree_view_path)
         .typed_get(log_view)