Replace avatar url + record API with dedicated read avatar API.

This commit is contained in:
Sebastian Jeltsch
2025-06-06 22:38:33 +02:00
parent 035b92512b
commit c3cd9bd2da
6 changed files with 65 additions and 139 deletions
+2 -2
View File
@@ -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"
@@ -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.
+1 -3
View File
@@ -429,9 +429,7 @@ export class Client {
public async avatarUrl(): Promise<string | undefined> {
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;
}
+57 -128
View File
@@ -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<url::Url> {
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<AppState>,
headers: HeaderMap,
Path(b64_user_id): Path<String>,
) -> Result<Response, AuthError> {
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
);
}
}
+2 -2
View File
@@ -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<crate::AppState> {
// 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(
+2 -3
View File
@@ -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()),