about summary refs log tree commit diff stats
path: root/src/web/mod.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-06-06 15:45:11 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-06-06 15:45:11 +0200
commita6baea06697f6c76c695dc4198099deb8ba916e0 (patch)
tree476a3865f6b4bef04751ba20534813a58892811b /src/web/mod.rs
parentchore: Initial commit (diff)
downloadback-a6baea06697f6c76c695dc4198099deb8ba916e0.zip
feat(treewide): Prepare for first release
This commit contains many changes, as they were developed alongside
`git-bug-rs` and unfortunately not separately committed.

A toplevel summary would include:
- Appropriate redirects,
- The templating moved to `vy` (as this works with rustfmt formatting),
- Search support (via `git-bug-rs`),
- And better layout in the link section.
Diffstat (limited to 'src/web/mod.rs')
-rw-r--r--src/web/mod.rs125
1 files changed, 87 insertions, 38 deletions
diff --git a/src/web/mod.rs b/src/web/mod.rs
index 8e2e9b0..0f9835a 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -9,21 +9,28 @@
 // You should have received a copy of the License along with this program.
 // If not, see <https://www.gnu.org/licenses/agpl.txt>.
 
-use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
+use std::{cell::OnceCell, convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
 
 use bytes::Bytes;
+use git_bug::{
+    entities::issue::{Issue, data::status::Status, query::MatchKeyValue},
+    query::{Matcher, ParseMode, Query},
+    replica::entity::{id::prefix::IdPrefix, snapshot::Snapshot},
+};
 use http_body_util::combinators::BoxBody;
 use hyper::{Method, Request, Response, StatusCode, server::conn::http1, service::service_fn};
 use hyper_util::rt::TokioIo;
-use log::{error, info};
-use responses::{html_response, html_response_status, html_response_status_content_type};
+use log::{error, info, warn};
+use responses::{html_response, html_response_status, html_response_status_content_type, redirect};
 use tokio::net::TcpListener;
+use url::Url;
 
-use crate::{config::BackConfig, error, git_bug::issue::Status};
+use crate::{config::BackConfig, error};
 
 mod generate;
 mod responses;
 
+#[allow(clippy::unused_async, clippy::too_many_lines)]
 async fn match_uri(
     config: Arc<BackConfig>,
     req: Request<hyper::body::Incoming>,
@@ -36,36 +43,69 @@ async fn match_uri(
     }
 
     let output = || -> Result<Response<BoxBody<Bytes, Infallible>>, error::Error> {
-        match req.uri().path().trim_end_matches("/") {
+        match req.uri().path().trim_matches('/') {
             "" => Ok(html_response(generate::repos(&config)?)),
 
-            "/style.css" => Ok(responses::html_response_status_content_type(
+            "style.css" => Ok(html_response_status_content_type(
                 include_str!("../../assets/style.css"),
                 StatusCode::OK,
                 "text/css",
             )),
+            "search.png" => Ok(html_response_status_content_type(
+                &include_bytes!("../../assets/search.png")[..],
+                StatusCode::OK,
+                "image/png",
+            )),
 
-            path if path.ends_with("/issues/open") => {
-                let repo_path = PathBuf::from(
-                    path.strip_suffix("/issues/open")
-                        .expect("This suffix exists")
-                        .strip_prefix("/")
-                        .expect("This also exists"),
-                );
-
-                let issues = generate::issues(&config, Status::Open, Status::Closed, &repo_path)?;
-                Ok(html_response(issues))
-            }
-            path if path.ends_with("/issues/closed") => {
-                let repo_path = PathBuf::from(
-                    path.strip_suffix("/issues/closed")
-                        .expect("This suffix exists")
-                        .strip_prefix("/")
-                        .expect("This also exists"),
-                );
+            path if path.ends_with("/issues") => {
+                let repo_path =
+                    PathBuf::from(path.strip_suffix("/issues").expect("This suffix exists"));
+
+                let replica = config
+                    .repositories()?
+                    .get(&repo_path)?
+                    .open(&config.scan_path)?;
+
+                let query = {
+                    // HACK(@bpeetz): We cannot use the uri directly, because that would require us
+                    // to parse the query string. As such, we need to turn into into a url::Url,
+                    // which provides this. Unfortunately, this re-parsing is the “officially”
+                    // sanctioned way of doing this. <2025-05-28>
+                    let url: Url = format!("https://{}{}", config.root_url, req.uri())
+                        .parse()
+                        .expect("Was a url before");
+
+                    let mut query = OnceCell::new();
+                    for (name, value) in url.query_pairs() {
+                        if name == "query" && query.get().is_none() {
+                            let val =
+                                Query::from_continuous_str(&replica, &value, ParseMode::Relaxed)
+                                    .map_err(|err| error::Error::InvalidQuery {
+                                        err,
+                                        query: value.to_string(),
+                                    })?;
+                            query.set(val).expect("We checked for initialized");
+                        } else {
+                            warn!("Unknown url query key: {name}");
+                        }
+                    }
+
+                    query.take()
+                };
 
-                let issues = generate::issues(&config, Status::Closed, Status::Open, &repo_path)?;
-                Ok(html_response(issues))
+                if let Some(query) = query {
+                    let issues = generate::issues(&config, &repo_path, &replica, &query)?;
+                    Ok(html_response(issues))
+                } else {
+                    let query = Query::<Snapshot<Issue>>::from_matcher(Matcher::Match {
+                        key_value: MatchKeyValue::Status(Status::Open),
+                    });
+                    Ok(redirect(&format!(
+                        "/{}/issues/?query={}",
+                        repo_path.display(),
+                        query
+                    )))
+                }
             }
             path if path.ends_with("/issues/feed") => {
                 let repo_path = PathBuf::from(
@@ -87,22 +127,30 @@ async fn match_uri(
                 let (repo_path, prefix) = {
                     let split: Vec<&str> = path.split("/issue/").collect();
 
-                    let prefix =
-                        gix::hash::Prefix::from_hex(split[1]).map_err(error::Error::from)?;
+                    let prefix = IdPrefix::from_hex_bytes(split[1].as_bytes()).map_err(|err| {
+                        error::Error::IssuesPrefixParse {
+                            prefix: split[1].to_owned(),
+                            error: err,
+                        }
+                    })?;
 
-                    let repo_path =
-                        PathBuf::from(split[0].strip_prefix("/").expect("This prefix exists"));
+                    let repo_path = PathBuf::from(split[0]);
 
                     (repo_path, prefix)
                 };
                 Ok(html_response(generate::issue(&config, &repo_path, prefix)?))
             }
 
-            other => Ok(responses::html_response_status_content_type(
-                format!("'{}' not found", other),
-                StatusCode::NOT_FOUND,
-                "text/plain",
-            )),
+            other if config.repositories()?.get(&PathBuf::from(other)).is_ok() => {
+                Ok(redirect(&format!("/{other}/issues/?query=status:open")))
+            }
+            other if config.repositories()?.get(&PathBuf::from(other)).is_err() => {
+                Err(error::Error::NotGitBug {
+                    path: PathBuf::from(other),
+                })
+            }
+
+            other => Err(error::Error::NotFound(PathBuf::from(other))),
         }
     };
     match output() {
@@ -111,13 +159,14 @@ async fn match_uri(
     }
 }
 
-pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
+pub(crate) async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
     let addr: SocketAddr = ([127, 0, 0, 1], 8000).into();
 
     let listener = TcpListener::bind(addr)
         .await
         .map_err(|err| error::Error::TcpBind { addr, err })?;
-    info!("Listening on http://{}", addr);
+    info!("Listening on http://{addr}");
+
     loop {
         let (stream, _) = listener
             .accept()
@@ -131,7 +180,7 @@ pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
 
         tokio::task::spawn(async move {
             if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
-                error!("Error serving connection: {:?}", err);
+                error!("Error serving connection: {err:?}");
             }
         });
     }