This commit is contained in:
2025-10-09 22:16:29 +02:00
commit 768f8cb21c
33 changed files with 12047 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
use std::collections::HashMap;
use std::time::Duration;
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::exists;
use diesel::{prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult};
use crate::models::{
Document, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag,
};
use crate::schema::{document_tags, document_versions, documents, folders, tags};
use crate::state::AppState;
const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300;
#[derive(Deserialize)]
pub struct DocumentListQuery {
pub folder_id: Option<Uuid>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Serialize)]
pub struct TagResponse {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
}
impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self {
Self {
id: tag.id,
label: tag.label,
color: tag.color,
}
}
}
#[derive(Serialize)]
pub struct DocumentResponse {
pub id: Uuid,
pub filename: String,
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub uploaded_at: String,
pub updated_at: String,
pub deleted_at: Option<String>,
pub metadata: Value,
pub tags: Vec<TagResponse>,
}
#[derive(Serialize)]
pub struct DocumentVersionResponse {
pub id: Uuid,
pub version_number: i32,
pub s3_key: String,
pub size_bytes: i64,
pub checksum: String,
pub created_at: String,
pub operations_summary: Value,
}
#[derive(Serialize)]
pub struct DocumentDetailResponse {
pub document: DocumentResponse,
pub current_version: DocumentVersionResponse,
}
#[derive(Serialize)]
pub struct DocumentDownloadResponse {
pub url: String,
pub expires_in: u64,
pub filename: String,
pub content_type: Option<String>,
pub size_bytes: i64,
}
#[derive(Deserialize)]
pub struct MoveDocumentRequest {
pub folder_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>,
}
pub async fn list_documents(
State(state): State<AppState>,
Query(query): Query<DocumentListQuery>,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let mut base_query = documents::table.into_boxed();
if !query.include_deleted {
base_query = base_query.filter(documents::deleted_at.is_null());
}
match query.folder_id {
Some(folder_id) => {
base_query = base_query.filter(documents::folder_id.eq(Some(folder_id)));
}
None => {
base_query = base_query.filter(documents::folder_id.is_null());
}
}
let docs: Vec<Document> = base_query
.order(documents::uploaded_at.desc())
.load(&mut conn)?;
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
Ok(Json(response))
}
pub async fn get_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let current_version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.first(&mut conn)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
Ok(Json(DocumentDetailResponse {
document: to_document_response(doc, tags_map.get(&document_id).cloned()),
current_version: to_version_response(current_version),
}))
}
pub async fn upload_document(
State(state): State<AppState>,
_user: AuthenticatedUser,
mut multipart: Multipart,
) -> AppResult<Json<DocumentDetailResponse>> {
let mut file_bytes: Option<Vec<u8>> = None;
let mut original_name: Option<String> = None;
let mut content_type: Option<String> = None;
let mut folder_id: Option<Uuid> = None;
let mut metadata: Value = Value::Object(Default::default());
while let Some(field) = multipart
.next_field()
.await
.map_err(|err| AppError::bad_request(format!("invalid multipart data: {err}")))?
{
let name = field.name().map(|n| n.to_string());
match name.as_deref() {
Some("file") => {
let file_name = field.file_name().map(|n| n.to_string());
original_name = file_name.clone();
content_type = field.content_type().map(|mime| mime.to_string());
let data = field.bytes().await.map_err(|err| {
AppError::bad_request(format!("failed to read file bytes: {err}"))
})?;
file_bytes = Some(data.to_vec());
}
Some("folder_id") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid folder id: {err}")))?;
if !value.trim().is_empty() {
let parsed = Uuid::parse_str(value.trim())
.map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?;
folder_id = Some(parsed);
}
}
Some("metadata") => {
let value = field
.text()
.await
.map_err(|err| AppError::bad_request(format!("invalid metadata: {err}")))?;
metadata = serde_json::from_str(&value).map_err(|err| {
AppError::bad_request(format!("metadata must be valid JSON: {err}"))
})?;
}
_ => {}
}
}
let file_bytes = file_bytes.ok_or_else(|| AppError::bad_request("file field is required"))?;
let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string());
if let Some(folder_id) = folder_id {
ensure_folder_exists(&state, folder_id)?;
}
let doc_id = Uuid::new_v4();
let version_id = Uuid::new_v4();
let version_number = 1;
let stored_filename = original_name.clone();
let checksum = Sha256::digest(&file_bytes);
let checksum_hex = hex::encode(checksum);
let size_bytes = file_bytes.len() as i64;
let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}");
{
let mut conn = state.db()?;
let existing = documents::table
.inner_join(
document_versions::table.on(document_versions::document_id
.eq(documents::id)
.and(document_versions::version_number.eq(documents::current_version))),
)
.filter(document_versions::checksum.eq(&checksum_hex))
.select((documents::all_columns, document_versions::all_columns))
.first::<(Document, DocumentVersion)>(&mut conn)
.optional()?;
if let Some((mut document, version)) = existing {
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
.set((
documents::deleted_at.eq(None::<NaiveDateTime>),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.deleted_at = None;
document.updated_at = now;
}
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let tags = tags_map.get(&document.id).cloned();
return Ok(Json(DocumentDetailResponse {
document: to_document_response(document, tags),
current_version: to_version_response(version),
}));
}
}
let mut put_request = state
.s3
.put_object()
.bucket(&state.config.s3_bucket)
.key(&s3_key)
.body(ByteStream::from(file_bytes.clone()));
if let Some(ref ct) = content_type {
put_request = put_request.content_type(ct.clone());
}
put_request
.send()
.await
.map_err(|err| AppError::internal(format!("failed to upload to s3: {err}")))?;
let metadata_value = if metadata.is_null() {
Value::Object(Default::default())
} else {
metadata
};
let (document, version) = {
let mut conn = state.db()?;
conn.transaction(|conn| {
let new_document = NewDocument {
id: doc_id,
filename: stored_filename.clone(),
original_name: original_name.clone(),
content_type: content_type.clone(),
folder_id,
current_version: version_number,
metadata: metadata_value.clone(),
};
diesel::insert_into(documents::table)
.values(&new_document)
.execute(conn)?;
let new_version = NewDocumentVersion {
id: version_id,
document_id: doc_id,
version_number,
s3_key: s3_key.clone(),
size_bytes,
checksum: checksum_hex.clone(),
operations_summary: Value::Object(Default::default()),
};
diesel::insert_into(document_versions::table)
.values(&new_version)
.execute(conn)?;
let document: Document = documents::table.find(doc_id).first(conn)?;
let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?;
Ok::<_, diesel::result::Error>((document, version))
})?
};
let response = DocumentDetailResponse {
document: to_document_response(document, None),
current_version: to_version_response(version),
};
Ok(Json(response))
}
pub async fn download_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<Json<DocumentDownloadResponse>> {
let mut conn = state.db()?;
let doc: Document = documents::table.find(document_id).first(&mut conn)?;
if doc.deleted_at.is_some() {
return Err(AppError::not_found());
}
let version: DocumentVersion = document_versions::table
.filter(document_versions::document_id.eq(document_id))
.filter(document_versions::version_number.eq(doc.current_version))
.first(&mut conn)?;
let presign_config = PresigningConfig::builder()
.expires_in(Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
.build()
.map_err(|err| AppError::internal(format!("failed to build presigning config: {err}")))?;
let presigned = state
.s3
.get_object()
.bucket(&state.config.s3_bucket)
.key(&version.s3_key)
.presigned(presign_config)
.await
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
Ok(Json(DocumentDownloadResponse {
url: presigned.uri().to_string(),
expires_in: PRESIGNED_URL_EXPIRY_SECONDS,
filename: doc.original_name.clone(),
content_type: doc.content_type.clone(),
size_bytes: version.size_bytes,
}))
}
pub async fn delete_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::deleted_at.eq(Some(now)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn move_document(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
Json(payload): Json<MoveDocumentRequest>,
) -> AppResult<impl IntoResponse> {
if let Some(folder_id) = payload.folder_id {
ensure_folder_exists(&state, folder_id)?;
}
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document_id))
.set((
documents::folder_id.eq(payload.folder_id),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn assign_tags(
State(state): State<AppState>,
Path(document_id): Path<Uuid>,
user: AuthenticatedUser,
Json(payload): Json<AssignTagsRequest>,
) -> AppResult<impl IntoResponse> {
if payload.tag_ids.is_empty() {
return Err(AppError::bad_request("tag_ids must not be empty"));
}
let mut conn = state.db()?;
// Ensure document exists
documents::table
.find(document_id)
.first::<Document>(&mut conn)?;
// Ensure tags exist
let existing_tags: Vec<Tag> = tags::table
.filter(tags::id.eq_any(&payload.tag_ids))
.load(&mut conn)?;
if existing_tags.len() != payload.tag_ids.len() {
return Err(AppError::bad_request("one or more tags do not exist"));
}
let new_tags: Vec<NewDocumentTag> = payload
.tag_ids
.iter()
.map(|tag_id| NewDocumentTag {
document_id,
tag_id: *tag_id,
assigned_by: Some(user.user_id),
})
.collect();
diesel::insert_into(document_tags::table)
.values(&new_tags)
.on_conflict_do_nothing()
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn remove_tag(
State(state): State<AppState>,
Path((document_id, tag_id)): Path<(Uuid, Uuid)>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
diesel::delete(
document_tags::table
.filter(document_tags::document_id.eq(document_id))
.filter(document_tags::tag_id.eq(tag_id)),
)
.execute(&mut conn)?;
Ok(StatusCode::NO_CONTENT)
}
fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> {
let mut conn = state.db()?;
let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id))))
.get_result(&mut conn)?;
if !exists {
return Err(AppError::bad_request("folder does not exist"));
}
Ok(())
}
pub(crate) fn load_tags_for_documents(
conn: &mut PgConnection,
document_ids: &[Uuid],
) -> AppResult<HashMap<Uuid, Vec<Tag>>> {
if document_ids.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(Uuid, Tag)> = document_tags::table
.inner_join(tags::table)
.filter(document_tags::document_id.eq_any(document_ids))
.select((document_tags::document_id, tags::all_columns))
.load(conn)?;
let mut map: HashMap<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
pub(crate) fn to_document_response(doc: Document, tags: Option<Vec<Tag>>) -> DocumentResponse {
DocumentResponse {
id: doc.id,
filename: doc.filename,
original_name: doc.original_name,
content_type: doc.content_type,
folder_id: doc.folder_id,
current_version: doc.current_version,
uploaded_at: to_iso(doc.uploaded_at),
updated_at: to_iso(doc.updated_at),
deleted_at: doc.deleted_at.map(to_iso),
metadata: doc.metadata,
tags: tags
.unwrap_or_default()
.into_iter()
.map(TagResponse::from)
.collect(),
}
}
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
DocumentVersionResponse {
id: version.id,
version_number: version.version_number,
s3_key: version.s3_key,
size_bytes: version.size_bytes,
checksum: version.checksum,
created_at: to_iso(version.created_at),
operations_summary: version.operations_summary,
}
}
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc).to_rfc3339()
}