fix(backend): cache-bust served avatars with an uploaded_at version

Avatar objects live at a deterministic key (avatars/<id>.png) served with a
300s cache, so after a re-upload the <img> kept pointing at the same URL and
the browser showed the stale crop until reload. Append ?v=<uploaded_at epoch>
to every avatar_url (me, public profile, and showcase/grpc batch) so the URL
changes whenever the avatar changes.
This commit is contained in:
loki5512344 2026-09-29 10:42:49 +02:00
parent eb854c32da
commit 8a758754dd
Signed by: boba
GPG key ID: 253067914055423B
3 changed files with 26 additions and 16 deletions

View file

@ -32,9 +32,9 @@ pub async fn me(
let account = repo::find_by_id(&state.pool, identity.account_id) let account = repo::find_by_id(&state.pool, identity.account_id)
.await? .await?
.ok_or(AppError::Unauthorized)?; .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? .await?
.map(|key| format!("{}/{key}", state.avatar_base_url)); .map(|(key, v)| format!("{}/{key}?v={v}", state.avatar_base_url));
Ok(Json(MeResponse { Ok(Json(MeResponse {
id: account.id, id: account.id,
email: account.email, email: account.email,
@ -69,9 +69,9 @@ pub async fn public_profile(
let account = repo::find_by_id(&state.pool, id) let account = repo::find_by_id(&state.pool, id)
.await? .await?
.ok_or_else(not_found)?; .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? .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 rank = repo::account_rank(&state.pool, account.created_at).await?;
let badges = if rank < EARLY_ADOPTER_LIMIT { let badges = if rank < EARLY_ADOPTER_LIMIT {
vec!["early".to_owned()] vec!["early".to_owned()]

View file

@ -58,21 +58,30 @@ pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result
Ok(()) Ok(())
} }
pub async fn avatar_key(pool: &PgPool, account_id: Uuid) -> Result<Option<String>, sqlx::Error> { /// Avatar reference for one account: its storage key plus an `uploaded_at`
sqlx::query_scalar("SELECT s3_key FROM avatars WHERE account_id = $1") /// version (epoch seconds) used to cache-bust the served image URL.
pub async fn avatar_ref(
pool: &PgPool,
account_id: Uuid,
) -> Result<Option<(String, i64)>, 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) .bind(account_id)
.fetch_optional(pool) .fetch_optional(pool)
.await .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. /// Missing ids are simply absent from the result.
pub async fn public_profiles( pub async fn public_profiles(
pool: &PgPool, pool: &PgPool,
ids: &[Uuid], ids: &[Uuid],
) -> Result<Vec<(Uuid, String, Option<String>)>, sqlx::Error> { ) -> Result<Vec<(Uuid, String, Option<String>, Option<i64>)>, sqlx::Error> {
sqlx::query_as( 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 LEFT JOIN avatars av ON av.account_id = a.id
WHERE a.id = ANY($1)", WHERE a.id = ANY($1)",
) )

View file

@ -74,12 +74,13 @@ impl AccountsInternal for AccountsGrpc {
})?; })?;
let profiles = rows let profiles = rows
.into_iter() .into_iter()
.map(|(id, nick, key)| PublicProfile { .map(|(id, nick, key, version)| PublicProfile {
account_id: id.to_string(), account_id: id.to_string(),
display_nick: nick, display_nick: nick,
avatar_url: key avatar_url: match (key, version) {
.map(|k| format!("{}/{k}", self.avatar_base_url)) (Some(k), Some(v)) => format!("{}/{k}?v={v}", self.avatar_base_url),
.unwrap_or_default(), _ => String::new(),
},
}) })
.collect(); .collect();
Ok(Response::new(GetPublicProfilesReply { profiles })) Ok(Response::new(GetPublicProfilesReply { profiles }))