Files
bhs/src/api.rs

178 lines
5.8 KiB
Rust

use actix_web::http::header;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use base64::{Engine, engine::general_purpose};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use crate::{
LOG_FILE_NAME,
data_mgt::{AppState, Asset},
logs::{LogEvent, LogEventType, log_event},
};
use crate::{MAX_ASSET_DURATION, MIN_ASSET_DURATION};
#[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: HttpRequest,
body: web::Json<UploadRequest>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
// Check for rate limiting
let now = Utc::now().timestamp_millis();
let connection_info = req.connection_info();
let uploader_ip = connection_info
.realip_remote_addr()
.map(|s| s.to_string())
.or_else(|| connection_info.peer_addr().map(|value| value.to_string()))
.ok_or_else(|| actix_web::error::ErrorBadRequest("Cannot determine client ip"))?;
// Convert to bytes
let content_bytes = if body.content_type == "text/plain" {
body.content.as_bytes().to_vec()
} else {
match general_purpose::STANDARD.decode(&body.content) {
Ok(bytes) => bytes,
Err(_) => return Ok(HttpResponse::BadRequest().json(json!({ "error": "Invalid base64 content" }))),
}
};
let clamped_duration = body.duration.clamp(MIN_ASSET_DURATION, MAX_ASSET_DURATION);
let asset_expiration_time = now + (clamped_duration as i64 * 60 * 1000);
let (allowed, retry_after_ms) = app_state
.connection_tracker
.check(&uploader_ip, asset_expiration_time)
.await;
if !allowed {
let retry_after_seconds = retry_after_ms.map(|ms| ((ms + 999) / 1000).max(1));
let response_body = match retry_after_seconds {
Some(seconds) => json!({ "error": "Upload limit exceeded", "retry_after_seconds": seconds }),
None => json!({ "error": "Upload limit exceeded" }),
};
// return Ok(HttpResponse::TooManyRequests().body("Upload limit exceeded"));
return Ok(HttpResponse::TooManyRequests().json(response_body));
}
let asset = crate::data_mgt::Asset::new(
clamped_duration,
body.content_type.clone(),
content_bytes,
Some(uploader_ip.clone()),
);
let id = asset.id();
log_event(LogEventType::AssetUploaded(asset.to_value()));
app_state.assets.add_asset(asset).await;
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>,
app_state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
log_event(LogEventType::HttpRequest(req.into()));
match app_state.assets.get_asset(&path.into_inner()).await {
None => Ok(HttpResponse::NotFound().body("Asset not found")),
Some(asset) => Ok(HttpResponse::Ok()
.content_type(asset.mime())
.body(asset.content().clone())),
}
}
#[derive(serde::Serialize)]
struct StatsResponse {
active_assets: usize,
total_uploads: usize,
total_deleted: usize,
storage_bytes: u64,
image_count: usize,
text_count: usize,
total_requests: usize,
recent_activity: Vec<ActivityItem>,
}
#[derive(serde::Serialize)]
struct ActivityItem {
action: String,
mime: String,
share_duration: u32,
timestamp: String,
}
#[get("/api/stats")]
async fn api_stats(app_state: web::Data<AppState>) -> Result<HttpResponse, actix_web::Error> {
use crate::LOG_DIR;
use std::fs;
let (active_assets, storage_bytes, image_count, text_count) = app_state.assets.stats_summary().await;
let mut total_uploads = 0;
let mut total_deleted = 0;
let mut recent_activity: Vec<ActivityItem> = Vec::new();
let mut request_count: usize = 0;
let log_path = format!("{}{}", LOG_DIR, LOG_FILE_NAME);
if let Ok(content) = fs::read_to_string(&log_path) {
for line in content.lines() {
if let Ok(entry) = serde_json::from_str::<LogEvent>(line) {
match entry.event {
LogEventType::HttpRequest(_req) => {
request_count += 1;
}
LogEventType::AssetUploaded(asset) => {
let asset = serde_json::from_value::<Asset>(asset).unwrap_or_default();
total_uploads += 1;
recent_activity.push(ActivityItem {
action: "upload".to_string(),
mime: asset.mime(),
share_duration: asset.share_duration(),
timestamp: entry.time,
});
}
LogEventType::AssetDeleted(asset) => {
let asset = serde_json::from_value::<Asset>(asset).unwrap_or_default();
total_deleted += 1;
recent_activity.push(ActivityItem {
action: "delete".to_string(),
mime: asset.mime(),
share_duration: asset.share_duration(),
timestamp: entry.time,
});
}
}
}
}
}
// Keep only last 20, most recent first
recent_activity.reverse();
recent_activity.truncate(20);
let response = StatsResponse {
active_assets,
total_uploads,
total_deleted,
storage_bytes,
image_count,
text_count,
total_requests: request_count,
recent_activity,
};
Ok(HttpResponse::Ok().json(response))
}