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, app_state: web::Data, ) -> Result { // 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, app_state: web::Data, ) -> Result { 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, } #[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) -> Result { 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 = 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::(line) { match entry.event { LogEventType::HttpRequest(_req) => { request_count += 1; } LogEventType::AssetUploaded(asset) => { let asset = serde_json::from_value::(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).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)) }