about summary refs log tree commit diff stats
path: root/pkgs/by-name/ba/back/src/web/mod.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-03-08 21:50:22 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-03-09 13:44:42 +0100
commita9ff6e1c86ad51b3fa568ea7caa992df5db8c316 (patch)
tree65cb6404fce4f84be9ed6561ed152d0d21e3b875 /pkgs/by-name/ba/back/src/web/mod.rs
parentscripts/get_dns.sh: Init (diff)
downloadnixos-server-a9ff6e1c86ad51b3fa568ea7caa992df5db8c316.zip
pkgs/back: Support listing all repos via the `/` path
This change required porting all webhandling from rocket to hyper,
because we needed fine grained control over the path the user
requested. This should also improve the memory and resources footprint
because hyper is more lower level.

I also changed all of the templates from `format!()` calls to a real
templating language because I needed to touch most code paths anyway.
Diffstat (limited to 'pkgs/by-name/ba/back/src/web/mod.rs')
-rw-r--r--pkgs/by-name/ba/back/src/web/mod.rs297
1 files changed, 119 insertions, 178 deletions
diff --git a/pkgs/by-name/ba/back/src/web/mod.rs b/pkgs/by-name/ba/back/src/web/mod.rs
index f7a4077..cc087ab 100644
--- a/pkgs/by-name/ba/back/src/web/mod.rs
+++ b/pkgs/by-name/ba/back/src/web/mod.rs
@@ -1,186 +1,127 @@
-// Back - An extremely simple git issue tracking system. Inspired by tvix's
-// panettone
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: AGPL-3.0-or-later
-//
-// This file is part of Back.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/agpl.txt>.
-
-use crate::{
-    config::BackConfig,
-    error::{self, Error},
-    git_bug::{
-        dag::issues_from_repository,
-        issue::{CollapsedIssue, Status},
-    },
-};
-use prefix::BackPrefix;
-use rocket::{
-    get,
-    response::content::{RawCss, RawHtml},
-    State,
-};
-
-mod issue_html;
-pub mod prefix;
-
-#[get("/style.css")]
-pub fn styles() -> RawCss<String> {
-    RawCss(include_str!("../../assets/style.css").to_owned())
-}
+use bytes::Bytes;
+use http_body_util::combinators::BoxBody;
+use hyper::{server::conn::http1, service::service_fn, Method, Request, Response, StatusCode};
+use hyper_util::rt::TokioIo;
+use log::{error, info};
+use responses::{html_response, html_response_status, html_response_status_content_type};
+use tokio::net::TcpListener;
+
+use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
+
+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,
+        ));
+    }
 
-pub fn issue_list_boilerplate(
-    config: &State<BackConfig>,
-    wanted_status: Status,
-    counter_status: Status,
-) -> error::Result<RawHtml<String>> {
-    let repository = &config.repository;
-
-    let mut issue_list = issues_from_repository(&repository.to_thread_local())?
-        .into_iter()
-        .map(|issue| issue.collapse())
-        .collect::<Vec<CollapsedIssue>>();
-
-    // Sort by date descending.
-    issue_list.sort_by_key(|issue| unsafe { issue.timestamp.to_unsafe() });
-    issue_list.reverse();
-
-    let issue_list_str = issue_list.into_iter().fold(String::new(), |acc, issue| {
-        format!("{}{}", acc, {
-            if issue.status == wanted_status {
-                let issue_entry = issue.to_list_entry();
-                issue_entry.0
-            } else {
-                String::new()
+    let 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",
+                ))
             }
-        })
-    });
-
-    let counter_status_lower = counter_status.to_string().to_lowercase();
-    Ok(RawHtml(format!(
-        r#"
-    <!DOCTYPE html>
-    <html lang="en">
-       <head>
-          <title>Back</title>
-          <link href="/style.css" rel="stylesheet" type="text/css">
-          <meta content="width=device-width,initial-scale=1" name="viewport">
-       </head>
-       <body>
-          <div class="content">
-             <header>
-                <h1>{wanted_status} Issues</h1>
-             </header>
-             <main>
-                <div class="issue-links">
-                   <a href="/issues/{counter_status_lower}/">View {counter_status} issues</a>
-                   <a href="{}">Source code</a>
-                   <!--
-                   <form class="issue-search" method="get">
-                       <input name="search" title="Issue search query" type="search">
-                       <input class="sr-only" type="submit" value="Search Issues">
-                   </form>
-                   -->
-                </div>
-                <ol class="issue-list">
-                {issue_list_str}
-                </ol>
-             </main>
-          </div>
-       </body>
-    </html>
-    "#,
-        config.source_code_repository_url
-    )))
-}
 
-#[get("/issues/open")]
-pub fn open(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
-    issue_list_boilerplate(config, Status::Open, Status::Closed)
-}
-#[get("/issues/closed")]
-pub fn closed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
-    issue_list_boilerplate(config, Status::Closed, Status::Open)
-}
+            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"));
 
-#[get("/issues/feed")]
-pub fn feed(config: &State<BackConfig>) -> error::Result<RawHtml<String>> {
-    use rss::{ChannelBuilder, Item, ItemBuilder};
-
-    //Collect all Items as rss items
-    let mut items: Vec<Item> = issues_from_repository(&config.repository.to_thread_local())?
-        .into_iter()
-        .map(|issue| issue.collapse())
-        .map(|issue| {
-            ItemBuilder::default()
-                .title(issue.title.to_string())
-                .author(issue.author.to_string())
-                .description(issue.message.to_string())
-                .pub_date(issue.timestamp.to_string())
-                .link(format!("{}/issue/{}", &config.root.to_string(), issue.id))
-                .build()
-        })
-        .collect();
-    //Append all comments after converting them to rss items
-    items.extend(
-        issues_from_repository(&config.repository.to_thread_local())?
-            .into_iter()
-            .map(|issue| issue.collapse())
-            .filter(|issue| issue.comments.len() > 0)
-            .map(|issue| {
-                issue
-                    .comments
-                    .into_iter()
-                    .map(|comment| {
-                        ItemBuilder::default()
-                            .title(issue.title.to_string())
-                            .author(comment.author.to_string())
-                            .description(comment.message.to_string())
-                            .pub_date(comment.timestamp.to_string())
-                            .link(format!("{}/issue/{}", &config.root.to_string(), issue.id))
-                            .build()
-                    })
-                    .collect::<Vec<Item>>()
-            })
-            .flatten()
-            .collect::<Vec<Item>>(),
-    );
-
-    let channel = ChannelBuilder::default()
-        .title("Issues")
-        .link(config.root.to_string())
-        .description(format!("The rss feed for issues on {}.", config.root))
-        .items(items)
-        .build();
-    Ok(RawHtml(channel.to_string()))
+                    (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()),
+    }
 }
 
-#[get("/issue/<prefix>")]
-pub fn show_issue(
-    config: &State<BackConfig>,
-    prefix: Result<BackPrefix, gix::hash::prefix::from_hex::Error>,
-) -> error::Result<RawHtml<String>> {
-    // NOTE(@bpeetz): Explicitly unwrap the `prefix` here (instead of taking the unwrapped value as
-    // argument), to avoid triggering rockets "errors forward to the next route" feature.
-    // This ensures, that our error message actually reaches the user. <2024-12-26>
-    let prefix = prefix?;
-
-    let repository = config.repository.to_thread_local();
-
-    let all_issues: Vec<CollapsedIssue> = issues_from_repository(&repository)?
-        .into_iter()
-        .map(|val| val.collapse())
-        .collect();
-
-    let maybe_issue = all_issues
-        .iter()
-        .find(|issue| issue.id.to_string().starts_with(&prefix.to_string()));
-
-    match maybe_issue {
-        Some(issue) => Ok(issue.to_html(config)),
-        None => Err(Error::IssuesPrefixMissing { prefix }),
+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);
+            }
+        });
     }
 }