diff --git a/.sqlx/query-bdb8d776b5cbf82b35b2d24a3fdb4ca049d36d8370861ba4be25372e542a0ba1.json b/.sqlx/query-bdb8d776b5cbf82b35b2d24a3fdb4ca049d36d8370861ba4be25372e542a0ba1.json new file mode 100644 index 0000000..1ec0cb5 --- /dev/null +++ b/.sqlx/query-bdb8d776b5cbf82b35b2d24a3fdb4ca049d36d8370861ba4be25372e542a0ba1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM file_key WHERE id = $1 AND file_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bdb8d776b5cbf82b35b2d24a3fdb4ca049d36d8370861ba4be25372e542a0ba1" +} diff --git a/.sqlx/query-ed6ee326516d37d078ce80b39d769747a88683fab15feb466c848c2f2ba65c50.json b/.sqlx/query-ed6ee326516d37d078ce80b39d769747a88683fab15feb466c848c2f2ba65c50.json new file mode 100644 index 0000000..5592394 --- /dev/null +++ b/.sqlx/query-ed6ee326516d37d078ce80b39d769747a88683fab15feb466c848c2f2ba65c50.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM file WHERE id = $1 RETURNING hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ed6ee326516d37d078ce80b39d769747a88683fab15feb466c848c2f2ba65c50" +} diff --git a/migrations/20240416191149_create-link-file.up.sql b/migrations/20240416191149_create-link-file.up.sql index 1f9dbe5..d386000 100644 --- a/migrations/20240416191149_create-link-file.up.sql +++ b/migrations/20240416191149_create-link-file.up.sql @@ -12,5 +12,5 @@ CREATE TABLE IF NOT EXISTS file ( CREATE TABLE IF NOT EXISTS file_key ( id UUID PRIMARY KEY, - file_id UUID REFERENCES file (id) NOT NULL + file_id UUID REFERENCES file (id) ON DELETE CASCADE NOT NULL ); diff --git a/src/app/api/files.rs b/src/app/api/files.rs index f82341e..125c3f7 100644 --- a/src/app/api/files.rs +++ b/src/app/api/files.rs @@ -3,11 +3,13 @@ use std::path::PathBuf; use axum::{ body::Body, extract::{Path, State}, - Json, + routing::{delete, get, post}, + Json, Router, }; -use axum_extra::{routing::Resource, TypedHeader}; +use axum_extra::TypedHeader; use futures_util::TryStreamExt; use headers::ContentType; +use http::StatusCode; use mime::Mime; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -20,10 +22,13 @@ use uuid::Uuid; use crate::{app::SharedState, error::AppError}; -pub fn resource() -> Resource { - Resource::named("files") - .create(upload_file) - .show(get_file_info) +pub fn resource() -> Router { + Router::new() + .route("/files", post(upload_file)) + .route("/files/:file_id", get(get_file_info)) + .route("/files/:file_id", delete(delete_file)) + .route("/files/:file_id/keys/", post(create_file_key)) + .route("/files/:file_id/keys/:key_id", delete(delete_file_key)) } #[derive(Serialize)] @@ -34,7 +39,7 @@ struct File { keys: Vec, } -#[instrument(skip(db, body))] +#[instrument(skip_all)] async fn upload_file( State(SharedState { db, config }): State, TypedHeader(content_type): TypedHeader, @@ -150,6 +155,64 @@ async fn get_file_info( mime: r.mime, keys: keys.into_iter().map(|r| r.id.into()).collect(), })), - None => Err(AppError::FileNotFoundId(id)), + None => Err(AppError::FileNotFound(id)), + } +} + +#[instrument(skip_all)] +async fn delete_file( + State(SharedState { db, config }): State, + Path(file_id): Path, +) -> Result { + let file_hash = query!( + "DELETE FROM file WHERE id = $1 RETURNING hash", + Uuid::from(file_id) + ) + .fetch_optional(&db) + .await? + .ok_or(AppError::FileNotFound(file_id))? + .hash; + let file_path = config.file_store_dir.join(hex::encode(file_hash)); + if let Err(err) = fs::remove_file(file_path).await { + error!(err = field::display(err), "failed to remove file"); + } + Ok(StatusCode::NO_CONTENT) +} + +async fn create_file_key( + State(SharedState { db, .. }): State, + Path(file_id): Path, +) -> Result<(StatusCode, Json), AppError> { + let key_id = Ulid::new(); + match query!( + "INSERT INTO file_key (id, file_id) VALUES ($1, $2)", + Uuid::from(key_id), + Uuid::from(file_id), + ) + .execute(&db) + .await? + .rows_affected() + { + 1 => Ok((StatusCode::CREATED, Json(key_id))), + rows => Err(AppError::ImpossibleAffectedRows(rows)), + } +} + +async fn delete_file_key( + State(SharedState { db, .. }): State, + Path((file_id, key_id)): Path<(Ulid, Ulid)>, +) -> Result { + match query!( + "DELETE FROM file_key WHERE id = $1 AND file_id = $2", + Uuid::from(key_id), + Uuid::from(file_id), + ) + .execute(&db) + .await? + .rows_affected() + { + 1 => Ok(StatusCode::NO_CONTENT), + 0 => Err(AppError::FileKeyNotFound(key_id)), + rows => Err(AppError::ImpossibleAffectedRows(rows)), } } diff --git a/src/app/root.rs b/src/app/root.rs index 3f1d51a..1e3e74f 100644 --- a/src/app/root.rs +++ b/src/app/root.rs @@ -41,26 +41,21 @@ async fn download_file( Path(key): Path, request: Request, ) -> Result>, AppError> { - match query!( + let file = query!( "SELECT hash, mime FROM file_key JOIN file ON file_id = file.id WHERE file_key.id = $1", Uuid::from(key), ) .fetch_optional(&db) .await? - .map(|r| (r.hash, r.mime)) - { - Some((hash, mime)) => { - let mime: Option = mime.parse().ok(); - let path = config.file_store_dir.join(hex::encode(hash)); - let mut sf = match mime { - Some(mime) => ServeFile::new_with_mime(path, &mime), - None => ServeFile::new(path), - }; - match sf.try_call(request).await { - Ok(response) => Ok(response.map(|body| body.map_err(Into::into).boxed_unsync())), - Err(err) => Err(AppError::Io(err)), - } - } - None => Err(AppError::FileKeyNotFound(key)), + .ok_or(AppError::FileKeyNotFound(key))?; + let mime: Option = file.mime.parse().ok(); + let path = config.file_store_dir.join(hex::encode(file.hash)); + let mut sf = match mime { + Some(mime) => ServeFile::new_with_mime(path, &mime), + None => ServeFile::new(path), + }; + match sf.try_call(request).await { + Ok(response) => Ok(response.map(|body| body.map_err(Into::into).boxed_unsync())), + Err(err) => Err(AppError::Io(err)), } } diff --git a/src/error.rs b/src/error.rs index 214203e..4d04985 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,14 +14,14 @@ pub enum AppError { #[error("link not found ({0})")] LinkNotFoundSlug(String), #[error("file not found ({0})")] - FileNotFoundId(Ulid), + FileNotFound(Ulid), #[error("file key not found ({0})")] FileKeyNotFound(Ulid), #[error("file is missing ({0})")] FileMissing(PathBuf), #[error("database returned an impossible number of affected rows ({0})")] ImpossibleAffectedRows(u64), - #[error("database error")] + #[error("database error: {0}")] Database(#[from] sqlx::Error), #[error(transparent)] Io(#[from] std::io::Error), @@ -37,7 +37,7 @@ impl IntoResponse for AppError { Self::LinkNotFoundId(_) | Self::LinkNotFoundSlug(_) => { (StatusCode::NOT_FOUND, "Link not found") } - Self::FileNotFoundId(_) => (StatusCode::NOT_FOUND, "File not found"), + Self::FileNotFound(_) => (StatusCode::NOT_FOUND, "File not found"), Self::FileKeyNotFound(_) => (StatusCode::NOT_FOUND, "File key not found"), Self::FileMissing(_) => (StatusCode::INTERNAL_SERVER_ERROR, "File is missing"), Self::ImpossibleAffectedRows(_) => (