diff --git a/backend/accounts-service/src/accounts/handlers.rs b/backend/accounts-service/src/accounts/handlers.rs index 577f7c5..2e91735 100644 --- a/backend/accounts-service/src/accounts/handlers.rs +++ b/backend/accounts-service/src/accounts/handlers.rs @@ -32,9 +32,9 @@ pub async fn me( let account = repo::find_by_id(&state.pool, identity.account_id) .await? .ok_or(AppError::Unauthorized)?; - let avatar_url = repo::avatar_key(&state.pool, account.id) + let avatar_url = repo::avatar_ref(&state.pool, account.id) .await? - .map(|key| format!("{}/{key}", state.avatar_base_url)); + .map(|(key, v)| format!("{}/{key}?v={v}", state.avatar_base_url)); Ok(Json(MeResponse { id: account.id, email: account.email, @@ -69,9 +69,9 @@ pub async fn public_profile( let account = repo::find_by_id(&state.pool, id) .await? .ok_or_else(not_found)?; - let avatar_url = repo::avatar_key(&state.pool, account.id) + let avatar_url = repo::avatar_ref(&state.pool, account.id) .await? - .map(|key| format!("{}/{key}", state.avatar_base_url)); + .map(|(key, v)| format!("{}/{key}?v={v}", state.avatar_base_url)); let rank = repo::account_rank(&state.pool, account.created_at).await?; let badges = if rank < EARLY_ADOPTER_LIMIT { vec!["early".to_owned()] diff --git a/backend/accounts-service/src/accounts/repo.rs b/backend/accounts-service/src/accounts/repo.rs index 3cab080..fd2db91 100644 --- a/backend/accounts-service/src/accounts/repo.rs +++ b/backend/accounts-service/src/accounts/repo.rs @@ -58,21 +58,30 @@ pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result Ok(()) } -pub async fn avatar_key(pool: &PgPool, account_id: Uuid) -> Result, sqlx::Error> { - sqlx::query_scalar("SELECT s3_key FROM avatars WHERE account_id = $1") - .bind(account_id) - .fetch_optional(pool) - .await +/// Avatar reference for one account: its storage key plus an `uploaded_at` +/// version (epoch seconds) used to cache-bust the served image URL. +pub async fn avatar_ref( + pool: &PgPool, + account_id: Uuid, +) -> Result, sqlx::Error> { + let row: Option<(String, i64)> = sqlx::query_as( + "SELECT s3_key, EXTRACT(EPOCH FROM uploaded_at)::bigint FROM avatars WHERE account_id = $1", + ) + .bind(account_id) + .fetch_optional(pool) + .await?; + Ok(row) } -/// Nick + avatar key for the given accounts (showcase authors etc.). +/// Nick + avatar (key, version) for the given accounts (showcase authors etc.). /// Missing ids are simply absent from the result. pub async fn public_profiles( pool: &PgPool, ids: &[Uuid], -) -> Result)>, sqlx::Error> { +) -> Result, Option)>, sqlx::Error> { sqlx::query_as( - "SELECT a.id, a.display_nick, av.s3_key FROM accounts a + "SELECT a.id, a.display_nick, av.s3_key, EXTRACT(EPOCH FROM av.uploaded_at)::bigint + FROM accounts a LEFT JOIN avatars av ON av.account_id = a.id WHERE a.id = ANY($1)", ) diff --git a/backend/accounts-service/src/grpc/mod.rs b/backend/accounts-service/src/grpc/mod.rs index bf48ba2..a2eda70 100644 --- a/backend/accounts-service/src/grpc/mod.rs +++ b/backend/accounts-service/src/grpc/mod.rs @@ -74,12 +74,13 @@ impl AccountsInternal for AccountsGrpc { })?; let profiles = rows .into_iter() - .map(|(id, nick, key)| PublicProfile { + .map(|(id, nick, key, version)| PublicProfile { account_id: id.to_string(), display_nick: nick, - avatar_url: key - .map(|k| format!("{}/{k}", self.avatar_base_url)) - .unwrap_or_default(), + avatar_url: match (key, version) { + (Some(k), Some(v)) => format!("{}/{k}?v={v}", self.avatar_base_url), + _ => String::new(), + }, }) .collect(); Ok(Response::new(GetPublicProfilesReply { profiles }))