Compare commits

...

9 Commits

Author SHA1 Message Date
e42baf3ca9 updates templates with new capabilities 2026-03-06 19:04:02 -07:00
cf3f8b0aea reorganize code & switch to gix 2026-03-06 18:05:57 -07:00
331387a56a removes original template example pages 2026-03-06 18:04:04 -07:00
d17d64876a partial code reorganization ; http_fe progress 2026-03-05 22:46:15 +00:00
ac7d7a44c7 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-03-05 00:24:37 +00:00
e59322c3cf temp commit for merge 2026-03-05 00:24:31 +00:00
6e1859943a templates: split the code path into multiple separate links 2025-08-25 18:42:03 -04:00
0daf300060 templates: added a tickets template
still need to replace the file list with a list of issues in the
repository, add discussions, etc.
2025-08-24 23:37:56 -04:00
27d82a2df5 templates: rename nav block to breadcrumbs 2025-08-24 23:31:45 -04:00
27 changed files with 2583 additions and 1358 deletions

1847
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,16 +5,17 @@ edition = "2024"
[[bin]]
name = "frontend"
path = "src/frontend.rs"
[[bin]]
name = "test_render"
path = "src/test-render.rs"
[dependencies]
git2 = { version = "0.20.2", default-features = false }
gix = "0.78.0"
serde = { version = "1.0.219", default-features = false, features = ["derive"] }
#inkjet = "0.11.1"
#markdown = "1.0.0"
tera = { version = "1.20.0", default-features = false, features = ["builtins"] }
httparse = "1.10.1"
futures = { version = "0.3.32", default-features = false, features = ["std", "thread-pool", "executor"] }
typed-path = "=0.12.3" # pinned to avoid slop
itertools = "0.14.0"

View File

@@ -1,170 +0,0 @@
<!DOCTYPE html>
<!--
Copyright (c) 2022 Sasha Koshka <sashakoshka@tebibyte.media>
Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
SPDX-License-Identifier: AGPL-3.0-or-later
This file is part of Mintee.
Mintee is free software: you can redistribute it and/or modify it under the
terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
You should have received a copy of the GNU Affero General Public License
along with Mintee. If not, see https://www.gnu.org/licenses/.
-->
<html>
<head>
<title>mintea</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link rel="stylesheet" href="res/style.css">
<link rel="stylesheet" href="res/mint.css">
<script>
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (C) 2022 Sasha Koshka <sashakoshka@tebibyte.media>
The JavaScript code in this page is free software: you can
redistribute it and/or modify it under the terms of the GNU
General Public License (GNU GPL) as published by the Free Software
Foundation, either version 3 of the License, or (at your option)
any later version. The code is distributed WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
As additional permission under GNU GPL version 3 section 7, you
may distribute non-source (e.g., minimized or compacted) forms of
that code without the copy of the GNU GPL normally required by
section 4, provided you include this license notice and a URL
through which recipients can access the Corresponding Source.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
</script>
</head>
<body>
<div id=stickyWrap>
<nav>
<div class=buttonListWrap>
<ul class=buttonList>
<li><a href="/">TiBM</a></li>
<li><a class=dashboardButton href="/"><span class=collapse>dashboard</span></a></li>
<li><a class=profileButton href="/"><span class=collapse>profile</span></a></li>
<li><a class=notificationsButton href="/"><span class=collapse>notifications</span> (17)</a></li>
</ul>
</div>
<div class=buttonListWrap>
<div class="tabList end"><span>code</span>
<a href="/">history</a>
<a href="/">tags</a>
<a href="/">tickets (17)</a>
<a href="/">releases</a>
<a href="/">settings</a>
</div>
</div>
</nav>
</div>
<header>
<span>
viewing
<span class=linkedPath>
<a href="/">owner</a>/<a href="/">repo</a>/<a href="/">branch</a>
</span>
</span>
<ul class=buttonList>
<li><a class=watchButton href="/">watch</a></li>
<li><a class=starButton href="/">star</a></li>
<li><a class=forkButton href="/">fork</a></li>
<li><a class=cloneButton href="/">clone</a></li>
</ul>
</header>
<div id=contentWrap>
<aside>
<p class=sidebarLabel>
<a href="/">/</a><a href="/">name/</a>
</p>
<table class=files border=1>
<tbody>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=directory href="/">directory/</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
</tbody>
</table>
</aside>
<main>
<p class=previewLabel>README.md</p>
<article class=preview>
<h1>Some Repository</h1>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
<h2>Heading</h2>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
<h2>Heading</h2>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
</article>
</main>
</div>
<script>
let stickyWrap = document.querySelector("#stickyWrap")
document.addEventListener ("scroll", () => {
if (document.documentElement.scrollTop > 0) {
stickyWrap.className = "lifted"
} else {
stickyWrap.className = ""
}
})
</script>
<footer>
<a href="https://git.tebibyte.media/meta/mintee">Mintee</a>,
the fresh and tasty git frontend. page: #s. template: #s.
</footer>
</body>
</html>

View File

@@ -66,7 +66,7 @@
<li><a class=dashboardButton href="/">
<span class=collapse>dashboard</span>
</a></li>
<li><a class=profileButton href="/{{ user }}">
<li><a class=profileButton href="/~{{ user }}">
<span class=collapse>profile</span>
</a>
</li>
@@ -93,9 +93,9 @@
<span>
viewing
<span class=linkedPath>
{% block nav %}
<a href="/">{{ owner }}</a>/<a href="/">{{ repo }}</a>
{% endblock nav %}
{% block breadcrumbs %}
<a href="/~{{ owner }}">{{ owner }}</a>/<a href="/~{{ owner }}/{{ repo }}">{{ repo }}</a>
{% endblock breadcrumbs %}
</span>
</span>
<ul class=buttonList>

View File

@@ -2,24 +2,25 @@
{% block page %}code{% endblock page %}
{% block nav %}
<a href="/">{{ owner }}</a>/<a href="/">{{ repo }}</a>/<a href="/">{{ branch }}</a>
{% endblock nav %}
{% block breadcrumbs %}
<a href="/~{{ owner }}">{{ owner }}</a>/<a href="/~{{ owner }}/{{ repo }}">{{ repo }}</a>/<a href="/">{{ branch }}</a>
{% endblock breadcrumbs %}
{% block content %}
<div id=contentWrap>
<aside>
<p class=sidebarLabel>
<a href="/"></a><a href="/">{{ directory }}</a>
{% for part in directory | split(pat="/") -%}
<a href="/">{{ part }}/</a>
{%- endfor %}
</p>
<table class=files border=1>
<tbody>
{% block entries_list %}
{% for entry in entries %}
<tr>
<td><a class={{ entry.class }} href="/">{{ entry.path }}</a></td>
<td><a class=commit href="./commit/{{ entry.last_commit }}">
<td><a class={{ entry.class }} href="./{{ entry.path }}">{{ entry.path }}</a></td>
<td><a class=commit href="./?commit={{ entry.last_commit }}">
{{ entry.last_commit_message }}
</a></td>
<td><time datetime="{{ entry.last_commit_time }}">

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block page %}tickets{% endblock page %}
{% block content %}
<div id=contentWrap>
<aside>
<div class=sidebarLabel>
tickets
<div class=tabList style="display: inline-flex">
<span>open</span>
<a href="/">closed</a>
</div>
</div>
<table class=files border=1>
<tbody>
{% block entries_list %}
{% for entry in entries %}
<tr>
<td><a class={{ entry.class }} href="/">{{ entry.path }}</a></td>
<td><a class=commit href="./commit/{{ entry.last_commit }}">
{{ entry.last_commit_message }}
</a></td>
<td><time datetime="{{ entry.last_commit_time }}">
{{ entry.last_commit_time | date(format="%Y-%m-%d %H:%M")}}
</time></td>
</tr>
{% endfor %}
{% endblock entries %}
</tbody>
</table>
</aside>
{% block readme %}
<main>
<article class=preview>
<h1>Ticket title</h1>
{{ readme_content }}
</article>
</main>
{% endblock readme %}
</div>
{% endblock content %}

View File

@@ -1,173 +0,0 @@
<!DOCTYPE html>
<!--
Copyright (c) 2022 Sasha Koshka <sashakoshka@tebibyte.media>
Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
SPDX-License-Identifier: AGPL-3.0-or-later
This file is part of Mintee.
Mintee is free software: you can redistribute it and/or modify it under the
terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
You should have received a copy of the GNU Affero General Public License
along with Mintee. If not, see https://www.gnu.org/licenses/.
-->
<html>
<head>
<title>mintea</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link rel="stylesheet" href="res/style.css">
<link rel="stylesheet" href="res/mint.css">
<script>
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (C) 2022 Sasha Koshka <sashakoshka@tebibyte.media>
The JavaScript code in this page is free software: you can
redistribute it and/or modify it under the terms of the GNU
General Public License (GNU GPL) as published by the Free Software
Foundation, either version 3 of the License, or (at your option)
any later version. The code is distributed WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
As additional permission under GNU GPL version 3 section 7, you
may distribute non-source (e.g., minimized or compacted) forms of
that code without the copy of the GNU GPL normally required by
section 4, provided you include this license notice and a URL
through which recipients can access the Corresponding Source.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
</script>
</head>
<body>
<div id=stickyWrap>
<nav>
<div class=buttonListWrap>
<ul class=buttonList>
<li><a href="/">TiBM</a></li>
<li><a class=dashboardButton href="/"><span class=collapse>dashboard</span></a></li>
<li><a class=profileButton href="/"><span class=collapse>profile</span></a></li>
<li><a class=notificationsButton href="/"><span class=collapse>notifications</span> (17)</a></li>
</ul>
</div>
<div class=buttonListWrap>
<div class="tabList end"><span>code</span>
<a href="/">history</a>
<a href="/">tags</a>
<a href="/">tickets (17)</a>
<a href="/">releases</a>
<a href="/">settings</a>
</div>
</div>
</nav>
</div>
<header>
<span>
viewing
<span class=linkedPath>
<a href="/">owner</a>/<a href="/">repo</a>
</span>
</span>
<ul class=buttonList>
<li><a class=watchButton href="/">watch</a></li>
<li><a class=starButton href="/">star</a></li>
<li><a class=forkButton href="/">fork</a></li>
<li><a class=cloneButton href="/">clone</a></li>
</ul>
</header>
<div id=contentWrap>
<aside>
<div class=sidebarLabel>
tickets
<div class=tabList style="display: inline-flex">
<span>open</span>
<a href="/">closed</a>
</div>
</div>
<table class=files border=1>
<tbody>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=directory href="/">directory/</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
<tr>
<td><a class=file href="/">file name</a></td>
<td><a class=commit href="/">most recent commit</a></td>
<td><time datetime="2023-01-01">in 2 months</time></td>
</tr>
</tbody>
</table>
</aside>
<main>
<article class=preview>
<h1>Ticket title</h1>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
<h2>Heading</h2>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
<h2>Heading</h2>
<p>Ratione quis totam tempora sit magnam voluptas. Recusandae qui illo nulla eligendi. Perspiciatis iusto numquam suscipit aspernatur quibusdam ex.</p>
<p>Consequuntur atque rerum culpa numquam et et possimus dolor. Quidem possimus quia et consectetur. Debitis autem cupiditate vero maxime et libero quae. Tenetur vero architecto iure dolores numquam assumenda enim.</p>
<p>Dolorem facere animi quis repudiandae rerum dolor. Reprehenderit consequatur quae quia quos illum. Labore ducimus ut quaerat et corrupti cupiditate tenetur.</p>
<p>Nisi ut aut sunt dignissimos. Alias magnam itaque deleniti alias quibusdam id possimus eos. Reiciendis et eos placeat. Vero eligendi occaecati quaerat vitae voluptatem deserunt.</p>
<p>Dolor aut corrupti et officia id. Minus ipsam assumenda fugiat neque. Neque et saepe maiores iusto maiores. Dolor et at reprehenderit exercitationem totam neque.</p>
</article>
</main>
</div>
<script>
let stickyWrap = document.querySelector("#stickyWrap")
document.addEventListener ("scroll", () => {
if (document.documentElement.scrollTop > 0) {
stickyWrap.className = "lifted"
} else {
stickyWrap.className = ""
}
})
</script>
<footer>
<a href="https://git.tebibyte.media/meta/mintee">Mintee</a>,
the fresh and tasty git frontend. page: #s. template: #s.
</footer>
</body>
</html>

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

74
src/backend/git.rs Normal file
View File

@@ -0,0 +1,74 @@
use std::error::Error;
use gix::{ Repository, object::tree::EntryKind };
use serde::Serialize;
use tera::Context;
#[derive(Serialize)]
struct Entry {
class: String,
last_commit: String,
last_commit_message: String,
last_commit_time: String,
path: String,
}
pub fn repo_to_context(r: Repository) -> Result<Context, Box<dyn Error>> {
let mut entries = Vec::new();
let branches = r.clone().branch_names()
.iter()
.map(|x| x.to_owned().to_owned())
.collect::<Vec<_>>();
let tree = r.rev_parse_single("@")?.object()?.peel_to_tree()?;
/* replace with configurable branch name when we have a database */
let current_branch = r.head()?.referent_name().unwrap().to_string();
for e in tree.iter() {
let entry = e.unwrap();
let last_commit = "TODO".to_string();
let last_commit_message = "TODO".to_string();
let last_commit_time = "1970-01-01T00:00:00".to_string();
let class = match entry.kind() {
EntryKind::Tree => "directory",
EntryKind::Blob => "file",
_ => "",
}.to_owned();
entries.push(Entry {
class,
last_commit,
last_commit_message,
last_commit_time,
path: entry.filename().to_string(),
});
}
let dir = r.git_dir();
let name = dir.file_name().unwrap().to_str().unwrap();
let directory = dir.as_os_str().to_str().unwrap();
let mut ctx = Context::new();
/* stubs til we have a real database */
ctx.insert("user", "anon");
ctx.insert("site", "TiB.");
ctx.insert("notif_count", "");
ctx.insert("ticket_count", "(47)");
ctx.insert("readme_content", "test readme");
ctx.insert("branches", &branches);
ctx.insert("entries", &entries);
ctx.insert("owner", "anon");
ctx.insert("branch", &current_branch);
ctx.insert("repo", name);
ctx.insert("directory", directory);
ctx.insert("entries", &entries);
Ok(ctx)
}

2
src/backend/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod git;
pub mod render;

25
src/backend/test.rs Normal file
View File

@@ -0,0 +1,25 @@
use std::{ env::current_dir, error::Error };
use gix::{ traverse::tree::Recorder, prelude::* };
fn main() -> Result<(), Box<dyn Error>> {
let r = gix::open(current_dir()?)?;
let mut rec = Recorder::default();
let tree = r.rev_parse_single("@")?.object()?.peel_to_tree()?;
tree.traverse().breadthfirst(&mut rec)?;
for e in rec.records.iter() {
println!("{}", e.filepath)
}
/*
for e in tree.iter() {
println!("{}", e?.filename())
}
*/
Ok(())
}

62
src/bin/backend/git.rs Normal file
View File

@@ -0,0 +1,62 @@
use std::error::Error;
use gix::{ Repository, traverse::tree::Recorder, prelude::* };
use serde::Serialize;
use tera::Context;
#[derive(Serialize)]
struct Entry {
mode: String,
hash: String,
path: String,
}
pub fn repo_to_context(r: Repository) -> Result<Context, Box<dyn Error>> {
let mut entries = Vec::new();
let branches = r.clone().branch_names()
.iter()
.map(|x| x.to_owned().to_owned())
.collect::<Vec<_>>();
let mut rec = Recorder::default();
let tree = r.rev_parse_single("@")?.object()?.peel_to_tree()?;
/* replace with configurable branch name when we have a database */
let current_branch = r.head()?.referent_name().unwrap().to_string();
tree.traverse().breadthfirst(&mut rec)?;
for e in rec.records.iter() {
entries.push(Entry {
mode: e.mode.value().to_string(),
hash: e.oid.to_string(),
path: e.filepath.to_string(),
});
}
let dir = r.git_dir();
let name = dir.file_name().unwrap().to_str().unwrap();
let directory = dir.as_os_str().to_str().unwrap();
let mut ctx = Context::new();
/* stubs til we have a real database */
ctx.insert("user", "anon");
ctx.insert("site", "TiB.");
ctx.insert("notif_count", "");
ctx.insert("ticket_count", "(47)");
ctx.insert("readme_content", "test readme");
ctx.insert("branches", &branches);
ctx.insert("entries", &entries);
ctx.insert("owner", "anon");
ctx.insert("branch", &current_branch);
ctx.insert("repo", name);
ctx.insert("directory", directory);
ctx.insert("entries", &entries);
Ok(ctx)
}

65
src/bin/backend/render.rs Normal file
View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Emma Tebibyte <emma@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
use std::{
error::Error,
fmt::{ self, Display, Formatter },
path::Path,
};
use tera::{ Context, Tera };
#[non_exhaustive]
pub enum PageKind {
Code,
Dashboard,
Tickets,
User,
}
impl PageKind {
pub fn render_page(&self, ctx: Context) -> Result<String, Box<dyn Error>> {
let page_dir = self.to_string();
let template = String::from_utf8(Path::new(&page_dir)
.to_path_buf()
.as_mut_os_string()
.as_encoded_bytes()
.to_vec()
)?;
let tera = Tera::new(&page_dir)?;
Ok(tera.render(&template, &ctx)?)
}
}
impl Display for PageKind {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> {
use PageKind::*;
let path = match self {
Code => "repo/code.html",
Dashboard => "dashboard.html",
Tickets => "repo/tickets.html",
User => "user.html",
};
write!(f, "./assets/templates/{}", path)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Emma Tebibyte <emma@tebibyte.media>
* Copyright (c) 20252026 Emma Tebibyte <emma@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
@@ -23,23 +23,14 @@ use std::{
error::Error,
};
use serde::Serialize;
use gix::open;
use tera::{ Context, Tera };
#[path="git.rs"]
mod git;
use git::GitRepo;
#[derive(Serialize)]
struct SampleEntry {
class: String,
last_commit_message: String,
last_commit: String,
last_commit_time: i64,
path: String,
}
use git::repo_to_context;
/*
impl From<GitRepo> for Context {
fn from(repo: GitRepo) -> Self {
let mut ctx = Context::new();
@@ -85,13 +76,12 @@ impl From<GitRepo> for Context {
ctx
}
}
*/
fn main() -> Result<(), Box<dyn Error>> {
if let Some(templates) = args().collect::<Vec<_>>().get(1) {
let tera = Tera::new(templates.as_str())?;
let ctx = Context::from(
GitRepo::open(current_dir()?.to_path_buf(), None)?
);
let ctx = repo_to_context(open(current_dir()?)?)?;
println!("{}", tera.render("code.html", &ctx)?);

25
src/bin/backend/test.rs Normal file
View File

@@ -0,0 +1,25 @@
use std::{ env::current_dir, error::Error };
use gix::{ traverse::tree::Recorder, prelude::* };
fn main() -> Result<(), Box<dyn Error>> {
let r = gix::open(current_dir()?)?;
let mut rec = Recorder::default();
let tree = r.rev_parse_single("@")?.object()?.peel_to_tree()?;
tree.traverse().breadthfirst(&mut rec)?;
for e in rec.records.iter() {
println!("{}", e.filepath)
}
/*
for e in tree.iter() {
println!("{}", e?.filename())
}
*/
Ok(())
}

View File

@@ -0,0 +1,465 @@
/*
* Copyright (c) 2025, 2026 silt <silt@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
use httparse::{self};
use itertools::Itertools;
use typed_path::{Utf8Component, Utf8UnixComponent, Utf8UnixPath, Utf8UnixPathBuf};
use std::{
error::Error, fmt, io::{self, BufRead, BufReader, Read}, net::{Incoming, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}, ops::Deref, pin::Pin, process::exit, str::FromStr, time::Duration
};
use mintee::util::yapper::{yap, eyap};
pub use super::manager::{Frontend, FrontendImpl};
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum HttpMethod {
GET,
POST,
HEAD,
PUT,
DELETE,
CONNECT,
OPTIONS,
TRACE,
PATCH,
Unknown,
}
impl From<&str> for HttpMethod {
fn from(val: &str) -> Self {
use HttpMethod::*;
match val {
"GET" => GET,
"POST" => POST,
"HEAD" => HEAD,
"PUT" => PUT,
"DELETE" => DELETE,
"CONNECT" => CONNECT,
"OPTIONS" => OPTIONS,
"TRACE" => TRACE,
"PATCH" => PATCH,
_ => Unknown,
}
}
}
impl From<HttpMethod> for &'static str {
fn from(val: HttpMethod) -> Self {
use HttpMethod::*;
match val {
GET => "GET",
POST => "POST",
HEAD => "HEAD",
PUT => "PUT",
DELETE => "DELETE",
CONNECT => "CONNECT",
OPTIONS => "OPTIONS",
TRACE => "TRACE",
PATCH => "PATCH",
Unknown => "?",
}
}
}
impl From<String> for HttpMethod {
fn from(val: String) -> Self {
use HttpMethod::*;
match val.as_str() {
"GET" => GET,
"POST" => POST,
"HEAD" => HEAD,
"PUT" => PUT,
"DELETE" => DELETE,
"CONNECT" => CONNECT,
"OPTIONS" => OPTIONS,
"TRACE" => TRACE,
"PATCH" => PATCH,
_ => Unknown,
}
}
}
impl From<HttpMethod> for String {
fn from(val: HttpMethod) -> Self {
use HttpMethod::*;
match val {
GET => "GET".to_string(),
POST => "POST".to_string(),
HEAD => "HEAD".to_string(),
PUT => "PUT".to_string(),
DELETE => "DELETE".to_string(),
CONNECT => "CONNECT".to_string(),
OPTIONS => "OPTIONS".to_string(),
TRACE => "TRACE".to_string(),
PATCH => "PATCH".to_string(),
Unknown => "?".to_string(),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ResponseStatus {
Okay,
Created,
MovedPermanently {
location: String,
},
SeeOther {
location: String,
},
TemporaryRedirect {
location: String,
},
PermanentRedirect {
location: String,
},
BadRequest,
Unauthorized,
Forbidden,
NotFound,
MethodNotAllowed {
allow: Vec<HttpMethod>,
},
UriTooLong,
ImATeapot,
InternalServerError,
NotImplemented,
HttpVersionNotSupported,
}
impl ResponseStatus {
fn as_code(&self) -> usize {
use ResponseStatus::*;
match self {
Okay => 200,
Created => 201,
MovedPermanently { .. } => 301,
SeeOther { .. } => 303,
TemporaryRedirect { .. } => 307,
PermanentRedirect { .. } => 308,
BadRequest => 400,
Unauthorized => 401,
Forbidden => 403,
NotFound => 404,
MethodNotAllowed { .. } => 405,
UriTooLong => 414,
ImATeapot => 418,
InternalServerError => 500,
NotImplemented => 501,
HttpVersionNotSupported => 505,
}
}
fn as_description(&self) -> &'static str {
use ResponseStatus::*;
match self {
Okay => "OK",
Created => "Created",
MovedPermanently { .. } => "Moved Permanently",
SeeOther { .. } => "See Other",
TemporaryRedirect { .. } => "Temporary Redirect",
PermanentRedirect { .. } => "Permanent Redirect",
BadRequest => "Bad Request",
Unauthorized => "Unauthorized",
Forbidden => "Forbidden",
NotFound => "Not Found",
MethodNotAllowed { .. } => "Method Not Allowed",
UriTooLong => "URI Too Long",
ImATeapot => "I'm A Teapot",
InternalServerError => "Internal Server Error",
NotImplemented => "Not Implemented",
HttpVersionNotSupported => "HTTP Version Not Supported",
}
}
}
#[derive(Debug, Clone)]
pub struct HttpError {
kind: ResponseStatus,
}
impl HttpError {
pub fn new(kind: ResponseStatus) -> Self {
Self { kind }
}
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"HTTP/1.1 {} {}",
self.kind.as_code(),
self.kind.as_description()
)
}
}
impl Error for HttpError {}
impl From<HttpError> for io::Error {
fn from(val: HttpError) -> Self {
io::Error::other(val.to_string())
}
}
impl From<httparse::Error> for HttpError {
fn from(_: httparse::Error) -> Self {
HttpError::new(ResponseStatus::BadRequest)
}
}
#[derive(Debug, Clone)]
pub struct Response<'a> {
pub status: ResponseStatus,
pub headers: Vec<(&'a str, String)>,
pub body: Option<&'a [u8]>,
}
impl<'a> From<Response<'a>> for Vec<u8> {
fn from(val: Response<'a>) -> Self {
[
"HTTP/1.1 ".as_bytes(),
val.status.as_code().to_string().as_bytes(),
b" ",
val.status.as_description().as_bytes(),
b"\r\n",
&val.headers.into_iter().fold(
Default::default(),
|mut acc: Vec<u8>, e: (&str, String)| -> Vec<u8> {
acc.append(&mut [e.0.as_bytes(), b": ", e.1.as_bytes(), b"\r\n"].concat());
acc
}
),
b"\r\n",
val.body.unwrap_or_default(),
].concat()
}
}
impl<'a> From<HttpError> for Response<'a> {
fn from(err: HttpError) -> Self {
let status = err.kind.clone();
let headers = match err.kind {
ResponseStatus::MovedPermanently { location }
| ResponseStatus::SeeOther { location }
| ResponseStatus::TemporaryRedirect { location }
| ResponseStatus::PermanentRedirect { location } => vec![("location", location)],
ResponseStatus::MethodNotAllowed { allow } => vec![(
"allow",
allow.iter().map(|x| Into::<String>::into(*x)).join(", ")
)],
_ => vec![],
};
Response {
status,
headers,
body: None
}
}
}
pub struct FeConfig {
bind_address: SocketAddr,
read_timeout: Duration,
write_timeout: Duration,
}
impl FeConfig {
pub fn init<A: ToSocketAddrs>(
bind_address: A,
read_timeout: Duration,
write_timeout: Duration,
) -> Result<Self, Box<dyn Error>> {
Ok(FeConfig {
bind_address: bind_address.to_socket_addrs()?.collect::<Vec<_>>()[0],
read_timeout,
write_timeout,
})
}
}
pub struct FeStorage {
listener: TcpListener,
// TODO: tera template store
}
impl Frontend<FeStorage, FeConfig> {
fn router(
method: HttpMethod,
path: Utf8UnixPathBuf,
params: Option<Vec<(&str, &str)>>,
headers: &[httparse::Header],
) -> Result<<Frontend<FeStorage, FeConfig> as FrontendImpl<TcpStream>>::Response, Box<dyn Error>> {
use HttpMethod::*;
use ResponseStatus::*;
// unwrapping is safe here because the resource path it came from is a valid UTF-8 &str
match (method, path.components().map(|c| c.as_str()).collect::<Vec<&str>>().deref(), params, headers) {
(method, ["/", "index.html"], _, _) => {
if matches!(method, GET) {
Ok(Response {
status: ResponseStatus::Okay,
headers: vec![("x-test1", "test1".to_string()), ("x-test2", "test2".to_string())],
body: Some(b"totally cool and swag homepage"),
}.into())
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] })))
}
}
(method, ["/", "login"], _, _) => {
if matches!(method, GET | POST) {
todo!()
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET, POST] })))
}
}
// oh how i long for inline const patterns
(method, ["/", user], _, _) if let Some(user) = user.strip_prefix('~') => {
if matches!(method, GET) {
todo!()
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] })))
}
}
(method, ["/", user, repo], _, _) if let Some(user) = user.strip_prefix('~') => {
if matches!(method, GET) {
todo!()
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] })))
}
}
(method, ["/", project], _, _) if let Some(project) = project.strip_prefix('+') => {
if matches!(method, GET) {
todo!()
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] })))
}
}
(method, ["/", project, repo], _, _) if let Some(project) = project.strip_prefix('+') => {
if matches!(method, GET) {
todo!()
} else {
Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] })))
}
}
_ => Err(Box::new(HttpError::new(ResponseStatus::NotFound))),
}
}
}
impl Iterator for Frontend<FeStorage, FeConfig> {
type Item = Incoming<'static>;
fn next(&mut self) -> Option<Self::Item> {
todo!()
}
}
impl FrontendImpl<TcpStream> for Frontend<FeStorage, FeConfig> {
type FeConfig = FeConfig;
type Request = TcpStream;
type Response = Vec<u8>;
fn init(config: FeConfig) -> Self {
// TODO: load tera templates into FeStorage
Frontend {
storage: self::FeStorage {
listener: TcpListener::bind(config.bind_address).unwrap_or_else(|e| {
eyap!(&e);
exit(1)
}),
},
config: config,
}
}
fn handle_request(&self, subj: Self::Request) -> Result<Self::Response, Box<dyn Error>> {
subj.set_read_timeout(Some(self.config.read_timeout))
.and_then(|_| subj.set_write_timeout(Some(self.config.write_timeout)))?;
let stream_read = BufReader::new(subj);
let mut headers = [httparse::EMPTY_HEADER; 32];
let mut req = httparse::Request::new(&mut headers);
let buf: &mut Vec<u8> = &mut vec![];
// TODO: validate more of the request before sending to the router
stream_read.take(8192).read_until(b'\n', buf)?;
let res = req.parse(buf);
Ok(match (res, req) {
// Presumably well-formed enough to get sent off to the route handler
(
Ok(httparse::Status::Partial),
httparse::Request {
method: Some(method),
path: Some(path),
version: Some(1),
headers,
},
) => {
// separate path containing get params into path and kv vec
let (path, params) = path.split_once("?").map_or_else(
|| (Utf8UnixPathBuf::from_str(path).unwrap(), None),
|(path, args)| {
(
Utf8UnixPathBuf::from_str(path).unwrap(),
Some(
args.split('&')
.filter_map(|e| e.split_once('='))
.collect::<Vec<(&str, &str)>>(),
),
)
},
);
if path.is_absolute() {
// context-valid lexical normalization without da feature
let path = Utf8UnixPathBuf::from_iter(path.components().try_fold(Vec::<Utf8UnixComponent>::new(), |mut acc, item| -> Result<Vec<Utf8UnixComponent<'_>>, Box<dyn Error>> {
match item {
Utf8UnixComponent::CurDir => Ok(acc),
Utf8UnixComponent::RootDir => {acc.push(item); Ok(acc)},
Utf8UnixComponent::Normal(_) => {acc.push(item); Ok(acc)},
Utf8UnixComponent::ParentDir => {acc.pop_if(|c| c != &Utf8UnixComponent::RootDir); Ok(acc)},
}
})?);
Self::router(method.into(), path, params, headers)
} else {
Err(Box::new(HttpError::new(ResponseStatus::BadRequest)) as Box<dyn Error>)
}
}
// Malformed request lines and HTTP/1.1 requests without a Host header
(Ok(httparse::Status::Partial), _) | (Ok(httparse::Status::Complete(_)), _) => {
Err(Box::new(HttpError::new(ResponseStatus::BadRequest)) as Box<dyn Error>)
}
// Fatal parsing error; obvious bad request
(Err(e), _) => Err(Box::new(e) as Box<dyn Error>),
}?)
}
fn handle_error(&mut self, res: Result<Self::Response, Box<dyn Error>>) -> Vec<u8> {
todo!()
}
}

View File

@@ -18,10 +18,12 @@
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
use std::{error::Error, time::Duration};
use std::{error::Error, thread::available_parallelism, time::Duration};
mod server;
use server::Pool;
use futures::{self, channel::mpsc, executor::ThreadPool};
mod manager;
use manager::Pool;
mod http_fe;
@@ -29,12 +31,32 @@ use http_fe::FrontendImpl;
mod gem_fe;
mod yapper;
// mod util;
// use crate::;
use mintee::util::yapper::eyap;
fn main() -> Result<(), Box<dyn Error>> {
let http_fe = http_fe::Frontend::init(http_fe::FeConfig::init("0.0.0.0:8080", Duration::new(2, 0), Duration::new(2, 0))?);
// let http_fe = http_fe::Frontend::init(http_fe::FeConfig::init("0.0.0.0:8080", Duration::new(2, 0), Duration::new(2, 0))?);
let pool = Pool::<32>::new();
// let pool = Pool::<32>::new();
let pool = ThreadPool::builder()
.pool_size(available_parallelism()?.into()) // TODO: or optional value from config
.name_prefix("mintfe-worker:")
.after_start(|i| {
eyap!("Spawned mintfe-worker:{i}");
})
.before_stop(|i| {
eyap!("Stopping mintfe-worker:{i}");
})
.create()?;
// let (tx, rx) = mpsc::unbounded::<todo!()>();
Ok(())
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 silt <silt@tebibyte.media>
* Copyright (c) 2025, 2026 silt <silt@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
@@ -24,8 +24,9 @@ use std::{
io::{Read, Write},
thread::{self, JoinHandle},
};
use futures::executor;
use crate::{eyap, yap};
use mintee::util::yapper::{yap, eyap};
pub struct Frontend<S, C> {
/// Holds data necessary for and private to the implementor.
@@ -35,15 +36,15 @@ pub struct Frontend<S, C> {
pub config: C,
}
pub trait FrontendImpl: Iterator {
pub trait FrontendImpl<Request>: Iterator where Request: Read, Self::Response: Into<Vec<u8>> {
type FeConfig: ?Sized;
type RequestSubject: Read;
type ReplySubject: Write;
type Request: Read;
type Response: Write;
fn init(storage: Self::FeConfig) -> Self;
fn handle_request(&self, subj: Self::RequestSubject) -> Result<(), Box<dyn Error>>;
fn send_reply(&self, subj: Self::ReplySubject) -> Result<(), Box<dyn Error>>;
// NOTE: handle_request.or_else(handle_error)
fn handle_error(&self, res: Result<(), Box<dyn Error>>);
fn handle_request(&self, subj: Request) -> Result<Self::Response, Box<dyn Error>>;
// fn send_reply(&self, subj: Self::Response) -> Result<(), Box<dyn Error>>;
// NOTE: handle_request().or_else(handle_error())
fn handle_error(&mut self, res: Result<Self::Response, Box<dyn Error>>) -> Self::Response;
}
// TODO: split frontend management code and Frontend trait stuff into diff files

91
src/bin/test_render.rs Normal file
View File

@@ -0,0 +1,91 @@
/*
* Copyright (c) 20252026 Emma Tebibyte <emma@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
use std::{
env::{ args, current_dir },
error::Error,
};
use gix::open;
use tera::{ Context, Tera };
use mintee::backend::git::repo_to_context;
/*
impl From<GitRepo> for Context {
fn from(repo: GitRepo) -> Self {
let mut ctx = Context::new();
let directory = format!("{}/{}", repo.owner, repo.name);
let main_branch = repo.branches
.iter()
.find(|b| b.name == "main")
.unwrap();
let latest_commit = &main_branch.commits[0];
let hash = latest_commit.hash.clone();
let mut entries = Vec::new();
for e in latest_commit.entries.iter() {
let entry = SampleEntry {
class: e.kind.to_string(),
last_commit: hash.clone(),
last_commit_message: latest_commit.message.clone().unwrap(),
last_commit_time: latest_commit.time,
path: e.path.clone(),
};
entries.push(entry);
}
/* stubs til we have a real database */
ctx.insert("user", "anon");
ctx.insert("site", "TiB.");
ctx.insert("notif_count", "");
ctx.insert("ticket_count", "(47)");
ctx.insert("readme_content", "this is a readme");
ctx.insert("owner", &repo.owner);
ctx.insert("branch", &main_branch.name);
ctx.insert("repo", &repo.name);
ctx.insert("directory", &directory);
ctx.insert("entries", &entries);
ctx
}
}
*/
fn main() -> Result<(), Box<dyn Error>> {
if let Some(templates) = args().collect::<Vec<_>>().get(1) {
let tera = Tera::new(templates.as_str())?;
let ctx = repo_to_context(open(current_dir()?)?)?;
println!("{}", tera.render("code.html", &ctx)?);
Ok(())
} else {
eprintln!("Usage: {} template_glob", args().collect::<Vec<_>>()[0]);
std::process::exit(64);
}
}

View File

@@ -1,247 +0,0 @@
/*
* Copyright (c) 2025 Emma Tebibyte <emma@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*
* This file incorporates work covered by the following copyright and
* permission notice:
*
* MIT License
*
* Copyright (c) 2021 Sergey "Shnatsel" Davidoff <shnatsel@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be included
* in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
use std::{
error::Error,
path::PathBuf,
};
use git2::{ Branch, Commit, FileMode, Repository, Sort, TreeEntry };
use tera::Context;
/* TODO: implement From<T> for Context where T: all Git* types */
pub enum GitEntryKind {
Directory,
File,
Link,
}
impl ToString for GitEntryKind {
fn to_string(&self) -> String {
match self {
GitEntryKind::Directory => "directory".to_owned(),
GitEntryKind::File => "file".to_owned(),
GitEntryKind::Link => "link".to_owned(),
}
}
}
pub struct GitEntry {
pub entries: Vec<GitEntry>,
pub kind: GitEntryKind,
pub path: String,
}
impl GitEntry {
fn new(entries: Vec<GitEntry>, kind: GitEntryKind, path: String) -> Self {
GitEntry { entries, kind, path }
}
}
impl TryFrom<TreeEntry<'_>> for GitEntry {
type Error = std::string::FromUtf8Error;
fn try_from(entry: TreeEntry) -> Result<Self, Self::Error> {
let entries = Vec::new();
let path = String::from_utf8(entry.name_bytes().to_vec())?;
let kind = match entry.filemode() {
m if m == <i32>::from(FileMode::Blob) => GitEntryKind::File,
m if m == <i32>::from(FileMode::BlobExecutable) => GitEntryKind::File,
m if m == <i32>::from(FileMode::BlobGroupWritable) => unreachable!(),
m if m == <i32>::from(FileMode::Link) => GitEntryKind::File,
m if m == <i32>::from(FileMode::Tree) => GitEntryKind::Directory,
m if m == <i32>::from(FileMode::Commit) => GitEntryKind::File,
m if m == <i32>::from(FileMode::Unreadable) => todo!(),
_ => GitEntryKind::Directory,
};
Ok(Self::new(entries, kind, path))
}
}
pub struct GitCommit {
pub author: (Option<String>, Option<String>), // name, e-mail
pub entries: Vec<GitEntry>,
pub hash: String,
pub message: Option<String>,
pub short_hash: Option<String>,
pub time: i64, // seconds since Unix epoch
}
impl From<GitCommit> for Context {
fn from(value: GitCommit) -> Self {
let mut ctx = Context::new();
todo!("proc_macro for populating");
ctx
}
}
impl TryFrom<Commit<'_>> for GitCommit {
type Error = Box<dyn Error>;
fn try_from(commit: Commit) -> Result<Self, Self::Error> {
let hash = commit.id().to_string();
let short_hash = commit.as_object().short_id()?.as_str().map(|f| f.to_owned());
let message = commit.message().map(|f| f.to_owned());
let commit_signature = commit.author();
let name = commit_signature.name().map(|f| f.to_owned());
let address = commit_signature.email().map(|f| f.to_owned());
let author = (name, address);
let time = commit.time().seconds();
let entries = commit
.tree()?
.iter()
.map(|c| -> Result<GitEntry, std::string::FromUtf8Error> {
Ok(GitEntry::try_from(c)?)
}).collect::<Result<Vec<_>, _>>()?;
Ok(GitCommit { author, entries, hash, message, short_hash, time })
}
}
pub struct GitBranch {
pub commits: Vec<GitCommit>,
pub name: String,
}
struct GitBranchWrapper<'a> {
branch: Branch<'a>,
repo: &'a Repository,
}
impl<'a> GitBranchWrapper<'a> {
fn new(branch: Branch<'a>, repo: &'a Repository) -> Self {
Self { branch, repo }
}
}
impl TryFrom<GitBranchWrapper<'_>> for GitBranch {
type Error = Box<dyn Error>;
fn try_from(branch: GitBranchWrapper) -> Result<Self, Self::Error> {
let name = String::from_utf8(branch.branch.name_bytes()?.to_vec())?;
let repo = branch.repo;
let branch_oid = branch.branch.get().resolve()?.target().unwrap();
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
revwalk.push(branch_oid)?;
let commits = revwalk
.into_iter()
.map(|o| -> Result<GitCommit, Box<dyn Error>> {
Ok(GitCommit::try_from(repo.find_commit(o?)?)?)
}).collect::<Result<Vec<_>, _>>()?;
Ok(GitBranch { commits, name })
}
}
pub struct GitRepo {
pub branches: Vec<GitBranch>,
pub name: String,
pub owner: String,
}
impl GitRepo {
pub fn open(
path: PathBuf, prefix: Option<String>
) -> Result<Self, Box<dyn Error>> {
let repo = Repository::open(path.clone())?;
let branches = repo
.branches(None)?
.map(|b| -> Result<GitBranch, Box<dyn Error>> {
Ok(GitBranch::try_from(GitBranchWrapper::new(b?.0, &repo))?)
}).collect::<Result<Vec<_>, _>>()?;
/*
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
revwalk.push_head()?;
let commits = revwalk
.into_iter()
.map(|o| -> Result<GitCommit, Box<dyn Error>> {
Ok(GitCommit::try_from(repo.find_commit(o?)?)?)
}).collect::<Result<Vec<_>, _>>()?;
*/
let full_path = path.clone().as_path().canonicalize()?;
let relative_path = match prefix {
Some(p) => full_path.strip_prefix(p)?.canonicalize()?,
None => full_path,
};
let owner = String::from_utf8(
relative_path
.parent()
.unwrap()
.to_path_buf()
.as_mut_os_string()
.as_encoded_bytes()
.to_vec()
)?;
let name = String::from_utf8(
relative_path
.file_name()
.unwrap()
.to_owned()
.as_encoded_bytes()
.to_vec()
)?;
Ok(GitRepo { branches, name, owner })
}
pub fn get_remote_repo() { // for AP repos
todo!();
}
}

View File

@@ -1,332 +0,0 @@
/*
* Copyright (c) 2025 silt <silt@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
use httparse;
use std::{
error::Error, fmt, io::{self, BufRead, BufReader, Read}, net::{Incoming, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}, ops::Deref, path::{Component, PathBuf}, process::exit, str::FromStr, time::Duration
};
use crate::{eyap, yap};
pub use super::server::{Frontend, FrontendImpl};
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum HttpMethod {
GET,
POST,
HEAD,
}
impl TryFrom<&str> for HttpMethod {
type Error = HttpError;
fn try_from(val: &str) -> Result<Self, Self::Error> {
use HttpMethod::*;
match val {
"GET" => Ok(GET),
"POST" => Ok(POST),
"HEAD" => Ok(HEAD),
_ => Err(HttpError {
kind: ResponseCode::MethodNotAllowed,
}),
}
}
}
impl From<HttpMethod> for &str {
fn from(val: HttpMethod) -> Self {
use HttpMethod::*;
match val {
GET => "GET",
POST => "POST",
HEAD => "HEAD",
}
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub enum ResponseCode {
Okay,
Created,
MovedPermanently,
SeeOther,
TemporaryRedirect,
PermanentRedirect,
BadRequest,
Unauthorized,
Forbidden,
NotFound,
MethodNotAllowed,
UriTooLong,
ImATeapot,
InternalServerError,
NotImplemented,
HttpVersionNotSupported,
}
impl From<ResponseCode> for usize {
fn from(val: ResponseCode) -> Self {
use ResponseCode::*;
match val {
Okay => 200,
Created => 201,
MovedPermanently => 301,
SeeOther => 303,
TemporaryRedirect => 307,
PermanentRedirect => 308,
BadRequest => 400,
Unauthorized => 401,
Forbidden => 403,
NotFound => 404,
MethodNotAllowed => 405,
UriTooLong => 414,
ImATeapot => 418,
InternalServerError => 500,
NotImplemented => 501,
HttpVersionNotSupported => 505,
}
}
}
impl From<ResponseCode> for &str {
fn from(val: ResponseCode) -> Self {
use ResponseCode::*;
match val {
Okay => "OK",
Created => "Created",
MovedPermanently => "Moved Permanently",
SeeOther => "See Other",
TemporaryRedirect => "Temporary Redirect",
PermanentRedirect => "Permanent Redirect",
BadRequest => "Bad Request",
Unauthorized => "Unauthorized",
Forbidden => "Forbidden",
NotFound => "NotFound",
MethodNotAllowed => "Method Not Allowed",
UriTooLong => "URI Too Long",
ImATeapot => "I'm A Teapot",
InternalServerError => "Internal Server Error",
NotImplemented => "Not Implemented",
HttpVersionNotSupported => "HTTP Version Not Supported",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct HttpError {
kind: ResponseCode,
}
impl HttpError {
pub fn new(kind: ResponseCode) -> Self {
Self { kind }
}
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"HTTP/1.1 {} {}",
Into::<usize>::into(self.kind),
Into::<&str>::into(self.kind)
)
}
}
impl Error for HttpError {}
impl From<HttpError> for io::Error {
fn from(val: HttpError) -> Self {
io::Error::other(val.to_string())
}
}
impl From<httparse::Error> for HttpError {
fn from(value: httparse::Error) -> Self {
HttpError {
kind: match value {
_ => ResponseCode::BadRequest,
},
}
}
}
pub struct FeConfig {
bind_address: SocketAddr,
read_timeout: Duration,
write_timeout: Duration,
}
impl FeConfig {
pub fn init<A: ToSocketAddrs>(
bind_address: A,
read_timeout: Duration,
write_timeout: Duration,
) -> Result<Self, Box<dyn Error>> {
Ok(FeConfig {
bind_address: bind_address.to_socket_addrs()?.collect::<Vec<_>>()[0],
read_timeout,
write_timeout,
})
}
}
pub struct FeStorage {
listener: TcpListener,
// TODO: tera template store
}
impl Frontend<FeStorage, FeConfig> {
fn router(
method: HttpMethod,
path: PathBuf,
params: Option<Vec<(&str, &str)>>,
headers: &[httparse::Header],
) -> Result<(), Box<dyn Error>> {
use HttpMethod::*;
// unwrapping is safe here because the resource path it came from is a valid UTF-8 &str
match (method, path.components().map(|c| c.as_os_str().to_str().unwrap()).collect::<Vec<&str>>().deref(), params, headers) {
(GET, ["/", "index.html"], _, _) => {
todo!()
}
(GET, ["/", "login.html"], _, _) => {
todo!()
}
// can be cleaned up with rust/rust-lang #76001 or maybe #87599
(GET, ["/", user], _, _) if user.starts_with('~') => {
todo!()
}
(GET, ["/", user, repo], _, _) if user.starts_with('~') => {
todo!()
}
(GET, ["/", project], _, _) if project.starts_with('+') => {
todo!()
}
(GET, ["/", project, repo], _, _) if project.starts_with('+') => {
todo!()
}
_ => Err(Box::new(HttpError::new(ResponseCode::NotFound))),
}
}
}
impl Iterator for Frontend<FeStorage, FeConfig> {
type Item = Incoming<'static>;
fn next(&mut self) -> Option<Self::Item> {
todo!()
}
}
impl FrontendImpl for Frontend<FeStorage, FeConfig> {
type FeConfig = FeConfig;
type RequestSubject = TcpStream;
type ReplySubject = TcpStream;
fn init(config: FeConfig) -> Self {
// TODO: load tera templates into FeStorage
Frontend {
storage: self::FeStorage {
listener: TcpListener::bind(config.bind_address).unwrap_or_else(|e| {
eyap!(&e);
exit(1)
}),
},
config: config,
}
}
fn handle_request(&self, subj: Self::RequestSubject) -> Result<(), Box<dyn Error>> {
subj.set_read_timeout(Some(self.config.read_timeout))
.and_then(|_| subj.set_write_timeout(Some(self.config.write_timeout)))?;
let stream_read = BufReader::new(subj);
let mut headers = [httparse::EMPTY_HEADER; 32];
let mut req = httparse::Request::new(&mut headers);
let buf: &mut Vec<u8> = &mut vec![];
// TODO: parse the rest of the request before sending to the router
stream_read.take(8192).read_until(b'\n', buf)?;
let res = req.parse(buf);
match (res, req) {
// Presumably well-formed enough to get sent off to the route handler
(
Ok(httparse::Status::Partial),
httparse::Request {
method: Some(method),
path: Some(path),
version: Some(1),
headers,
},
) => {
// separate path containing get params into path and kv vec
let (path, params) = path.split_once("?").map_or_else(
|| (PathBuf::from_str(path).unwrap(), None),
|(path, args)| {
(
PathBuf::from_str(path).unwrap(),
Some(
args.split('&')
.filter_map(|e| e.split_once('='))
.collect::<Vec<(&str, &str)>>(),
),
)
},
);
if path.is_absolute() {
// context-valid lexical normalization without da feature
let path = PathBuf::from_iter(path.components().try_fold(Vec::<Component>::new(), |mut acc, item| -> Result<Vec<Component<'_>>, Box<dyn Error>> {
match item {
Component::CurDir => Ok(acc),
Component::RootDir => {acc.push(item); Ok(acc)},
Component::Normal(_) => {acc.push(item); Ok(acc)},
Component::ParentDir => {acc.pop_if(|c| c != &Component::RootDir); Ok(acc)},
Component::Prefix(_) => Err(Box::new(HttpError::new(ResponseCode::BadRequest)) as Box<dyn Error>),
}
})?);
Self::router(method.try_into()?, path, params, headers)
} else {
Err(Box::new(HttpError::new(ResponseCode::BadRequest)) as Box<dyn Error>)
}
}
// Malformed request lines and HTTP/1.1 requests without a Host header
(Ok(httparse::Status::Partial), _) | (Ok(httparse::Status::Complete(_)), _) => {
Err(Box::new(HttpError::new(ResponseCode::BadRequest)) as Box<dyn Error>)
}
// Fatal parsing error; obvious bad request
(Err(e), _) => Err(Box::new(e) as Box<dyn Error>),
}?;
Ok(())
}
fn send_reply(&self, subj: Self::ReplySubject) -> Result<(), Box<dyn Error>> {
todo!()
}
fn handle_error(&self, res: Result<(), Box<dyn Error>>) {
todo!()
}
}

2
src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod backend;
pub mod util;

1
src/util/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod yapper;

114
src/util/yapper.rs Normal file
View File

@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 silt <silt@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
pub mod _inner {
#[doc(hidden)]
#[macro_export]
macro_rules! _yap_inner {
($m:expr) => {{
println!("[{}:{}] {}", file!(), line!(), $m);
}};
}
pub use _yap_inner;
#[doc(hidden)]
#[macro_export]
macro_rules! _eyap_inner {
($m:expr) => {{
eprintln!("[{}:{}] {}", file!(), line!(), $m);
}};
}
pub use _eyap_inner;
}
macro_rules! mk_yap {() => (
#[macro_export]
macro_rules! yap {
($m:ident) => {{
let v = $m;
$crate::util::yapper::_inner::_yap_inner!(format!("{}", $m));
v
}};
($f:literal , $m:ident) => {{
let v = $m;
$crate::util::yapper::_inner::_yap_inner!(format!($f, $m));
v
}};
($m:literal) => {{
let v = $m;
$crate::util::yapper::_inner::_yap_inner!(format!($m));
v
}};
($f:literal , $m:expr) => {{
let v = $m;
$crate::util::yapper::_inner::_yap_inner!(format!($f, v));
v
}};
($m:expr) => {{
let v = $m;
$crate::util::yapper::_inner::_yap_inner!(format!("{}", v));
v
}};
}
pub use yap;
)}
mk_yap!();
macro_rules! mk_eyap {() => (
#[macro_export]
macro_rules! eyap {
($m:ident) => {{
let v = $m;
$crate::util::yapper::_inner::_eyap_inner!(format!("{}", $m));
v
}};
($f:literal , $m:ident) => {{
let v = $m;
$crate::util::yapper::_inner::_eyap_inner!(format!($f, $m));
v
}};
($m:literal) => {{
let v = $m;
$crate::util::yapper::_inner::_eyap_inner!(format!($m));
v
}};
($f:literal , $m:expr) => {{
let v = $m;
$crate::util::yapper::_inner::_eyap_inner!(format!($f, v));
v
}};
($m:expr) => {{
let v = $m;
$crate::util::yapper::_inner::_eyap_inner!(format!("{}", v));
v
}};
}
pub use eyap;
)}
mk_eyap!();

View File

@@ -1,97 +0,0 @@
/*
* Copyright (c) 2025 silt <silt@tebibyte.media>
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This file is part of Mintee.
*
* Mintee is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
*
* Mintee is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Mintee. If not, see https://www.gnu.org/licenses/.
*/
pub mod _inner {
macro_rules! _yap_inner {
($m:expr) => {{
println!("[{}:{}] {}", file!(), line!(), $m);
}};
}
pub(crate) use _yap_inner;
macro_rules! _eyap_inner {
($m:expr) => {{
println!("[{}:{}] {}", file!(), line!(), $m);
}};
}
pub(crate) use _eyap_inner;
}
#[macro_export]
macro_rules! yap {
($m:ident) => {{
let v = $m;
crate::yapper::_inner::_yap_inner!(format!("{}", $m));
v
}};
($f:literal , $m:ident) => {{
let v = $m;
crate::yapper::_inner::_yap_inner!(format!($f, $m));
v
}};
($m:literal) => {{
let v = $m;
crate::yapper::_inner::_yap_inner!(format!($m));
v
}};
($f:literal , $m:expr) => {{
let v = $m;
crate::yapper::_inner::_yap_inner!(format!($f, v));
v
}};
($m:expr) => {{
let v = $m;
crate::yapper::_inner::_yap_inner!(format!("{}", v));
v
}};
}
#[macro_export]
macro_rules! eyap {
($m:ident) => {{
let v = $m;
crate::yapper::_inner::_eyap_inner!(format!("{}", $m));
v
}};
($f:literal , $m:ident) => {{
let v = $m;
crate::yapper::_inner::_eyap_inner!(format!($f, $m));
v
}};
($m:literal) => {{
let v = $m;
crate::yapper::_inner::_eyap_inner!(format!($m));
v
}};
($f:literal , $m:expr) => {{
let v = $m;
crate::yapper::_inner::_eyap_inner!(format!($f, v));
v
}};
($m:expr) => {{
let v = $m;
crate::yapper::_inner::_eyap_inner!(format!("{}", v));
v
}};
}