diff options
Diffstat (limited to 'pkgs/by-name/ba/back/src')
-rw-r--r-- | pkgs/by-name/ba/back/src/config/mod.rs | 110 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/error/mod.rs | 66 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/error/responder.rs | 23 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/git_bug/dag/mod.rs | 14 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/git_bug/format/mod.rs | 15 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/git_bug/issue/mod.rs | 2 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/main.rs | 33 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/web/generate/mod.rs | 227 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/web/issue_html.rs | 166 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/web/mod.rs | 297 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/web/prefix.rs | 35 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/web/responses.rs | 50 |
12 files changed, 592 insertions, 446 deletions
diff --git a/pkgs/by-name/ba/back/src/config/mod.rs b/pkgs/by-name/ba/back/src/config/mod.rs index 7351ad8..1161ce3 100644 --- a/pkgs/by-name/ba/back/src/config/mod.rs +++ b/pkgs/by-name/ba/back/src/config/mod.rs @@ -18,21 +18,97 @@ use gix::ThreadSafeRepository; use serde::Deserialize; use url::Url; -use crate::error::{self, Error}; +use crate::{ + error::{self, Error}, + git_bug::dag::is_git_bug, +}; pub struct BackConfig { - // NOTE(@bpeetz): We do not need to html escape this, as the value must be a valid url. As such - // `<tags>` of all kinds _should_ be invalid. <2024-12-26> + /// The url to the source code of back. This is needed, because back is licensed under the + /// AGPL. pub source_code_repository_url: Url, - pub repository: ThreadSafeRepository, + + /// A list of the repositories known to back. + /// This list is constructed from the `scan_path` and the `project_list` file. + pub repositories: BackRepositories, + + /// The root url this instance of back is hosted on. + /// For example: + /// `issues.foss-syndicate.org` pub root: Url, } +pub struct BackRepositories { + repositories: Vec<BackRepository>, + + /// The path that is the common parent of all the repositories. + scan_path: PathBuf, +} + +impl BackRepositories { + pub fn iter(&self) -> <&Self as IntoIterator>::IntoIter { + self.into_iter() + } +} + +impl<'a> IntoIterator for &'a BackRepositories { + type Item = <&'a Vec<BackRepository> as IntoIterator>::Item; + + type IntoIter = <&'a Vec<BackRepository> as IntoIterator>::IntoIter; + + 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 fn scan_path(&self) -> &Path { + &self.scan_path + } +} + +pub struct BackRepository { + repo_path: PathBuf, +} + +impl BackRepository { + pub fn open(&self, scan_path: &Path) -> Result<ThreadSafeRepository, error::Error> { + let repo = ThreadSafeRepository::open(scan_path.join(&self.repo_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 + } +} + #[derive(Deserialize)] struct RawBackConfig { source_code_repository_url: Url, - repository_path: PathBuf, root_url: Url, + project_list: PathBuf, + scan_path: PathBuf, } impl BackConfig { @@ -56,16 +132,26 @@ impl TryFrom<RawBackConfig> for BackConfig { type Error = error::Error; fn try_from(value: RawBackConfig) -> Result<Self, Self::Error> { - let repository = { - ThreadSafeRepository::open(&value.repository_path).map_err(|err| Error::RepoOpen { - repository_path: value.repository_path, - error: Box::new(err), - }) - }?; + let repositories = fs::read_to_string(&value.project_list) + .map_err(|err| error::Error::ProjectListRead { + error: err, + file: value.project_list, + })? + .lines() + .try_fold(vec![], |mut acc, path| { + acc.push(BackRepository { + repo_path: PathBuf::from(path), + }); + + Ok::<_, Self::Error>(acc) + })?; Ok(Self { - repository, source_code_repository_url: value.source_code_repository_url, + repositories: BackRepositories { + repositories, + scan_path: value.scan_path, + }, root: value.root_url, }) } diff --git a/pkgs/by-name/ba/back/src/error/mod.rs b/pkgs/by-name/ba/back/src/error/mod.rs index 8b71700..8889033 100644 --- a/pkgs/by-name/ba/back/src/error/mod.rs +++ b/pkgs/by-name/ba/back/src/error/mod.rs @@ -9,37 +9,53 @@ // 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, path::PathBuf}; +use std::{fmt::Display, io, net::SocketAddr, path::PathBuf}; +use gix::hash::Prefix; use thiserror::Error; -use crate::web::prefix::BackPrefix; - pub type Result<T> = std::result::Result<T, Error>; -pub mod responder; - #[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, }, - RocketLaunch(#[from] rocket::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(#[from] std::io::Error), + RepoRefsPrefixed { + error: io::Error, + }, + + TcpBind { + addr: SocketAddr, + err: io::Error, + }, + TcpAccept { + err: io::Error, + }, IssuesPrefixMissing { - prefix: BackPrefix, + prefix: Prefix, }, IssuesPrefixParse(#[from] gix::hash::prefix::from_hex::Error), } @@ -54,6 +70,13 @@ impl Display for 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, @@ -61,9 +84,6 @@ impl Display for Error { file.display() ) } - Error::RocketLaunch(error) => { - write!(f, "while trying to start back: {error}") - } Error::RepoOpen { repository_path, error, @@ -74,10 +94,24 @@ impl Display for 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) => { + Error::RepoRefsPrefixed { error, .. } => { write!(f, "while prefixing the refs with a path: {error}") } Error::IssuesPrefixMissing { prefix } => { @@ -89,6 +123,12 @@ impl Display for Error { 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/pkgs/by-name/ba/back/src/error/responder.rs b/pkgs/by-name/ba/back/src/error/responder.rs deleted file mode 100644 index 7bea961..0000000 --- a/pkgs/by-name/ba/back/src/error/responder.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 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 rocket::{ - response::{self, Responder, Response}, - Request, -}; - -use super::Error; - -impl<'r> Responder<'r, 'static> for Error { - fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> { - Response::build_from(self.to_string().respond_to(req)?).ok() - } -} diff --git a/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs b/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs index 9c158a7..3d22b04 100644 --- a/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs +++ b/pkgs/by-name/ba/back/src/git_bug/dag/mod.rs @@ -123,11 +123,23 @@ impl Dag { } } +/// Check whether `git-bug` has been initialized in this repository +pub fn is_git_bug(repo: &Repository) -> error::Result<bool> { + Ok(repo + .refs + .iter()? + .prefixed(Path::new("refs/bugs/")) + .map_err(|err| error::Error::RepoRefsPrefixed { error: err })? + .count() + > 0) +} + pub fn issues_from_repository(repo: &Repository) -> error::Result<Vec<Dag>> { let dags = repo .refs .iter()? - .prefixed(Path::new("refs/bugs/"))? + .prefixed(Path::new("refs/bugs/")) + .map_err(|err| error::Error::RepoRefsPrefixed { error: err })? .map(|val| { let reference = val.expect("All `git-bug` references in 'refs/bugs' should be objects"); diff --git a/pkgs/by-name/ba/back/src/git_bug/format/mod.rs b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs index b3b6bcc..ffe44fd 100644 --- a/pkgs/by-name/ba/back/src/git_bug/format/mod.rs +++ b/pkgs/by-name/ba/back/src/git_bug/format/mod.rs @@ -16,8 +16,8 @@ use markdown::to_html; use serde::Deserialize; use serde_json::Value; -#[derive(Debug, Default, Clone)] /// Markdown content. +#[derive(Debug, Default, Clone)] pub struct MarkDown { value: String, } @@ -88,6 +88,19 @@ pub struct HtmlString { value: String, } +impl From<String> for HtmlString { + fn from(value: String) -> Self { + Self { value } + } +} +impl From<&str> for HtmlString { + fn from(value: &str) -> Self { + Self { + value: value.to_owned(), + } + } +} + impl From<MarkDown> for HtmlString { fn from(value: MarkDown) -> Self { Self { value: value.value } diff --git a/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs b/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs index f27bfec..d382b54 100644 --- a/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs +++ b/pkgs/by-name/ba/back/src/git_bug/issue/mod.rs @@ -128,7 +128,7 @@ impl RawCollapsedIssue { } => { self.id = Some(entity.id.clone()); self.author = Some(entity.author.clone()); - self.timestamp = Some(timestamp.clone()); + self.timestamp = Some(timestamp); self.title = Some(title); self.message = Some(message); self.status = Some(Status::Open); // This is the default in git_bug diff --git a/pkgs/by-name/ba/back/src/main.rs b/pkgs/by-name/ba/back/src/main.rs index 961c39b..61953c4 100644 --- a/pkgs/by-name/ba/back/src/main.rs +++ b/pkgs/by-name/ba/back/src/main.rs @@ -9,14 +9,11 @@ // 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; +use std::{process, sync::Arc}; use clap::Parser; -use config::BackConfig; -use rocket::routes; -use web::feed; -use crate::web::{closed, open, show_issue, styles}; +use crate::config::BackConfig; mod cli; pub mod config; @@ -25,7 +22,7 @@ pub mod git_bug; mod web; fn main() -> Result<(), String> { - if let Err(err) = rocket_main() { + if let Err(err) = server_main() { eprintln!("Error {err}"); process::exit(1); } else { @@ -33,20 +30,24 @@ fn main() -> Result<(), String> { } } -#[rocket::main] -async fn rocket_main() -> Result<(), error::Error> { +#[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)?; - rocket::build() - .mount("/", routes![open, closed, show_issue, styles, feed]) - .manage(config) - .ignite() - .await - .expect("This error should only happen on a miss-configuration.") - .launch() - .await?; + web::main(Arc::new(config)).await?; Ok(()) } diff --git a/pkgs/by-name/ba/back/src/web/generate/mod.rs b/pkgs/by-name/ba/back/src/web/generate/mod.rs new file mode 100644 index 0000000..10146bb --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/generate/mod.rs @@ -0,0 +1,227 @@ +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.repositories.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.repositories.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.repositories.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.repositories.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, + 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, + issue.id + )) + .build() + }) + .collect::<Vec<Item>>() + }) + .collect::<Vec<Item>>(), + ); + + let channel = ChannelBuilder::default() + .title("Issues") + .link(config.root.to_string()) + .description(format!("The rss feed for issues on {}.", &config.root)) + .items(items) + .build(); + Ok(channel.to_string()) +} diff --git a/pkgs/by-name/ba/back/src/web/issue_html.rs b/pkgs/by-name/ba/back/src/web/issue_html.rs deleted file mode 100644 index 45c0281..0000000 --- a/pkgs/by-name/ba/back/src/web/issue_html.rs +++ /dev/null @@ -1,166 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 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 rocket::response::content::RawHtml; - -use crate::{ - config::BackConfig, - git_bug::{ - format::HtmlString, - issue::{identity::Author, CollapsedIssue, Comment}, - }, -}; - -impl CollapsedIssue { - pub fn to_list_entry(&self) -> RawHtml<String> { - let comment_list = if self.comments.is_empty() { - String::new() - } else { - let comments_string = if self.comments.len() > 1 { - "comments" - } else { - "comment" - }; - - format!( - r#" - <span class="comment-count"> - {} {}</span> - "#, - self.comments.len(), - comments_string - ) - }; - - let CollapsedIssue { - id, - title, - message: _, - author, - timestamp, - comments: _, - status: _, - last_status_change: _, - labels: _, - } = self; - - let Author { name, email, id: _ } = author; - - RawHtml(format!( - r#" - <li> - <a href="/issue/{id}"> - <p> - <span class="issue-subject">{title}</span> - </p> - <span class="issue-number">{id}</span> - <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email"><{email}></span> at <span class="timestamp">{timestamp}</span></span>{comment_list} </a> - </li> -"#, - )) - } - - pub fn to_html(&self, config: &BackConfig) -> RawHtml<String> { - let comments = if self.comments.is_empty() { - String::new() - } else { - let fmt_comments: String = self - .comments - .iter() - .map(|val| { - let Comment { - id, - author, - message, - timestamp, - } = val; - let Author { - name, - email: _, - id: _, - } = author; - - format!( - r#" - <li class="comment" id="{id}"> - {message} - <p class="comment-info"><span class="user-name">{name} at {timestamp}</span></p> - </li> - "#, - ) - }) - .collect::<Vec<String>>() - .join("\n"); - - format!( - r#" - <ol class="issue-history"> - {fmt_comments} - </ol> - "# - ) - }; - - { - let CollapsedIssue { - id, - title, - message, - author, - timestamp, - comments: _, - status: _, - last_status_change: _, - labels: _, - } = self; - let Author { name, email, id: _ } = author; - let html_title = HtmlString::from(title.clone()); - - RawHtml(format!( - r#" -<!DOCTYPE html> -<html lang="en"> - <head> - <title>{html_title} | Back</title> - <link href="/style.css" rel="stylesheet" type="text/css"> - <meta content="width=device-width,initial-scale=1" name="viewport"> - </head> - <body> - <div class="content"> - <nav> - <a href="/issues/open">Open Issues</a> - <a href="/issues/closed">Closed Issues</a> - </nav> - <header> - <h1>{title}</h1> - <div class="issue-number">{id}</div> - </header> - <main> - <div class="issue-info"> - <span class="created-by-at">Opened by <span class="user-name">{name}</span> <span class="user-email"><{email}></span> at <span class="timestamp">{timestamp}</span></span> - </div> - {message} - {comments} - </main> - <footer> - <nav> - <a href="/issues/open">Open Issues</a> - <a href="{}">Source code</a> - <a href="/issues/closed">Closed Issues</a> - </nav> - </footer> - </div> - </body> -</html> -"#, - config.source_code_repository_url - )) - } - } -} diff --git a/pkgs/by-name/ba/back/src/web/mod.rs b/pkgs/by-name/ba/back/src/web/mod.rs index f7a4077..cc087ab 100644 --- a/pkgs/by-name/ba/back/src/web/mod.rs +++ b/pkgs/by-name/ba/back/src/web/mod.rs @@ -1,186 +1,127 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 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 crate::{ - config::BackConfig, - error::{self, Error}, - git_bug::{ - dag::issues_from_repository, - issue::{CollapsedIssue, Status}, - }, -}; -use prefix::BackPrefix; -use rocket::{ - get, - response::content::{RawCss, RawHtml}, - State, -}; - -mod issue_html; -pub mod prefix; - -#[get("/style.css")] -pub fn styles() -> RawCss<String> { - RawCss(include_str!("../../assets/style.css").to_owned()) -} +use bytes::Bytes; +use http_body_util::combinators::BoxBody; +use hyper::{server::conn::http1, service::service_fn, Method, Request, Response, StatusCode}; +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 std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc}; + +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, + )); + } -pub fn issue_list_boilerplate( - config: &State<BackConfig>, - wanted_status: Status, - counter_status: Status, -) -> error::Result<RawHtml<String>> { - let repository = &config.repository; - - let mut issue_list = issues_from_repository(&repository.to_thread_local())? - .into_iter() - .map(|issue| issue.collapse()) - .collect::<Vec<CollapsedIssue>>(); - - // Sort by date descending. - issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() }); - issue_list.reverse(); - - let issue_list_str = issue_list.into_iter().fold(String::new(), |acc, issue| { - format!("{}{}", acc, { - if issue.status == wanted_status { - let issue_entry = issue.to_list_entry(); - issue_entry.0 - } else { - String::new() + 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", + )) } - }) - }); - - let counter_status_lower = counter_status.to_string().to_lowercase(); - Ok(RawHtml(format!( - r#" - <!DOCTYPE html> - <html lang="en"> - <head> - <title>Back</title> - <link href="/style.css" rel="stylesheet" type="text/css"> - <meta content="width=device-width,initial-scale=1" name="viewport"> - </head> - <body> - <div class="content"> - <header> - <h1>{wanted_status} Issues</h1> - </header> - <main> - <div class="issue-links"> - <a href="/issues/{counter_status_lower}/">View {counter_status} issues</a> - <a href="{}">Source code</a> - <!-- - <form class="issue-search" method="get"> - <input name="search" title="Issue search query" type="search"> - <input class="sr-only" type="submit" value="Search Issues"> - </form> - --> - </div> - <ol class="issue-list"> - {issue_list_str} - </ol> - </main> - </div> - </body> - </html> - "#, - config.source_code_repository_url - ))) -} -#[get("/issues/open")] -pub fn open(config: &State<BackConfig>) -> error::Result<RawHtml<String>> { - issue_list_boilerplate(config, Status::Open, Status::Closed) -} -#[get("/issues/closed")] -pub fn closed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> { - issue_list_boilerplate(config, Status::Closed, Status::Open) -} + 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")); -#[get("/issues/feed")] -pub fn feed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> { - use rss::{ChannelBuilder, Item, ItemBuilder}; - - //Collect all Items as rss items - let mut items: Vec<Item> = issues_from_repository(&config.repository.to_thread_local())? - .into_iter() - .map(|issue| issue.collapse()) - .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/{}", &config.root.to_string(), issue.id)) - .build() - }) - .collect(); - //Append all comments after converting them to rss items - items.extend( - issues_from_repository(&config.repository.to_thread_local())? - .into_iter() - .map(|issue| issue.collapse()) - .filter(|issue| issue.comments.len() > 0) - .map(|issue| { - issue - .comments - .into_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/{}", &config.root.to_string(), issue.id)) - .build() - }) - .collect::<Vec<Item>>() - }) - .flatten() - .collect::<Vec<Item>>(), - ); - - let channel = ChannelBuilder::default() - .title("Issues") - .link(config.root.to_string()) - .description(format!("The rss feed for issues on {}.", config.root)) - .items(items) - .build(); - Ok(RawHtml(channel.to_string())) + (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()), + } } -#[get("/issue/<prefix>")] -pub fn show_issue( - config: &State<BackConfig>, - prefix: Result<BackPrefix, gix::hash::prefix::from_hex::Error>, -) -> error::Result<RawHtml<String>> { - // NOTE(@bpeetz): Explicitly unwrap the `prefix` here (instead of taking the unwrapped value as - // argument), to avoid triggering rockets "errors forward to the next route" feature. - // This ensures, that our error message actually reaches the user. <2024-12-26> - let prefix = prefix?; - - let repository = config.repository.to_thread_local(); - - let all_issues: Vec<CollapsedIssue> = issues_from_repository(&repository)? - .into_iter() - .map(|val| val.collapse()) - .collect(); - - let maybe_issue = all_issues - .iter() - .find(|issue| issue.id.to_string().starts_with(&prefix.to_string())); - - match maybe_issue { - Some(issue) => Ok(issue.to_html(config)), - None => Err(Error::IssuesPrefixMissing { prefix }), +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/pkgs/by-name/ba/back/src/web/prefix.rs b/pkgs/by-name/ba/back/src/web/prefix.rs deleted file mode 100644 index 5143799..0000000 --- a/pkgs/by-name/ba/back/src/web/prefix.rs +++ /dev/null @@ -1,35 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 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; - -use gix::hash::Prefix; -use rocket::request::FromParam; - -#[derive(Debug)] -pub struct BackPrefix { - prefix: Prefix, -} -impl Display for BackPrefix { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.prefix.fmt(f) - } -} - -impl<'a> FromParam<'a> for BackPrefix { - type Error = gix::hash::prefix::from_hex::Error; - - fn from_param(param: &'a str) -> Result<Self, Self::Error> { - let prefix = Prefix::from_hex(param)?; - - Ok(Self { prefix }) - } -} diff --git a/pkgs/by-name/ba/back/src/web/responses.rs b/pkgs/by-name/ba/back/src/web/responses.rs new file mode 100644 index 0000000..e50f8c2 --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/responses.rs @@ -0,0 +1,50 @@ +use std::convert::Infallible; + +use bytes::Bytes; +use http::{Response, StatusCode, Version}; +use http_body_util::{combinators::BoxBody, BodyExt, Full}; + +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, + ) + } +} |