diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-06-06 15:45:11 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-06-06 15:45:11 +0200 |
commit | a6baea06697f6c76c695dc4198099deb8ba916e0 (patch) | |
tree | 476a3865f6b4bef04751ba20534813a58892811b /src/web | |
parent | chore: Initial commit (diff) | |
download | back-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')
-rw-r--r-- | src/web/generate/mod.rs | 522 | ||||
-rw-r--r-- | src/web/generate/templates.rs | 44 | ||||
-rw-r--r-- | src/web/mod.rs | 125 | ||||
-rw-r--r-- | src/web/responses.rs | 135 |
4 files changed, 647 insertions, 179 deletions
diff --git a/src/web/generate/mod.rs b/src/web/generate/mod.rs index 06bab17..e68bddf 100644 --- a/src/web/generate/mod.rs +++ b/src/web/generate/mod.rs @@ -1,144 +1,353 @@ -// Back - An extremely simple git bug visualization system. Inspired by TVL's -// panettone. -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// 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 <https://www.gnu.org/licenses/agpl.txt>. - use std::{fs, path::Path}; -use gix::hash::Prefix; -use log::info; -use rinja::Template; -use url::Url; - -use crate::{ - config::BackConfig, - error, - git_bug::{ - dag::issues_from_repository, - issue::{CollapsedIssue, Status}, +use git_bug::{ + entities::{ + identity::Identity, + issue::{ + Issue, data::status::Status, query::MatchKeyValue, snapshot::timeline::IssueTimeline, + }, + }, + query::{Matcher, Query}, + replica::{ + Replica, + entity::{ + Entity, + id::prefix::IdPrefix, + snapshot::{ + Snapshot, + timeline::{Timeline, history_step::HistoryStep}, + }, + }, }, }; +use log::info; +use templates::to_markdown; +use vy::{IntoHtml, a, div, footer, form, h1, header, input, li, main, nav, ol, p, span}; -#[derive(Template)] -#[template(path = "./issues.html")] -struct IssuesTemplate { - wanted_status: Status, - counter_status: Status, - issues: Vec<CollapsedIssue>, +use crate::{config::BackConfig, error, web::generate::templates::make_page}; - /// The path to the repository - repo_path: String, +pub(crate) mod templates; - /// The URL to `back`'s source code - source_code_repository_url: Url, +fn get_other_status(current: Status) -> Status { + match current { + Status::Open => Status::Closed, + Status::Closed => Status::Open, + } } -pub fn issues( + +fn set_query_status( + mut root_matcher: Matcher<Snapshot<Issue>>, + status: Status, +) -> Matcher<Snapshot<Issue>> { + fn change_status(matcher: &mut Matcher<Snapshot<Issue>>, status: Status) { + match matcher { + Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => { + change_status(lhs, status); + change_status(rhs, status); + } + Matcher::Match { key_value } => { + if let MatchKeyValue::Status(found_status) = key_value { + *found_status = status; + } + } + } + } + + change_status(&mut root_matcher, status); + root_matcher +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn issues( config: &BackConfig, - wanted_status: Status, - counter_status: Status, repo_path: &Path, + replica: &Replica, + query: &Query<Snapshot<Issue>>, ) -> error::Result<String> { - let repository = config - .repositories()? - .get(repo_path)? - .open(&config.scan_path)?; - - let mut issue_list = issues_from_repository(&repository.to_thread_local())? + let mut issue_list = replica + .get_all_with_query::<Issue>(query)? + .collect::<Result<Result<Vec<Issue>, _>, _>>()?? .into_iter() - .map(|issue| issue.collapse()) - .filter(|issue| issue.status == wanted_status) - .collect::<Vec<CollapsedIssue>>(); + .map(|i| i.snapshot()) + .map(|i| -> error::Result<_> { + let author = i.author(); + Ok((i, author.resolve(replica)?.snapshot())) + }) + .collect::<error::Result<Vec<_>>>()?; // Sort by date descending. // SAFETY: // The time stamp is only used for sorting, so a malicious attacker could only affect the issue // sorting. - issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() }); - issue_list.reverse(); - - Ok(IssuesTemplate { - wanted_status, - counter_status, - source_code_repository_url: config.source_code_repository_url.clone(), - issues: issue_list, - repo_path: repo_path.display().to_string(), - } - .render() - .expect("This should always work")) -} + issue_list.sort_by_key(|(issue, _)| unsafe { + issue + .timeline() + .history() + .first() + .expect("Is some") + .at() + .to_unsafe() + }); + + let root_matcher = query.as_matcher().unwrap_or(&Matcher::Match { + key_value: MatchKeyValue::Status(Status::Open), + }); + + let status = { + fn find_status(matcher: &Matcher<Snapshot<Issue>>) -> Option<Status> { + fn merge(status1: Option<Status>, status2: Option<Status>) -> Option<Status> { + match (status1, status2) { + (None, None) => None, + (None, Some(b)) => Some(b), + #[allow(clippy::match_same_arms)] + (Some(a), None) => Some(a), + // TODO(@bpeetz): Should we warn the user somehow? <2025-05-28> + (Some(a), Some(_)) => Some(a), + } + } -use crate::git_bug::format::HtmlString; -#[derive(Template)] -#[template(path = "./issue.html")] -struct IssueTemplate { - issue: CollapsedIssue, + match matcher { + Matcher::Or { lhs, rhs } | Matcher::And { lhs, rhs } => { + merge(find_status(lhs), find_status(rhs)) + } + Matcher::Match { key_value } => match key_value { + MatchKeyValue::Status(status) => Some(*status), + _ => None, + }, + } + } - /// The path to the repository - repo_path: String, + find_status(root_matcher) + }; - /// The URL to `back`'s source code - source_code_repository_url: Url, + // TODO(@bpeetz): Be less verbose. <2025-06-06> + let query_string = query.to_string(); + + Ok(make_page( + ( + header!(h1!(( + status.map_or(String::new(), |a| a.to_string()), + " Issues" + ))), + main!( + div!( + class = "issue-links", + if let Some(status) = status { + a!( + href = format!( + "/{}/issues/?query={}", + repo_path.display(), + Query::from_matcher(set_query_status( + root_matcher.clone(), + get_other_status(status) + )) + .to_string() + ), + "View ", + get_other_status(status).to_string(), + " issues" + ) + }, + a!(href = "/", "View repos",), + form!( + class = "issue-search", + method = "get", + input!( + name = "query", + value = &query_string, + title = "Issue search query", + "type" = "search", + placeholder = "status:open title:\"Test\"", + size = query_string.chars().count().clamp(20, 40), + ), + input!( + class = "sr-only", + "type" = "submit", + value = "Search Issues" + ) + ) + ), + ol!( + class = "issue-list", + issue_list.iter().map(|(issue, identity)| { + li!(a!( + href = format!("/{}/issue/{}", repo_path.display(), issue.id()), + p!(span!( + class = "issue-subject", + to_markdown(issue.title(), true) + )), + span!( + class = "issue-number", + issue.id().as_id().shorten().to_string() + ), + " ", + display_issue_open(identity, issue.timeline()), + if !issue.comments().is_empty() { + span!( + class = "comment-count", + " - ", + issue.comments().len(), + " ", + { + if issue.comments().len() > 1 { + "comments" + } else { + "comment" + } + } + ) + } + )) + }) + ) + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source Code" + ))), + ), + "issue list page", + None, + ) + .into_string()) } -pub fn issue(config: &BackConfig, repo_path: &Path, prefix: Prefix) -> error::Result<String> { - let repository = config + +pub(crate) fn issue( + config: &BackConfig, + repo_path: &Path, + prefix: IdPrefix, +) -> error::Result<String> { + let replica = config .repositories()? .get(repo_path)? - .open(&config.scan_path)? - .to_thread_local(); + .open(&config.scan_path)?; - let maybe_issue = issues_from_repository(&repository)? - .into_iter() - .map(|val| val.collapse()) - .find(|issue| issue.id.to_string().starts_with(&prefix.to_string())); - - match maybe_issue { - Some(issue) => Ok(IssueTemplate { - issue, - repo_path: repo_path.display().to_string(), - source_code_repository_url: config.source_code_repository_url.clone(), - } - .render() - .expect("This should always work")), - None => Err(error::Error::IssuesPrefixMissing { prefix }), - } -} + let issue = replica + .get(prefix.resolve::<Issue>(replica.repo()).map_err(|err| { + error::Error::IssuesPrefixMissing { + prefix: Box::new(prefix), + err, + } + })?)? + .snapshot(); + + let identity = issue.author().resolve(&replica)?.snapshot(); -#[derive(Template)] -#[template(path = "./repos.html")] -struct ReposTemplate { - repos: Vec<RepoValue>, + Ok(make_page( + div!( + class = "content", + nav!( + a!( + href = format!("/{}/issues/?query=status:open", repo_path.display()), + "Open Issues" + ), + a!( + href = format!("/{}/issues/?query=status:closed", repo_path.display()), + "Closed Issues" + ), + ), + header!( + h1!(to_markdown(issue.title(), true)), + div!( + class = "issue-number", + issue.id().as_id().shorten().to_string() + ) + ), + main!( + div!( + class = "issue-info", + display_issue_open(&identity, issue.timeline()) + ), + to_markdown(issue.body(), false), + if !issue.comments().is_empty() { + ol!( + class = "issue-history", + issue + .comments() + .into_iter() + .map(|comment| { + Ok(li!( + class = "comment", + id = comment.combined_id.shorten().to_string(), + to_markdown(&comment.message, false), + p!( + class = "comment-info", + span!( + class = "user-name", + comment + .author + .resolve(&replica)? + .snapshot() + .name() + .to_owned(), + " at ", + comment.timestamp.to_string() + ) + ) + )) + }) + .collect::<error::Result<Vec<_>>>()? + ) + } + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source code" + ),)) + ), + "issue detail page", + Some(format!("{} | Back", issue.title()).as_str()), + ) + .into_string()) +} - /// The URL to `back`'s source code - source_code_repository_url: Url, +fn display_issue_open( + identity: &Snapshot<Identity>, + issue_timeline: &IssueTimeline, +) -> impl IntoHtml { + span!( + class = "created-by-at", + "Opened by ", + span!(class = "user-name", identity.name()), + " ", + identity + .email() + .map(|email| span!(class = "user-email", "<", email, "> ",)), + "at ", + span!(class = "timestamp", { + { + issue_timeline + .history() + .last() + .expect("Exists") + .at() + .to_string() + } + }) + ) } + struct RepoValue { description: String, owner: String, path: String, } -pub fn repos(config: &BackConfig) -> error::Result<String> { + +pub(crate) fn repos(config: &BackConfig) -> error::Result<String> { let repos: Vec<RepoValue> = config .repositories()? .iter() .filter_map(|raw_repo| match raw_repo.open(&config.scan_path) { - Ok(repo) => { - let repo = repo.to_thread_local(); - let git_config = repo.config_snapshot(); + Ok(replica) => { + let git_config = replica.repo().config_snapshot(); let path = raw_repo.path().to_string_lossy().to_string(); let owner = git_config .string("cgit.owner") - .map(|v| v.to_string()) - .unwrap_or("<No owner>".to_owned()); + .map_or("<No owner>".to_owned(), |v| v.to_string()); - let description = fs::read_to_string(repo.git_dir().join("description")) + let description = fs::read_to_string(replica.repo().git_dir().join("description")) .unwrap_or("<No description>".to_owned()); Some(RepoValue { @@ -157,73 +366,124 @@ pub fn repos(config: &BackConfig) -> error::Result<String> { }) .collect(); - Ok(ReposTemplate { - repos, - source_code_repository_url: config.source_code_repository_url.clone(), - } - .render() - .expect("this should work")) + Ok(make_page( + div!( + class = "content", + header!(h1!("Repositiories")), + main!( + div!( + class = "issue-links", + // TODO(@bpeetz): Add a search. <2025-05-21> + ), + if !repos.is_empty() { + ol!( + class = "issue-list", + repos.into_iter().map(|repo| { + li!(a!( + href = format!("/{}/issues/?query=status:open", repo.path), + p!(span!(class = "issue-subject", repo.path)), + span!( + class = "created-by-at", + span!(class = "timestamp", repo.description), + " - ", + span!(class = "user-name", repo.owner) + ) + )) + }) + ) + } + ), + footer!(nav!(a!( + href = config.source_code_repository_url.to_string(), + "Source Code" + ))), + ), + "repository listing page", + Some("Repos | Back"), + ) + .into_string()) } -pub fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> { +pub(crate) fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> { use rss::{ChannelBuilder, Item, ItemBuilder}; - let repository = config + let replica = config .repositories()? .get(repo_path)? - .open(&config.scan_path)? - .to_thread_local(); + .open(&config.scan_path)?; - let issues: Vec<CollapsedIssue> = issues_from_repository(&repository)? + let issues: Vec<Snapshot<Issue>> = replica + .get_all::<Issue>()? + .collect::<Result<Result<Vec<Issue>, _>, _>>()?? .into_iter() - .map(|issue| issue.collapse()) - .collect(); + .map(|i| i.snapshot()) + .collect::<Vec<_>>(); // Collect all Items as rss items let mut items: Vec<Item> = issues .iter() - .map(|issue| { - ItemBuilder::default() - .title(issue.title.to_string()) - .author(issue.author.to_string()) - .description(issue.message.to_string()) - .pub_date(issue.timestamp.to_string()) + .map(|issue| -> error::Result<_> { + Ok(ItemBuilder::default() + .title(issue.title().to_string()) + .author( + replica + .get(issue.author().id())? + .snapshot() + .name() + .to_owned(), + ) + .description(issue.body().to_string()) + .pub_date( + issue + .timeline() + .history() + .last() + .expect("Exists") + .at() + .to_string(), + ) .link(format!( "/{}/{}/issue/{}", repo_path.display(), &config.root_url, - issue.id + issue.id() )) - .build() + .build()) }) - .collect(); + .collect::<error::Result<Vec<Item>>>()?; // Append all comments after converting them to rss items items.extend( issues .iter() - .filter(|issue| !issue.comments.is_empty()) + .filter(|issue| !issue.comments().is_empty()) .flat_map(|issue| { issue - .comments + .comments() .iter() - .map(|comment| { - ItemBuilder::default() - .title(issue.title.to_string()) - .author(comment.author.to_string()) + .map(|comment| -> error::Result<_> { + Ok(ItemBuilder::default() + .title(issue.title().to_string()) + .author( + replica + .get(comment.author.id())? + .snapshot() + .name() + .to_owned(), + ) .description(comment.message.to_string()) .pub_date(comment.timestamp.to_string()) .link(format!( "/{}/{}/issue/{}", repo_path.display(), &config.root_url, - issue.id + comment.combined_id.shorten() )) - .build() + .build()) }) - .collect::<Vec<Item>>() + .collect::<Vec<_>>() }) - .collect::<Vec<Item>>(), + .collect::<error::Result<Vec<Item>>>()?, ); let channel = ChannelBuilder::default() diff --git a/src/web/generate/templates.rs b/src/web/generate/templates.rs new file mode 100644 index 0000000..693b862 --- /dev/null +++ b/src/web/generate/templates.rs @@ -0,0 +1,44 @@ +use vy::{DOCTYPE, IntoHtml, PreEscaped, body, div, head, html, link, meta, title}; + +pub(crate) fn make_page( + content: impl IntoHtml, + description: &str, + title: Option<&str>, +) -> impl IntoHtml { + ( + DOCTYPE, + html!( + lang = "en", + head!( + title!(title.unwrap_or("Back")), + link!(rel = "icon", href = "/favicon.ico"), + link!(rel = "stylesheet", "type" = "text/css", href = "/style.css"), + meta!(charset = "UTF-8"), + meta!( + name = "viewport", + content = "width=device-width,initial-scale=1" + ), + meta!(name = "description", content = description), + ), + body!(div!(class = "content", content)) + ), + ) +} + +pub(super) fn to_markdown(input: &str, is_title: bool) -> PreEscaped<String> { + let markdown = markdown::to_html(input.trim()); + + // If the markdown contains only one line line, assuming that it is a title is okay. + if input.lines().count() == 1 && markdown.starts_with("<p>") && is_title { + PreEscaped( + markdown + .strip_prefix("<p>") + .expect("We checked") + .strip_suffix("</p>") + .expect("markdown crate produces no invalid html") + .to_owned(), + ) + } else { + PreEscaped(markdown) + } +} 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:?}"); } }); } diff --git a/src/web/responses.rs b/src/web/responses.rs index bcdcc0a..00af35a 100644 --- a/src/web/responses.rs +++ b/src/web/responses.rs @@ -12,10 +12,13 @@ use std::convert::Infallible; use bytes::Bytes; +use git_bug::replica::entity::id::prefix::IdPrefix; use http::{Response, StatusCode, Version}; use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use vy::{IntoHtml, h1, p, pre}; -use crate::{error, git_bug::format::HtmlString}; +use super::generate::templates::make_page; +use crate::error; pub(super) fn html_response<T: Into<Bytes>>(html_text: T) -> Response<BoxBody<Bytes, Infallible>> { html_response_status(html_text, StatusCode::OK) @@ -28,6 +31,15 @@ pub(super) fn html_response_status<T: Into<Bytes>>( html_response_status_content_type(html_text, status, "text/html") } +pub(super) fn redirect(target: &str) -> Response<BoxBody<Bytes, Infallible>> { + Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .version(Version::HTTP_2) + .header("Location", target) + .body(full("")) + .expect("This is hardcoded and will build") +} + pub(super) fn html_response_status_content_type<T: Into<Bytes>>( html_text: T, status: StatusCode, @@ -36,26 +48,129 @@ pub(super) fn html_response_status_content_type<T: Into<Bytes>>( Response::builder() .status(status) .version(Version::HTTP_2) - .header("Content-Type", format!("{}; charset=utf-8", content_type)) + .header("Content-Type", format!("{content_type}; charset=utf-8")) .header("x-content-type-options", "nosniff") .header("x-frame-options", "SAMEORIGIN") .body(full(html_text)) .expect("This will always build") } -fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Infallible> { +pub(super) fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, Infallible> { Full::new(chunk.into()).boxed() } -// FIXME: Not all errors should return `INTERNAL_SERVER_ERROR`. <2025-03-08> impl error::Error { + #[allow(clippy::too_many_lines)] pub fn into_response(self) -> Response<BoxBody<Bytes, Infallible>> { - html_response_status( - format!( - "<h1> Internal server error. </h1> <pre>Error: {}</pre>", - HtmlString::from(self.to_string()) + match self { + error::Error::ConfigParse { .. } + | error::Error::ProjectListRead { .. } + | error::Error::ConfigRead { .. } + | error::Error::RepoGetReferences(_) + | error::Error::RepoIssueRead(_) + | error::Error::RepoIdentityRead(_) + | error::Error::RepoFind { .. } + | error::Error::RepoRefsIter(_) + | error::Error::RepoRefsPrefixed { .. } + | error::Error::TcpBind { .. } + | error::Error::RepoOpen { .. } + | error::Error::TcpAccept { .. } => html_response_status( + make_page( + ( + h1!("Internal server error"), + pre!(format!("Error {}", self.to_string())), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::INTERNAL_SERVER_ERROR, + ), + error::Error::NotGitBug { path } => html_response_status( + make_page( + ( + h1!("Expectation Failed"), + p!( + "Repository '", + path.display().to_string(), + "' has no git bug data to show." + ), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::EXPECTATION_FAILED, + ), + error::Error::IssuesPrefixMissing { prefix, err } => html_response_status( + make_page( + ( + h1!("Expectation Failed"), + p!( + "There is no issue associated with the prefix ", + "'", + prefix.to_string(), + "': ", + err.to_string() + ), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::EXPECTATION_FAILED, + ), + error::Error::IssuesPrefixParse { prefix, error } => html_response_status( + make_page( + ( + h1!("Expectation Failed"), + p!( + "The prefix '", + prefix, + "' cannot be interperted as a prefix", + ), + p!(error.to_string()), + p!( + "The prefix is composed of ", + IdPrefix::REQUIRED_LENGTH, + " or more hex chars." + ), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::EXPECTATION_FAILED, + ), + error::Error::NotFound(path_buf) => html_response_status( + make_page( + ( + h1!("Expectation Failed"), + p!( + "The path '", + path_buf.display().to_string(), + "' was unkonwn", + ), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::EXPECTATION_FAILED, + ), + error::Error::InvalidQuery { err, query } => html_response_status( + make_page( + ( + h1!("Expectation Failed"), + p!("The query '", query, "' was invalid",), + p!(err.to_string()), + ), + "Error page", + Some("Error | Back"), + ) + .into_string(), + StatusCode::EXPECTATION_FAILED, ), - StatusCode::INTERNAL_SERVER_ERROR, - ) + } } } |