diff --git a/src/application/mod.rs b/src/application/mod.rs index eaea6d5..c1f10e3 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -8,4 +8,5 @@ pub mod proxy; pub mod search; pub mod stalk; pub mod tools; +pub mod uploader; pub mod weebs; diff --git a/src/application/uploader.rs b/src/application/uploader.rs new file mode 100644 index 0000000..ecce741 --- /dev/null +++ b/src/application/uploader.rs @@ -0,0 +1,13 @@ +//! Application use-cases for the uploader. + +use crate::domain::error::ScrapingError; +use crate::infrastructure::repository::uploader as Repo; +use serde_json::Value; + +pub fn upload(buffer: &[u8], file_name: &str) -> Result { + Repo::save_file(buffer, file_name).map_err(ScrapingError::Http) +} + +pub fn serve(file_name: &str) -> Result<(Vec, String), ScrapingError> { + Repo::read_file(file_name).map_err(ScrapingError::Http) +} diff --git a/src/infrastructure/repository/mod.rs b/src/infrastructure/repository/mod.rs index 2cc010c..75e17b4 100644 --- a/src/infrastructure/repository/mod.rs +++ b/src/infrastructure/repository/mod.rs @@ -9,6 +9,7 @@ pub mod proxy; pub mod search; pub mod stalk; pub mod tools; +pub mod uploader; pub mod weebs; pub use alqanime::AlqanimeRepository; diff --git a/src/infrastructure/repository/uploader.rs b/src/infrastructure/repository/uploader.rs new file mode 100644 index 0000000..fd82b97 --- /dev/null +++ b/src/infrastructure/repository/uploader.rs @@ -0,0 +1,102 @@ +//! Infrastructure — Uploader utilities. +//! +//! The Shirokami-API source uploads to Ryzumi S3 (s3.ryzumi.vip) which is +//! DNS-dead from this VPS. This implementation stores uploaded files to a +//! local directory and serves them back, keeping the source's response shape: +//! `{ success, url, fileName, size }`. + +use std::path::{Path, PathBuf}; + +use fastrand; +use serde_json::{json, Value}; + +/// Directory where uploaded files are stored. +const UPLOAD_DIR: &str = "/var/lib/scraper/uploads"; + +/// Base URL prefix used in the returned `url` field. +fn public_base() -> String { + "/uploader/file".to_string() +} + +/// Save an uploaded buffer to disk with a random filename + detected extension. +pub fn save_file(buffer: &[u8], file_name: &str) -> Result { + if buffer.is_empty() { + return Err("Invalid content: buffer is required".into()); + } + + let dir = PathBuf::from(UPLOAD_DIR); + std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir: {e}"))?; + + // Detect extension: prefer the provided filename, else infer from magic. + let extension = if file_name.contains('.') { + file_name.split('.').last().unwrap_or("").to_lowercase() + } else { + infer_extension(buffer).to_string() + }; + + if extension.is_empty() { + return Err("Unable to determine file extension".into()); + } + + let random = hex::encode(&fastrand::u64(..).to_le_bytes()); + let key = format!("{random}.{extension}"); + let path = dir.join(&key); + + std::fs::write(&path, buffer).map_err(|e| format!("write: {e}"))?; + + let url = format!("{}/{}", public_base(), key); + Ok(json!({ + "success": true, + "url": url, + "fileName": key, + "size": buffer.len(), + })) +} + +/// Serve the file bytes for a given filename. +pub fn read_file(file_name: &str) -> Result<(Vec, String), String> { + // Prevent path traversal. + let safe = Path::new(file_name) + .file_name() + .and_then(|s| s.to_str()) + .ok_or("invalid filename")?; + let path = PathBuf::from(UPLOAD_DIR).join(safe); + let bytes = std::fs::read(&path).map_err(|e| format!("read: {e}"))?; + let mime = infer_mime(safe).to_string(); + Ok((bytes, mime)) +} + +fn infer_extension(buf: &[u8]) -> &'static str { + if buf.starts_with(b"\x89PNG\r\n\x1a\n") { + "png" + } else if buf.starts_with(b"\xff\xd8\xff") { + "jpg" + } else if buf.starts_with(b"GIF87a") || buf.starts_with(b"GIF89a") { + "gif" + } else if buf.starts_with(b"RIFF") && &buf[8..12] == b"WEBP" { + "webp" + } else if buf.starts_with(b"%PDF") { + "pdf" + } else if buf.starts_with(b"PK\x03\x04") { + "zip" + } else { + "bin" + } +} + +fn infer_mime(name: &str) -> &'static str { + let ext = name.rsplit('.').next().unwrap_or(""); + match ext { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + "zip" => "application/zip", + "txt" => "text/plain", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mp3" => "audio/mpeg", + _ => "application/octet-stream", + } +} diff --git a/src/presentation/handler/mod.rs b/src/presentation/handler/mod.rs index 7316e0d..8d406f5 100644 --- a/src/presentation/handler/mod.rs +++ b/src/presentation/handler/mod.rs @@ -9,4 +9,5 @@ pub mod proxy; pub mod search; pub mod stalk; pub mod tools; +pub mod uploader; pub mod weebs; diff --git a/src/presentation/handler/uploader.rs b/src/presentation/handler/uploader.rs new file mode 100644 index 0000000..e14622e --- /dev/null +++ b/src/presentation/handler/uploader.rs @@ -0,0 +1,58 @@ +//! Axum handlers for the uploader. +//! +//! POST /uploader/ryzencdn — multipart file upload, saved to local disk. +//! GET /uploader/file/:name — serve an uploaded file. + +use axum::body::Body; +use axum::extract::{Multipart, Path}; +use axum::http::{header, StatusCode}; +use axum::response::Response; +use axum::Json; +use serde_json::Value; + +use crate::application::uploader as use_cases; +use crate::presentation::error::AppError; + +/// POST /uploader/ryzencdn — accept a multipart file upload. +pub async fn ryzencdn_handler(mut multipart: Multipart) -> Result, AppError> { + // Read the first file field. + let mut file_name = String::from(""); + let mut data: Vec = Vec::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("multipart: {e}")))? + { + let name = field.name().unwrap_or("file").to_string(); + if name == "file" { + file_name = field.file_name().unwrap_or("").to_string(); + let content = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("bytes: {e}")))?; + data = content.to_vec(); + break; + } + } + + if data.is_empty() { + return Err(AppError::BadRequest( + "No file field named 'file' in multipart body".into(), + )); + } + + let result = use_cases::upload(&data, &file_name)?; + Ok(Json(result)) +} + +/// GET /uploader/file/:name — serve an uploaded file. +pub async fn serve_file_handler(Path(name): Path) -> Result, AppError> { + let (bytes, mime) = use_cases::serve(&name)?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(bytes)) + .unwrap()) +} diff --git a/src/presentation/router.rs b/src/presentation/router.rs index 653b25e..9c8a5b3 100644 --- a/src/presentation/router.rs +++ b/src/presentation/router.rs @@ -382,6 +382,15 @@ pub fn build_router(app_state: Arc) -> anyhow::Result { "/image/brat/animated", axum::routing::get(crate::presentation::handler::image::brat_animated_handler), ) + // Uploader routes + .route( + "/uploader/ryzencdn", + axum::routing::post(crate::presentation::handler::uploader::ryzencdn_handler), + ) + .route( + "/uploader/file/{name}", + axum::routing::get(crate::presentation::handler::uploader::serve_file_handler), + ) // Health .route( "/health",