diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-12-23 18:29:35 +0100 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-12-23 18:29:35 +0100 |
commit | 4bcadcd2244989a8bb7d8dffdaf11f131b96c8ff (patch) | |
tree | a081c1c4216f4d1517940c2bc92dc7d8b3e15bb9 /pkgs/by-name/ba/back/src | |
parent | fix(modules/disko): Remove deprecated legacy type and migrate to `by-name` (diff) | |
download | nixos-server-4bcadcd2244989a8bb7d8dffdaf11f131b96c8ff.tar.gz nixos-server-4bcadcd2244989a8bb7d8dffdaf11f131b96c8ff.zip |
feat(pkgs/back): Init
Other options, for example `git-bug webui --read-only` is just to bugged to be useful.
Diffstat (limited to 'pkgs/by-name/ba/back/src')
-rw-r--r-- | pkgs/by-name/ba/back/src/issues/format/mod.rs | 88 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/issues/issue/mod.rs | 328 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/issues/issue/raw.rs | 145 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/issues/issue_show.rs | 34 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/issues/mod.rs | 129 | ||||
-rw-r--r-- | pkgs/by-name/ba/back/src/main.rs | 49 |
6 files changed, 773 insertions, 0 deletions
diff --git a/pkgs/by-name/ba/back/src/issues/format/mod.rs b/pkgs/by-name/ba/back/src/issues/format/mod.rs new file mode 100644 index 0000000..f78d3b3 --- /dev/null +++ b/pkgs/by-name/ba/back/src/issues/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 <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 markdown::to_html; + +#[derive(Debug, Default, Clone)] +pub struct Markdown { + value: String, +} + +impl From<String> 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<Markdown> for BackString { + fn from(value: Markdown) -> Self { + Self { value: value.value } + } +} + +impl From<String> 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 new file mode 100644 index 0000000..ada7593 --- /dev/null +++ b/pkgs/by-name/ba/back/src/issues/issue/mod.rs @@ -0,0 +1,328 @@ +// 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 chrono::DateTime; +use gix::{bstr::ByteSlice, Commit, Id, ObjectId, Repository}; +use raw::{Operation, RawIssue}; +use rocket::response::content::RawHtml; + +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<Comment<'a>>, + pub status: Status, + pub last_status_change: Option<TimeStamp>, +} +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: <Vec<Comment>>::default(), + status: Status::default(), + last_status_change: <Option<TimeStamp>>::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<String> { + let comment_list = if self.comments.is_empty() { + String::new() + } else { + format!( + r#" + <span class="comment-count"> - {} comments</span> + "#, + self.comments.len() + ) + }; + let Issue { + id, + title, + message: _, + author, + timestamp, + comments: _, + status: _, + last_status_change: _, + } = self; + let Author { name, email } = 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) -> RawHtml<String> { + let fmt_comments: String = self + .comments + .iter() + .map(|val| { + let Comment { + id, + author, + message, + timestamp, + } = val; + let Author { name, email: _ } = 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"); + + let maybe_comments = if fmt_comments.is_empty() { + String::new() + } else { + format!( + r#" + <ol class="issue-history"> + {fmt_comments} + </ol> + "# + ) + }; + + { + 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#" +<!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} + {maybe_comments} + </main> + <footer> + <nav> + <a href="/issues/open">Open Issues</a> + <a href="/issues/closed">Closed Issues</a> + </nav> + </footer> + </div> + </body> +</html> +"#, + )) + } + } +} diff --git a/pkgs/by-name/ba/back/src/issues/issue/raw.rs b/pkgs/by-name/ba/back/src/issues/issue/raw.rs new file mode 100644 index 0000000..48d2a9f --- /dev/null +++ b/pkgs/by-name/ba/back/src/issues/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 <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 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<Operation>, +} + +#[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<String>, TODO + }, + SetStatus { + timestamp: u64, + status: Status, + }, + Create { + timestamp: u64, + title: String, + message: String, + // files: Option<String>, TODO + }, +} + +impl From<u64> 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<Value> 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 new file mode 100644 index 0000000..638840e --- /dev/null +++ b/pkgs/by-name/ba/back/src/issues/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 <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; + +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/issues/mod.rs b/pkgs/by-name/ba/back/src/issues/mod.rs new file mode 100644 index 0000000..e2af5de --- /dev/null +++ b/pkgs/by-name/ba/back/src/issues/mod.rs @@ -0,0 +1,129 @@ +// 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::path::Path; + +use crate::issues::issue::{Issue, Status}; +use issue_show::BackPrefix; +use gix::{refs::Target, Repository}; +use rocket::{ + get, + response::content::{RawCss, RawHtml}, +}; + +use crate::REPOSITORY; + +mod issue; +mod issue_show; +mod format; + +#[get("/style.css")] +pub fn styles() -> RawCss<String> { + RawCss(include_str!("../../assets/style.css").to_owned()) +} + +fn list_all_issues(repo: &'_ Repository) -> Vec<Issue<'_>> { + 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<String> { + 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#" +<!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> + <!-- + <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"> + {} + </ol> + </main> + </div> + </body> +</html> +"#, + issue_list + )) +} + +#[get("/issues/open")] +pub fn open() -> RawHtml<String> { + issue_list_boilerplate(Status::Open, Status::Closed) +} +#[get("/issues/closed")] +pub fn closed() -> RawHtml<String> { + issue_list_boilerplate(Status::Closed, Status::Open) +} + +#[get("/issue/<prefix>")] +pub fn show_issue(prefix: BackPrefix) -> RawHtml<String> { + 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<String> { + 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 new file mode 100644 index 0000000..d0b0f81 --- /dev/null +++ b/pkgs/by-name/ba/back/src/main.rs @@ -0,0 +1,49 @@ +// 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::{env::args, path::PathBuf, process, sync::OnceLock}; + +use gix::ThreadSafeRepository; +use rocket::{launch, routes}; + +use crate::issues::{closed, open, show_issue, styles}; + +mod issues; + +static REPOSITORY: OnceLock<ThreadSafeRepository> = OnceLock::new(); + +#[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 <issue repoitory>"); + process::exit(1); + } + }; + + 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]) +} |