diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-03-08 21:50:22 +0100 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2025-03-09 13:44:42 +0100 |
commit | a9ff6e1c86ad51b3fa568ea7caa992df5db8c316 (patch) | |
tree | 65cb6404fce4f84be9ed6561ed152d0d21e3b875 /pkgs/by-name/ba/back/src | |
parent | scripts/get_dns.sh: Init (diff) | |
download | nixos-server-a9ff6e1c86ad51b3fa568ea7caa992df5db8c316.zip |
pkgs/back: Support listing all repos via the `/` path
This change required porting all webhandling from rocket to hyper, because we needed fine grained control over the path the user requested. This should also improve the memory and resources footprint because hyper is more lower level. I also changed all of the templates from `format!()` calls to a real templating language because I needed to touch most code paths anyway.
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, + ) + } +} |