From 07fff532f790979a3f2ab142eabba8eb5d0161d2 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Wed, 4 Dec 2024 18:25:56 +0100 Subject: [PATCH] feat: user avatar as public asset --- Cargo.lock | 8 ++++ Cargo.toml | 2 + TODO.md | 3 ++ justfile | 2 +- lib/http_server/src/controllers/api/mod.rs | 1 + .../src/controllers/api/public_assets.rs | 27 ++++++++++++ lib/http_server/src/controllers/ui/me.rs | 41 +++++++++++++++---- .../src/controllers/ui/register.rs | 2 +- lib/http_server/src/router.rs | 3 +- .../src/templates/pages/me/details-form.html | 5 +-- .../src/templates/pages/me/index.html | 9 ++-- lib/kernel/Cargo.toml | 2 + lib/kernel/src/models/mod.rs | 1 + lib/kernel/src/models/user.rs | 4 +- lib/kernel/src/models/user_asset.rs | 41 +++++++++++++++++++ lib/kernel/src/repositories/users.rs | 29 ++++++++++++- migrations/all.sql | 13 +++++- 17 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 lib/http_server/src/controllers/api/public_assets.rs create mode 100644 lib/kernel/src/models/user_asset.rs diff --git a/Cargo.lock b/Cargo.lock index 2874db8..fca0ec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hkdf" version = "0.12.4" @@ -1256,9 +1262,11 @@ dependencies = [ "dotenvy", "env_logger", "fully_pub", + "hex-literal", "log", "serde", "serde_json", + "sha2", "sqlx", "strum", "strum_macros", diff --git a/Cargo.toml b/Cargo.toml index 70a7822..c384216 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ strum_macros = "0.26" uuid = { version = "1.8", features = ["serde", "v4"] } dotenvy = "0.15.7" url = "2.5.3" +sha2 = "0.10" +hex-literal = "0.4" # CLI argh = "0.1" diff --git a/TODO.md b/TODO.md index 47f541f..6d8df20 100644 --- a/TODO.md +++ b/TODO.md @@ -47,3 +47,6 @@ - [x] UserWebGUI: activate account with token +- [X] basic docker setup +- [ ] make `docker stop` working (handle SIGTERM/SIGINT) +- [ ] implement docker secrets. https://docs.docker.com/engine/swarm/secrets/ diff --git a/justfile b/justfile index de21365..c3c20d9 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ export RUST_BACKTRACE := "1" export RUST_LOG := "trace" -export CONTEXT_ARGS := "--config ./config.toml --database ./tmp/dbs/minauthator.db --static-assets ./assets" +export CONTEXT_ARGS := "--config config.toml --database tmp/dbs/minauthator.db --static-assets ./assets" watch-server: cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS" diff --git a/lib/http_server/src/controllers/api/mod.rs b/lib/http_server/src/controllers/api/mod.rs index eef80ff..861d05a 100644 --- a/lib/http_server/src/controllers/api/mod.rs +++ b/lib/http_server/src/controllers/api/mod.rs @@ -2,3 +2,4 @@ pub mod index; pub mod oauth2; pub mod read_user; pub mod openid; +pub mod public_assets; diff --git a/lib/http_server/src/controllers/api/public_assets.rs b/lib/http_server/src/controllers/api/public_assets.rs new file mode 100644 index 0000000..ed34737 --- /dev/null +++ b/lib/http_server/src/controllers/api/public_assets.rs @@ -0,0 +1,27 @@ +use axum::{extract::{Path, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse}}; +use kernel::repositories::users::get_user_asset_by_id; + +use crate::AppState; + +pub async fn get_user_asset( + State(app_state): State, + Path(asset_id): Path +) -> impl IntoResponse { + let user_asset = match get_user_asset_by_id(&app_state.db, &asset_id).await { + Err(_) => { + return ( + StatusCode::NOT_FOUND, + Html("Could not find user asset") + ).into_response(); + }, + Ok(ua) => ua + }; + + let mut hm = HeaderMap::new(); + hm.insert( + header::CONTENT_TYPE, + HeaderValue::from_str(&user_asset.mime_type).expect("Constructing header value.") + ); + + (hm, user_asset.content).into_response() +} diff --git a/lib/http_server/src/controllers/ui/me.rs b/lib/http_server/src/controllers/ui/me.rs index 6d3113d..12107d6 100644 --- a/lib/http_server/src/controllers/ui/me.rs +++ b/lib/http_server/src/controllers/ui/me.rs @@ -1,7 +1,8 @@ +use anyhow::Context; use axum::{body::Bytes, extract::State, response::IntoResponse, Extension}; use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; use fully_pub::fully_pub; -use log::error; +use log::{error, info}; use minijinja::context; use crate::{ @@ -9,7 +10,7 @@ use crate::{ renderer::TemplateRenderer, AppState }; -use kernel::models::user::User; +use kernel::{models::{user::User, user_asset::UserAsset}, repositories::users::create_user_asset}; pub async fn me_page( State(app_state): State, @@ -61,7 +62,7 @@ struct UserDetailsUpdateForm { website: String, #[form_data(limit = "5MiB")] - picture: FieldData + avatar: FieldData } @@ -73,17 +74,41 @@ pub async fn me_perform_update_details( ) -> impl IntoResponse { let template_path = "pages/me/details-form"; - let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1") + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(&token_claims.sub) + .fetch_one(&app_state.db.0) + .await + .expect("To get user from claim"); + + let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5 WHERE id = $1") .bind(&token_claims.sub) .bind(details_update.handle) .bind(details_update.email) .bind(details_update.full_name) .bind(details_update.website) - .bind(details_update.picture.contents.to_vec()) .execute(&app_state.db.0) .await; - let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + if !details_update.avatar.contents.is_empty() { + let user_asset = UserAsset::new( + &user, + details_update.avatar.contents.to_vec(), + details_update.avatar.metadata.content_type.expect("Expected mimetype on avatar content"), + details_update.avatar.metadata.name + ); + let _update_res = sqlx::query("UPDATE users SET avatar_asset_id = $2 WHERE id = $1") + .bind(&token_claims.sub) + .bind(user_asset.id.clone()) + .execute(&app_state.db.0) + .await; + // TODO: handle possible error + let _ = create_user_asset(&app_state.db, user_asset) + .await + .context("Creating user avatar asset."); + info!("Uploaded new avatar as user asset"); + } + + let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(&token_claims.sub) .fetch_one(&app_state.db.0) .await @@ -95,7 +120,7 @@ pub async fn me_perform_update_details( template_path, context!( success => true, - user => user_res + user => user ) ) }, @@ -105,7 +130,7 @@ pub async fn me_perform_update_details( template_path, context!( error => Some("Cannot update user details.".to_string()), - user => user_res + user => user ) ) } diff --git a/lib/http_server/src/controllers/ui/register.rs b/lib/http_server/src/controllers/ui/register.rs index dca68a1..c128b7c 100644 --- a/lib/http_server/src/controllers/ui/register.rs +++ b/lib/http_server/src/controllers/ui/register.rs @@ -46,7 +46,7 @@ pub async fn perform_register( email: Some(register.email), handle: register.handle, full_name: None, - picture: None, + avatar_asset_id: None, password_hash, status: UserStatus::Active, diff --git a/lib/http_server/src/router.rs b/lib/http_server/src/router.rs index 401b34e..7901928 100644 --- a/lib/http_server/src/router.rs +++ b/lib/http_server/src/router.rs @@ -47,7 +47,8 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router let api_user_routes = Router::new() .route("/api/user", get(api::read_user::read_user_basic)) .layer(middleware::from_fn_with_state(app_state.clone(), app_auth::enforce_jwt_auth_middleware)) - .route("/api", get(api::index::get_index)); + .route("/api", get(api::index::get_index)) + .route("/api/user-assets/:asset_id", get(api::public_assets::get_user_asset)); let well_known_routes = Router::new() .route("/.well-known/openid-configuration", get(api::openid::well_known::get_well_known_openid_configuration)); diff --git a/lib/http_server/src/templates/pages/me/details-form.html b/lib/http_server/src/templates/pages/me/details-form.html index 226a762..0cb970d 100644 --- a/lib/http_server/src/templates/pages/me/details-form.html +++ b/lib/http_server/src/templates/pages/me/details-form.html @@ -55,10 +55,9 @@ />
- - + Update details. Manage authorizations. -

-{% if user.picture %} - +{% if user.avatar_asset_id %} +

+ +
{% endif %}
  • diff --git a/lib/kernel/Cargo.toml b/lib/kernel/Cargo.toml index 57eaea8..57cf72d 100644 --- a/lib/kernel/Cargo.toml +++ b/lib/kernel/Cargo.toml @@ -18,6 +18,8 @@ chrono = { workspace = true } toml = { workspace = true } sqlx = { workspace = true } dotenvy = { workspace = true } +sha2 = { workspace = true } +hex-literal = { workspace = true } uuid = { workspace = true } url = { workspace = true } diff --git a/lib/kernel/src/models/mod.rs b/lib/kernel/src/models/mod.rs index 37a7310..3d84efb 100644 --- a/lib/kernel/src/models/mod.rs +++ b/lib/kernel/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod config; pub mod user; +pub mod user_asset; pub mod authorization; diff --git a/lib/kernel/src/models/user.rs b/lib/kernel/src/models/user.rs index 266ef5b..9634fa0 100644 --- a/lib/kernel/src/models/user.rs +++ b/lib/kernel/src/models/user.rs @@ -23,7 +23,7 @@ struct User { full_name: Option, email: Option, website: Option, - picture: Option>, // embeded blob to store profile pic + avatar_asset_id: Option, password_hash: Option, // argon2 password hash status: UserStatus, roles: Json>, @@ -43,7 +43,7 @@ impl User { full_name: None, email: None, website: None, - picture: None, + avatar_asset_id: None, password_hash: None, status: UserStatus::Disabled, roles: Json(Vec::new()), diff --git a/lib/kernel/src/models/user_asset.rs b/lib/kernel/src/models/user_asset.rs new file mode 100644 index 0000000..b063a53 --- /dev/null +++ b/lib/kernel/src/models/user_asset.rs @@ -0,0 +1,41 @@ +use fully_pub::fully_pub; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use sha2::{Sha256, Digest}; + +use super::user::User; + + +#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)] +#[fully_pub] +struct UserAsset { + /// uuid + id: String, + user_id: String, + mime_type: String, + fingerprint: String, + name: Option, + content: Vec, + created_at: DateTime +} + +impl UserAsset { + pub fn new( + user: &User, + content: Vec, + mime_type: String, + name: Option + ) -> UserAsset { + let digest = Sha256::digest(&content); + UserAsset { + id: Uuid::new_v4().to_string(), + user_id: user.id.clone(), + fingerprint: format!("{:x}", digest), + mime_type, + content, + name, + created_at: Utc::now() + } + } +} diff --git a/lib/kernel/src/repositories/users.rs b/lib/kernel/src/repositories/users.rs index cf3095e..c6ddf47 100644 --- a/lib/kernel/src/repositories/users.rs +++ b/lib/kernel/src/repositories/users.rs @@ -1,6 +1,6 @@ // user repositories -use crate::models::user::User; +use crate::models::{user::User, user_asset::UserAsset}; use super::storage::Storage; use anyhow::{Result, Context}; @@ -19,3 +19,30 @@ pub async fn get_users(storage: &Storage) -> Result> { .await .context("To get users.") } + +pub async fn create_user_asset(storage: &Storage, asset: UserAsset) -> Result<()> { + sqlx::query("INSERT INTO user_assets + (id, user_id, mime_type, fingerprint, content, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ") + .bind(&asset.id) + .bind(&asset.user_id) + .bind(&asset.mime_type) + .bind(&asset.fingerprint) + .bind(&asset.content) + .bind(&asset.created_at) + .execute(&storage.0) + .await + .context("While inserting user asset.")?; + // .bind(details_update.avatar.contents.to_vec()) + Ok(()) +} + +pub async fn get_user_asset_by_id(storage: &Storage, id: &str) -> Result { + sqlx::query_as("SELECT * FROM user_assets WHERE id = $1") + .bind(id) + .fetch_one(&storage.0) + .await + .context("To get user asset by id.") +} + diff --git a/migrations/all.sql b/migrations/all.sql index 27cc5eb..2f71c3c 100644 --- a/migrations/all.sql +++ b/migrations/all.sql @@ -5,8 +5,8 @@ CREATE TABLE users ( full_name TEXT, email TEXT UNIQUE, website TEXT, - picture BLOB, roles TEXT NOT NULL, -- json array of user roles + avatar_asset_id TEXT, status TEXT CHECK(status IN ('Invited', 'Active', 'Disabled')) NOT NULL DEFAULT 'Disabled', password_hash TEXT, @@ -15,6 +15,17 @@ CREATE TABLE users ( created_at DATETIME NOT NULL ); +DROP TABLE IF EXISTS user_assets; +CREATE TABLE user_assets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + mime_type TEXT NOT NULL, + fingerprint TEXT NOT NULL, + name TEXT, -- file name + content BLOB NOT NULL, + created_at DATETIME NOT NULL +); + DROP TABLE IF EXISTS authorizations; CREATE TABLE authorizations ( id TEXT PRIMARY KEY,