2025-11-18 16:41:58 +00:00
|
|
|
use axum::{
|
|
|
|
|
Json, Router,
|
2026-07-24 08:45:02 +00:00
|
|
|
http::{HeaderValue, Method, StatusCode, header},
|
2025-11-18 16:41:58 +00:00
|
|
|
response::Html,
|
|
|
|
|
routing::{get, post},
|
|
|
|
|
};
|
|
|
|
|
use message_tools::BoxedMessage;
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
use std::{
|
|
|
|
|
fs::OpenOptions,
|
|
|
|
|
io::{BufRead, Read},
|
|
|
|
|
path::{Path, PathBuf},
|
2026-02-08 19:24:47 +00:00
|
|
|
time::Duration,
|
2025-11-18 16:41:58 +00:00
|
|
|
};
|
|
|
|
|
use tokio::io::AsyncWriteExt;
|
2026-07-24 08:45:02 +00:00
|
|
|
use tower_http::{
|
|
|
|
|
cors::CorsLayer, limit::RequestBodyLimitLayer, services::ServeDir,
|
|
|
|
|
set_header::SetResponseHeaderLayer,
|
|
|
|
|
};
|
2025-11-18 02:22:05 +00:00
|
|
|
|
|
|
|
|
const PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
|
2025-11-18 16:41:58 +00:00
|
|
|
const MESSAGE_STORAGE: &str = "./messages";
|
|
|
|
|
|
2025-11-18 02:22:05 +00:00
|
|
|
lazy_static::lazy_static! {
|
|
|
|
|
static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path");
|
2026-02-08 19:24:47 +00:00
|
|
|
static ref PUBLISH_LIMITER: std::sync::Mutex<(std::time::Instant, u32)> =
|
|
|
|
|
std::sync::Mutex::new((std::time::Instant::now(), 0));
|
2025-11-18 02:22:05 +00:00
|
|
|
}
|
2025-11-17 22:21:49 +00:00
|
|
|
|
2026-02-08 19:24:47 +00:00
|
|
|
const MAX_PUBLISHES_PER_MINUTE: u32 = 10;
|
|
|
|
|
|
2025-11-17 22:21:49 +00:00
|
|
|
type Error = Box<dyn std::error::Error>;
|
|
|
|
|
type Result<T> = std::result::Result<T, Error>;
|
|
|
|
|
|
2025-11-18 02:22:05 +00:00
|
|
|
fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
|
|
|
|
|
// Open the file
|
2025-11-18 16:41:58 +00:00
|
|
|
let f = OpenOptions::new().read(true).write(false).open(path)?;
|
2025-11-18 02:22:05 +00:00
|
|
|
|
|
|
|
|
// Wrap it in a BufReader for efficient line-by-line reading
|
|
|
|
|
let reader = std::io::BufReader::new(f);
|
|
|
|
|
|
|
|
|
|
let mut public_keys = Vec::new();
|
|
|
|
|
|
|
|
|
|
// Iterate over each line in the file
|
|
|
|
|
for line_result in reader.lines() {
|
|
|
|
|
let line = line_result?; // Handle potential IO errors during reading
|
|
|
|
|
|
|
|
|
|
// Skip empty lines or lines with only whitespace
|
|
|
|
|
let trimmed_line = line.trim();
|
|
|
|
|
if trimmed_line.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deserialize the line (which is a single JSON array) into a Vec<u8>
|
|
|
|
|
let key: Vec<u8> = serde_json::from_str(trimmed_line).map_err(|e| {
|
|
|
|
|
std::io::Error::new(
|
|
|
|
|
std::io::ErrorKind::InvalidData,
|
|
|
|
|
format!("Failed to parse JSON line: {}", e),
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
public_keys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(public_keys)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:21:49 +00:00
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() -> Result<()> {
|
2026-02-08 19:24:47 +00:00
|
|
|
let cors = CorsLayer::new()
|
|
|
|
|
.allow_origin([
|
|
|
|
|
"https://zachery.lol"
|
|
|
|
|
.parse::<axum::http::HeaderValue>()
|
|
|
|
|
.unwrap(),
|
|
|
|
|
"https://www.zachery.lol"
|
|
|
|
|
.parse::<axum::http::HeaderValue>()
|
|
|
|
|
.unwrap(),
|
|
|
|
|
])
|
|
|
|
|
.allow_methods([Method::GET, Method::POST])
|
|
|
|
|
.allow_headers([axum::http::header::CONTENT_TYPE]);
|
|
|
|
|
|
2026-07-24 08:45:02 +00:00
|
|
|
// Cache static assets so navigating between pages reuses the streamed field
|
|
|
|
|
// instead of re-downloading it. Scoped to /routes only; HTML routes stay fresh.
|
|
|
|
|
let routes_static = Router::new()
|
|
|
|
|
.fallback_service(ServeDir::new("./routes"))
|
|
|
|
|
.layer(SetResponseHeaderLayer::overriding(
|
|
|
|
|
header::CACHE_CONTROL,
|
|
|
|
|
HeaderValue::from_static("public, max-age=86400"),
|
|
|
|
|
));
|
|
|
|
|
|
2025-11-17 22:21:49 +00:00
|
|
|
let app = Router::new()
|
|
|
|
|
.route("/", get(serve_path("./routes/root/index.html")?))
|
2026-07-24 08:45:02 +00:00
|
|
|
.route("/about", get(serve_path("./routes/about/index.html")?))
|
|
|
|
|
.route(
|
|
|
|
|
"/projects",
|
|
|
|
|
get(serve_path("./routes/projects/index.html")?),
|
|
|
|
|
)
|
|
|
|
|
.route(
|
|
|
|
|
"/projects/swactor",
|
|
|
|
|
get(serve_path("./routes/projects/swactor/index.html")?),
|
|
|
|
|
)
|
|
|
|
|
.route(
|
|
|
|
|
"/projects/airfrans-neural-cfd-surrogate",
|
|
|
|
|
get(serve_path(
|
|
|
|
|
"./routes/projects/airfrans-neural-cfd-surrogate/index.html",
|
|
|
|
|
)?),
|
|
|
|
|
)
|
|
|
|
|
.route(
|
|
|
|
|
"/projects/cstat-yoke",
|
|
|
|
|
get(serve_path("./routes/projects/cstat-yoke/index.html")?),
|
|
|
|
|
)
|
2025-11-18 02:22:05 +00:00
|
|
|
.route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS)))
|
2025-11-17 22:21:49 +00:00
|
|
|
.route("/contact", get(serve_path("./routes/contact/index.html")?))
|
2026-07-24 08:45:02 +00:00
|
|
|
.route(
|
|
|
|
|
"/contact/message",
|
|
|
|
|
get(serve_path("./routes/contact/message/index.html")?),
|
|
|
|
|
)
|
2026-02-08 19:24:47 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/publish",
|
|
|
|
|
post(publish_message).layer(RequestBodyLimitLayer::new(64 * 1024)),
|
|
|
|
|
)
|
2026-07-24 08:45:02 +00:00
|
|
|
.nest_service(
|
|
|
|
|
"/gossip-dashboard",
|
|
|
|
|
ServeDir::new("./routes/gossip-dashboard"),
|
|
|
|
|
)
|
|
|
|
|
.nest("/routes", routes_static)
|
2026-02-08 19:24:47 +00:00
|
|
|
.layer(cors);
|
2025-11-17 22:21:49 +00:00
|
|
|
|
2026-02-06 16:55:15 +00:00
|
|
|
let addr = "0.0.0.0:3000";
|
|
|
|
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
|
|
|
|
println!("Server running at http://{addr}");
|
2025-11-17 22:21:49 +00:00
|
|
|
axum::serve(listener, app).await.unwrap();
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 16:41:58 +00:00
|
|
|
// Define a struct for the response
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct UserCreated {
|
|
|
|
|
tag: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 19:24:47 +00:00
|
|
|
/// Strip everything except ASCII alphanumerics, dashes, and underscores
|
|
|
|
|
/// so a tag can never escape the storage directory.
|
|
|
|
|
fn sanitize_tag(s: &str) -> String {
|
|
|
|
|
s.chars()
|
|
|
|
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 16:41:58 +00:00
|
|
|
async fn publish_message(Json(msg): Json<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
|
2026-02-08 19:24:47 +00:00
|
|
|
// Rate limit: sliding window of MAX_PUBLISHES_PER_MINUTE
|
|
|
|
|
{
|
|
|
|
|
let mut limiter = PUBLISH_LIMITER.lock().unwrap();
|
|
|
|
|
let now = std::time::Instant::now();
|
|
|
|
|
if now.duration_since(limiter.0) > Duration::from_secs(60) {
|
|
|
|
|
*limiter = (now, 0);
|
|
|
|
|
}
|
|
|
|
|
if limiter.1 >= MAX_PUBLISHES_PER_MINUTE {
|
|
|
|
|
return (
|
|
|
|
|
StatusCode::TOO_MANY_REQUESTS,
|
2026-07-24 08:45:02 +00:00
|
|
|
Json(UserCreated { tag: String::new() }),
|
2026-02-08 19:24:47 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
limiter.1 += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let tag = sanitize_tag(&msg.tag());
|
|
|
|
|
|
|
|
|
|
if tag.is_empty() {
|
|
|
|
|
return (StatusCode::BAD_REQUEST, Json(UserCreated { tag }));
|
|
|
|
|
}
|
2025-11-18 16:41:58 +00:00
|
|
|
|
2026-02-08 19:24:47 +00:00
|
|
|
let file_path = PathBuf::from(MESSAGE_STORAGE).join(format!("{tag}.json"));
|
2025-11-18 16:41:58 +00:00
|
|
|
|
|
|
|
|
// 3. Create all necessary parent directories recursively (async operation)
|
|
|
|
|
if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await {
|
2026-07-24 08:45:02 +00:00
|
|
|
eprintln!("Failed to create directory {}: {}", MESSAGE_STORAGE, e);
|
2025-11-18 16:41:58 +00:00
|
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Open the file asynchronously
|
|
|
|
|
let mut f = match tokio::fs::OpenOptions::new()
|
|
|
|
|
.create(true)
|
|
|
|
|
.write(true)
|
|
|
|
|
.append(true)
|
|
|
|
|
.open(&file_path)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(file) => file,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("Failed to open file {}: {}", file_path.display(), e);
|
|
|
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 5. Serialize and write the data asynchronously
|
|
|
|
|
// We append a newline character for standard text file formatting
|
|
|
|
|
let s = serde_json::to_string(&msg).expect("failed to serialize boxed message");
|
|
|
|
|
if let Err(e) = f.write_all(format!("{}\n", s).as_bytes()).await {
|
|
|
|
|
eprintln!("Failed to write message to file: {}", e);
|
|
|
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(StatusCode::CREATED, Json(UserCreated { tag }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 19:24:47 +00:00
|
|
|
fn select_key(pks: &[Vec<u8>]) -> (StatusCode, String) {
|
|
|
|
|
if pks.is_empty() {
|
|
|
|
|
return (
|
|
|
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
|
|
|
"no keys available".to_string(),
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-11-18 02:22:05 +00:00
|
|
|
let idx = getrandom::u64().expect("random integer") as usize % pks.len();
|
2026-02-08 19:24:47 +00:00
|
|
|
(
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
serde_json::to_string(&pks[idx]).expect("serialize vec of bytes"),
|
|
|
|
|
)
|
2025-11-18 02:22:05 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:21:49 +00:00
|
|
|
fn serve_path(path: impl AsRef<std::path::Path>) -> Result<Html<String>> {
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
OpenOptions::new()
|
|
|
|
|
.read(true)
|
|
|
|
|
.write(false)
|
|
|
|
|
.open(path)?
|
|
|
|
|
.read_to_string(&mut s)?;
|
|
|
|
|
|
|
|
|
|
Ok(Html(s))
|
|
|
|
|
}
|