diff --git a/Cargo.lock b/Cargo.lock index af8e2bc..0107434 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1801,6 +1801,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "testdir", "time 0.3.27", "tokio", @@ -3257,9 +3258,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.7.1" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc02fddf48964c42031a0b3fe0428320ecf3a73c401040fc0096f97794310651" +checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ "cfg-if", "fastrand", diff --git a/Cargo.toml b/Cargo.toml index 2926f05..1e8e1e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ pretty_assertions = "1.3.0" rand = "0.8.5" rstest = "0.18.2" -rumqttc = "0.22.0" +rumqttc = "0.23.0" rsa = "0.9.2" reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "stream", "multipart"] } @@ -85,6 +85,7 @@ smallvec = "1.8.0" sqlx = "0.7.1" testdir = "0.8.0" +tempfile = "3.8.0" thiserror = "1.0.40" time = "0.3.27" tokio = { version = "1.30.0", features = ["full"] } diff --git a/crates/common/src/auth/user.rs b/crates/common/src/auth/user.rs index 82215ec..ca3d5c7 100644 --- a/crates/common/src/auth/user.rs +++ b/crates/common/src/auth/user.rs @@ -45,7 +45,9 @@ impl fmt::Display for User { impl User { pub(crate) fn new(email: String) -> User { User { - uuid: Uuid::new_v4().hyphenated().to_string(), + uuid: Uuid::parse_str("808c78e4-34bc-486a-902f-929e8b146d20") + .unwrap() + .to_string(), email, password: Option::None, lastname: Option::None, diff --git a/crates/common/src/database/mod.rs b/crates/common/src/database/mod.rs index a29110d..321db81 100644 --- a/crates/common/src/database/mod.rs +++ b/crates/common/src/database/mod.rs @@ -28,6 +28,7 @@ pub mod location; pub mod media_item; pub mod reference; pub mod tag; +pub mod user; #[async_trait] pub trait Database { diff --git a/crates/common/src/database/reference.rs b/crates/common/src/database/reference.rs index fb2e8f4..988dac3 100644 --- a/crates/common/src/database/reference.rs +++ b/crates/common/src/database/reference.rs @@ -18,7 +18,7 @@ use time::OffsetDateTime; pub struct Reference { - pub uuid: &'static str, + pub uuid: String, pub filepath: String, pub filename: String, pub size: u64, diff --git a/crates/database/src/sqlite.rs b/crates/database/src/sqlite.rs index e6ed51d..78975c6 100644 --- a/crates/database/src/sqlite.rs +++ b/crates/database/src/sqlite.rs @@ -28,6 +28,7 @@ use sqlx::Row; use sqlx::SqlitePool; use std::error::Error; use std::i64; +use tracing::error; use tracing::info; use uuid::Uuid; @@ -155,35 +156,42 @@ impl Database for SqliteDatabase { return match rows { Some(r) => { - info!( - "found media item with same name and taken_at for current owner. uuid = `{}`.", - r.uuid.clone() - ); + info!("Found media item with same 'name' and 'taken_at' for owner."); Ok(r.uuid) } _ => { let query = "INSERT INTO media (uuid, owner, name, is_sensitive, added_at, taken_at) VALUES ($1, $2, $3, $4, $5, $6)"; let id = Uuid::new_v4().hyphenated().to_string(); - info!("create new media item with id `{}`.", id); - sqlx::query(query) + let db_result = sqlx::query(query) .bind(id.clone()) - .bind(&user_id) - .bind(&name) + .bind(&user_id.to_string()) + .bind(&name.to_string()) .bind(false) .bind(OffsetDateTime::now_utc()) .bind(date_taken) .execute(&self.pool) - .await?; + .await; + + match db_result { + Ok(_) => { + info!("New media item created with id {}.", id) + } + Err(e) => { + error!("Could not create new media item in database! {}", e); + } + } Ok(id) } }; } + async fn get_media_item(&self, _media_id: &str) -> Result> { Err("Not implemented".into()) } + async fn add_reference( &self, user_id: &str, @@ -406,6 +414,7 @@ mod tests { Ok(()) } + //noinspection DuplicatedCode #[sqlx::test] async fn create_media_item_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { // given @@ -437,6 +446,7 @@ mod tests { Ok(()) } + //noinspection DuplicatedCode #[sqlx::test] async fn create_media_item_should_return_existing_uuid(pool: SqlitePool) -> sqlx::Result<()> { // given @@ -480,6 +490,7 @@ mod tests { Ok(()) } + //noinspection DuplicatedCode #[sqlx::test] async fn add_reference_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { // given @@ -518,7 +529,7 @@ mod tests { let metadata = std::fs::metadata(path.clone()).unwrap(); let reference = Reference { - uuid: reference_id, + uuid: reference_id.to_string(), filepath, filename: filename.to_string(), size: metadata.len(), diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 4f5f41c..55b58aa 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -23,12 +23,15 @@ database.workspace = true time.workspace = true tracing.workspace = true +tokio = { workspace = true, features = ["full"] } + # serialization serde = { workspace = true, features = ["derive"] } serde_json.workspace = true # Router axum = { workspace = true, features = ["multipart"] } +hyper = { workspace = true, features = ["full"] } tower-http.workspace = true mime.workspace = true @@ -36,13 +39,12 @@ mime.workspace = true uuid = { workspace = true, features = ["serde"] } sqlx.workspace = true rand.workspace = true +tempfile.workspace = true [dev-dependencies] # testing mockall.workspace = true rstest.workspace = true -tokio.workspace = true tower = { workspace = true, features = ["util"] } -hyper = { workspace = true, features = ["full"] } testdir.workspace = true diff --git a/crates/media/src/api/routes/get_media.rs b/crates/media/src/api/routes/get_media.rs index e225e78..9060981 100644 --- a/crates/media/src/api/routes/get_media.rs +++ b/crates/media/src/api/routes/get_media.rs @@ -40,8 +40,9 @@ pub(crate) async fn get_media( user: User, Query(query): Query, ) -> Result, StatusCode> { - let items: Result, DataAccessError> = - repo.get_media_items_for_user(Uuid::parse_str(user.uuid.as_str()).unwrap()).await; + let items: Result, DataAccessError> = repo + .get_media_items_for_user(Uuid::parse_str(user.uuid.as_str()).unwrap()) + .await; match items { Ok(i) => { error!("Found {} items for user.", i.len()); @@ -50,8 +51,6 @@ pub(crate) async fn get_media( error!("Failed to get media items!"); } } - //tracing::error!("GET /media user={}", user); - // TODO: check auth header // TODO: read list from persistency // TODO: return list Ok(Json( diff --git a/crates/media/src/api/routes/mod.rs b/crates/media/src/api/routes/mod.rs index 07010f0..b6c1d6d 100644 --- a/crates/media/src/api/routes/mod.rs +++ b/crates/media/src/api/routes/mod.rs @@ -10,3 +10,5 @@ pub(crate) mod patch_media_id; pub(crate) mod post_albums; pub(crate) mod post_media; pub(crate) mod post_media_id; + +pub(crate) mod photo_details; diff --git a/crates/media/src/api/routes/photo_details.rs b/crates/media/src/api/routes/photo_details.rs index 20deac1..9720588 100644 --- a/crates/media/src/api/routes/photo_details.rs +++ b/crates/media/src/api/routes/photo_details.rs @@ -17,6 +17,9 @@ //! Returns the details of a given media item //! +use axum::http::StatusCode; + +#[allow(dead_code)] pub(crate) async fn photo_details() -> std::result::Result { Err(StatusCode::NOT_IMPLEMENTED) } diff --git a/crates/media/src/api/routes/post_media.rs b/crates/media/src/api/routes/post_media.rs index 4eff1cf..f7e0c8c 100644 --- a/crates/media/src/api/routes/post_media.rs +++ b/crates/media/src/api/routes/post_media.rs @@ -20,22 +20,32 @@ use axum::{ extract::{Multipart, State}, http::StatusCode, + Json, }; +use common::auth::user::User; +use hyper::header::LOCATION; +use hyper::HeaderMap; +use serde::{Deserialize, Serialize}; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; -use common::auth::user::User; use tracing::debug; use uuid::Uuid; use crate::{data::error::DataAccessError, repository::MediaRepositoryState}; +#[derive(Serialize, Deserialize)] +pub struct ResponseId { + pub id: String, +} + pub(crate) async fn post_media( State(repo): State, user: User, mut multipart: Multipart, -) -> Result { +) -> Result<(StatusCode, Json), StatusCode> { let mut name = None; let mut date_taken = None; + let mut headers = HeaderMap::new(); while let Some(field) = multipart.next_field().await.unwrap() { if let Some(field_name) = field.name() { @@ -53,14 +63,16 @@ pub(crate) async fn post_media( let date = OffsetDateTime::parse(date_taken.unwrap().as_str(), &Rfc3339); if date.is_err() { - return Err(StatusCode::CREATED); + return Err(StatusCode::BAD_REQUEST); } - let result = repo.create_media_item_for_user( - Uuid::parse_str(user.uuid.as_str()).unwrap(), - name.clone().unwrap(), - date.unwrap(), - ).await; + let result = repo + .create_media_item_for_user( + Uuid::parse_str(user.uuid.as_str()).unwrap(), + name.clone().unwrap(), + date.unwrap(), + ) + .await; match result { Ok(uuid) => { @@ -71,12 +83,20 @@ pub(crate) async fn post_media( uuid.clone().hyphenated().to_string() ); - Ok(uuid.hyphenated().to_string()) + Ok(( + StatusCode::OK, + Json(ResponseId { + id: uuid.hyphenated().to_string(), + }), + )) } Err(error) => { match error { - DataAccessError::AlreadyExist => { + DataAccessError::AlreadyExist(id) => { // TODO: use Redirect::permanent to add a Location header to the already existing item + let location = format!("/media/{}", id); + headers.insert(LOCATION, location.parse().unwrap()); + return Err(StatusCode::SEE_OTHER); } _ => { @@ -102,10 +122,10 @@ mod tests { use tower::ServiceExt; use crate::api::router::MediaApi; - use std::io::Write; - use std::path::PathBuf; use axum::http::header::CONTENT_TYPE; use hyper::header::CONNECTION; + use std::io::Write; + use std::path::PathBuf; use testdir::testdir; use tokio::io::AsyncReadExt; @@ -161,7 +181,7 @@ mod tests { .header(CONNECTION, "Keep-Alive") .header( CONTENT_TYPE, - format!("multipart/form-data; boundary={}", BOUNDARY) + format!("multipart/form-data; boundary={}", BOUNDARY), ) // .header(CONTENT_TYPE, &*format!("multipart/form-data; boundary={}", BOUNDARY)) .body(data.into()) @@ -184,7 +204,10 @@ mod tests { write!(data, "\r\n")?; write!(data, "--{}\r\n", BOUNDARY)?; - write!(data, "Content-Disposition: form-data; name=\"date_taken\";\r\n")?; + write!( + data, + "Content-Disposition: form-data; name=\"date_taken\";\r\n" + )?; write!(data, "\r\n")?; write!(data, "1985-04-12T23:20:50.52Z")?; write!(data, "\r\n")?; @@ -202,7 +225,10 @@ mod tests { let mut data: Vec = Vec::new(); write!(data, "--{}\r\n", BOUNDARY)?; - write!(data, "Content-Disposition: form-data; name=\"DSC_1234\"; filename=\"11.jpg\"\r\n")?; + write!( + data, + "Content-Disposition: form-data; name=\"DSC_1234\"; filename=\"11.jpg\"\r\n" + )?; write!(data, "Content-Type: image/jpeg\r\n")?; write!(data, "\r\n")?; diff --git a/crates/media/src/api/routes/post_media_id.rs b/crates/media/src/api/routes/post_media_id.rs index 13f5d3c..75342f0 100644 --- a/crates/media/src/api/routes/post_media_id.rs +++ b/crates/media/src/api/routes/post_media_id.rs @@ -18,25 +18,54 @@ //! Add files for a specific media item //! -use axum::extract::{Multipart, Path}; +use axum::extract::{Multipart, Path, State}; use axum::http::StatusCode; use common::auth::user::User; +use hyper::header::LOCATION; +use hyper::HeaderMap; +use tempfile::tempfile; +use tokio::fs::File; use tracing::{debug, error}; +use uuid::Uuid; + +use std::io::SeekFrom; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; + +use crate::data::error::DataAccessError; +use crate::repository::MediaRepositoryState; pub(crate) async fn post_media_id( + State(repo): State, Path(media_id): Path, user: User, mut multipart: Multipart, -) -> std::result::Result { +) -> Result { error!("POST /media/{} user={}", media_id, user); + let mut headers = HeaderMap::new(); + let tempfile = tempfile().unwrap(); + let mut tempfile = File::from_std(tempfile); + let mut name: String = "".to_string(); while let Some(mut field) = multipart.next_field().await.unwrap() { if let Some(field_name) = field.name() { match field_name { "name" => { - debug!("name={}", field.text().await.unwrap()); + name = field.text().await.unwrap(); + debug!("name={}", name.clone()); } "file" => { - // TODO: wrap bytes and write to persistency + while let Some(chunk) = field + .chunk() + .await + .expect("Could not read file from multipart upload!") + { + tempfile + .write_all(&chunk) + .await + .expect("Could not write reference file to tmp!") + } + tempfile.seek(SeekFrom::Start(0)).await.unwrap(); + + // TODO: wrap bytes and write to persistence debug!("filesize={}", field.chunk().await.unwrap().unwrap().len()); } _ => continue, @@ -44,8 +73,38 @@ pub(crate) async fn post_media_id( } } - // TODO: write file to storage - // TODO: add file reference in database + let result = repo + .add_reference_for_media_item( + Uuid::parse_str(user.uuid.as_str()).unwrap(), + media_id, + name, + tempfile, + ) + .await; + + match result { + Ok(uuid) => { + debug!( + "reference added. uuid={}", + uuid.clone().hyphenated().to_string() + ); + + Ok(uuid.hyphenated().to_string()) + } + Err(error) => { + match error { + DataAccessError::AlreadyExist(id) => { + // TODO: use Redirect::permanent to add a Location header to the already existing item + + let location = format!("/media/{}", id); + headers.insert(LOCATION, location.parse().unwrap()); - Err(StatusCode::OK) + return Err(StatusCode::SEE_OTHER); + } + _ => { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + } + } } diff --git a/crates/media/src/data/error.rs b/crates/media/src/data/error.rs index cae01bd..6108cc7 100644 --- a/crates/media/src/data/error.rs +++ b/crates/media/src/data/error.rs @@ -20,7 +20,7 @@ pub enum DataAccessError { NotFound, #[allow(dead_code)] InvalidDateFormat, - AlreadyExist, + AlreadyExist(String), TechnicalError, #[allow(dead_code)] OtherError, diff --git a/crates/media/src/repository.rs b/crates/media/src/repository.rs index cb49f90..410c1df 100644 --- a/crates/media/src/repository.rs +++ b/crates/media/src/repository.rs @@ -19,11 +19,13 @@ use crate::data::error::DataAccessError; use crate::data::media_item::MediaItem; use axum::async_trait; use common::config::configuration::Configuration; +use common::database::reference::Reference; use common::database::Database; use database::sqlite::SqliteDatabase; -use std::fs::File; +use std::path::Path; use std::sync::Arc; use time::OffsetDateTime; +use tokio::fs::File; use tracing::info; use uuid::Uuid; @@ -52,6 +54,14 @@ pub trait MediaRepositoryTrait { name: String, date_taken: OffsetDateTime, ) -> Result; + + async fn add_reference_for_media_item( + &self, + user_id: Uuid, + media_id: String, + name: String, + file: File, + ) -> Result; } impl MediaRepository { @@ -72,9 +82,9 @@ impl MediaRepositoryTrait for MediaRepository { .database .get_media_items(user_id.hyphenated().to_string().as_str()) .await; - match items_result { + return match items_result { Ok(items) => { - return Ok(items + Ok(items .into_iter() .map(|d| MediaItem { // TODO: fill in missing info like references, details, tags @@ -88,10 +98,10 @@ impl MediaRepositoryTrait for MediaRepository { location: None, references: None, }) - .collect()); + .collect()) } - Err(_) => return Err(DataAccessError::OtherError), - } + Err(_) => Err(DataAccessError::OtherError), + }; } async fn create_media_item_for_user( @@ -100,8 +110,7 @@ impl MediaRepositoryTrait for MediaRepository { name: String, date_taken: OffsetDateTime, ) -> Result { - // TODO: map result to - let _ = &self + let db_result = &self .database .create_media_item( user_id.hyphenated().to_string().as_str(), @@ -110,7 +119,49 @@ impl MediaRepositoryTrait for MediaRepository { ) .await; - Ok(Uuid::new_v4()) + match db_result { + Ok(id) => Ok(Uuid::parse_str(id.as_str()).unwrap()), + Err(_) => Err(DataAccessError::OtherError), + } + } + + async fn add_reference_for_media_item( + &self, + user_id: Uuid, + media_id: String, + name: String, + mut tmp_file: File, + ) -> Result { + let path = Path::new("data/files/") + .join(user_id.clone().hyphenated().to_string()) + .join(media_id.clone()) + .join(name.clone()); + + let mut dest_file = File::create(path.clone()).await.unwrap(); + + let num_bytes = tokio::io::copy(&mut tmp_file, &mut dest_file) + .await + .expect("Coudl not copy tmp file to path!"); + println!( + "{} bytes copied to path {}", + num_bytes, + path.clone().to_string_lossy() + ); + + let reference = Reference { + uuid: Uuid::new_v4().hyphenated().to_string(), + filepath: path.to_str().unwrap().to_string(), + filename: name.to_string(), + size: 0u64, + description: "", + last_modified: OffsetDateTime::now_utc(), + is_missing: false, + }; + let _ = &self + .database + .add_reference(media_id.as_str(), name.as_str(), &reference) + .await; + Err(DataAccessError::OtherError) } } @@ -121,6 +172,7 @@ mod tests { use super::*; + //noinspection DuplicatedCode #[sqlx::test(migrations = "../database/migrations")] async fn get_media_items_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { // given @@ -148,7 +200,7 @@ mod tests { // when let result = repository - .get_media_items_for_user(uuid::Uuid::parse_str(user_id).unwrap()) + .get_media_items_for_user(Uuid::parse_str(user_id).unwrap()) .await; // then diff --git a/documentation/http/post_media.hurl b/documentation/http/post_media.hurl new file mode 100644 index 0000000..087391b --- /dev/null +++ b/documentation/http/post_media.hurl @@ -0,0 +1,12 @@ +POST http://127.0.0.1:7777/media +Authorization: FakeToken +Connection: Keep-Alive + +[MultipartFormData] +name: DSC_1234 +date_taken: 1985-04-12T23:20:50.52Z + +HTTP 200 + +[Asserts] +jsonpath "$.id" != null