about summary refs log tree commit diff stats
path: root/src/web
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-05-12 12:39:10 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-05-12 12:39:10 +0200
commit1e4dff1995833538f436b381bc0450a7c0080bad (patch)
tree2dc620ac9ea683cbee412b8d5818b3992462677c /src/web
downloadback-1e4dff1995833538f436b381bc0450a7c0080bad.zip
chore: Initial commit
Based on the version that was previously in `vhack.eu/nixos-server/pkgs/by-name/ba/back`.
Diffstat (limited to 'src/web')
-rw-r--r--src/web/generate/mod.rs236
-rw-r--r--src/web/mod.rs138
-rw-r--r--src/web/responses.rs61
3 files changed, 435 insertions, 0 deletions
diff --git a/src/web/generate/mod.rs b/src/web/generate/mod.rs
new file mode 100644
index 0000000..06bab17
--- /dev/null
+++ b/src/web/generate/mod.rs
@@ -0,0 +1,236 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2025 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::{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.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.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.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.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_url,
+                    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_url,
+                                issue.id
+                            ))
+                            .build()
+                    })
+                    .collect::<Vec<Item>>()
+            })
+            .collect::<Vec<Item>>(),
+    );
+
+    let channel = ChannelBuilder::default()
+        .title("Issues")
+        .link(config.root_url.to_string())
+        .description(format!("The rss feed for issues on {}.", &config.root_url))
+        .items(items)
+        .build();
+    Ok(channel.to_string())
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
new file mode 100644
index 0000000..8e2e9b0
--- /dev/null
+++ b/src/web/mod.rs
@@ -0,0 +1,138 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2025 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::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
+
+use bytes::Bytes;
+use http_body_util::combinators::BoxBody;
+use hyper::{Method, Request, Response, StatusCode, server::conn::http1, service::service_fn};
+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, git_bug::issue::Status};
+
+mod generate;
+mod responses;
+
+async fn match_uri(
+    config: Arc<BackConfig>,
+    req: Request<hyper::body::Incoming>,
+) -> Result<Response<BoxBody<Bytes, Infallible>>, hyper::Error> {
+    if req.method() != Method::GET {
+        return Ok(html_response_status(
+            "Only get requests are supported",
+            StatusCode::NOT_ACCEPTABLE,
+        ));
+    }
+
+    let output = || -> Result<Response<BoxBody<Bytes, Infallible>>, error::Error> {
+        match req.uri().path().trim_end_matches("/") {
+            "" => Ok(html_response(generate::repos(&config)?)),
+
+            "/style.css" => Ok(responses::html_response_status_content_type(
+                include_str!("../../assets/style.css"),
+                StatusCode::OK,
+                "text/css",
+            )),
+
+            path if path.ends_with("/issues/open") => {
+                let repo_path = PathBuf::from(
+                    path.strip_suffix("/issues/open")
+                        .expect("This suffix exists")
+                        .strip_prefix("/")
+                        .expect("This also exists"),
+                );
+
+                let issues = generate::issues(&config, Status::Open, Status::Closed, &repo_path)?;
+                Ok(html_response(issues))
+            }
+            path if path.ends_with("/issues/closed") => {
+                let repo_path = PathBuf::from(
+                    path.strip_suffix("/issues/closed")
+                        .expect("This suffix exists")
+                        .strip_prefix("/")
+                        .expect("This also exists"),
+                );
+
+                let issues = generate::issues(&config, Status::Closed, Status::Open, &repo_path)?;
+                Ok(html_response(issues))
+            }
+            path if path.ends_with("/issues/feed") => {
+                let repo_path = PathBuf::from(
+                    path.strip_suffix("/issues/feed")
+                        .expect("This suffix exists")
+                        .strip_prefix("/")
+                        .expect("This also exists"),
+                );
+
+                let feed = generate::feed(&config, &repo_path)?;
+                Ok(html_response_status_content_type(
+                    feed,
+                    StatusCode::OK,
+                    "text/xml",
+                ))
+            }
+
+            path if path.contains("/issue/") => {
+                let (repo_path, prefix) = {
+                    let split: Vec<&str> = path.split("/issue/").collect();
+
+                    let prefix =
+                        gix::hash::Prefix::from_hex(split[1]).map_err(error::Error::from)?;
+
+                    let repo_path =
+                        PathBuf::from(split[0].strip_prefix("/").expect("This prefix exists"));
+
+                    (repo_path, prefix)
+                };
+                Ok(html_response(generate::issue(&config, &repo_path, prefix)?))
+            }
+
+            other => Ok(responses::html_response_status_content_type(
+                format!("'{}' not found", other),
+                StatusCode::NOT_FOUND,
+                "text/plain",
+            )),
+        }
+    };
+    match output() {
+        Ok(response) => Ok(response),
+        Err(err) => Ok(err.into_response()),
+    }
+}
+
+pub async fn main(config: Arc<BackConfig>) -> Result<(), error::Error> {
+    let addr: SocketAddr = ([127, 0, 0, 1], 8000).into();
+
+    let listener = TcpListener::bind(addr)
+        .await
+        .map_err(|err| error::Error::TcpBind { addr, err })?;
+    info!("Listening on http://{}", addr);
+    loop {
+        let (stream, _) = listener
+            .accept()
+            .await
+            .map_err(|err| error::Error::TcpAccept { err })?;
+        let io = TokioIo::new(stream);
+
+        let local_config = Arc::clone(&config);
+
+        let service = service_fn(move |req| match_uri(Arc::clone(&local_config), req));
+
+        tokio::task::spawn(async move {
+            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
+                error!("Error serving connection: {:?}", err);
+            }
+        });
+    }
+}
diff --git a/src/web/responses.rs b/src/web/responses.rs
new file mode 100644
index 0000000..bcdcc0a
--- /dev/null
+++ b/src/web/responses.rs
@@ -0,0 +1,61 @@
+// Back - An extremely simple git bug visualization system. Inspired by TVL's
+// panettone.
+//
+// Copyright (C) 2025 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::convert::Infallible;
+
+use bytes::Bytes;
+use http::{Response, StatusCode, Version};
+use http_body_util::{BodyExt, Full, combinators::BoxBody};
+
+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,
+        )
+    }
+}