v0.2.0
This commit is contained in:
1 parent
fc81ecafd6
commit
dc57aa7089
89 files changed
+5290
-1551
No files matched your search
+54
-87
@@ -1,46 +1,17 @@
|
||||
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 sha2::{Digest, Sha256};
|
||||
|
||||
use crate::auth::session::SessionState;
|
||||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::storage::{BlobStorage, attachment_key};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UploadResponse {
|
||||
@@ -58,6 +29,7 @@ struct DeleteResponse {
|
||||
pub async fn upload_attachment(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
AxumPath(draft_id): AxumPath<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Response, AppError> {
|
||||
@@ -74,15 +46,6 @@ pub async fn upload_attachment(
|
||||
.map_err(AppError::InternalError)?;
|
||||
}
|
||||
|
||||
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();
|
||||
let max_attachment_size = config.max_attachment_size_bytes();
|
||||
|
||||
@@ -97,19 +60,20 @@ pub async fn upload_attachment(
|
||||
.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 key = attachment_key(&session.user_hash, &draft_id, &att_id)
|
||||
.map_err(AppError::InternalError)?;
|
||||
let mut writer = storage
|
||||
.begin_upload(&key)
|
||||
.await
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("Storage unavailable: {e}")))?;
|
||||
let mut size: usize = 0;
|
||||
let mut checksum = Sha256::new();
|
||||
|
||||
loop {
|
||||
let chunk = match field.chunk().await {
|
||||
Ok(chunk) => chunk,
|
||||
Err(e) => {
|
||||
drop(file);
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
let _ = writer.abort().await;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Failed to read field data: {e}"
|
||||
)));
|
||||
@@ -118,47 +82,43 @@ pub async fn upload_attachment(
|
||||
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;
|
||||
let _ = writer.abort().await;
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"File '{}' exceeds maximum size of {} MiB",
|
||||
filename, config.max_attachment_size_mb
|
||||
)));
|
||||
}
|
||||
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}"
|
||||
checksum.update(&chunk);
|
||||
if let Err(e) = writer.write(chunk).await {
|
||||
let _ = writer.abort().await;
|
||||
return Err(AppError::ServiceUnavailable(format!(
|
||||
"Failed to upload 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 stat = writer.finish().await.map_err(|e| {
|
||||
AppError::ServiceUnavailable(format!("Failed to finalize attachment upload: {e}"))
|
||||
})?;
|
||||
if stat.size != size as u64 {
|
||||
let _ = storage.delete(&key).await;
|
||||
return Err(AppError::InternalError(format!(
|
||||
"Attachment size verification failed for '{filename}'"
|
||||
)));
|
||||
}
|
||||
let checksum = format!("{:x}", checksum.finalize());
|
||||
let size = size as i64;
|
||||
if let Err(e) = db::drafts::add_draft_attachment(
|
||||
&conn,
|
||||
&att_id,
|
||||
&draft_id,
|
||||
&filename,
|
||||
&content_type,
|
||||
let attachment = db::drafts::NewDraftAttachment {
|
||||
id: &att_id,
|
||||
draft_id: &draft_id,
|
||||
filename: &filename,
|
||||
content_type: &content_type,
|
||||
size,
|
||||
file_path.to_str().unwrap_or(""),
|
||||
) {
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
storage_key: &key,
|
||||
checksum_sha256: Some(&checksum),
|
||||
};
|
||||
if let Err(e) = db::drafts::add_draft_attachment(&conn, &attachment) {
|
||||
let _ = storage.delete(&key).await;
|
||||
return Err(AppError::InternalError(e));
|
||||
}
|
||||
|
||||
@@ -183,6 +143,7 @@ pub async fn upload_attachment(
|
||||
pub async fn get_attachment_content(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>,
|
||||
) -> Result<Response, AppError> {
|
||||
db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?;
|
||||
@@ -191,26 +152,28 @@ pub async fn get_attachment_content(
|
||||
|
||||
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)
|
||||
let stream = storage
|
||||
.get(&attachment.storage_key)
|
||||
.await
|
||||
.map_err(|e| AppError::InternalError(format!("Failed to read attachment file: {e}")))?;
|
||||
.map_err(|e| AppError::ServiceUnavailable(format!("Storage read failed: {e}")))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.header("content-type", &attachment.content_type)
|
||||
.header("content-length", attachment.size.max(0).to_string())
|
||||
.header("cache-control", "private, max-age=3600")
|
||||
.body(axum::body::Body::from(data))
|
||||
.body(axum::body::Body::from_stream(stream))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
pub async fn delete_attachment(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(storage): Extension<Arc<dyn BlobStorage>>,
|
||||
AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>,
|
||||
) -> Result<Response, AppError> {
|
||||
db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?;
|
||||
@@ -219,18 +182,22 @@ pub async fn delete_attachment(
|
||||
|
||||
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 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");
|
||||
let storage_key = attachment.storage_key.clone();
|
||||
let deleted = db::drafts::delete_draft_attachment(&conn, &attachment_id)
|
||||
.map_err(AppError::InternalError)?;
|
||||
if !deleted {
|
||||
return Err(AppError::NotFound("Attachment not found".to_string()));
|
||||
}
|
||||
if let Err(error) = storage.delete(&storage_key).await {
|
||||
// SQL is authoritative. A failed physical delete leaves an orphaned blob,
|
||||
// which is safer than leaving a DB row pointing at a missing attachment.
|
||||
tracing::warn!(error = %error, storage_key = %storage_key, "Failed to remove deleted attachment blob");
|
||||
}
|
||||
|
||||
db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?;
|
||||
|
||||
Ok(Json(DeleteResponse {
|
||||
status: "deleted".to_string(),
|
||||
|
||||
Reference in new issue
Block a user