first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::extract::Multipart;
use axum::extract::Path as AxumPath;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Serialize;
use tokio::io::AsyncWriteExt;
use crate::auth::session::SessionState;
use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
/// Maximum attachment file size: 25 MB.
const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024;
const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
async fn cleanup_stale_upload_parts(dir: &Path) {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let is_part = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".part"));
if !is_part {
continue;
}
let stale = entry
.metadata()
.await
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= STALE_PART_MAX_AGE);
if stale {
let _ = tokio::fs::remove_file(path).await;
}
}
}
#[derive(Debug, Serialize)]
struct UploadResponse {
id: String,
filename: String,
content_type: String,
size: i64,
}
#[derive(Debug, Serialize)]
struct DeleteResponse {
status: String,
}
/// Handler for `POST /api/drafts/{draft_id}/attachments`.
///
/// Accepts a multipart upload containing one or more files. Each file is
/// saved to disk and recorded in the `draft_attachments` table.
pub async fn upload_attachment(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
AxumPath(draft_id): AxumPath<String>,
mut multipart: Multipart,
) -> Result<Response, AppError> {
db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
// Ensure draft exists (create it if not — auto-creates on first attachment upload).
let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?;
if existing.is_none() {
db::drafts::upsert_draft(
&conn, &draft_id, "", "", "", "", "", None, None, None, false, "normal", "html",
"none", false, false, false,
)
.map_err(AppError::InternalError)?;
}
// Build the attachment storage directory.
let att_dir = Path::new(&config.data_dir)
.join(&session.user_hash)
.join("attachments")
.join(&draft_id);
tokio::fs::create_dir_all(&att_dir)
.await
.map_err(|e| AppError::InternalError(format!("Failed to create attachment dir: {e}")))?;
cleanup_stale_upload_parts(&att_dir).await;
let mut uploaded = Vec::new();
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))?
{
let filename = field.file_name().unwrap_or("unnamed").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let att_id = uuid::Uuid::new_v4().to_string();
let file_path = att_dir.join(&att_id);
let part_path = att_dir.join(format!(".{att_id}.part"));
let mut file = tokio::fs::File::create(&part_path).await.map_err(|e| {
AppError::InternalError(format!("Failed to create attachment temp file: {e}"))
})?;
let mut size: usize = 0;
loop {
let chunk = match field.chunk().await {
Ok(chunk) => chunk,
Err(e) => {
drop(file);
let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::BadRequest(format!(
"Failed to read field data: {e}"
)));
}
};
let Some(chunk) = chunk else { break };
size = size.saturating_add(chunk.len());
if size > MAX_ATTACHMENT_SIZE {
drop(file);
let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::BadRequest(format!(
"File '{}' exceeds maximum size of 25 MB",
filename
)));
}
if let Err(e) = file.write_all(&chunk).await {
drop(file);
let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::InternalError(format!(
"Failed to write attachment: {e}"
)));
}
}
if let Err(e) = file.flush().await {
drop(file);
let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::InternalError(format!(
"Failed to flush attachment: {e}"
)));
}
drop(file);
if let Err(e) = tokio::fs::rename(&part_path, &file_path).await {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(AppError::InternalError(format!(
"Failed to finalize attachment: {e}"
)));
}
let size = size as i64;
if let Err(e) = db::drafts::add_draft_attachment(
&conn,
&att_id,
&draft_id,
&filename,
&content_type,
size,
file_path.to_str().unwrap_or(""),
) {
let _ = tokio::fs::remove_file(&file_path).await;
return Err(AppError::InternalError(e));
}
uploaded.push(UploadResponse {
id: att_id,
filename,
content_type,
size,
});
}
if uploaded.is_empty() {
return Err(AppError::BadRequest("No files uploaded".to_string()));
}
Ok(Json(serde_json::json!({
"attachments": uploaded,
}))
.into_response())
}
/// Handler for `GET /api/drafts/{draft_id}/attachments/{attachment_id}/content`.
///
/// Serves the raw file content of a draft attachment for inline preview.
pub async fn get_attachment_content(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>,
) -> Result<Response, AppError> {
db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let attachments =
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
let attachment = attachments
.iter()
.find(|a| a.id == attachment_id)
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
let data = tokio::fs::read(&attachment.file_path)
.await
.map_err(|e| AppError::InternalError(format!("Failed to read attachment file: {e}")))?;
Ok(Response::builder()
.header("content-type", &attachment.content_type)
.header("cache-control", "private, max-age=3600")
.body(axum::body::Body::from(data))
.unwrap())
}
/// Handler for `DELETE /api/drafts/{draft_id}/attachments/{attachment_id}`.
///
/// Removes an attachment from the database and deletes the file from disk.
pub async fn delete_attachment(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>,
) -> Result<Response, AppError> {
db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
// Get the attachment record so we can find the file path.
let attachments =
db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?;
let attachment = attachments
.iter()
.find(|a| a.id == attachment_id)
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
// Delete file from disk (best-effort).
let file_path = attachment.file_path.clone();
if let Err(e) = tokio::fs::remove_file(&file_path).await {
tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk");
}
// Delete from DB.
db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?;
Ok(Json(DeleteResponse {
status: "deleted".to_string(),
})
.into_response())
}