diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto index 0190d204..01b59959 100644 --- a/client/testfixture/config.textproto +++ b/client/testfixture/config.textproto @@ -30,9 +30,9 @@ record_apis: [{ name: "_user_avatar" table_name: "_user_avatar" conflict_resolution: REPLACE + excluded_columns: ["updated"] autofill_missing_user_id_columns: true - acl_world: [READ] - acl_authenticated: [CREATE, READ, UPDATE, DELETE] + acl_authenticated: [CREATE, UPDATE, DELETE] create_access_rule: "_REQ_.user IS NULL OR _REQ_.user = _USER_.id" update_access_rule: "_ROW_.user = _USER_.id" delete_access_rule: "_ROW_.user = _USER_.id" diff --git a/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql b/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql index 39fb4eea..45362ca7 100644 --- a/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql +++ b/examples/blog/traildepot/migrations/U1725019361__create_profiles.sql @@ -27,7 +27,7 @@ CREATE VIEW profiles_view AS p.*, -- TrailBase requires top-level cast to determine result type and generate JSON schemas. CAST(CASE - WHEN avatar.file IS NOT NULL THEN CONCAT('/api/records/v1/_user_avatar/', uuid_text(p.user), '/file/file') + WHEN avatar.file IS NOT NULL THEN CONCAT('/api/auth/avatar/', uuid_text(p.user)) ELSE NULL END AS TEXT) AS avatar_url, -- TrailBase requires top-level cast to determine result type and generate JSON schemas. diff --git a/trailbase-assets/js/client/src/index.ts b/trailbase-assets/js/client/src/index.ts index 67ec4f5b..23ccfbcf 100644 --- a/trailbase-assets/js/client/src/index.ts +++ b/trailbase-assets/js/client/src/index.ts @@ -429,9 +429,7 @@ export class Client { public async avatarUrl(): Promise { const user = this.user(); if (user) { - const response = await this.fetch(`${authApiBasePath}/avatar/${user.id}`); - const json = (await response.json()) as { avatar_url: string }; - return json.avatar_url; + return `${authApiBasePath}/avatar/${user.id}`; } return undefined; } diff --git a/trailbase-core/src/auth/api/avatar.rs b/trailbase-core/src/auth/api/avatar.rs index 63eebdea..ab4abf38 100644 --- a/trailbase-core/src/auth/api/avatar.rs +++ b/trailbase-core/src/auth/api/avatar.rs @@ -1,59 +1,21 @@ -use axum::extract::{Json, Path, State}; -use axum::http::{HeaderMap, StatusCode, header}; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::extract::{Path, State}; +use axum::response::Response; use lazy_static::lazy_static; -use serde::{Deserialize, Serialize}; -use trailbase_schema::FileUpload; -use trailbase_sqlite::params; -use uuid::Uuid; +use trailbase_schema::QualifiedName; use crate::app_state::AppState; use crate::auth::AuthError; -use crate::auth::user::DbUser; -use crate::auth::util::user_by_id; -use crate::constants::{AVATAR_TABLE, RECORD_API_PATH}; -use crate::util::{assert_uuidv7_version, id_to_b64}; +use crate::constants::AVATAR_TABLE; +use crate::records::query_builder::QueryError; +use crate::util::assert_uuidv7_version; -async fn get_avatar_url(state: &AppState, user: &DbUser) -> Option { - lazy_static! { - static ref QUERY: String = - format!(r#"SELECT EXISTS(SELECT user FROM "{AVATAR_TABLE}" WHERE user = $1)"#); - }; - - let has_avatar = state - .user_conn() - .read_query_row_f(&*QUERY, params!(user.id), |row| row.get::<_, bool>(0)) - .await - .map_err(|err| { - log::debug!("avatar query broken?"); - return err; - }) - .unwrap_or_default() - .unwrap_or(false); - - if has_avatar { - let record_user_id = id_to_b64(&user.id); - let col_name = "file"; - return state - .site_url() - .join(&format!( - "/{RECORD_API_PATH}/{AVATAR_TABLE}/{record_user_id}/file/{col_name}" - )) - .ok(); - } - - return None; -} - -/// Get a user's avatar url if available. #[utoipa::path( get, path = "/avatar/:b64_user_id", - responses((status = 200, description = "Optional Avatar url")) + responses((status = 200, description = "Optional Avatar file")) )] -pub async fn get_avatar_url_handler( +pub async fn get_avatar_handler( State(state): State, - headers: HeaderMap, Path(b64_user_id): Path, ) -> Result { let Ok(user_id) = crate::util::b64_to_uuid(&b64_user_id) else { @@ -61,47 +23,42 @@ pub async fn get_avatar_url_handler( }; assert_uuidv7_version(&user_id); - let json = headers - .get(header::CONTENT_TYPE) - .is_some_and(|t| t == "application/json"); + lazy_static! { + static ref table_name: QualifiedName = QualifiedName { + name: AVATAR_TABLE.to_string(), + database_schema: None, + }; + } - let db_user = user_by_id(&state, &user_id).await?; - - // TODO: Allow a configurable fallback url. - let avatar_url = get_avatar_url(&state, &db_user) - .await - .map_or_else(|| db_user.provider_avatar_url, |url| Some(url.to_string())); - - // TODO: Maybe return a JSON response with url if content-type is JSON. - return match avatar_url { - Some(url) => { - if json { - Ok( - Json(serde_json::json!({ - "avatar_url": url, - })) - .into_response(), - ) - } else { - Ok(Redirect::to(&url).into_response()) - } - } - None => Ok(StatusCode::NOT_FOUND.into_response()), + let Some(table) = state.schema_metadata().get_table(&table_name) else { + return Err(AuthError::Internal("missing table".into())); }; -} -#[derive(Debug, Clone, Deserialize, Serialize)] -pub(crate) struct DbAvatar { - pub user: [u8; 16], - pub file: String, - pub updated: i64, -} + let Some((index, file_column)) = table.column_by_name("file") else { + return Err(AuthError::Internal("missing column".into())); + }; -#[allow(unused)] -#[derive(Debug, Clone)] -pub struct Avatar { - pub user: Uuid, - pub file: FileUpload, + let Some(ref column_json_metadata) = table.json_metadata.columns[index] else { + return Err(AuthError::Internal("missing metadata".into())); + }; + + let file_upload = crate::records::query_builder::GetFileQueryBuilder::run( + &state, + &trailbase_schema::QualifiedNameEscaped::new(&table_name), + file_column, + column_json_metadata, + "user", + rusqlite::types::Value::Blob(user_id.into()), + ) + .await + .map_err(|err| match err { + QueryError::NotFound => AuthError::NotFound, + _ => AuthError::Internal(err.into()), + })?; + + return crate::records::files::read_file_into_response(&state, file_upload) + .await + .map_err(|err| AuthError::Internal(err.into())); } #[cfg(test)] @@ -116,13 +73,11 @@ mod tests { use crate::app_state::*; use crate::auth::api::login::login_with_password; use crate::auth::user::{DbUser, User}; - use crate::constants::RECORD_API_PATH; use crate::constants::{AVATAR_TABLE, USER_TABLE}; use crate::extract::Either; use crate::records::create_record::{ CreateRecordQuery, CreateRecordResponse, create_record_handler, }; - use crate::records::read_record::get_uploaded_file_from_record_handler; use crate::test::unpack_json_response; use crate::util::{b64_to_uuid, id_to_b64, uuid_to_b64}; @@ -180,17 +135,9 @@ mod tests { } async fn download_avatar(state: &AppState, record_id: &[u8; 16]) -> Response { - return get_uploaded_file_from_record_handler( - State(state.clone()), - Path(( - AVATAR_COLLECTION_NAME.to_string(), - id_to_b64(record_id), - COL_NAME.to_string(), - )), - None, - ) - .await - .unwrap(); + return get_avatar_handler(State(state.clone()), Path(id_to_b64(record_id))) + .await + .unwrap(); } #[tokio::test] @@ -216,17 +163,14 @@ mod tests { .unwrap() .unwrap(); - let missing_profile_response = get_avatar_url_handler( - State(state.clone()), - HeaderMap::new(), - Path(id_to_b64(&db_user.id)), - ) - .await - .unwrap(); - assert_eq!( - missing_profile_response.status(), - http::StatusCode::NOT_FOUND - ); + let missing_profile_response = + get_avatar_handler(State(state.clone()), Path(id_to_b64(&db_user.id))) + .await + .err(); + assert!(matches!( + missing_profile_response, + Some(AuthError::NotFound) + )); const PNG0: &[u8] = b"\x89PNG\x0d\x0a\x1a\x0b"; const PNG1: &[u8] = b"\x89PNG\x0d\x0a\x1a\x0c"; @@ -274,29 +218,14 @@ mod tests { .is_err() ); - let avatar_response = get_avatar_url_handler( - State(state.clone()), - HeaderMap::new(), - Path(id_to_b64(&db_user.id)), - ) - .await - .unwrap(); - - assert_eq!(avatar_response.status(), http::StatusCode::SEE_OTHER); - let location = avatar_response - .headers() - .get("location") - .unwrap() - .to_str() + let response = get_avatar_handler(State(state.clone()), Path(id_to_b64(&db_user.id))) + .await .unwrap(); - - let mut _url = url::Url::parse(location).unwrap(); assert_eq!( - location, - format!( - "https://test.org/{RECORD_API_PATH}/{AVATAR_COLLECTION_NAME}/{record_id_b64}/file/{COL_NAME}", - record_id_b64 = uuid_to_b64(&record_id), - ) + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + PNG1 ); } } diff --git a/trailbase-core/src/auth/mod.rs b/trailbase-core/src/auth/mod.rs index e6ba554b..b0d96518 100644 --- a/trailbase-core/src/auth/mod.rs +++ b/trailbase-core/src/auth/mod.rs @@ -34,7 +34,7 @@ use crate::constants::AUTH_API_PATH; api::logout::logout_handler, api::refresh::refresh_handler, api::register::register_user_handler, - api::avatar::get_avatar_url_handler, + api::avatar::get_avatar_handler, api::delete::delete_handler, api::verify_email::verify_email_handler, api::verify_email::request_email_verification_handler, @@ -153,7 +153,7 @@ pub(super) fn router() -> Router { // Get a user's avatar. .route( &format!("/{AUTH_API_PATH}/avatar/{{b64_user_id}}"), - get(api::avatar::get_avatar_url_handler), + get(api::avatar::get_avatar_handler), ) // User delete. .route( diff --git a/trailbase-core/src/config.rs b/trailbase-core/src/config.rs index 76811c68..41e5322a 100644 --- a/trailbase-core/src/config.rs +++ b/trailbase-core/src/config.rs @@ -141,14 +141,13 @@ pub mod proto { conflict_resolution: Some(ConflictResolutionStrategy::Replace.into()), autofill_missing_user_id_columns: Some(true), enable_subscriptions: None, - acl_world: vec![PermissionFlag::Read as i32], + acl_world: vec![], acl_authenticated: vec![ PermissionFlag::Create as i32, - PermissionFlag::Read as i32, PermissionFlag::Update as i32, PermissionFlag::Delete as i32, ], - excluded_columns: vec![], + excluded_columns: vec!["updated".to_string()], read_access_rule: None, create_access_rule: Some("_REQ_.user IS NULL OR _REQ_.user = _USER_.id".to_string()), update_access_rule: Some("_ROW_.user = _USER_.id".to_string()),