This commit is contained in:
Mark 2025-03-02 02:46:48 +01:00
commit 606e7d03d0
8 changed files with 2214 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1615
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[package]
name = "tomatenmharksite"
version = "0.1.0"
edition = "2021"
[dependencies]
html-escape = "0.2.13"
rocket = "0.5.1"
tokio = { version = "1.42.0", features = ["fs", "process"] }

55
src/data.rs Normal file
View File

@ -0,0 +1,55 @@
use std::time::Instant;
use tokio::sync::{Mutex, MutexGuard};
use crate::status::Status;
pub struct Data {
pub status: Mutex<Status>,
pub status_updated: Mutex<(Instant, bool)>,
}
impl Data {
pub fn new_sync() -> Self {
Self {
status: Mutex::new(Status::query_sync(false)),
status_updated: Mutex::new((Instant::now(), false)),
}
}
pub async fn new_async() -> Self {
Self {
status: Mutex::new(Status::query_async(false).await),
status_updated: Mutex::new((Instant::now(), false)),
}
}
pub fn status_sync(&self, dbg: bool) -> MutexGuard<Status> {
let mut updated = self.status_updated.blocking_lock();
let now = Instant::now();
if (now - updated.0).as_secs_f32() > 3.0 || (dbg && !updated.1) {
updated.0 = now;
updated.1 = dbg;
drop(updated);
let mut lock = self.status.blocking_lock();
*lock = Status::query_sync(dbg);
lock
} else {
drop(updated);
self.status.blocking_lock()
}
}
pub async fn status_async(&self, dbg: bool) -> MutexGuard<Status> {
let mut updated = self.status_updated.lock().await;
let now = Instant::now();
if (now - updated.0).as_secs_f32() > 3.0 || (dbg && !updated.1) {
updated.0 = now;
updated.1 = dbg;
drop(updated);
let mut lock = self.status.lock().await;
*lock = Status::query_async(dbg).await;
lock
} else {
drop(updated);
self.status.lock().await
}
}
}

44
src/int.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tomatenmharksite documentation</title>
</head>
<body>
<h2>tomatenmharksite documentation</h2>
<div>documentation of how the tomatenmharksite server works for people who care or people who keep forgetting (me)</div>
<div>go to <a href="/">index without</a> or <a href="/dbg">with debugging information</a></div>
<h4>links</h4>
In the "running" section on the website, the ids may or may not be links.
If a <code>/srv/tomatenmhark-slashinfo/*</code> entry exists, the link will point to that info site,
If only a <code>/srv/tomatenmhark-redirect/*</code> entry exists, the link will follow that redirect,
and if neither exist, there will be no link.<br>
In the "everything else" section, every entry links to its info page.
<h4>/tmp/tomatenmhark-status-*</h4>
For each file <code>/tmp/tomatenmhark-status-*</code>, an entry will be created on <a href="/">tomatenmhark.org</a>.
This entry will have the id <code>*</code> and the file's contents will be shown after that.
<h4>/srv/tomatenmhark-dystatus/*</h4>
Each file in <code>/srv/tomatenmhark-dystatus/</code> acts like a <code>/tmp/tomatenmhark-status-*</code> file,
but instead of displaying its contents, the tomatenmharksite server will execute the file and display its output.
<h4>/srv/tomatenmhark-slashinfo/*</h4>
The files in <code>/srv/tomatenmhark-slashinfo/</code> are served under <a href="/info">/info</a>, and <code>index.html</code> will be served
when a directory is requested (<code>/</code> &rarr; <code>/index.html</code>, <code>/thing</code> &rarr; <code>/thing/index.html</code>).<br>
For all directories in <code>/srv/tomatenmhark-slashinfo/</code> which contain a <code>desc</code>
and are not already listed as "running", an entry is created in the "everything else" section.
<h4>/srv/tomatenmhark-redirect/*</h4>
Each file in <code>/srv/tomatenmhark-redirect/</code> contains a port number or a domain, leading/trailing whitespace characters are ignored.<br>
If the file contains a port number, <code>/filename/...</code> will be redirected to <code>samedomain:portnumber/...</code>.<br>
If the file contains anything else, <code>/filename/...</code> will be redirected to <code>filecontent/...</code>.<br>
In the port number case, <code>http</code> is used. Otherwise, the protocol must be included in the file.
<code>%DOMAIN%</code> will be replaced with <code>tomatenmhark.org</code> or whatever domain was used in the request.
To redirect to port <code>8000</code> using <code>https</code>, use <code>https://%DOMAIN%:8000</code>
</body>
</html>

172
src/main.rs Normal file
View File

@ -0,0 +1,172 @@
mod data;
mod redirecter;
mod status;
use std::time::Duration;
use data::Data;
use redirecter::Redirecter;
use rocket::{
fs::{FileServer, Options},
get,
response::content::RawHtml,
routes, State,
};
use tokio::time::Instant;
#[get("/")]
async fn index(data: &State<Data>) -> RawHtml<String> {
index_gen(data, false).await
}
#[get("/dbg")]
async fn index_dbg(data: &State<Data>) -> RawHtml<String> {
index_gen(data, true).await
}
async fn index_gen(data: &State<Data>, dbg: bool) -> RawHtml<String> {
let mut o = "<!DOCTYPE html><html><head>".to_owned();
o.push_str(r#"<meta charset="UTF-8">"#);
o.push_str(r#"<meta name="color-scheme" content="light dark">"#);
o.push_str(r#"<meta name="viewport" content="width=device-width, initial-scale=1">"#);
o.push_str("<title>tomatenmhark</title>");
o.push_str(
"<style>
a:link {
color: DarkCyan;
text-decoration: none;
font-weight: normal;
}
a:hover {
color: DarkCyan;
text-decoration: none;
font-weight: bold;
}
a:visited {
color: DarkCyan;
text-decoration: underline;
font-weight: normal;
}
span {
color: SeaGreen;
font-weight: normal;
}
.ohs {
display: none;
}
.oht:hover + .ohs, .ohs:hover {
display: block;
}
</style>
",
);
o.push_str("</head><body>");
o.push_str("<h2>:) tomatenmhark :)</h2>");
let status = data.status_async(dbg).await;
let time_queried = Instant::now();
if !status.0.is_empty() {
o.push_str("<h3>things running on the server</h3>");
o.push_str("<ul>");
for (id, (info, redirect, status, additional, dur)) in status.0.iter() {
o.push_str("<li>");
if *info {
o.push_str(r#"<a href="/info/"#);
o.push_str(&html_escape::encode_double_quoted_attribute(&id));
o.push_str(r#"">"#);
} else if *redirect {
o.push_str(r#"<a href="/"#);
o.push_str(&html_escape::encode_double_quoted_attribute(&id));
o.push_str(r#"">"#);
} else {
o.push_str("<span>");
}
o.push_str(&html_escape::encode_text(&id));
if *info || *redirect {
o.push_str("</a>");
} else {
o.push_str("</span>");
}
o.push_str(": ");
if additional.is_some() {
o.push_str(r#"<em class="oht">"#);
}
o.push_str(&html_escape::encode_text(&status));
if let Some(additional) = additional {
o.push_str(r#"</em><div class="ohs">"#);
o.push_str(
&html_escape::encode_text(additional)
.replace('\r', "")
.replace('\n', "<br>"),
);
o.push_str("</div>");
}
if dbg {
if let Some(dur) = *dur {
o.push_str(&format!(" | {}ms", dur.as_millis()));
}
}
o.push_str("</li>");
}
o.push_str("</ul>");
}
if !status.1.is_empty() {
o.push_str("<h3>everything else</h3>");
o.push_str("<ul>");
for (id, text) in status.1.iter() {
o.push_str("<li>");
o.push_str(r#"<a href="/info/"#);
o.push_str(&html_escape::encode_double_quoted_attribute(&id));
o.push_str(r#"">"#);
o.push_str(&html_escape::encode_text(&id));
o.push_str("</a>");
o.push_str(": ");
o.push_str(&html_escape::encode_text(&text));
o.push_str("</li>");
}
o.push_str("</ul>");
}
o.push_str(
r#"<br><small><small><a href="/int">tomatenmharksite documentation</a></small></small>"#,
);
o.push_str(r#"<br><br><br><br><br><hl><br><footer><p>tomatenmhark.org und subdomains: <a href="/info/me">Mark</a>"#);
if dbg {
o.push_str(r#"<br><small><small>"#);
let time_pagegen = Instant::now();
o.push_str(&format!(
"{}/{}ms querying + {:.2}ms pagegen",
status
.0
.values()
.filter_map(|v| v.4)
.sum::<Duration>()
.as_millis(),
status.2.as_millis(),
(time_pagegen - time_queried).as_micros() as f32 / 1000.0,
));
o.push_str(r#"</small></small>"#);
}
o.push_str(r#"</p></footer>"#);
o.push_str("</body></html>");
RawHtml(o)
}
#[get("/int")]
async fn int() -> RawHtml<&'static str> {
RawHtml(include_str!("int.html"))
}
#[rocket::launch]
fn rocket() -> _ {
let data = Data::new_sync();
rocket::build()
.manage(data)
.mount("/", routes![index, index_dbg, int])
.mount(
"/info",
FileServer::new(
"/srv/tomatenmhark-slashinfo/",
Options::Index | Options::NormalizeDirs | Options::Missing,
),
)
.mount("/", Redirecter)
}

97
src/redirecter.rs Normal file
View File

@ -0,0 +1,97 @@
use rocket::{
http::{Method, Status},
outcome::Outcome,
response::{Redirect, Responder},
route::Handler,
Data, Request, Response, Route,
};
#[derive(Clone, Copy)]
pub struct Redirecter;
impl From<Redirecter> for Vec<Route> {
fn from(redirecter: Redirecter) -> Self {
let mut out = vec![];
for method in [
Method::Get,
Method::Put,
Method::Post,
Method::Delete,
Method::Options,
Method::Head,
Method::Trace,
Method::Connect,
Method::Patch,
] {
let mut route = Route::ranked(50, method, "/<path..>", redirecter);
route.name = Some("TomatenmharksiteRedirecter".into());
out.push(route);
}
out
}
}
#[rocket::async_trait]
impl Handler for Redirecter {
// fn respond_to(self, request: &'r Request<'_>) -> Result<'o> {
async fn handle<'r>(
&self,
request: &'r Request<'_>,
_data: Data<'r>,
) -> Outcome<Response<'r>, Status, (Data<'r>, Status)> {
let uri = request.uri();
let mut path = uri.path().raw_segments();
if let Some(which) = path.next().and_then(|v| v.percent_decode().ok()) {
if which
.chars()
.all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-')
{
let path = path.map(|v| format!("/{v}")).collect::<String>();
if let Some(port_or_domain) =
tokio::fs::read_to_string(format!("/srv/tomatenmhark-redirect/{}", which))
.await
.ok()
.map(|c| c.trim().parse::<u16>().map_err(|_| c))
{
let redirect_target = match port_or_domain {
Ok(port) => {
let domain = request
.host()
.map(|host| host.domain().as_str())
.unwrap_or("tomatenmhark.org");
format!("http://{domain}:{port}{path}")
}
Err(domain) => {
let domain = domain.trim();
if !domain.contains("%DOMAIN%") {
format!("{domain}{path}")
} else {
format!(
"{}/{path}",
domain.replace(
"%DOMAIN%",
request
.host()
.map(|host| host.domain().as_str())
.unwrap_or("tomatenmhark.org")
)
)
}
}
};
if let Ok(redirect) = Redirect::temporary(redirect_target).respond_to(request) {
Outcome::Success(redirect)
} else {
Outcome::error(Status::InternalServerError)
}
} else {
Outcome::error(Status::NotFound)
}
} else {
Outcome::error(Status::NotFound)
}
} else {
Outcome::error(Status::NotFound)
}
}
}

221
src/status.rs Normal file
View File

@ -0,0 +1,221 @@
use std::{collections::BTreeMap, os::unix::fs::PermissionsExt, path::Path, time::Duration};
use tokio::time::Instant;
const SLASHINFO: &'static str = "/srv/tomatenmhark-slashinfo/";
const REDIRECT: &'static str = "/srv/tomatenmhark-redirect/";
pub struct Status(
pub BTreeMap<String, (bool, bool, String, Option<String>, Option<Duration>)>,
pub BTreeMap<String, String>,
pub Duration,
);
impl Status {
pub fn query_sync(dbg: bool) -> Self {
let start = Instant::now();
let mut map = BTreeMap::new();
query_status_sync(
|k, e, r, v, dur| {
let (v, add) = v
.split_once('\n')
.map(|(v, add)| (v, add.trim()))
.unwrap_or((v, ""));
map.insert(
k.to_owned(),
(
e,
r,
v.trim().to_owned(),
if add.is_empty() {
None
} else {
Some(add.to_owned())
},
dur,
),
);
},
dbg,
);
let mut rest = BTreeMap::new();
if let Ok(rd) = std::fs::read_dir(SLASHINFO) {
for f in rd {
if let Ok(f) = f {
if let Some(id) = f.file_name().to_str() {
if !map.contains_key(id) {
let mut p = f.path();
p.push("desc");
if let Ok(desc) = std::fs::read_to_string(&p) {
rest.insert(id.to_owned(), desc);
}
}
}
}
}
}
Self(map, rest, start.elapsed())
}
pub async fn query_async(dbg: bool) -> Self {
let start = Instant::now();
let mut map = BTreeMap::new();
query_status_async(
|k, e, r, v, dur| {
let (v, add) = v
.split_once('\n')
.map(|(v, add)| (v, add.trim()))
.unwrap_or((v, ""));
map.insert(
k.to_owned(),
(
e,
r,
v.trim().to_owned(),
if add.is_empty() {
None
} else {
Some(add.to_owned())
},
dur,
),
);
},
dbg,
)
.await;
let mut rest = BTreeMap::new();
if let Ok(mut rd) = tokio::fs::read_dir(SLASHINFO).await {
while let Ok(Some(f)) = rd.next_entry().await {
if let Some(id) = f.file_name().to_str() {
if !map.contains_key(id) {
let mut p = f.path();
p.push("desc");
if let Ok(desc) = tokio::fs::read_to_string(&p).await {
rest.insert(id.to_owned(), desc);
}
}
}
}
}
Self(map, rest, start.elapsed())
}
}
fn query_status_sync(mut func: impl FnMut(&str, bool, bool, &str, Option<Duration>), dbg: bool) {
if let Ok(rd) = std::fs::read_dir("/tmp/") {
for f in rd {
if let Ok(f) = f {
if let Some(name) = f.file_name().to_str() {
if name.starts_with("tomatenmhark-status-") {
let id = name["tomatenmhark-status-".len()..].trim();
if !id.is_empty() {
if let Ok(status) = std::fs::read_to_string(f.path())
.as_ref()
.map(|v| v.trim_end())
{
if !status.is_empty() {
let info = Path::new(SLASHINFO).join(id);
let info = info.starts_with(SLASHINFO)
&& info.try_exists().ok() == Some(true);
let redirect = Path::new(REDIRECT).join(id);
let redirect = redirect.starts_with(REDIRECT)
&& redirect.try_exists().ok() == Some(true);
func(id, info, redirect, status, None);
}
}
}
}
}
}
}
}
if let Ok(rd) = std::fs::read_dir("/srv/tomatenmhark-dystatus/") {
for f in rd {
if let Ok(f) = f {
if let Some(name) = f.file_name().to_str() {
if f.metadata()
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 1 == 1)
{
let id = name.trim();
if !id.is_empty() {
let start = dbg.then(Instant::now);
if let Ok(output) = std::process::Command::new(f.path()).output() {
let elapsed = start.map(|v| v.elapsed());
let out = String::from_utf8_lossy(&output.stdout);
let out = out.trim_end();
if dbg || !out.is_empty() {
let info = Path::new(SLASHINFO).join(id);
let info = info.starts_with(SLASHINFO)
&& info.try_exists().ok() == Some(true);
let redirect = Path::new(REDIRECT).join(id);
let redirect = redirect.starts_with(REDIRECT)
&& redirect.try_exists().ok() == Some(true);
func(id, info, redirect, out, elapsed);
}
}
}
}
}
}
}
}
}
async fn query_status_async(
mut func: impl FnMut(&str, bool, bool, &str, Option<Duration>),
dbg: bool,
) {
if let Ok(mut rd) = tokio::fs::read_dir("/tmp/").await {
while let Ok(Some(f)) = rd.next_entry().await {
if let Some(name) = f.file_name().to_str() {
if name.starts_with("tomatenmhark-status-") {
let id = name["tomatenmhark-status-".len()..].trim();
if !id.is_empty() {
if let Ok(status) = tokio::fs::read_to_string(f.path())
.await
.as_ref()
.map(|v| v.trim_end())
{
if !status.is_empty() {
let info = Path::new(SLASHINFO).join(id);
let info = info.starts_with(SLASHINFO)
&& tokio::fs::try_exists(info).await.ok() == Some(true);
let redirect = Path::new(REDIRECT).join(id);
let redirect = redirect.starts_with(REDIRECT)
&& tokio::fs::try_exists(redirect).await.ok() == Some(true);
func(id, info, redirect, status, None);
}
}
}
}
}
}
}
if let Ok(mut rd) = tokio::fs::read_dir("/srv/tomatenmhark-dystatus/").await {
while let Ok(Some(f)) = rd.next_entry().await {
if let Some(name) = f.file_name().to_str() {
if f.metadata()
.await
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 1 == 1)
{
let id = name.trim();
if !id.is_empty() {
let start = dbg.then(Instant::now);
if let Ok(output) = tokio::process::Command::new(f.path()).output().await {
let elapsed = start.map(|v| v.elapsed());
let out = String::from_utf8_lossy(&output.stdout);
let out = out.trim_end();
if dbg || !out.is_empty() {
let info = Path::new(SLASHINFO).join(id);
let info = info.starts_with(SLASHINFO)
&& tokio::fs::try_exists(info).await.ok() == Some(true);
let redirect = Path::new(REDIRECT).join(id);
let redirect = redirect.starts_with(REDIRECT)
&& tokio::fs::try_exists(redirect).await.ok() == Some(true);
func(id, info, redirect, out, elapsed);
}
}
}
}
}
}
}
}