aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/ba/back/src/web
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-12-26 10:00:45 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2024-12-26 10:01:56 +0100
commita3111a5d214db66b7d676940b8f8bfda5074bd45 (patch)
treea68cc1458386052a7c97a07b69bd161866ad7046 /pkgs/by-name/ba/back/src/web
parentfix(hosts/server2): Use correct path to `vhack.eu/nixos-server` repo (diff)
downloadnixos-server-a3111a5d214db66b7d676940b8f8bfda5074bd45.zip
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.
Diffstat (limited to 'pkgs/by-name/ba/back/src/web')
-rw-r--r--pkgs/by-name/ba/back/src/web/format/mod.rs88
-rw-r--r--pkgs/by-name/ba/back/src/web/issue/mod.rs337
-rw-r--r--pkgs/by-name/ba/back/src/web/issue/raw.rs145
-rw-r--r--pkgs/by-name/ba/back/src/web/issue_show.rs34
-rw-r--r--pkgs/by-name/ba/back/src/web/mod.rs133
5 files changed, 737 insertions, 0 deletions
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 <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
+/// & --> &amp;
+/// < --> &lt;
+/// > --> &gt;
+/// " --> &quot;
+/// ' --> &#x27; &apos; is not recommended
+/// / --> &#x2F; 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("&amp;"),
+ '<' => output.push_str("&lt;"),
+ '>' => output.push_str("&gt;"),
+ '"' => output.push_str("&quot;"),
+ '\'' => output.push_str("&#x27;"),
+ '/' => output.push_str("&#x2F;"),
+ _ => 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 <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 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<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()
+ .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<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">&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 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">&lt;{email}&gt;</span> at <span class="timestamp">{timestamp}</span></span>
+ </div>
+ {message}
+ {maybe_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/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 <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::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<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/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 <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/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 <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::{
+ 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<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(
+ config: &State<BackConfig>,
+ wanted_status: Status,
+ counter_status: Status,
+) -> RawHtml<String> {
+ 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#"
+<!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}
+ </ol>
+ </main>
+ </div>
+ </body>
+</html>
+"#,
+ config.source_code_repository_url
+ ))
+}
+
+#[get("/issues/open")]
+pub fn open(config: &State<BackConfig>) -> RawHtml<String> {
+ issue_list_boilerplate(config, Status::Open, Status::Closed)
+}
+#[get("/issues/closed")]
+pub fn closed(config: &State<BackConfig>) -> RawHtml<String> {
+ issue_list_boilerplate(config, Status::Closed, Status::Open)
+}
+
+#[get("/issue/<prefix>")]
+pub fn show_issue(config: &State<BackConfig>, prefix: BackPrefix) -> RawHtml<String> {
+ 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<String> {
+ if issue.status == status {
+ issue.to_list_entry()
+ } else {
+ RawHtml(String::default())
+ }
+}