feat(uploader): add ryzencdn local-disk uploader (POST multipart + GET serve)
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Deploy Scraper / build-and-deploy (push) Canceled after 0s
Shirokami-API source uploads to Ryzumi S3 (s3.ryzumi.vip) which is DNS-dead
from this VPS. Implemented a local-disk uploader keeping the source's response
shape {success, url, fileName, size}:
- POST /uploader/ryzencdn (multipart 'file' field, magic-byte ext detection)
- GET /uploader/file/{name} (path-traversal safe, MIME by extension)
Files stored under /var/lib/scraper/uploads (owned by scraper user).
This commit is contained in:
@@ -8,4 +8,5 @@ pub mod proxy;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod uploader;
|
||||||
pub mod weebs;
|
pub mod weebs;
|
||||||
|
|||||||
@@ -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<Value, ScrapingError> {
|
||||||
|
Repo::save_file(buffer, file_name).map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serve(file_name: &str) -> Result<(Vec<u8>, String), ScrapingError> {
|
||||||
|
Repo::read_file(file_name).map_err(ScrapingError::Http)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod proxy;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod uploader;
|
||||||
pub mod weebs;
|
pub mod weebs;
|
||||||
|
|
||||||
pub use alqanime::AlqanimeRepository;
|
pub use alqanime::AlqanimeRepository;
|
||||||
|
|||||||
@@ -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<Value, String> {
|
||||||
|
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<u8>, 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ pub mod proxy;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod stalk;
|
pub mod stalk;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod uploader;
|
||||||
pub mod weebs;
|
pub mod weebs;
|
||||||
|
|||||||
@@ -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<Json<Value>, AppError> {
|
||||||
|
// Read the first file field.
|
||||||
|
let mut file_name = String::from("");
|
||||||
|
let mut data: Vec<u8> = 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<String>) -> Result<Response<Body>, 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())
|
||||||
|
}
|
||||||
@@ -382,6 +382,15 @@ pub fn build_router(app_state: Arc<AppState>) -> anyhow::Result<Router> {
|
|||||||
"/image/brat/animated",
|
"/image/brat/animated",
|
||||||
axum::routing::get(crate::presentation::handler::image::brat_animated_handler),
|
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
|
// Health
|
||||||
.route(
|
.route(
|
||||||
"/health",
|
"/health",
|
||||||
|
|||||||
Reference in New Issue
Block a user