Files
infra/apps/tools/backend/gateway/src/routes/upload.rs
T
asepharyanaandKilo 67288c8723 feat(infra): add tools service with document scanner, image & PDF tools
Implement self-hosted document scanner and media processing tools as
an alternative to CamScanner/ilovepdf without third-party uploads.

Backend: Rust Axum gateway + worker pool with NATS JetStream queue
Frontend: Next.js 16 + shadcn/ui + Tailwind v4 + Framer Motion
Pipeline: Canny edge detection -> DLT homography warp -> Sauvola
binarization -> Hough deskew -> Tesseract OCR -> searchable PDF

Phase 1 (MVP) delivers:
- Document scanner with perspective correction and OCR
- Image compress/resize/convert tools
- PDF merge/split/compress tools
- Real-time WebSocket progress updates
- Rate limiting, auto-cleanup, Prometheus metrics
- Full CI/CD pipeline with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 13:08:09 +07:00

261 lines
8.2 KiB
Rust

use std::sync::Arc;
use axum::{
extract::{Multipart, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use tokio::fs;
use uuid::Uuid;
use crate::routes::health::AppState;
use tools_common::error::UploadError;
use tools_common::types::*;
/// Handle POST /api/upload
pub async fn upload_handler(
State(state): State<Arc<AppState>>,
mut multipart: Multipart,
) -> Result<Json<UploadResponse>, (StatusCode, Json<serde_json::Value>)> {
let config = &state.config;
let max_size = config.max_file_size_bytes();
// Extract fields from multipart
let mut file_data: Option<(String, Vec<u8>)> = None;
let mut tool_str: Option<String> = None;
let mut options: serde_json::Value = serde_json::Value::Null;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"file" => {
let filename = field.file_name().unwrap_or("unknown").to_string();
let data = field.bytes().await.map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Failed to read file",
"detail": e.to_string()
})),
)
})?;
file_data = Some((filename, data.to_vec()));
}
"tool" => {
tool_str = Some(field.text().await.unwrap_or_default());
}
"options" => {
let text = field.text().await.unwrap_or_default();
if !text.is_empty() {
options = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
}
}
_ => {}
}
}
// Validate fields
let (filename, data) = file_data.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "No file provided" })),
)
})?;
let tool_str = tool_str.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "No tool specified" })),
)
})?;
let tool = Tool::from_str(&tool_str).ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("Unknown tool: {}", tool_str) })),
)
})?;
// Validate file size
let file_size = data.len() as u64;
if file_size > max_size {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({
"error": format!("File too large: {} bytes (max {} bytes)", file_size, max_size)
})),
));
}
// Validate MIME type based on tool
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
validate_mime(&tool, &ext).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })),
)
})?;
// Verify magic bytes
if !verify_magic_bytes(&data, &ext) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "File content does not match extension" })),
));
}
// Create directories
let upload_dir = config.storage_path.join("upload");
fs::create_dir_all(&upload_dir).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Storage error: {}", e) })),
)
})?;
// Generate job ID and save file
let job_id = Uuid::new_v4();
let storage_filename = format!("{}.{}", job_id, ext);
let file_path = upload_dir.join(&storage_filename);
fs::write(&file_path, &data).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to save file: {}", e) })),
)
})?;
// Create job record
let job = Job {
id: job_id,
tool: tool.clone(),
status: JobStatus::Queued,
file_path: file_path.to_string_lossy().to_string(),
result_path: None,
file_size,
options: options.clone(),
created_at: Utc::now(),
ttl_seconds: config.job_ttl_seconds,
};
// Save to Redis
{
let mut conn = state.redis.get_multiplexed_async_connection().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Redis error: {}", e) })),
)
})?;
crate::redis::job::JobRepository::create(&mut conn, &job)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to create job: {}", e) })),
)
})?;
}
// Publish to NATS
crate::nats::publisher::NatsPublisher::publish_job(&state.nats, &tool, &job)
.await
.map_err(|e| {
tracing::error!("Failed to publish job to NATS: {}", e);
});
// Update metrics
state.metrics.increment_jobs_total(tool.as_str(), "queued");
// Return response
Ok(Json(UploadResponse {
job_id,
status: "queued".to_string(),
tool: tool_str,
ws_url: format!("/api/job/{}/ws", job_id),
created_at: job.created_at,
estimated_seconds: match tool {
Tool::Scan => 5,
_ => 3,
},
}))
}
fn validate_mime(tool: &Tool, ext: &str) -> Result<(), UploadError> {
let image_exts = ["jpg", "jpeg", "png", "webp", "heic", "bmp", "tiff", "tif"];
let pdf_exts = ["pdf"];
let video_exts = ["mp4", "webm", "avi", "mov", "mkv"];
let audio_exts = ["mp3", "wav", "flac", "aac", "ogg", "m4a"];
match tool {
Tool::Scan
| Tool::ImageCompress
| Tool::ImageResize
| Tool::ImageConvert
| Tool::RemoveBg => {
if !image_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected image file, got .{}",
ext
)));
}
}
Tool::PdfMerge | Tool::PdfSplit | Tool::PdfCompress | Tool::PdfToImages => {
if !pdf_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected PDF file, got .{}",
ext
)));
}
}
Tool::ImagesToPdf => {
if !image_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected image file, got .{}",
ext
)));
}
}
Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => {
if !video_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected video file, got .{}",
ext
)));
}
}
Tool::AudioExtract => {
if !video_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected video file, got .{}",
ext
)));
}
}
Tool::AudioConvert => {
if !audio_exts.contains(&ext) {
return Err(UploadError::InvalidMime(format!(
"Expected audio file, got .{}",
ext
)));
}
}
}
Ok(())
}
fn verify_magic_bytes(data: &[u8], ext: &str) -> bool {
if data.is_empty() {
return false;
}
match ext {
"jpg" | "jpeg" => data.starts_with(&[0xFF, 0xD8, 0xFF]),
"png" => data.starts_with(&[0x89, 0x50, 0x4E, 0x47]),
"webp" => data.len() > 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP",
"gif" => data.starts_with(b"GIF8"),
"bmp" => data.starts_with(b"BM"),
"pdf" => data.starts_with(b"%PDF"),
"mp4" => data.len() > 8 && (&data[4..8] == b"ftyp" || &data[4..8] == b"ftyp"),
"heic" => data.len() > 12 && &data[4..12] == b"ftypheic",
_ => true,
}
}