From a3111a5d214db66b7d676940b8f8bfda5074bd45 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Thu, 26 Dec 2024 10:00:45 +0100 Subject: fix(pkgs/back): Use rocket to manage the configuration values This reduces the amount of needed `unwraps`/`expects` and allows us to streamline the parsing processes. --- pkgs/by-name/ba/back/src/cli.rs | 13 + pkgs/by-name/ba/back/src/config/mod.rs | 58 +++++ pkgs/by-name/ba/back/src/error/mod.rs | 56 +++++ pkgs/by-name/ba/back/src/issues/format/mod.rs | 88 ------- pkgs/by-name/ba/back/src/issues/issue/mod.rs | 332 ------------------------- pkgs/by-name/ba/back/src/issues/issue/raw.rs | 145 ----------- pkgs/by-name/ba/back/src/issues/issue_show.rs | 34 --- pkgs/by-name/ba/back/src/issues/mod.rs | 134 ---------- pkgs/by-name/ba/back/src/main.rs | 73 ++---- pkgs/by-name/ba/back/src/web/format/mod.rs | 88 +++++++ pkgs/by-name/ba/back/src/web/issue/mod.rs | 337 ++++++++++++++++++++++++++ pkgs/by-name/ba/back/src/web/issue/raw.rs | 145 +++++++++++ pkgs/by-name/ba/back/src/web/issue_show.rs | 34 +++ pkgs/by-name/ba/back/src/web/mod.rs | 133 ++++++++++ 14 files changed, 885 insertions(+), 785 deletions(-) create mode 100644 pkgs/by-name/ba/back/src/cli.rs create mode 100644 pkgs/by-name/ba/back/src/config/mod.rs create mode 100644 pkgs/by-name/ba/back/src/error/mod.rs delete mode 100644 pkgs/by-name/ba/back/src/issues/format/mod.rs delete mode 100644 pkgs/by-name/ba/back/src/issues/issue/mod.rs delete mode 100644 pkgs/by-name/ba/back/src/issues/issue/raw.rs delete mode 100644 pkgs/by-name/ba/back/src/issues/issue_show.rs delete mode 100644 pkgs/by-name/ba/back/src/issues/mod.rs create mode 100644 pkgs/by-name/ba/back/src/web/format/mod.rs create mode 100644 pkgs/by-name/ba/back/src/web/issue/mod.rs create mode 100644 pkgs/by-name/ba/back/src/web/issue/raw.rs create mode 100644 pkgs/by-name/ba/back/src/web/issue_show.rs create mode 100644 pkgs/by-name/ba/back/src/web/mod.rs (limited to 'pkgs/by-name/ba/back/src') diff --git a/pkgs/by-name/ba/back/src/cli.rs b/pkgs/by-name/ba/back/src/cli.rs new file mode 100644 index 0000000..1820bf3 --- /dev/null +++ b/pkgs/by-name/ba/back/src/cli.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +#[allow(clippy::module_name_repetitions)] +/// An extremely simple git issue tracking system. +/// Inspired by tvix's panettone +pub struct Cli { + /// The path to the configuration file. The file should be written in JSON. + pub config_file: PathBuf, +} diff --git a/pkgs/by-name/ba/back/src/config/mod.rs b/pkgs/by-name/ba/back/src/config/mod.rs new file mode 100644 index 0000000..4986a41 --- /dev/null +++ b/pkgs/by-name/ba/back/src/config/mod.rs @@ -0,0 +1,58 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use gix::ThreadSafeRepository; +use serde::Deserialize; +use url::Url; + +use crate::error::{self, Error}; + +pub struct BackConfig { + // NOTE(@bpeetz): We do not need to html escape this, as the value must be a valid url. As such + // `` of all kinds _should_ be invalid. <2024-12-26> + pub source_code_repository_url: Url, + pub repository: ThreadSafeRepository, +} + +#[derive(Deserialize)] +struct RawBackConfig { + source_code_repository_url: Url, + repository_path: PathBuf, +} + +impl BackConfig { + pub fn from_config_file(path: &Path) -> error::Result { + let value = fs::read_to_string(path).map_err(|err| Error::ConfigRead { + file: path.to_owned(), + error: err, + })?; + + let raw: RawBackConfig = + serde_json::from_str(&value).map_err(|err| Error::ConfigParse { + file: path.to_owned(), + error: err, + })?; + + Self::try_from(raw) + } +} + +impl TryFrom for BackConfig { + type Error = error::Error; + + fn try_from(value: RawBackConfig) -> Result { + let repository = { + ThreadSafeRepository::open(&value.repository_path).map_err(|err| Error::RepoOpen { + repository_path: value.repository_path, + error: Box::new(err), + }) + }?; + + Ok(Self { + repository, + source_code_repository_url: value.source_code_repository_url, + }) + } +} diff --git a/pkgs/by-name/ba/back/src/error/mod.rs b/pkgs/by-name/ba/back/src/error/mod.rs new file mode 100644 index 0000000..7e1c9cf --- /dev/null +++ b/pkgs/by-name/ba/back/src/error/mod.rs @@ -0,0 +1,56 @@ +use std::{fmt::Display, io, path::PathBuf}; + +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Error, Debug)] +pub enum Error { + ConfigParse { + file: PathBuf, + error: serde_json::Error, + }, + ConfigRead { + file: PathBuf, + error: io::Error, + }, + RepoOpen { + repository_path: PathBuf, + error: Box, + }, + RocketLaunch(#[from] rocket::Error), +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ConfigParse { file, error } => { + write!( + f, + "while trying to parse the config file ({}): {error}", + file.display() + ) + } + Error::ConfigRead { file, error } => { + write!( + f, + "while trying to read the config file ({}): {error}", + file.display() + ) + } + Error::RepoOpen { + repository_path, + error, + } => { + write!( + f, + "while trying to open the repository ({}): {error}", + repository_path.display() + ) + } + Error::RocketLaunch(error) => { + write!(f, "while trying to start back: {error}") + } + } + } +} diff --git a/pkgs/by-name/ba/back/src/issues/format/mod.rs b/pkgs/by-name/ba/back/src/issues/format/mod.rs deleted file mode 100644 index f78d3b3..0000000 --- a/pkgs/by-name/ba/back/src/issues/format/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 Benedikt Peetz -// 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 . - -use std::fmt::Display; - -use markdown::to_html; - -#[derive(Debug, Default, Clone)] -pub struct Markdown { - value: String, -} - -impl From for Markdown { - fn from(value: String) -> Self { - Self { value } - } -} -impl Display for Markdown { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(to_html(&self.value).as_str()) - } -} - -#[derive(Debug, Default)] -pub struct BackString { - value: String, -} - -impl From for BackString { - fn from(value: Markdown) -> Self { - Self { value: value.value } - } -} - -impl From for BackString { - fn from(value: String) -> Self { - Self { value } - } -} -impl Display for BackString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(escape_html(&self.value).as_str()) - } -} - -// From `tera::escape_html` -/// Escape HTML following [OWASP](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet) -/// -/// Escape the following characters with HTML entity encoding to prevent switching -/// into any execution context, such as script, style, or event handlers. Using -/// hex entities is recommended in the spec. In addition to the 5 characters -/// significant in XML (&, <, >, ", '), the forward slash is included as it helps -/// to end an HTML entity. -/// -/// ```text -/// & --> & -/// < --> < -/// > --> > -/// " --> " -/// ' --> ' ' is not recommended -/// / --> / forward slash is included as it helps end an HTML entity -/// ``` -#[inline] -pub fn escape_html(input: &str) -> String { - let mut output = String::with_capacity(input.len() * 2); - for c in input.chars() { - match c { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '>' => output.push_str(">"), - '"' => output.push_str("""), - '\'' => output.push_str("'"), - '/' => output.push_str("/"), - _ => output.push(c), - } - } - - // Not using shrink_to_fit() on purpose - output -} diff --git a/pkgs/by-name/ba/back/src/issues/issue/mod.rs b/pkgs/by-name/ba/back/src/issues/issue/mod.rs deleted file mode 100644 index b78f473..0000000 --- a/pkgs/by-name/ba/back/src/issues/issue/mod.rs +++ /dev/null @@ -1,332 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 Benedikt Peetz -// 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 . - -use std::fmt::Display; - -use chrono::DateTime; -use gix::{bstr::ByteSlice, Commit, Id, ObjectId, Repository}; -use raw::{Operation, RawIssue}; -use rocket::response::content::RawHtml; - -use crate::SOURCE_CODE_REPOSITORY; - -use super::format::{BackString, Markdown}; - -mod raw; - -#[derive(Debug, Default)] -pub struct TimeStamp { - value: u64, -} -impl TimeStamp { - pub fn new(val: u64) -> Self { - Self { value: val } - } -} -impl Display for TimeStamp { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let date = - DateTime::from_timestamp(self.value as i64, 0).expect("This timestamp should be vaild"); - - let newdate = date.format("%Y-%m-%d %H:%M:%S"); - f.write_str(newdate.to_string().as_str()) - } -} - -#[derive(Debug)] -pub struct Comment<'a> { - pub id: Id<'a>, - pub author: Author, - pub message: Markdown, - pub timestamp: TimeStamp, -} - -#[derive(Debug, Default)] -pub struct Author { - name: BackString, - email: BackString, -} - -#[derive(Debug)] -pub struct IssueId<'a> { - value: Id<'a>, -} -impl<'a> IssueId<'a> { - pub fn new(id: Id<'a>) -> Self { - Self { value: id } - } -} -impl Display for IssueId<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let shortend = self.value.shorten().expect("This should work."); - f.write_str(shortend.to_string().as_str()) - } -} - -#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] -pub enum Status { - #[default] - Open, - Closed, -} -impl Display for Status { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Status::Open => f.write_str("Open"), - Status::Closed => f.write_str("Closed"), - } - } -} - -#[derive(Debug)] -pub struct Issue<'a> { - pub id: IssueId<'a>, - pub author: Author, - pub timestamp: TimeStamp, - pub title: Markdown, - pub message: Markdown, - pub comments: Vec>, - pub status: Status, - pub last_status_change: Option, -} -impl<'a> Issue<'a> { - pub fn default_with_id(id: Id<'a>) -> Self { - Self { - id: IssueId::new(id), - author: Author::default(), - timestamp: TimeStamp::default(), - title: Markdown::default(), - message: Markdown::default(), - comments: >::default(), - status: Status::default(), - last_status_change: >::default(), - } - } - - pub fn from_commit_id(repo: &'a Repository, commit_id: ObjectId) -> Self { - fn unwrap_id<'b>(repo: &Repository, id: &Commit<'b>) -> (RawIssue, Id<'b>) { - let tree_obj = repo - .find_object(id.tree_id().unwrap()) - .expect("The object with this id should exist.") - .try_into_tree() - .expect("The git-bug's data model enforces this."); - - let ops_ref = tree_obj.find_entry("ops").unwrap(); - - let issue_data = repo - .find_object(ops_ref.object_id()) - .expect("The object with this id should exist.") - .try_into_blob() - .expect("The git-bug's data model enforces this.") - .data - .clone(); - - let raw_issue = serde_json::from_str( - issue_data - .to_str() - .expect("git-bug's ensures, that this is valid json."), - ) - .expect("The returned json should be valid"); - - (raw_issue, id.id()) - } - - let commit_obj = repo - .find_object(commit_id) - .expect("The object with this id should exist.") - .try_into_commit() - .expect("The git-bug's data model enforces this."); - - let mut issues = vec![unwrap_id(repo, &commit_obj)]; - - let mut current_commit_obj = commit_obj; - while current_commit_obj.parent_ids().count() != 0 { - assert_eq!( - current_commit_obj.parent_ids().count(), - 1, - "There should be only one parent" - ); - let parent = current_commit_obj - .parent_ids() - .last() - .expect("One does exist"); - - let parent_id = parent.object().expect("The object exists").id; - let parent_commit = repo - .find_object(parent_id) - .expect("This is a valid id") - .try_into_commit() - .expect("This should be a commit"); - - issues.push(unwrap_id(repo, &parent_commit)); - current_commit_obj = parent_commit; - } - - let mut final_issue = Self::default_with_id(current_commit_obj.id()); - for (issue, id) in issues { - for op in issue.operations { - match op { - Operation::AddComment { timestamp, message } => { - final_issue.comments.push(Comment { - id, - author: issue.author.load_identity(repo), - message: Markdown::from(message), - timestamp: TimeStamp::new(timestamp), - }) - } - Operation::Create { - timestamp, - title, - message, - } => { - final_issue.author = issue.author.load_identity(repo); - final_issue.title = Markdown::from(title); - final_issue.message = Markdown::from(message); - final_issue.timestamp = TimeStamp::new(timestamp); - } - Operation::SetStatus { timestamp, status } => { - final_issue.status = status; - final_issue.last_status_change = Some(TimeStamp::new(timestamp)); - } - } - } - } - final_issue - } - - pub fn to_list_entry(&self) -> RawHtml { - let comment_list = if self.comments.is_empty() { - String::new() - } else { - format!( - r#" - - {} comments - "#, - self.comments.len() - ) - }; - let Issue { - id, - title, - message: _, - author, - timestamp, - comments: _, - status: _, - last_status_change: _, - } = self; - let Author { name, email } = author; - RawHtml(format!( - r#" -
  • - -

    - {title} -

    - {id} - Opened by {name} <{email}> at {timestamp}{comment_list}
    -
  • -"#, - )) - } - - pub fn to_html(&self) -> RawHtml { - let fmt_comments: String = self - .comments - .iter() - .map(|val| { - let Comment { - id, - author, - message, - timestamp, - } = val; - let Author { name, email: _ } = author; - - format!( - r#" -
  • - {message} -

    {name} at {timestamp}

    -
  • - "#, - ) - }) - .collect::>() - .join("\n"); - - let maybe_comments = if fmt_comments.is_empty() { - String::new() - } else { - format!( - r#" -
      - {fmt_comments} -
    - "# - ) - }; - - { - let Issue { - id, - title, - message, - author, - timestamp, - comments: _, - status: _, - last_status_change: _, - } = self; - let Author { name, email } = author; - let html_title = BackString::from(title.clone()); - - RawHtml(format!( - r#" - - - - {html_title} | Back - - - - -
    - -
    -

    {title}

    -
    {id}
    -
    -
    -
    - Opened by {name} <{email}> at {timestamp} -
    - {message} - {maybe_comments} -
    - -
    - - -"#, - SOURCE_CODE_REPOSITORY.get().expect("This should be set") - )) - } - } -} diff --git a/pkgs/by-name/ba/back/src/issues/issue/raw.rs b/pkgs/by-name/ba/back/src/issues/issue/raw.rs deleted file mode 100644 index 48d2a9f..0000000 --- a/pkgs/by-name/ba/back/src/issues/issue/raw.rs +++ /dev/null @@ -1,145 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 Benedikt Peetz -// 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 . - -use gix::{bstr::ByteSlice, Repository}; -use serde::Deserialize; -use serde_json::Value; - -use crate::issues::format::BackString; - -use super::{Author, Status}; - -macro_rules! get { - ($value:expr, $name:expr, $type_fun:ident) => { - $value - .get($name) - .expect(concat!( - "Expected field ", - stringify!($name), - "to exists, but was missing." - )) - .$type_fun() - .expect(concat!( - "Failed to interpret field ", - stringify!($name), - " as ", - stringify!($type), - "!" - )) - }; -} - -#[derive(Deserialize)] -pub(super) struct RawIssue { - pub(super) author: RawAuthor, - - #[serde(alias = "ops")] - pub(super) operations: Vec, -} - -#[derive(Deserialize, Clone, Default, Debug)] -pub(super) struct RawAuthor { - id: String, -} - -impl RawAuthor { - pub fn load_identity(&self, repo: &Repository) -> Author { - let commit_obj = repo - .find_reference(&format!("refs/identities/{}", self.id)) - .expect("All authors should also have identities") - .peel_to_commit() - .expect("All identities should be commits"); - let tree_obj = repo - .find_tree( - commit_obj - .tree() - .expect("The commit should have an tree associated with it") - .id, - ) - .expect("This should be a tree"); - let data = repo - .find_blob( - tree_obj - .find_entry("version") - .expect("This entry should exist") - .object() - .expect("This should point to a blob entry") - .id, - ) - .expect("This blob should exist") - .data - .clone(); - - let json: Value = serde_json::from_str(data.to_str().expect("This is encoded json")) - .expect("This is valid json"); - - Author { - name: BackString::from(get! {json, "name", as_str}.to_owned()), - email: BackString::from(get! {json, "email", as_str}.to_owned()), - } - } -} - -#[derive(Deserialize)] -#[serde(from = "Value")] -pub(super) enum Operation { - AddComment { - timestamp: u64, - message: String, - // files: Option, TODO - }, - SetStatus { - timestamp: u64, - status: Status, - }, - Create { - timestamp: u64, - title: String, - message: String, - // files: Option, TODO - }, -} - -impl From for Status { - fn from(value: u64) -> Self { - match value { - 1 => Status::Open, - 2 => Status::Closed, - other => todo!("The status ({other}) is not yet implemented."), - } - } -} - -impl From for Operation { - fn from(value: Value) -> Self { - match value - .get("type") - .expect("Should exist") - .as_u64() - .expect("This should work") - { - 1 => Self::Create { - title: get! {value, "title", as_str}.to_owned(), - message: get! {value, "message", as_str}.to_owned(), - timestamp: get! {value, "timestamp", as_u64}, - }, - 3 => Self::AddComment { - message: get! {value, "message", as_str}.to_owned(), - timestamp: get! {value, "timestamp", as_u64}, - }, - 4 => Self::SetStatus { - status: Status::from(get! {value, "status", as_u64}), - timestamp: get! {value, "timestamp", as_u64}, - }, - other => todo!("The type ({other}) is not yet added as a a valid operation. It's value is: '{value}''"), - } - } -} diff --git a/pkgs/by-name/ba/back/src/issues/issue_show.rs b/pkgs/by-name/ba/back/src/issues/issue_show.rs deleted file mode 100644 index 638840e..0000000 --- a/pkgs/by-name/ba/back/src/issues/issue_show.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 Benedikt Peetz -// 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 . - -use std::fmt::Display; - -use gix::hash::Prefix; -use rocket::request::FromParam; - -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 { - let prefix = Prefix::from_hex(param)?; - - Ok(Self { prefix }) - } -} diff --git a/pkgs/by-name/ba/back/src/issues/mod.rs b/pkgs/by-name/ba/back/src/issues/mod.rs deleted file mode 100644 index 744d5ba..0000000 --- a/pkgs/by-name/ba/back/src/issues/mod.rs +++ /dev/null @@ -1,134 +0,0 @@ -// Back - An extremely simple git issue tracking system. Inspired by tvix's -// panettone -// -// Copyright (C) 2024 Benedikt Peetz -// 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 . - -use std::path::Path; - -use crate::{ - issues::issue::{Issue, Status}, - SOURCE_CODE_REPOSITORY, -}; -use format::BackString; -use gix::{refs::Target, Repository}; -use issue_show::BackPrefix; -use rocket::{ - get, - response::content::{RawCss, RawHtml}, -}; - -use crate::REPOSITORY; - -mod format; -mod issue; -mod issue_show; - -#[get("/style.css")] -pub fn styles() -> RawCss { - RawCss(include_str!("../../assets/style.css").to_owned()) -} - -fn list_all_issues(repo: &'_ Repository) -> Vec> { - repo.refs - .iter() - .expect("We should be able to iterate over references") - .prefixed(Path::new("refs/bugs/")) - .expect("The 'refs/bugs/' namespace should exist") - .map(|val| { - let reference = val.expect("'val' should be an object?"); - if let Target::Object(commit_id) = reference.target { - Issue::from_commit_id(repo, commit_id) - } else { - unreachable!("All 'refs/bugs/' should contain a clear target."); - } - }) - .collect() -} - -pub fn issue_list_boilerplate(wanted_status: Status, counter_status: Status) -> RawHtml { - let repository = REPOSITORY.get().expect("This should have been set"); - - let issue_list = list_all_issues(&repository.to_thread_local()) - .iter() - .fold(String::new(), |acc, val| { - format!("{}{}", acc, &issue_to_string(val, wanted_status).0) - }); - - let counter_status_lower = counter_status.to_string().to_lowercase(); - RawHtml(format!( - r#" - - - - Back - - - - -
    -
    -

    {wanted_status} Issues

    -
    -
    - -
      - {issue_list} -
    -
    -
    - - -"#, - SOURCE_CODE_REPOSITORY.get().expect("This should be set") - )) -} - -#[get("/issues/open")] -pub fn open() -> RawHtml { - issue_list_boilerplate(Status::Open, Status::Closed) -} -#[get("/issues/closed")] -pub fn closed() -> RawHtml { - issue_list_boilerplate(Status::Closed, Status::Open) -} - -#[get("/issue/")] -pub fn show_issue(prefix: BackPrefix) -> RawHtml { - let repository = REPOSITORY - .get() - .expect("This should have been set") - .to_thread_local(); - - let all_issues = list_all_issues(&repository); - let maybe_issue = all_issues - .iter() - .find(|issue| issue.id.to_string().starts_with(&prefix.to_string())); - - match maybe_issue { - Some(issue) => issue.to_html(), - None => RawHtml(format!("Issue with id '{prefix}' not found!")), - } -} - -fn issue_to_string(issue: &Issue<'_>, status: Status) -> RawHtml { - if issue.status == status { - issue.to_list_entry() - } else { - RawHtml(String::default()) - } -} diff --git a/pkgs/by-name/ba/back/src/main.rs b/pkgs/by-name/ba/back/src/main.rs index 86fe196..e8f36d2 100644 --- a/pkgs/by-name/ba/back/src/main.rs +++ b/pkgs/by-name/ba/back/src/main.rs @@ -9,62 +9,31 @@ // You should have received a copy of the License along with this program. // If not, see . -use std::{env::args, path::PathBuf, process, sync::OnceLock}; +use clap::Parser; +use config::BackConfig; +use rocket::routes; -use gix::ThreadSafeRepository; -use rocket::{launch, routes}; -use url::Url; +use crate::web::{closed, open, show_issue, styles}; -use crate::issues::{closed, open, show_issue, styles}; +mod cli; +pub mod config; +mod error; +mod web; -mod issues; +#[rocket::main] +async fn main() -> Result<(), error::Error> { + let args = cli::Cli::parse(); -static REPOSITORY: OnceLock = OnceLock::new(); -static SOURCE_CODE_REPOSITORY: OnceLock = OnceLock::new(); + let config = BackConfig::from_config_file(&args.config_file)?; -#[launch] -fn rocket() -> _ { - let repository_path = { - let maybe_path = args().skip(1).rev().last(); - if let Some(path) = maybe_path { - PathBuf::from(path) - } else { - eprintln!("Usage: back "); - process::exit(1); - } - }; - let source_code_url = { - match std::env::var("BACK_SOURCE_CODE_REPOSITORY_URL") { - Ok(value) => match Url::parse(&value) { - Ok(url) => url, - Err(err) => { - eprintln!("Can't parse `BACK_SOURCE_CODE_REPOSITORY_URL` value as url: {err}"); - process::exit(1); - } - }, - Err(err) => { - eprintln!("back needs you to specify a source code repositiory as `BACK_SOURCE_CODE_REPOSITORY_URL`: {err}"); - process::exit(1); - } - } - }; + rocket::build() + .mount("/", routes![open, closed, show_issue, styles]) + .manage(config) + .ignite() + .await + .expect("This error should only happen on a miss-configuration.") + .launch() + .await?; - SOURCE_CODE_REPOSITORY - .set(source_code_url) - .expect("This should be unset by this stage"); - - REPOSITORY - .set( - ThreadSafeRepository::open(&repository_path).unwrap_or_else(|err| { - eprintln!( - "Error while opening repository ('{}'): {} ", - repository_path.display(), - err - ); - process::exit(1); - }), - ) - .expect("There should be only one thread accessing this right now"); - - rocket::build().mount("/", routes![open, closed, show_issue, styles]) + Ok(()) } diff --git a/pkgs/by-name/ba/back/src/web/format/mod.rs b/pkgs/by-name/ba/back/src/web/format/mod.rs new file mode 100644 index 0000000..f78d3b3 --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/format/mod.rs @@ -0,0 +1,88 @@ +// Back - An extremely simple git issue tracking system. Inspired by tvix's +// panettone +// +// Copyright (C) 2024 Benedikt Peetz +// 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 . + +use std::fmt::Display; + +use markdown::to_html; + +#[derive(Debug, Default, Clone)] +pub struct Markdown { + value: String, +} + +impl From for Markdown { + fn from(value: String) -> Self { + Self { value } + } +} +impl Display for Markdown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(to_html(&self.value).as_str()) + } +} + +#[derive(Debug, Default)] +pub struct BackString { + value: String, +} + +impl From for BackString { + fn from(value: Markdown) -> Self { + Self { value: value.value } + } +} + +impl From for BackString { + fn from(value: String) -> Self { + Self { value } + } +} +impl Display for BackString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(escape_html(&self.value).as_str()) + } +} + +// From `tera::escape_html` +/// Escape HTML following [OWASP](https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet) +/// +/// Escape the following characters with HTML entity encoding to prevent switching +/// into any execution context, such as script, style, or event handlers. Using +/// hex entities is recommended in the spec. In addition to the 5 characters +/// significant in XML (&, <, >, ", '), the forward slash is included as it helps +/// to end an HTML entity. +/// +/// ```text +/// & --> & +/// < --> < +/// > --> > +/// " --> " +/// ' --> ' ' is not recommended +/// / --> / forward slash is included as it helps end an HTML entity +/// ``` +#[inline] +pub fn escape_html(input: &str) -> String { + let mut output = String::with_capacity(input.len() * 2); + for c in input.chars() { + match c { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + '/' => output.push_str("/"), + _ => output.push(c), + } + } + + // Not using shrink_to_fit() on purpose + output +} diff --git a/pkgs/by-name/ba/back/src/web/issue/mod.rs b/pkgs/by-name/ba/back/src/web/issue/mod.rs new file mode 100644 index 0000000..79ef70f --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/issue/mod.rs @@ -0,0 +1,337 @@ +// Back - An extremely simple git issue tracking system. Inspired by tvix's +// panettone +// +// Copyright (C) 2024 Benedikt Peetz +// 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 . + +use std::fmt::Display; + +use chrono::DateTime; +use gix::{bstr::ByteSlice, Commit, Id, ObjectId, Repository}; +use raw::{Operation, RawIssue}; +use rocket::response::content::RawHtml; + +use crate::config::BackConfig; + +use super::format::{BackString, Markdown}; + +mod raw; + +#[derive(Debug, Default)] +pub struct TimeStamp { + value: u64, +} +impl TimeStamp { + pub fn new(val: u64) -> Self { + Self { value: val } + } +} +impl Display for TimeStamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let date = + DateTime::from_timestamp(self.value as i64, 0).expect("This timestamp should be vaild"); + + let newdate = date.format("%Y-%m-%d %H:%M:%S"); + f.write_str(newdate.to_string().as_str()) + } +} + +#[derive(Debug)] +pub struct Comment<'a> { + pub id: Id<'a>, + pub author: Author, + pub message: Markdown, + pub timestamp: TimeStamp, +} + +#[derive(Debug, Default)] +pub struct Author { + name: BackString, + email: BackString, +} + +#[derive(Debug)] +pub struct IssueId<'a> { + value: Id<'a>, +} +impl<'a> IssueId<'a> { + pub fn new(id: Id<'a>) -> Self { + Self { value: id } + } +} +impl Display for IssueId<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let shortend = self.value.shorten().expect("This should work."); + f.write_str(shortend.to_string().as_str()) + } +} + +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] +pub enum Status { + #[default] + Open, + Closed, +} +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Open => f.write_str("Open"), + Status::Closed => f.write_str("Closed"), + } + } +} + +#[derive(Debug)] +pub struct Issue<'a> { + pub id: IssueId<'a>, + pub author: Author, + pub timestamp: TimeStamp, + pub title: Markdown, + pub message: Markdown, + pub comments: Vec>, + pub status: Status, + pub last_status_change: Option, +} +impl<'a> Issue<'a> { + pub fn default_with_id(id: Id<'a>) -> Self { + Self { + id: IssueId::new(id), + author: Author::default(), + timestamp: TimeStamp::default(), + title: Markdown::default(), + message: Markdown::default(), + comments: >::default(), + status: Status::default(), + last_status_change: >::default(), + } + } + + pub fn from_commit_id(repo: &'a Repository, commit_id: ObjectId) -> Self { + fn unwrap_id<'b>(repo: &Repository, id: &Commit<'b>) -> (RawIssue, Id<'b>) { + let tree_obj = repo + .find_object( + id.tree_id() + .expect("All of git-bug's commits should have trees attached to them'"), + ) + .expect("The object with this id should exist.") + .try_into_tree() + .expect("The git-bug's data model enforces this."); + + let ops_ref = tree_obj + .find_entry("ops") + .expect("All of git-bug's trees should contain a 'ops' json file"); + + let issue_data = repo + .find_object(ops_ref.object_id()) + .expect("The object with this id should exist.") + .try_into_blob() + .expect("The git-bug's data model enforces this.") + .data + .clone(); + + let raw_issue = serde_json::from_str( + issue_data + .to_str() + .expect("git-bug's ensures, that this is valid json."), + ) + .expect("The returned json should be valid"); + + (raw_issue, id.id()) + } + + let commit_obj = repo + .find_object(commit_id) + .expect("The object with this id should exist.") + .try_into_commit() + .expect("The git-bug's data model enforces this."); + + let mut issues = vec![unwrap_id(repo, &commit_obj)]; + + let mut current_commit_obj = commit_obj; + while current_commit_obj.parent_ids().count() != 0 { + assert_eq!( + current_commit_obj.parent_ids().count(), + 1, + "There should be only one parent" + ); + let parent = current_commit_obj + .parent_ids() + .last() + .expect("One does exist"); + + let parent_id = parent.object().expect("The object exists").id; + let parent_commit = repo + .find_object(parent_id) + .expect("This is a valid id") + .try_into_commit() + .expect("This should be a commit"); + + issues.push(unwrap_id(repo, &parent_commit)); + current_commit_obj = parent_commit; + } + + let mut final_issue = Self::default_with_id(current_commit_obj.id()); + for (issue, id) in issues { + for op in issue.operations { + match op { + Operation::AddComment { timestamp, message } => { + final_issue.comments.push(Comment { + id, + author: issue.author.load_identity(repo), + message: Markdown::from(message), + timestamp: TimeStamp::new(timestamp), + }) + } + Operation::Create { + timestamp, + title, + message, + } => { + final_issue.author = issue.author.load_identity(repo); + final_issue.title = Markdown::from(title); + final_issue.message = Markdown::from(message); + final_issue.timestamp = TimeStamp::new(timestamp); + } + Operation::SetStatus { timestamp, status } => { + final_issue.status = status; + final_issue.last_status_change = Some(TimeStamp::new(timestamp)); + } + } + } + } + final_issue + } + + pub fn to_list_entry(&self) -> RawHtml { + let comment_list = if self.comments.is_empty() { + String::new() + } else { + format!( + r#" + - {} comments + "#, + self.comments.len() + ) + }; + let Issue { + id, + title, + message: _, + author, + timestamp, + comments: _, + status: _, + last_status_change: _, + } = self; + let Author { name, email } = author; + RawHtml(format!( + r#" +
  • + +

    + {title} +

    + {id} - Opened by {name} <{email}> at {timestamp}{comment_list}
    +
  • +"#, + )) + } + + pub fn to_html(&self, config: &BackConfig) -> RawHtml { + let fmt_comments: String = self + .comments + .iter() + .map(|val| { + let Comment { + id, + author, + message, + timestamp, + } = val; + let Author { name, email: _ } = author; + + format!( + r#" +
  • + {message} +

    {name} at {timestamp}

    +
  • + "#, + ) + }) + .collect::>() + .join("\n"); + + let maybe_comments = if fmt_comments.is_empty() { + String::new() + } else { + format!( + r#" +
      + {fmt_comments} +
    + "# + ) + }; + + { + let Issue { + id, + title, + message, + author, + timestamp, + comments: _, + status: _, + last_status_change: _, + } = self; + let Author { name, email } = author; + let html_title = BackString::from(title.clone()); + + RawHtml(format!( + r#" + + + + {html_title} | Back + + + + +
    + +
    +

    {title}

    +
    {id}
    +
    +
    +
    + Opened by {name} <{email}> at {timestamp} +
    + {message} + {maybe_comments} +
    + +
    + + +"#, + config.source_code_repository_url + )) + } + } +} diff --git a/pkgs/by-name/ba/back/src/web/issue/raw.rs b/pkgs/by-name/ba/back/src/web/issue/raw.rs new file mode 100644 index 0000000..bb447ec --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/issue/raw.rs @@ -0,0 +1,145 @@ +// Back - An extremely simple git issue tracking system. Inspired by tvix's +// panettone +// +// Copyright (C) 2024 Benedikt Peetz +// 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 . + +use gix::{bstr::ByteSlice, Repository}; +use serde::Deserialize; +use serde_json::Value; + +use crate::web::format::BackString; + +use super::{Author, Status}; + +macro_rules! get { + ($value:expr, $name:expr, $type_fun:ident) => { + $value + .get($name) + .expect(concat!( + "Expected field ", + stringify!($name), + "to exists, but was missing." + )) + .$type_fun() + .expect(concat!( + "Failed to interpret field ", + stringify!($name), + " as ", + stringify!($type), + "!" + )) + }; +} + +#[derive(Deserialize)] +pub(super) struct RawIssue { + pub(super) author: RawAuthor, + + #[serde(alias = "ops")] + pub(super) operations: Vec, +} + +#[derive(Deserialize, Clone, Default, Debug)] +pub(super) struct RawAuthor { + id: String, +} + +impl RawAuthor { + pub fn load_identity(&self, repo: &Repository) -> Author { + let commit_obj = repo + .find_reference(&format!("refs/identities/{}", self.id)) + .expect("All authors should also have identities") + .peel_to_commit() + .expect("All identities should be commits"); + let tree_obj = repo + .find_tree( + commit_obj + .tree() + .expect("The commit should have an tree associated with it") + .id, + ) + .expect("This should be a tree"); + let data = repo + .find_blob( + tree_obj + .find_entry("version") + .expect("This entry should exist") + .object() + .expect("This should point to a blob entry") + .id, + ) + .expect("This blob should exist") + .data + .clone(); + + let json: Value = serde_json::from_str(data.to_str().expect("This is encoded json")) + .expect("This is valid json"); + + Author { + name: BackString::from(get! {json, "name", as_str}.to_owned()), + email: BackString::from(get! {json, "email", as_str}.to_owned()), + } + } +} + +#[derive(Deserialize)] +#[serde(from = "Value")] +pub(super) enum Operation { + AddComment { + timestamp: u64, + message: String, + // files: Option, TODO + }, + SetStatus { + timestamp: u64, + status: Status, + }, + Create { + timestamp: u64, + title: String, + message: String, + // files: Option, TODO + }, +} + +impl From for Status { + fn from(value: u64) -> Self { + match value { + 1 => Status::Open, + 2 => Status::Closed, + other => todo!("The status ({other}) is not yet implemented."), + } + } +} + +impl From for Operation { + fn from(value: Value) -> Self { + match value + .get("type") + .expect("Should exist") + .as_u64() + .expect("This should work") + { + 1 => Self::Create { + title: get! {value, "title", as_str}.to_owned(), + message: get! {value, "message", as_str}.to_owned(), + timestamp: get! {value, "timestamp", as_u64}, + }, + 3 => Self::AddComment { + message: get! {value, "message", as_str}.to_owned(), + timestamp: get! {value, "timestamp", as_u64}, + }, + 4 => Self::SetStatus { + status: Status::from(get! {value, "status", as_u64}), + timestamp: get! {value, "timestamp", as_u64}, + }, + other => todo!("The type ({other}) is not yet added as a a valid operation. It's value is: '{value}''"), + } + } +} diff --git a/pkgs/by-name/ba/back/src/web/issue_show.rs b/pkgs/by-name/ba/back/src/web/issue_show.rs new file mode 100644 index 0000000..638840e --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/issue_show.rs @@ -0,0 +1,34 @@ +// Back - An extremely simple git issue tracking system. Inspired by tvix's +// panettone +// +// Copyright (C) 2024 Benedikt Peetz +// 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 . + +use std::fmt::Display; + +use gix::hash::Prefix; +use rocket::request::FromParam; + +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 { + let prefix = Prefix::from_hex(param)?; + + Ok(Self { prefix }) + } +} diff --git a/pkgs/by-name/ba/back/src/web/mod.rs b/pkgs/by-name/ba/back/src/web/mod.rs new file mode 100644 index 0000000..ed91e7e --- /dev/null +++ b/pkgs/by-name/ba/back/src/web/mod.rs @@ -0,0 +1,133 @@ +// Back - An extremely simple git issue tracking system. Inspired by tvix's +// panettone +// +// Copyright (C) 2024 Benedikt Peetz +// 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 . + +use std::path::Path; + +use crate::{ + config::BackConfig, + web::issue::{Issue, Status}, +}; +use gix::{refs::Target, Repository}; +use issue_show::BackPrefix; +use rocket::{ + get, + response::content::{RawCss, RawHtml}, + State, +}; + +mod format; +mod issue; +mod issue_show; + +#[get("/style.css")] +pub fn styles() -> RawCss { + RawCss(include_str!("../../assets/style.css").to_owned()) +} + +fn list_all_issues(repo: &'_ Repository) -> Vec> { + repo.refs + .iter() + .expect("We should be able to iterate over references") + .prefixed(Path::new("refs/bugs/")) + .expect("The 'refs/bugs/' namespace should exist") + .map(|val| { + let reference = val.expect("'val' should be an object?"); + if let Target::Object(commit_id) = reference.target { + Issue::from_commit_id(repo, commit_id) + } else { + unreachable!("All 'refs/bugs/' should contain a clear target."); + } + }) + .collect() +} + +pub fn issue_list_boilerplate( + config: &State, + wanted_status: Status, + counter_status: Status, +) -> RawHtml { + let repository = &config.repository; + + let issue_list = list_all_issues(&repository.to_thread_local()) + .iter() + .fold(String::new(), |acc, val| { + format!("{}{}", acc, &issue_to_string(val, wanted_status).0) + }); + + let counter_status_lower = counter_status.to_string().to_lowercase(); + RawHtml(format!( + r#" + + + + Back + + + + +
    +
    +

    {wanted_status} Issues

    +
    +
    + +
      + {issue_list} +
    +
    +
    + + +"#, + config.source_code_repository_url + )) +} + +#[get("/issues/open")] +pub fn open(config: &State) -> RawHtml { + issue_list_boilerplate(config, Status::Open, Status::Closed) +} +#[get("/issues/closed")] +pub fn closed(config: &State) -> RawHtml { + issue_list_boilerplate(config, Status::Closed, Status::Open) +} + +#[get("/issue/")] +pub fn show_issue(config: &State, prefix: BackPrefix) -> RawHtml { + let repository = config.repository.to_thread_local(); + + let all_issues = list_all_issues(&repository); + let maybe_issue = all_issues + .iter() + .find(|issue| issue.id.to_string().starts_with(&prefix.to_string())); + + match maybe_issue { + Some(issue) => issue.to_html(config), + None => RawHtml(format!("Issue with id '{prefix}' not found!")), + } +} + +fn issue_to_string(issue: &Issue<'_>, status: Status) -> RawHtml { + if issue.status == status { + issue.to_list_entry() + } else { + RawHtml(String::default()) + } +} -- cgit 1.4.1