aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-05-12 12:39:10 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-05-12 12:39:10 +0200
commit1e4dff1995833538f436b381bc0450a7c0080bad (patch)
tree2dc620ac9ea683cbee412b8d5818b3992462677c /src
downloadback-1e4dff1995833538f436b381bc0450a7c0080bad.zip
chore: Initial commit
Based on the version that was previously in `vhack.eu/nixos-server/pkgs/by-name/ba/back`.
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs25
-rw-r--r--src/config/mod.rs137
-rw-r--r--src/error/mod.rs135
-rw-r--r--src/main.rs54
-rw-r--r--src/web/generate/mod.rs236
-rw-r--r--src/web/mod.rs138
-rw-r--r--src/web/responses.rs61
7 files changed, 786 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..eeeed2b
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,25 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// 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::path::PathBuf;
+
+use clap::Parser;
+
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+#[allow(clippy::module_name_repetitions)]
+/// An extremely simple git issue tracking system.
+/// Inspired by tvix's panettone
+pub struct Cli {
+ /// The path to the configuration file. The file should be written in JSON.
+ pub config_file: PathBuf,
+}
diff --git a/src/config/mod.rs b/src/config/mod.rs
new file mode 100644
index 0000000..6c90fce
--- /dev/null
+++ b/src/config/mod.rs
@@ -0,0 +1,137 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// 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, PathBuf},
+};
+
+use gix::ThreadSafeRepository;
+use serde::Deserialize;
+use url::Url;
+
+use crate::{
+ error::{self, Error},
+ git_bug::dag::is_git_bug,
+};
+
+#[derive(Deserialize)]
+pub struct BackConfig {
+ /// The url to the source code of back. This is needed, because back is licensed under the
+ /// AGPL.
+ pub source_code_repository_url: Url,
+
+ /// The root url this instance of back is hosted on.
+ /// For example:
+ /// `issues.foss-syndicate.org`
+ pub root_url: Url,
+
+ project_list: PathBuf,
+
+ /// The path that is the common parent of all the repositories.
+ pub scan_path: PathBuf,
+}
+
+pub struct BackRepositories {
+ repositories: Vec<BackRepository>,
+}
+
+impl BackRepositories {
+ pub fn iter(&self) -> <&Self as IntoIterator>::IntoIter {
+ self.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a BackRepositories {
+ type IntoIter = <&'a Vec<BackRepository> as IntoIterator>::IntoIter;
+ type Item = <&'a Vec<BackRepository> as IntoIterator>::Item;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.repositories.iter()
+ }
+}
+
+impl BackRepositories {
+ /// Try to get the repository at path `path`.
+ /// If no repository was registered/found at `path`, returns an error.
+ pub fn get(&self, path: &Path) -> Result<&BackRepository, error::Error> {
+ self.repositories
+ .iter()
+ .find(|p| p.repo_path == path)
+ .ok_or(error::Error::RepoFind {
+ repository_path: path.to_owned(),
+ })
+ }
+}
+
+pub struct BackRepository {
+ repo_path: PathBuf,
+}
+
+impl BackRepository {
+ pub fn open(&self, scan_path: &Path) -> Result<ThreadSafeRepository, error::Error> {
+ let path = {
+ let base = scan_path.join(&self.repo_path);
+ if base.is_dir() {
+ base
+ } else {
+ PathBuf::from(base.display().to_string() + ".git")
+ }
+ };
+ let repo = ThreadSafeRepository::open(path).map_err(|err| Error::RepoOpen {
+ repository_path: self.repo_path.to_owned(),
+ error: Box::new(err),
+ })?;
+ if is_git_bug(&repo.to_thread_local())? {
+ Ok(repo)
+ } else {
+ Err(error::Error::NotGitBug {
+ path: self.repo_path.clone(),
+ })
+ }
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.repo_path
+ }
+}
+
+impl BackConfig {
+ pub fn repositories(&self) -> error::Result<BackRepositories> {
+ let repositories = fs::read_to_string(&self.project_list)
+ .map_err(|err| error::Error::ProjectListRead {
+ error: err,
+ file: self.project_list.to_owned(),
+ })?
+ .lines()
+ .try_fold(vec![], |mut acc, path| {
+ acc.push(BackRepository {
+ repo_path: PathBuf::from(path),
+ });
+
+ Ok::<_, error::Error>(acc)
+ })?;
+ Ok(BackRepositories { repositories })
+ }
+
+ pub fn from_config_file(path: &Path) -> error::Result<Self> {
+ let value = fs::read_to_string(path).map_err(|err| Error::ConfigRead {
+ file: path.to_owned(),
+ error: err,
+ })?;
+
+ serde_json::from_str(&value).map_err(|err| Error::ConfigParse {
+ file: path.to_owned(),
+ error: err,
+ })
+ }
+}
diff --git a/src/error/mod.rs b/src/error/mod.rs
new file mode 100644
index 0000000..026cc58
--- /dev/null
+++ b/src/error/mod.rs
@@ -0,0 +1,135 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// 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::{fmt::Display, io, net::SocketAddr, path::PathBuf};
+
+use gix::hash::Prefix;
+use thiserror::Error;
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Error, Debug)]
+pub enum Error {
+ ConfigParse {
+ file: PathBuf,
+ error: serde_json::Error,
+ },
+
+ ProjectListRead {
+ file: PathBuf,
+ error: io::Error,
+ },
+ ConfigRead {
+ file: PathBuf,
+ error: io::Error,
+ },
+ NotGitBug {
+ path: PathBuf,
+ },
+ RepoOpen {
+ repository_path: PathBuf,
+ error: Box<gix::open::Error>,
+ },
+ RepoFind {
+ repository_path: PathBuf,
+ },
+ RepoRefsIter(#[from] gix::refs::packed::buffer::open::Error),
+ RepoRefsPrefixed {
+ error: io::Error,
+ },
+
+ TcpBind {
+ addr: SocketAddr,
+ err: io::Error,
+ },
+ TcpAccept {
+ err: io::Error,
+ },
+
+ IssuesPrefixMissing {
+ prefix: Prefix,
+ },
+ IssuesPrefixParse(#[from] gix::hash::prefix::from_hex::Error),
+}
+
+impl Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Error::ConfigParse { file, error } => {
+ write!(
+ f,
+ "while trying to parse the config file ({}): {error}",
+ file.display()
+ )
+ }
+ Error::ProjectListRead { file, error } => {
+ write!(
+ f,
+ "while trying to read the project.list file ({}): {error}",
+ file.display()
+ )
+ }
+ Error::ConfigRead { file, error } => {
+ write!(
+ f,
+ "while trying to read the config file ({}): {error}",
+ file.display()
+ )
+ }
+ Error::RepoOpen {
+ repository_path,
+ error,
+ } => {
+ write!(
+ f,
+ "while trying to open the repository ({}): {error}",
+ repository_path.display()
+ )
+ }
+ Error::NotGitBug { path } => {
+ write!(
+ f,
+ "Repository ('{}') has no initialized git-bug data",
+ path.display()
+ )
+ }
+ Error::RepoFind { repository_path } => {
+ write!(
+ f,
+ "failed to find the repository at path: '{}'",
+ repository_path.display()
+ )
+ }
+ Error::RepoRefsIter(error) => {
+ write!(f, "while iteration over the refs in a repository: {error}",)
+ }
+ Error::RepoRefsPrefixed { error, .. } => {
+ write!(f, "while prefixing the refs with a path: {error}")
+ }
+ Error::IssuesPrefixMissing { prefix } => {
+ write!(
+ f,
+ "There is no 'issue' associated with the prefix: {prefix}"
+ )
+ }
+ Error::IssuesPrefixParse(error) => {
+ write!(f, "The given prefix can not be parsed as prefix: {error}")
+ }
+ Error::TcpBind { addr, err } => {
+ write!(f, "while trying to open tcp {addr} for listening: {err}.")
+ }
+ Error::TcpAccept { err } => {
+ write!(f, "while trying to accept a tcp connection: {err}.")
+ }
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..49ffe5c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,54 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// 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::{process, sync::Arc};
+
+use clap::Parser;
+
+use crate::config::BackConfig;
+
+mod cli;
+pub mod config;
+mod error;
+pub mod git_bug;
+mod web;
+
+fn main() -> Result<(), String> {
+ if let Err(err) = server_main() {
+ eprintln!("Error {err}");
+ process::exit(1);
+ } else {
+ Ok(())
+ }
+}
+
+#[tokio::main]
+async fn server_main() -> Result<(), error::Error> {
+ let args = cli::Cli::parse();
+
+ stderrlog::new()
+ .module(module_path!())
+ .modules(["hyper", "http"])
+ .quiet(false)
+ .show_module_names(false)
+ .color(stderrlog::ColorChoice::Auto)
+ .verbosity(2)
+ .timestamp(stderrlog::Timestamp::Off)
+ .init()
+ .expect("Let's just hope that this does not panic");
+
+ let config = BackConfig::from_config_file(&args.config_file)?;
+
+ web::main(Arc::new(config)).await?;
+
+ Ok(())
+}
diff --git a/src/web/generate/mod.rs b/src/web/generate/mod.rs
new file mode 100644
index 0000000..06bab17
--- /dev/null
+++ b/src/web/generate/mod.rs
@@ -0,0 +1,236 @@
+// 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},
+ },
+};
+
+#[derive(Template)]
+#[template(path = "./issues.html")]
+struct IssuesTemplate {
+ wanted_status: Status,
+ counter_status: Status,
+ issues: Vec<CollapsedIssue>,
+
+ /// The path to the repository
+ repo_path: String,
+
+ /// The URL to `back`'s source code
+ source_code_repository_url: Url,
+}
+pub fn issues(
+ config: &BackConfig,
+ wanted_status: Status,
+ counter_status: Status,
+ repo_path: &Path,
+) -> 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())?
+ .into_iter()
+ .map(|issue| issue.collapse())
+ .filter(|issue| issue.status == wanted_status)
+ .collect::<Vec<CollapsedIssue>>();
+
+ // 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"))
+}
+
+use crate::git_bug::format::HtmlString;
+#[derive(Template)]
+#[template(path = "./issue.html")]
+struct IssueTemplate {
+ issue: CollapsedIssue,
+
+ /// The path to the repository
+ repo_path: String,
+
+ /// The URL to `back`'s source code
+ source_code_repository_url: Url,
+}
+pub fn issue(config: &BackConfig, repo_path: &Path, prefix: Prefix) -> error::Result<String> {
+ let repository = config
+ .repositories()?
+ .get(repo_path)?
+ .open(&config.scan_path)?
+ .to_thread_local();
+
+ 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 }),
+ }
+}
+
+#[derive(Template)]
+#[template(path = "./repos.html")]
+struct ReposTemplate {
+ repos: Vec<RepoValue>,
+
+ /// The URL to `back`'s source code
+ source_code_repository_url: Url,
+}
+struct RepoValue {
+ description: String,
+ owner: String,
+ path: String,
+}
+pub 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();
+
+ 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());
+
+ let description = fs::read_to_string(repo.git_dir().join("description"))
+ .unwrap_or("<No description>".to_owned());
+
+ Some(RepoValue {
+ description,
+ owner,
+ path,
+ })
+ }
+ Err(err) => {
+ info!(
+ "Repo '{}' could not be opened: '{err}'",
+ raw_repo.path().display()
+ );
+ None
+ }
+ })
+ .collect();
+
+ Ok(ReposTemplate {
+ repos,
+ source_code_repository_url: config.source_code_repository_url.clone(),
+ }
+ .render()
+ .expect("this should work"))
+}
+
+pub fn feed(config: &BackConfig, repo_path: &Path) -> error::Result<String> {
+ use rss::{ChannelBuilder, Item, ItemBuilder};
+
+ let repository = config
+ .repositories()?
+ .get(repo_path)?
+ .open(&config.scan_path)?
+ .to_thread_local();
+
+ let issues: Vec<CollapsedIssue> = issues_from_repository(&repository)?
+ .into_iter()
+ .map(|issue| issue.collapse())
+ .collect();
+
+ // 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())
+ .link(format!(
+ "/{}/{}/issue/{}",
+ repo_path.display(),
+ &config.root_url,
+ issue.id
+ ))
+ .build()
+ })
+ .collect();
+
+ // Append all comments after converting them to rss items
+ items.extend(
+ issues
+ .iter()
+ .filter(|issue| !issue.comments.is_empty())
+ .flat_map(|issue| {
+ issue
+ .comments
+ .iter()
+ .map(|comment| {
+ ItemBuilder::default()
+ .title(issue.title.to_string())
+ .author(comment.author.to_string())
+ .description(comment.message.to_string())
+ .pub_date(comment.timestamp.to_string())
+ .link(format!(
+ "/{}/{}/issue/{}",
+ repo_path.display(),
+ &config.root_url,
+ issue.id
+ ))
+ .build()
+ })
+ .collect::<Vec<Item>>()
+ })
+ .collect::<Vec<Item>>(),
+ );
+
+ let channel = ChannelBuilder::default()
+ .title("Issues")
+ .link(config.root_url.to_string())
+ .description(format!("The rss feed for issues on {}.", &config.root_url))
+ .items(items)
+ .build();
+ Ok(channel.to_string())
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
new file mode 100644
index 0000000..8e2e9b0
--- /dev/null
+++ b/src/web/mod.rs
@@ -0,0 +1,138 @@
+// 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::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
+
+use bytes::Bytes;
+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 tokio::net::TcpListener;
+
+use crate::{config::BackConfig, error, git_bug::issue::Status};
+
+mod generate;
+mod responses;
+
+async fn match_uri(
+ config: Arc<BackConfig>,
+ req: Request<hyper::body::Incoming>,
+) -> Result<Response<BoxBody<Bytes, Infallible>>, hyper::Error> {
+ if req.method() != Method::GET {
+ return Ok(html_response_status(
+ "Only get requests are supported",
+ StatusCode::NOT_ACCEPTABLE,
+ ));
+ }
+
+ let output = || -> Result<Response<BoxBody<Bytes, Infallible>>, error::Error> {
+ match req.uri().path().trim_end_matches("/") {
+ "" => Ok(html_response(generate::repos(&config)?)),
+
+ "/style.css" => Ok(responses::html_response_status_content_type(
+ include_str!("../../assets/style.css"),
+ StatusCode::OK,
+ "text/css",
+ )),
+
+ 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"),
+ );
+
+ let issues = generate::issues(&config, Status::Closed, Status::Open, &repo_path)?;
+ Ok(html_response(issues))
+ }
+ 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 =
+ gix::hash::Prefix::from_hex(split[1]).map_err(error::Error::from)?;
+
+ let repo_path =
+ PathBuf::from(split[0].strip_prefix("/").expect("This prefix exists"));
+
+ (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",
+ )),
+ }
+ };
+ match output() {
+ Ok(response) => Ok(response),
+ Err(err) => Ok(err.into_response()),
+ }
+}
+
+pub 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);
+ 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);
+ }
+ });
+ }
+}
diff --git a/src/web/responses.rs b/src/web/responses.rs
new file mode 100644
index 0000000..bcdcc0a
--- /dev/null
+++ b/src/web/responses.rs
@@ -0,0 +1,61 @@
+// 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::convert::Infallible;
+
+use bytes::Bytes;
+use http::{Response, StatusCode, Version};
+use http_body_util::{BodyExt, Full, combinators::BoxBody};
+
+use crate::{error, git_bug::format::HtmlString};
+
+pub(super) fn html_response<T: Into<Bytes>>(html_text: T) -> Response<BoxBody<Bytes, Infallible>> {
+ html_response_status(html_text, StatusCode::OK)
+}
+
+pub(super) fn html_response_status<T: Into<Bytes>>(
+ html_text: T,
+ status: StatusCode,
+) -> Response<BoxBody<Bytes, Infallible>> {
+ html_response_status_content_type(html_text, status, "text/html")
+}
+
+pub(super) fn html_response_status_content_type<T: Into<Bytes>>(
+ html_text: T,
+ status: StatusCode,
+ content_type: &str,
+) -> Response<BoxBody<Bytes, Infallible>> {
+ Response::builder()
+ .status(status)
+ .version(Version::HTTP_2)
+ .header("Content-Type", format!("{}; charset=utf-8", content_type))
+ .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> {
+ Full::new(chunk.into()).boxed()
+}
+
+// FIXME: Not all errors should return `INTERNAL_SERVER_ERROR`. <2025-03-08>
+impl error::Error {
+ 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())
+ ),
+ StatusCode::INTERNAL_SERVER_ERROR,
+ )
+ }
+}