This commit is contained in:
2026-01-06 10:10:36 +01:00
commit 6a0f1771f6
15 changed files with 3295 additions and 0 deletions

50
src/api.rs Normal file
View File

@@ -0,0 +1,50 @@
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use base64::{Engine, engine::general_purpose};
use serde::Deserialize;
use serde_json::json;
use crate::{DATA_STORAGE, logs::log_to_file};
#[derive(Deserialize, Debug)]
pub struct UploadRequest {
duration: u32,
content_type: String,
content: String, // text or base64 string
}
#[post("/api/upload")]
async fn api_upload(req: web::Json<UploadRequest>) -> Result<HttpResponse, actix_web::Error> {
// Convert to bytes
let content_bytes = if req.content_type == "text/plain" {
req.content.as_bytes().to_vec() // UTF-8 bytes
} else {
// Decode base64 → bytes
general_purpose::STANDARD.decode(&req.content).unwrap()
};
let asset = crate::data_mgt::Asset::new(req.duration, req.content_type.clone(), content_bytes);
let id = asset
.save()
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Failed to save asset: {}", e)))?;
let response_body = json!({ "link": format!("/bhs/{}", id) });
Ok(HttpResponse::Ok().json(response_body))
}
#[get("/api/content/{id}")]
async fn api_get_asset(req: HttpRequest, path: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
let now = std::time::Instant::now();
let id = path.into_inner();
let asset_path = format!("{}{}", DATA_STORAGE, id);
let data = std::fs::read(&asset_path).map_err(|_| actix_web::error::ErrorNotFound("Asset not found"))?;
let asset = serde_json::from_slice::<crate::data_mgt::Asset>(&data)
.map_err(|_| actix_web::error::ErrorInternalServerError("Failed to parse asset data"))?;
if asset.is_expired() {
return Err(actix_web::error::ErrorNotFound("Asset has expired"));
}
log_to_file(&req, now);
Ok(HttpResponse::Ok().content_type(asset.mime()).body(asset.content()))
}

76
src/data_mgt.rs Normal file
View File

@@ -0,0 +1,76 @@
use anyhow::Result;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::DATA_STORAGE;
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Asset {
id: String,
share_duration: u32,
created_at: i64,
expires_at: i64,
mime: String,
content: Vec<u8>,
}
impl Asset {
pub fn new(share_duration: u32, mime: String, content: Vec<u8>) -> Self {
let id = uuid::Uuid::new_v4().to_string();
let created_at = Utc::now().timestamp_millis();
let expires_at = created_at + Duration::minutes(share_duration as i64).num_milliseconds();
Asset {
id,
share_duration,
created_at,
expires_at,
mime,
content,
}
}
pub fn is_expired(&self) -> bool {
Utc::now().timestamp_millis() > self.expires_at
}
pub fn id(&self) -> &str {
&self.id
}
pub fn mime(&self) -> &str {
&self.mime
}
pub fn content(&self) -> Vec<u8> {
self.content.clone()
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(self)?;
Ok(bytes)
}
pub fn save(&self) -> Result<String> {
let id = self.id.clone();
let path = format!("{}{}", DATA_STORAGE, self.id);
std::fs::create_dir_all(DATA_STORAGE)?;
std::fs::write(&path, self.to_bytes()?)?;
Ok(id)
}
}
pub async fn clear_assets() -> Result<()> {
let entries = std::fs::read_dir(DATA_STORAGE)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let data = std::fs::read(&path)?;
let asset = serde_json::from_slice::<Asset>(&data)?;
if asset.is_expired() {
println!("Removing expired asset: {}", asset.id());
std::fs::remove_file(&path)?;
}
}
}
Ok(())
}

47
src/logs.rs Normal file
View File

@@ -0,0 +1,47 @@
use std::{
fs::{self, OpenOptions},
io::Write,
time::Instant,
};
use actix_web::HttpRequest;
use crate::LOG_DIR;
pub fn log_to_file(req: &HttpRequest, start: Instant) {
let delta = start.elapsed().as_nanos();
println!("Request processed in {} ns", delta);
let duration_ms = delta as f64 / 1000_000.0;
let _ = fs::create_dir_all(LOG_DIR);
let log_path = LOG_DIR.to_string() + "access.log";
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) else {
eprintln!("failed to open log file");
return;
};
let ts = chrono::Local::now().to_rfc3339();
let method = req.method();
let uri = req.uri();
let path = uri.path();
let query = uri.query().unwrap_or("-");
let connection_info = req.connection_info();
let scheme = connection_info.scheme();
let ip = connection_info.peer_addr().unwrap_or("-");
let real_ip = connection_info.realip_remote_addr().unwrap_or("-");
let ua = req
.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let line = format!(
"{ts} scheme={scheme} ip={ip} real_ip={real_ip} method={method} path={path} qs={query} dur_ms={duration_ms} ua=\"{ua}\"\n"
);
let _ = file.write_all(line.as_bytes());
}

81
src/main.rs Normal file
View File

@@ -0,0 +1,81 @@
mod api;
mod data_mgt;
mod logs;
use actix_files::NamedFile;
use actix_web::{
App, HttpRequest, HttpResponse, HttpServer, get, route,
web::{self},
};
use serde_json::Value;
use std::path::PathBuf;
pub static BIND_ADDR: &str = "0.0.0.0";
pub static BIND_PORT: u16 = 80;
pub static STATIC_PAGES: &[&str] = &["index.html", "style.css", "view.html", "logo.png"];
pub static HTML_DIR: &str = "html/";
pub static LOG_DIR: &str = "logs/";
pub static DATA_STORAGE: &str = "storage/";
use crate::{
api::{api_get_asset, api_upload},
logs::log_to_file,
};
#[get("/")]
async fn index(reg: HttpRequest) -> actix_web::Result<NamedFile> {
let now = std::time::Instant::now();
let path: PathBuf = PathBuf::from(HTML_DIR.to_string() + "index.html");
log_to_file(&reg, now);
Ok(NamedFile::open(path)?)
}
#[get("/bhs/{id}")]
async fn view_asset(req: HttpRequest) -> actix_web::Result<NamedFile> {
let now = std::time::Instant::now();
let path: PathBuf = PathBuf::from(HTML_DIR.to_string() + "view.html");
log_to_file(&req, now);
Ok(NamedFile::open(path)?)
}
#[route("/{tail:.*}", method = "GET", method = "POST")]
async fn catch_all(req: HttpRequest, _payload: Option<web::Json<Value>>) -> actix_web::Result<HttpResponse> {
let now = std::time::Instant::now();
let response = match req.uri().path() {
path if STATIC_PAGES.contains(&&path[1..]) => {
let file_path = HTML_DIR.to_string() + path;
Ok(NamedFile::open(file_path)?.into_response(&req))
}
_ => Ok(HttpResponse::NotFound().body("Not Found")),
};
log_to_file(&req, now);
response
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("Starting server at http://{}:{}/", BIND_ADDR, BIND_PORT);
tokio::spawn(async {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
if let Err(e) = data_mgt::clear_assets().await {
eprintln!("Error clearing assets: {}", e);
}
}
});
HttpServer::new(|| {
App::new()
.app_data(web::JsonConfig::default().limit(1024 * 1024 * 3))
.service(index)
.service(view_asset)
.service(api_get_asset)
.service(api_upload)
.service(catch_all)
})
.bind((BIND_ADDR, BIND_PORT))?
.run()
.await
}