// Back - An extremely simple git bug visualization system. Inspired by TVL's // panettone. // // Copyright (C) 2025 Benedikt Peetz // SPDX-License-Identifier: AGPL-3.0-or-later // // This file is part of Back. // // You should have received a copy of the License along with this program. // If not, see . 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, 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}; mod generate; mod responses; #[allow(clippy::unused_async, clippy::too_many_lines)] async fn match_uri( config: Arc, req: Request, ) -> Result>, hyper::Error> { if req.method() != Method::GET { return Ok(html_response_status( "Only get requests are supported", StatusCode::NOT_ACCEPTABLE, )); } let output = || -> Result>, error::Error> { match req.uri().path().trim_matches('/') { "" => Ok(html_response(generate::repos(&config)?)), "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") => { 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() }; if let Some(query) = query { let issues = generate::issues(&config, &repo_path, &replica, &query)?; Ok(html_response(issues)) } else { let query = Query::>::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( path.strip_suffix("/issues/feed") .expect("This suffix exists") .strip_prefix("/") .expect("This also exists"), ); let feed = generate::feed(&config, &repo_path)?; Ok(html_response_status_content_type( feed, StatusCode::OK, "text/xml", )) } path if path.contains("/issue/") => { let (repo_path, prefix) = { let split: Vec<&str> = path.split("/issue/").collect(); 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]); (repo_path, prefix) }; Ok(html_response(generate::issue(&config, &repo_path, prefix)?)) } 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() { Ok(response) => Ok(response), Err(err) => Ok(err.into_response()), } } pub(crate) async fn main(config: Arc) -> 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}"); loop { let (stream, _) = listener .accept() .await .map_err(|err| error::Error::TcpAccept { err })?; let io = TokioIo::new(stream); let local_config = Arc::clone(&config); let service = service_fn(move |req| match_uri(Arc::clone(&local_config), req)); tokio::task::spawn(async move { if let Err(err) = http1::Builder::new().serve_connection(io, service).await { error!("Error serving connection: {err}"); } }); } }