From e54caca4d48029b647a3da5dd1588a5934e52f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20St=C3=BCrmer?= Date: Sat, 16 Sep 2023 00:00:15 +0200 Subject: [PATCH] add sqlite and postgres database draft --- .github/workflows/check.yaml | 1 + Cargo.lock | 12 +- Cargo.toml | 1 + crates/common/Cargo.toml | 3 + crates/common/src/{model => }/auth/login.rs | 37 +-- crates/common/src/auth/mod.rs | 2 + crates/common/src/{model => }/auth/user.rs | 20 +- crates/common/src/database/mod.rs | 12 + crates/common/src/http/extractors/optuser.rs | 2 +- crates/common/src/http/extractors/user.rs | 2 +- crates/common/src/lib.rs | 34 ++- crates/common/src/model/auth/mod.rs | 1 - crates/database/Cargo.toml | 4 + crates/database/migrations/0001_initial.sql | 2 +- crates/database/src/lib.rs | 115 +------- crates/database/src/postgres.rs | 104 ++++++++ crates/database/src/sqlite.rs | 266 +++++++++++++++++++ crates/media/Cargo.toml | 2 +- crates/media/src/api/router.rs | 73 +++-- crates/media/src/api/routes/get_media.rs | 24 +- crates/media/src/api/routes/post_media.rs | 9 +- crates/media/src/api/routes/post_media_id.rs | 2 +- crates/media/src/repository.rs | 61 +++-- run_tests | 5 + src/lib.rs | 90 +++---- src/plugin/plugin_manager.rs | 8 +- 26 files changed, 631 insertions(+), 261 deletions(-) rename crates/common/src/{model => }/auth/login.rs (70%) create mode 100644 crates/common/src/auth/mod.rs rename crates/common/src/{model => }/auth/user.rs (80%) create mode 100644 crates/common/src/database/mod.rs delete mode 100644 crates/common/src/model/auth/mod.rs create mode 100644 crates/database/src/postgres.rs create mode 100644 crates/database/src/sqlite.rs create mode 100755 run_tests diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7d87ccf..4aa2e18 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -40,6 +40,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test + args: --workspace --all-targets lints: name: Lints diff --git a/Cargo.lock b/Cargo.lock index 93e9174..857869b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,8 +628,10 @@ dependencies = [ "async-trait", "axum", "http", + "photos_network_plugin", "serde", "serde_json", + "serde_with", "time 0.3.27", "tracing", "uuid", @@ -881,7 +883,9 @@ name = "database" version = "0.6.0" dependencies = [ "async-trait", + "common", "sqlx", + "testdir", "tokio", "tracing", "uuid", @@ -2825,9 +2829,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1402f54f9a3b9e2efe71c1cea24e648acce55887983553eeb858cf3115acfd49" +checksum = "1ca3b16a3d82c4088f343b7480a93550b3eabe1a358569c2dfe38bbcead07237" dependencies = [ "base64 0.21.2", "chrono", @@ -2842,9 +2846,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9197f1ad0e3c173a0222d3c4404fb04c3afe87e962bcb327af73e8301fa203c7" +checksum = "2e6be15c453eb305019bfa438b1593c731f36a289a7853f7707ee29e870b3b3c" dependencies = [ "darling 0.20.3", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 7aa5944..2926f05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ reqwest = { version = "0.11", default-features = false, features = ["blocking", serde = "1.0.183" serde_json = { version = "1.0.104", features = ["raw_value"] } +serde_with = "3.3.0" serde_urlencoded = "0.7.1" smallvec = "1.8.0" sqlx = "0.7.1" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 8041b77..e6d4ef9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -19,8 +19,11 @@ doctest = false async-trait.workspace = true axum.workspace = true http.workspace = true +photos_network_plugin = { path = "../plugin_interface" } + serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +serde_with.workspace = true time.workspace = true tracing.workspace = true uuid = { workspace = true, features = ["serde"] } diff --git a/crates/common/src/model/auth/login.rs b/crates/common/src/auth/login.rs similarity index 70% rename from crates/common/src/model/auth/login.rs rename to crates/common/src/auth/login.rs index 3e85e20..979da2f 100644 --- a/crates/common/src/model/auth/login.rs +++ b/crates/common/src/auth/login.rs @@ -1,43 +1,44 @@ /* Photos.network · A privacy first photo storage and sharing service for fediverse. * Copyright (C) 2020 Photos network developers - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - use crate::sensitive::Sensitive; - //! Login request //! //! Provides an abstraction over a vlue for sensitive data like passwords. //! It is not printing its value to logs or tracing -//! +//! +use serde::{Deserialize, Serialize}; + +use crate::model::sensitive::Sensitive; + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct Login { - pub username_or_email: Sensitive, - pub password: Sensitive, - pub totp_2fa_token: Option, + pub username_or_email: Sensitive, + pub password: Sensitive, + pub totp_2fa_token: Option, } -//! Login response -//! -//! * `jwt` - None if email verification is enabled. -//! * `verify_email_sent` - Indicates if an email verification is needed. -//! -#[skip_serializing_none] +// Login response +// +// * `jwt` - None if email verification is enabled. +// * `verify_email_sent` - Indicates if an email verification is needed. +// #[derive(Debug, Serialize, Deserialize, Clone)] pub struct LoginResponse { - pub jwt: Option>, - pub registration_created: bool, - pub verify_email_sent: bool, + pub jwt: Option>, + pub registration_created: bool, + pub verify_email_sent: bool, } diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs new file mode 100644 index 0000000..063e88f --- /dev/null +++ b/crates/common/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod login; +pub mod user; diff --git a/crates/common/src/model/auth/user.rs b/crates/common/src/auth/user.rs similarity index 80% rename from crates/common/src/model/auth/user.rs rename to crates/common/src/auth/user.rs index b397749..82215ec 100644 --- a/crates/common/src/model/auth/user.rs +++ b/crates/common/src/auth/user.rs @@ -21,15 +21,15 @@ use uuid::Uuid; #[derive(Clone, Debug, Eq, PartialEq)] pub struct User { - pub uuid: Uuid, - email: String, - password: Option, - lastname: Option, - firstname: Option, - is_locked: bool, - created_at: OffsetDateTime, - updated_at: Option, - last_login: Option, + pub uuid: String, //Uuid, + pub email: String, + pub password: Option, + pub lastname: Option, + pub firstname: Option, + pub is_locked: bool, + pub created_at: OffsetDateTime, + pub updated_at: Option, + pub last_login: Option, } impl fmt::Display for User { @@ -45,7 +45,7 @@ impl fmt::Display for User { impl User { pub(crate) fn new(email: String) -> User { User { - uuid: Uuid::new_v4(), + uuid: Uuid::new_v4().hyphenated().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 new file mode 100644 index 0000000..f09198b --- /dev/null +++ b/crates/common/src/database/mod.rs @@ -0,0 +1,12 @@ +use async_trait::async_trait; +use std::error::Error; + +use crate::auth::user::User; + +#[async_trait] +pub trait Database { + async fn setup(&mut self) -> Result<(), Box>; + async fn create_user(&self, user: &User) -> Result<(), Box>; + async fn update_email(&self, email: &str, user_id: &str) -> Result<(), Box>; + async fn get_users(&self) -> Result, Box>; +} diff --git a/crates/common/src/http/extractors/optuser.rs b/crates/common/src/http/extractors/optuser.rs index 8aaf8ba..64a75c1 100644 --- a/crates/common/src/http/extractors/optuser.rs +++ b/crates/common/src/http/extractors/optuser.rs @@ -18,7 +18,7 @@ //! This extractor checks if an `Authorization` is header and contains a valid JWT token. //! Otherwise it will respond `Some(None)` to indicate an unauthorized user or a visiter without an account at all. //! -use crate::model::auth::user::User; +use crate::auth::user::User; use async_trait::async_trait; use axum::extract::FromRequestParts; use http::request::Parts; diff --git a/crates/common/src/http/extractors/user.rs b/crates/common/src/http/extractors/user.rs index 762253e..2803b6f 100644 --- a/crates/common/src/http/extractors/user.rs +++ b/crates/common/src/http/extractors/user.rs @@ -23,7 +23,7 @@ use axum::extract::FromRequestParts; use axum::http::StatusCode; use http::request::Parts; -use crate::model::auth::user::User; +use crate::auth::user::User; #[async_trait] impl FromRequestParts for User diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index df8467f..74b59d3 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -17,9 +17,41 @@ //! This crate offers shared data models for [Photos.network](https://photos.network) core application. //! + +use std::collections::HashMap; + +use axum::Router; +use config::configuration::Configuration; +use database::Database; +use photos_network_plugin::{PluginFactoryRef, PluginId}; + +pub mod auth; pub mod config; +pub mod database; pub mod http; pub mod model { - pub mod auth; pub mod sensitive; } + +/// Aggregates the applications configuration, its loaded plugins and the router for all REST APIs +#[derive(Clone)] +pub struct ApplicationState { + pub config: Configuration, + pub plugins: HashMap, + pub router: Option, + pub database: D, +} + +impl ApplicationState +where + D: Database, +{ + pub fn new(config: Configuration, database: D) -> Self { + Self { + config, + plugins: HashMap::new(), + router: None, + database, + } + } +} diff --git a/crates/common/src/model/auth/mod.rs b/crates/common/src/model/auth/mod.rs deleted file mode 100644 index 22d12a3..0000000 --- a/crates/common/src/model/auth/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod user; diff --git a/crates/database/Cargo.toml b/crates/database/Cargo.toml index 4b60827..80ae5d9 100644 --- a/crates/database/Cargo.toml +++ b/crates/database/Cargo.toml @@ -16,8 +16,12 @@ path = "src/lib.rs" doctest = false [dependencies] +common.workspace = true async-trait.workspace = true tracing.workspace = true uuid.workspace = true tokio.workspace = true sqlx = { workspace = true, features = ["runtime-tokio", "tls-native-tls", "postgres", "mysql", "sqlite", "any", "macros", "migrate", "time" ] } + +[dev-dependencies] +testdir.workspace = true diff --git a/crates/database/migrations/0001_initial.sql b/crates/database/migrations/0001_initial.sql index 23b93c3..21177f5 100644 --- a/crates/database/migrations/0001_initial.sql +++ b/crates/database/migrations/0001_initial.sql @@ -1,7 +1,7 @@ CREATE TABLE IF NOT EXISTS users ( --auto generated uuid VARCHAR NOT NULL, - email VARCHAR, + email VARCHAR UNIQUE, password VARCHAR, lastname VARCHAR, firstname VARCHAR, diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index f689ffe..fc3f629 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -1,112 +1,3 @@ -/* Photos.network · A privacy first photo storage and sharing service for fediverse. - * Copyright (C) 2020 Photos network developers - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -//! This crate offers a database abstraction for [Photos.network](https://photos.network) core application. -//! -use async_trait::async_trait; -use sqlx::PgPool; -use sqlx::Row; -use std::error::Error; -use tracing::{error, info}; -use uuid::Uuid; - -pub struct User { - pub uuid: String, - pub email: String, - pub password: String, - pub lastname: String, - pub firstname: String, -} - -#[async_trait] -pub trait Database { - async fn setup(&mut self) -> Result<(), Box>; - async fn create_user(&self, user: &User) -> Result<(), Box>; - async fn update_user(&self, user: &User) -> Result<(), Box>; - async fn get_users(&self) -> Result, Box>; -} - -pub struct PostgresDatabase { - pub pool: PgPool, -} - -impl PostgresDatabase { - pub async fn new(db_url: &str) -> Self { - let pool = sqlx::postgres::PgPool::connect(db_url).await.unwrap(); - - PostgresDatabase { pool } - } -} - -#[async_trait] -impl Database for PostgresDatabase { - async fn setup(&mut self) -> Result<(), Box> { - // run migrations from `migrations` directory - sqlx::migrate!("./migrations").run(&self.pool).await?; - - Ok(()) - } - - async fn create_user(&self, user: &User) -> Result<(), Box> { - let query = "INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)"; - let id = Uuid::new_v4().hyphenated().to_string(); - info!("create new user with id `{}`.", id); - sqlx::query(query) - .bind(id) - .bind(&user.email) - .bind(&user.password) - .bind(&user.lastname) - .bind(&user.firstname) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn update_user(&self, user: &User) -> Result<(), Box> { - let query = "UPDATE users SET email = %1 WHERE uuid = $2"; - - sqlx::query(query) - .bind(&user.email) - .bind(&user.uuid) - .execute(&self.pool) - .await?; - - Ok(()) - } - - async fn get_users(&self) -> Result, Box> { - let query = "SELECT uuid, email, password, lastname, firstname FROM users"; - - let res = sqlx::query(query); - - let rows = res.fetch_all(&self.pool).await?; - - let users = rows - .iter() - .map(|row| User { - uuid: row.get("uuid"), - email: row.get("email"), - password: row.get("password"), - lastname: row.get("lastname"), - firstname: row.get("firstname"), - }) - .collect(); - - Ok(users) - } -} +//pub mod postgres; +pub mod postgres; +pub mod sqlite; diff --git a/crates/database/src/postgres.rs b/crates/database/src/postgres.rs new file mode 100644 index 0000000..43f0055 --- /dev/null +++ b/crates/database/src/postgres.rs @@ -0,0 +1,104 @@ +/* Photos.network · A privacy first photo storage and sharing service for fediverse. + * Copyright (C) 2020 Photos network developers + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +//! This crate offers a database abstraction for [Photos.network](https://photos.network) core application. +//! +use async_trait::async_trait; +use common::auth::user::User; +use common::database::Database; +use sqlx::types::time::OffsetDateTime; +use sqlx::PgPool; +use sqlx::Row; +use std::error::Error; +use tracing::info; +use uuid::Uuid; + +#[derive(Clone)] +pub struct PostgresDatabase { + pub pool: PgPool, +} + +impl PostgresDatabase { + pub async fn new(db_url: &str) -> Self { + let pool = sqlx::postgres::PgPool::connect(db_url).await.unwrap(); + + PostgresDatabase { pool } + } +} + +#[async_trait] +impl Database for PostgresDatabase { + async fn setup(&mut self) -> Result<(), Box> { + // run migrations from `migrations` directory + sqlx::migrate!("./migrations").run(&self.pool).await?; + + Ok(()) + } + + async fn create_user(&self, user: &User) -> Result<(), Box> { + let query = "INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)"; + let id = Uuid::new_v4().hyphenated().to_string(); + info!("create new user with id `{}`.", id); + sqlx::query(query) + .bind(id) + .bind(&user.email) + .bind(&user.password) + .bind(&user.lastname) + .bind(&user.firstname) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn update_email(&self, email: &str, user_id: &str) -> Result<(), Box> { + let query = "UPDATE users SET email = $1 WHERE uuid = $2"; + + sqlx::query(query) + .bind(email) + .bind(user_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_users(&self) -> Result, Box> { + let query = "SELECT uuid, email, password, lastname, firstname FROM users"; + + let res = sqlx::query(query); + + let rows = res.fetch_all(&self.pool).await?; + + let users = rows + .iter() + .map(|row| User { + uuid: row.get("uuid"), + email: row.get("email"), + password: row.get("password"), + lastname: row.get("lastname"), + firstname: row.get("firstname"), + is_locked: false, + created_at: OffsetDateTime::now_utc(), + updated_at: None, + last_login: None, + }) + .collect(); + + Ok(users) + } +} diff --git a/crates/database/src/sqlite.rs b/crates/database/src/sqlite.rs new file mode 100644 index 0000000..fbce878 --- /dev/null +++ b/crates/database/src/sqlite.rs @@ -0,0 +1,266 @@ +use async_trait::async_trait; +use common::auth::user::User; +use common::database::Database; +use sqlx::types::time::OffsetDateTime; +use sqlx::Row; +use sqlx::SqlitePool; +use std::error::Error; +use tracing::info; +use uuid::Uuid; + +#[derive(Clone)] +pub struct SqliteDatabase { + pub pool: SqlitePool, +} + +impl SqliteDatabase { + pub async fn new(db_url: &str) -> Self { + let pool = SqlitePool::connect(db_url).await.unwrap(); + + SqliteDatabase { pool } + } +} + +#[async_trait] +impl Database for SqliteDatabase { + async fn setup(&mut self) -> Result<(), Box> { + sqlx::migrate!("./migrations").run(&self.pool).await?; + + Ok(()) + } + + async fn create_user(&self, user: &User) -> Result<(), Box> { + let query = "INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)"; + let id = Uuid::new_v4().hyphenated().to_string(); + info!("create new user with id `{}`.", id); + sqlx::query(query) + .bind(id) + .bind(&user.email) + .bind(&user.password) + .bind(&user.lastname) + .bind(&user.firstname) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn update_email(&self, email: &str, user_id: &str) -> Result<(), Box> { + let query = "UPDATE users SET email = $1 WHERE uuid = $2"; + + sqlx::query(query) + .bind(email) + .bind(user_id) + .execute(&self.pool) + .await?; + + Ok(()) + } + + async fn get_users(&self) -> Result, Box> { + let query = "SELECT uuid, email, password, lastname, firstname FROM users"; + + let res = sqlx::query(query); + + let rows = res.fetch_all(&self.pool).await?; + + let users = rows + .iter() + .map(|row| User { + uuid: row.get("uuid"), + email: row.get("email"), + password: row.get("password"), + lastname: row.get("lastname"), + firstname: row.get("firstname"), + is_locked: false, + created_at: OffsetDateTime::now_utc(), + updated_at: None, + last_login: None, + }) + .collect(); + + Ok(users) + } +} + +#[allow(unused_imports)] +mod tests { + use super::*; + + #[sqlx::test] + async fn create_user_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { + // given + let db = SqliteDatabase::new( + "target/sqlx/test-dbs/database/sqlite/tests/create_user_should_succeed.sqlite", + ) + .await; + + // when + for i in 0..3 { + let user = User { + uuid: uuid::Uuid::new_v4().hyphenated().to_string(), + email: format!("test_{}@photos.network", i), + password: Some("unsecure".into()), + lastname: Some("Stuermer".into()), + firstname: Some("Benjamin".into()), + is_locked: false, + created_at: OffsetDateTime::now_utc(), + updated_at: None, + last_login: None, + }; + + // when + let _ = db.create_user(&user).await; + } + + // then + let count = sqlx::query("SELECT COUNT(*) AS 'count!' FROM users") + .fetch_one(&pool) + .await?; + assert_eq!(count.get::("count!"), 3); + + Ok(()) + } + + #[sqlx::test] + async fn create_already_existing_user_should_fail(pool: SqlitePool) -> sqlx::Result<()> { + // given + let db = SqliteDatabase::new( + "target/sqlx/test-dbs/database/sqlite/tests/create_already_existing_user_should_fail.sqlite", + ) + .await; + + // when + let uuid = uuid::Uuid::new_v4().hyphenated().to_string(); + let user = User { + uuid, + email: "info@photos.network".into(), + password: Some("unsecure".into()), + lastname: Some("Stuermer".into()), + firstname: Some("Benjamin".into()), + is_locked: false, + created_at: OffsetDateTime::now_utc(), + updated_at: None, + last_login: None, + }; + + // then + let result1 = db.create_user(&user.clone()).await; + assert!(result1.is_ok()); + + let result2 = db.create_user(&user.clone()).await; + assert!(result2.is_err()); + + let count = sqlx::query("SELECT COUNT(*) AS 'count!' FROM users") + .fetch_one(&pool) + .await?; + assert_eq!(count.get::("count!"), 1); + + Ok(()) + } + + #[sqlx::test] + async fn update_email_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { + // given + sqlx::query("INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)") + .bind("570DC079-664A-4496-BAA3-668C445A447") + .bind("info@photos.network") + .bind("unsecure") + .bind("Stuermer") + .bind("Benjamin") + .execute(&pool).await?; + let db = SqliteDatabase::new( + "target/sqlx/test-dbs/database/sqlite/tests/update_email_should_succeed.sqlite", + ) + .await; + + // when + let result = db + .update_email( + "security@photos.network".into(), + "570DC079-664A-4496-BAA3-668C445A447".into(), + ) + .await; + + // then + assert!(result.is_ok()); + let count = sqlx::query("SELECT email FROM users LIMIT 1") + .fetch_one(&pool) + .await?; + assert_eq!(count.get::("email"), "security@photos.network"); + + Ok(()) + } + + #[sqlx::test] + async fn update_email_to_existing_should_fail(pool: SqlitePool) -> sqlx::Result<()> { + // given + sqlx::query("INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)") + .bind("570DC079-664A-4496-BAA3-668C445A447") + .bind("info@photos.network") + .bind("unsecure") + .bind("Stuermer") + .bind("Benjamin") + .execute(&pool).await?; + + sqlx::query("INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)") + .bind("0D341AD3-D38F-455F-8411-E25186665FC5") + .bind("security@photos.network") + .bind("unsecure") + .bind("Stuermer") + .bind("Benjamin") + .execute(&pool).await?; + + let db = SqliteDatabase::new( + "target/sqlx/test-dbs/database/sqlite/tests/update_email_to_existing_should_fail.sqlite", + ) + .await; + + // when + let result = db + .update_email( + "security@photos.network".into(), + "570DC079-664A-4496-BAA3-668C445A447".into(), + ) + .await; + + // then + assert!(result.is_err()); + + let rows = sqlx::query("SELECT email FROM users") + .fetch_all(&pool) + .await?; + assert_eq!(rows[0].get::("email"), "info@photos.network"); + assert_eq!(rows[1].get::("email"), "security@photos.network"); + + Ok(()) + } + + #[sqlx::test] + async fn get_users_should_succeed(pool: SqlitePool) -> sqlx::Result<()> { + // given + sqlx::query("INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)") + .bind("570DC079-664A-4496-BAA3-668C445A447") + .bind("info@photos.network") + .bind("unsecure") + .bind("Stuermer") + .bind("Benjamin") + .execute(&pool).await?; + let db = SqliteDatabase::new( + "target/sqlx/test-dbs/database/sqlite/tests/get_users_should_succeed.sqlite", + ) + .await; + + // when + let users = db.get_users().await.unwrap(); + + // then + assert_eq!(users.clone().len(), 1); + assert_eq!( + users.get(0).unwrap().uuid, + "570DC079-664A-4496-BAA3-668C445A447" + ); + + Ok(()) + } +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 660ffe5..458fbf7 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -16,7 +16,7 @@ path = "src/lib.rs" doctest = false [dependencies] -common = { path = "../common" } +common.workspace = true database = { path = "../database" } tracing.workspace = true diff --git a/crates/media/src/api/router.rs b/crates/media/src/api/router.rs index cb10267..584ac8e 100644 --- a/crates/media/src/api/router.rs +++ b/crates/media/src/api/router.rs @@ -15,17 +15,6 @@ * along with this program. If not, see . */ -use std::sync::Arc; - -use axum::routing::{delete, get, patch, post}; -use axum::Router; -use common::config::configuration::Configuration; -use database::Database; -use tracing::error; - -use crate::data::error; -use crate::repository::{MediaRepository, MediaRepositoryState}; - use super::routes::delete_media_id::delete_media_id; use super::routes::get_albums::get_albums; use super::routes::get_albums_id::get_albums_id; @@ -38,15 +27,22 @@ use super::routes::patch_media_id::patch_media_id; use super::routes::post_albums::post_albums; use super::routes::post_media::post_media; use super::routes::post_media_id::post_media_id; +use crate::repository::{MediaRepository, MediaRepositoryState}; +use axum::routing::{delete, get, patch, post}; +use axum::Router; +use common::ApplicationState; +use database::sqlite::SqliteDatabase; +use std::sync::Arc; pub struct MediaApi {} impl MediaApi { - pub async fn routes(database: &dyn Database) -> Router + pub async fn routes(state: ApplicationState) -> Router where S: Send + Sync + Clone, { - let media_repository: MediaRepository = MediaRepository::new(database).await; + let media_repository: MediaRepository = + MediaRepository::new(state.database.clone()).await; let repository_state: MediaRepositoryState = Arc::new(media_repository); Router::new() @@ -95,18 +91,28 @@ impl MediaApi { #[cfg(test)] mod tests { + use std::collections::HashMap; + use super::*; use axum::{ body::Body, http::{self, Request, StatusCode}, }; + use common::config::configuration::Configuration; use serde_json::json; + use sqlx::SqlitePool; use tower::ServiceExt; - #[tokio::test] - async fn get_media_with_query_success() { + #[sqlx::test] + async fn get_media_with_query_success(pool: SqlitePool) { // given - let app = Router::new().nest("/", MediaApi::routes(Configuration::empty()).await); + let state: ApplicationState = ApplicationState { + config: Configuration::empty(), + plugins: HashMap::new(), + router: None, + database: SqliteDatabase { pool }, + }; + let app = Router::new().nest("/", MediaApi::routes(state).await); // when let response = app @@ -130,10 +136,16 @@ mod tests { assert_eq!(body, "list media items. limit=100000, offset=1"); } - #[tokio::test] - async fn get_media_without_query_success() { + #[sqlx::test] + async fn get_media_without_query_success(pool: SqlitePool) { // given - let app = Router::new().nest("/", MediaApi::routes(Configuration::empty()).await); + let state: ApplicationState = ApplicationState { + config: Configuration::empty(), + plugins: HashMap::new(), + router: None, + database: SqliteDatabase { pool }, + }; + let app = Router::new().nest("/", MediaApi::routes(state).await); // when let response = app @@ -157,12 +169,18 @@ mod tests { assert_eq!(body, "list media items. limit=1000, offset=0"); } - // TODO: re-enable test - // #[tokio::test] + // TODO: test is failing due to missing multi-part body + //#[sqlx::test] #[allow(dead_code)] - async fn post_media_success() { + async fn post_media_success(pool: SqlitePool) { // given - let app = Router::new().nest("/", MediaApi::routes(Configuration::empty()).await); + let state: ApplicationState = ApplicationState { + config: Configuration::empty(), + plugins: HashMap::new(), + router: None, + database: SqliteDatabase { pool }, + }; + let app = Router::new().nest("/", MediaApi::routes(state).await); // when let response = app @@ -170,8 +188,15 @@ mod tests { Request::builder() .uri("/media") .method("POST") + .header("Authorization", "FakeAuth") .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - // add multipart file to body + .header( + "Content-Disposition", + "attachment; filename=\"DSC_1234.NEF\"", + ) + //.body(Body::from(bytes)) + //.body(Body::empty()) + // TODO: add multipart file to body .body(Body::from( serde_json::to_vec(&json!([1, 2, 3, 4])).unwrap(), )) diff --git a/crates/media/src/api/routes/get_media.rs b/crates/media/src/api/routes/get_media.rs index 1145317..e18f42e 100644 --- a/crates/media/src/api/routes/get_media.rs +++ b/crates/media/src/api/routes/get_media.rs @@ -2,10 +2,11 @@ //! use axum::extract::State; use axum::{extract::Query, http::StatusCode, Json}; -use common::model::auth::user::User; +use common::auth::user::User; use serde::{Deserialize, Serialize}; use std::result::Result; use tracing::error; +use uuid::Uuid; use crate::data::error::DataAccessError; use crate::data::media_item::MediaItem; @@ -23,7 +24,7 @@ pub(crate) async fn get_media( Query(query): Query, ) -> Result, StatusCode> { let items: Result, DataAccessError> = - repo.get_media_items_for_user(user.uuid.into()); + repo.get_media_items_for_user(Uuid::parse_str(user.uuid.as_str()).unwrap()); match items { Ok(i) => { error!("Found {} items for user.", i.len()); @@ -48,19 +49,30 @@ pub(crate) async fn get_media( #[cfg(test)] mod tests { + use std::collections::HashMap; + use axum::Router; - use common::config::configuration::Configuration; + use common::{config::configuration::Configuration, ApplicationState}; + use database::sqlite::SqliteDatabase; use hyper::{Body, Request}; + use sqlx::SqlitePool; use tower::ServiceExt; use crate::api::router::MediaApi; use super::*; - #[tokio::test] - async fn get_media_unauthorized_should_not_fail() { + #[sqlx::test] + async fn get_media_unauthorized_should_not_fail(pool: SqlitePool) { // given - let app = Router::new().nest("/", MediaApi::routes(Configuration::empty()).await); + let state: ApplicationState = ApplicationState { + config: Configuration::empty(), + plugins: HashMap::new(), + router: None, + database: SqliteDatabase { pool }, + }; + + let app = Router::new().nest("/", MediaApi::routes(state).await); // when let response = app diff --git a/crates/media/src/api/routes/post_media.rs b/crates/media/src/api/routes/post_media.rs index 9155265..e7788e4 100644 --- a/crates/media/src/api/routes/post_media.rs +++ b/crates/media/src/api/routes/post_media.rs @@ -1,10 +1,13 @@ //! Creates a new media item to aggregate related files for current user //! -use axum::http::StatusCode; -use common::model::auth::user::User; +use axum::{extract::Multipart, http::StatusCode}; +use common::auth::user::User; use tracing::{debug, error}; -pub(crate) async fn post_media(user: User) -> std::result::Result { +pub(crate) async fn post_media( + user: User, + mut _multipart: Multipart, +) -> std::result::Result { error!("POST /media user={}", user); let id = uuid::Uuid::new_v4(); diff --git a/crates/media/src/api/routes/post_media_id.rs b/crates/media/src/api/routes/post_media_id.rs index 82079e0..f9b34bc 100644 --- a/crates/media/src/api/routes/post_media_id.rs +++ b/crates/media/src/api/routes/post_media_id.rs @@ -3,7 +3,7 @@ use axum::extract::{Multipart, Path}; use axum::http::StatusCode; -use common::model::auth::user::User; +use common::auth::user::User; use tracing::{debug, error}; pub(crate) async fn post_media_id( diff --git a/crates/media/src/repository.rs b/crates/media/src/repository.rs index 1cf6bd9..e831542 100644 --- a/crates/media/src/repository.rs +++ b/crates/media/src/repository.rs @@ -15,24 +15,18 @@ * along with this program. If not, see . */ +use axum::async_trait; use std::sync::Arc; use std::time::Instant; - -use axum::async_trait; -use common::config::database_config::DatabaseConfig; -use common::config::database_config::DatabaseDriver; -use database::Database; - -use rand::{distributions::Alphanumeric, Rng}; -use tracing::error; use tracing::info; use uuid::Uuid; use crate::data::error::DataAccessError; use crate::data::media_item::MediaItem; -pub struct MediaRepository { - pub(crate) database: Database, +#[allow(dead_code)] +pub struct MediaRepository { + pub(crate) database: D, } pub type MediaRepositoryState = Arc; @@ -45,20 +39,21 @@ pub trait MediaRepositoryTrait { fn get_media_items_for_user(&self, user_id: Uuid) -> Result, DataAccessError>; } -impl MediaRepository<'a> { - pub async fn new(database: Database) -> self { - self { database } +impl MediaRepository { + pub async fn new(database: D) -> Self { + Self { database } } } #[async_trait] -impl MediaRepositoryTrait for MediaRepository { +impl MediaRepositoryTrait for MediaRepository { fn get_media_items_for_user(&self, user_id: Uuid) -> Result, DataAccessError> { info!("get items for user {}", user_id); // TODO: read from database + // TODO: read from filesystem Ok(vec![MediaItem { - uuid: "".into(), + uuid: "", name: "", date_added: Instant::now(), date_taken: None, @@ -68,23 +63,35 @@ impl MediaRepositoryTrait for MediaRepository { references: None, }]) } - - // async fn get_media_item() { - // TODO: read from filesystem - // } } +#[allow(unused_imports)] mod tests { + use database::sqlite::SqliteDatabase; + use sqlx::SqlitePool; + use super::*; - #[tokio::test] - async fn test_new() { + #[sqlx::test(migrations = "../database/migrations")] + async fn test_new(pool: SqlitePool) -> sqlx::Result<()> { // given - let repository = MediaRepository::new( - common::config::database_config::DatabaseDriver::SQLite, - "file::memory:?cache=shared".into(), - ) - .await; + sqlx::query("INSERT INTO users (uuid, email, password, lastname, firstname) VALUES ($1, $2, $3, $4, $5)") + .bind("570DC079-664A-4496-BAA3-668C445A447") + .bind("info@photos.network") + .bind("unsecure") + .bind("Stuermer") + .bind("Benjamin") + .execute(&pool).await?; + sqlx::query("INSERT INTO media (uuid, name, owner) VALUES ($1, $2, $3)") + .bind("6A92460C-53FB-4B42-AC1B-E6760A34E169") + .bind("DSC_1234") + .bind("570DC079-664A-4496-BAA3-668C445A447") + .execute(&pool) + .await?; + + let db = SqliteDatabase::new("target/sqlx/test-dbs/media/repository/tests/test_new.sqlite") + .await; + let repository = MediaRepository::new(db).await; // when let result = repository.get_media_items_for_user(Uuid::new_v4()); @@ -92,5 +99,7 @@ mod tests { // then assert_eq!(result.is_ok(), true); assert_eq!(result.ok().unwrap().len(), 1); + + Ok(()) } } diff --git a/run_tests b/run_tests new file mode 100755 index 0000000..3b6f2e5 --- /dev/null +++ b/run_tests @@ -0,0 +1,5 @@ +#!/bin/bash + +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy -- -D warnings diff --git a/src/lib.rs b/src/lib.rs index 9ab8a3a..4cd718e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,8 +27,7 @@ //! See also the following crates //! * [Authentication](../oauth_authentication/index.html) -use std::collections::HashMap; -use std::fs; +use std::fs::{self, OpenOptions}; use std::net::SocketAddr; use abi_stable::external_types::crossbeam_channel; @@ -38,8 +37,10 @@ use anyhow::Result; use axum::extract::DefaultBodyLimit; use axum::routing::{get, head}; use axum::{Json, Router}; -use common::model::auth::user::User; -use database::{Database, PostgresDatabase}; +use common::auth::user::User; +use common::database::Database; +use common::ApplicationState; +use database::sqlite::SqliteDatabase; use media::api::router::MediaApi; use oauth_authentication::AuthenticationManager; use oauth_authorization_server::client::Client; @@ -47,14 +48,12 @@ use oauth_authorization_server::config::ConfigRealm; use oauth_authorization_server::config::ServerConfig; use oauth_authorization_server::state::ServerState; use oauth_authorization_server::AuthorizationServerManager; -use photos_network_plugin::{PluginFactoryRef, PluginId}; use serde::{Deserialize, Serialize}; +use sqlx::types::time::OffsetDateTime; use std::path::Path; -use tokio::join; use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; -use tracing::callsite::DefaultCallsite; use tracing::{debug, error, info}; use tracing_subscriber::{fmt, layer::SubscriberExt}; @@ -99,8 +98,43 @@ pub async fn start_server() -> Result<()> { let configuration = Configuration::new(CONFIG_PATH).expect("Could not parse configuration!"); debug!("Configuration: {}", configuration); + // init database + //let db = PostgresDatabase::new("postgres://postgres:unsecure@localhost:5432/postgres").await; + + let _file = OpenOptions::new() + .write(true) + .create_new(true) + .open("data/core.sqlite3"); + let db = SqliteDatabase::new("data/core.sqlite3").await; + let _ = db.clone().setup().await; + let users = db.clone().get_users().await; + if users.unwrap().is_empty() { + info!("No user found, create a default admin user. Please check `data/credentials.txt` for details."); + let default_user = "photo@photos.network"; + let default_pass = "unsecure"; + let path = Path::new(DATA_PATH).join("credentials.txt"); + let _ = fs::write(path, format!("{}\n{}", default_user, default_pass)); + // let mut output = File::create(path)?; + // let line = "hello"; + // write!(output, "{}\n{}", default_user, default_pass); + + let user = User { + uuid: "".to_string(), + email: default_user.to_string(), + password: Some(default_pass.to_string()), + lastname: Some("Admin".to_string()), + firstname: Some("".to_string()), + is_locked: false, + created_at: OffsetDateTime::now_utc(), + updated_at: None, + last_login: None, + }; + let _ = db.clone().create_user(&user).await; + } + // init application state - let mut app_state = ApplicationState::new(configuration.clone()); + //let mut app_state = ApplicationState::::new(configuration.clone(), db); + let mut app_state = ApplicationState::::new(configuration.clone(), db); let cfg = ServerConfig { listen_addr: configuration.internal_url.to_owned(), @@ -119,27 +153,6 @@ pub async fn start_server() -> Result<()> { }; let server = ServerState::new(cfg)?; - let db = PostgresDatabase::new("postgres://postgres:unsecure@localhost:5432/postgres").await; - let users = db.get_users().await; - if users.unwrap().len() < 1 { - info!("No user found, create a default admin user. Please check `data/credentials.txt` for details."); - let default_user = "photo@photos.network"; - let default_pass = "unsecure"; - let path = Path::new(DATA_PATH).join("credentials.txt"); - let mut output = File::create(path)?; - let line = "hello"; - write!(output, "{}\n{}", default_user, default_pass); - - let user = database::User { - uuid: "".to_string(), - email: default_user.to_string(), - password: default_pass.to_string(), - lastname: "Admin".to_string(), - firstname: "".to_string(), - }; - db.create_user(&user).await; - } - // TODO: check if `data/credentials.txt` still exists and stop immediately! let mut router = Router::new() // favicon @@ -150,7 +163,7 @@ pub async fn start_server() -> Result<()> { .route("/", head(status)) // Media items - .nest("/", MediaApi::routes(db.clone()).await) + .nest("/", MediaApi::routes(app_state.clone()).await) // OAuth 2.0 Authentication .nest("/", AuthenticationManager::routes()) @@ -239,23 +252,6 @@ pub async fn start_server() -> Result<()> { Ok(()) } -/// Aggregates the applications configuration, its loaded plugins and the router for all REST APIs -pub struct ApplicationState { - pub config: Configuration, - pub plugins: HashMap, - pub router: Option, -} - -impl ApplicationState { - pub fn new(config: Configuration) -> Self { - Self { - config, - plugins: HashMap::new(), - router: None, - } - } -} - async fn status() -> Json { // TODO: get app state diff --git a/src/plugin/plugin_manager.rs b/src/plugin/plugin_manager.rs index ac20cad..4bb7cfe 100644 --- a/src/plugin/plugin_manager.rs +++ b/src/plugin/plugin_manager.rs @@ -20,24 +20,24 @@ use std::path::PathBuf; use abi_stable::library::{lib_header_from_path, LibrarySuffix, RawLibrary}; use anyhow::Result; -use common::config::configuration::Configuration; +use common::{config::configuration::Configuration, ApplicationState}; -use crate::ApplicationState; use core_extensions::SelfOps; +use database::sqlite::SqliteDatabase; use photos_network_plugin::{PluginFactoryRef, PluginId}; use tracing::{debug, error, info}; pub struct PluginManager<'a> { config: Configuration, path: String, - state: &'a mut ApplicationState, + state: &'a mut ApplicationState, } impl<'a> PluginManager<'a> { pub fn new( config: Configuration, path: String, - state: &'a mut ApplicationState, + state: &'a mut ApplicationState, ) -> Result { Ok(Self { config,