aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/ba/back/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/ba/back/src/config/mod.rs110
-rw-r--r--pkgs/by-name/ba/back/src/error/mod.rs66
-rw-r--r--pkgs/by-name/ba/back/src/error/responder.rs23
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/dag/mod.rs14
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/format/mod.rs15
-rw-r--r--pkgs/by-name/ba/back/src/git_bug/issue/mod.rs2
-rw-r--r--pkgs/by-name/ba/back/src/main.rs33
-rw-r--r--pkgs/by-name/ba/back/src/web/generate/mod.rs227
-rw-r--r--pkgs/by-name/ba/back/src/web/issue_html.rs166
-rw-r--r--pkgs/by-name/ba/back/src/web/mod.rs271
-rw-r--r--pkgs/by-name/ba/back/src/web/prefix.rs35
-rw-r--r--pkgs/by-name/ba/back/src/web/responses.rs50
12 files changed, 579 insertions, 433 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">&lt;{email}&gt;</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">&lt;{email}&gt;</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 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 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,
-};
+use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
-mod issue_html;
-pub mod prefix;
+use crate::{config::BackConfig, error, git_bug::issue::Status};
-#[get("/style.css")]
-pub fn styles() -> RawCss<String> {
- RawCss(include_str!("../../assets/style.css").to_owned())
-}
+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 output = || -> Result<Response<BoxBody<Bytes, Infallible>>, error::Error> {
+ match req.uri().path().trim_end_matches("/") {
+ "" => Ok(html_response(generate::repos(&config)?)),
- let mut issue_list = issues_from_repository(&repository.to_thread_local())?
- .into_iter()
- .map(|issue| issue.collapse())
- .collect::<Vec<CollapsedIssue>>();
+ "/style.css" => Ok(responses::html_response_status_content_type(
+ include_str!("../../assets/style.css"),
+ StatusCode::OK,
+ "text/css",
+ )),
- // Sort by date descending.
- issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() });
- issue_list.reverse();
+ 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 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 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 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
- )))
-}
+ 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"),
+ );
-#[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)
-}
+ 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)?;
-#[get("/issues/feed")]
-pub fn feed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
- use rss::{ChannelBuilder, Item, ItemBuilder};
+ let repo_path =
+ PathBuf::from(split[0].strip_prefix("/").expect("This prefix exists"));
- //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>>(),
- );
+ (repo_path, prefix)
+ };
+ Ok(html_response(generate::issue(&config, &repo_path, prefix)?))
+ }
- 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()))
+ 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?;
+pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
+ let addr: SocketAddr = ([127, 0, 0, 1], 8000).into();
- let repository = config.repository.to_thread_local();
+ 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 all_issues: Vec<CollapsedIssue> = issues_from_repository(&repository)?
- .into_iter()
- .map(|val| val.collapse())
- .collect();
+ let local_config = Arc::clone(&config);
- let maybe_issue = all_issues
- .iter()
- .find(|issue| issue.id.to_string().starts_with(&prefix.to_string()));
+ let service = service_fn(move |req| match_uri(Arc::clone(&local_config), req));
- match maybe_issue {
- Some(issue) => Ok(issue.to_html(config)),
- None => Err(Error::IssuesPrefixMissing { prefix }),
+ 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,
+ )
+ }
+}