LoVisual/backend/accounts-service/src/accounts/handlers.rs
loki5512344 8a758754dd
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.
2026-09-29 10:42:49 +02:00

88 lines
2.5 KiB
Rust

use super::repo;
use crate::error::AppError;
use axum::{
Json,
extract::{Path, State},
};
use chrono::{DateTime, Utc};
use common::internal::GatewayIdentity;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone)]
pub struct AccountsState {
pub pool: sqlx::PgPool,
pub avatar_base_url: String,
}
#[derive(Serialize)]
pub struct MeResponse {
pub id: Uuid,
pub email: String,
pub display_nick: String,
pub role: String,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
}
pub async fn me(
State(state): State<AccountsState>,
identity: GatewayIdentity,
) -> Result<Json<MeResponse>, AppError> {
let account = repo::find_by_id(&state.pool, identity.account_id)
.await?
.ok_or(AppError::Unauthorized)?;
let avatar_url = repo::avatar_ref(&state.pool, account.id)
.await?
.map(|(key, v)| format!("{}/{key}?v={v}", state.avatar_base_url));
Ok(Json(MeResponse {
id: account.id,
email: account.email,
display_nick: account.display_nick,
role: account.role,
avatar_url,
created_at: account.created_at,
}))
}
/// Public profile for the site's `/u/:id` page: no identity required, and no
/// private fields (email, role) — only what showcase viewers may see.
#[derive(Serialize)]
pub struct PublicProfileResponse {
pub id: Uuid,
pub display_nick: String,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
pub badges: Vec<String>,
}
/// Accounts are handed out in `created_at` order, so the first 1000 to sign
/// up get the `early` badge.
const EARLY_ADOPTER_LIMIT: i64 = 1000;
pub async fn public_profile(
State(state): State<AccountsState>,
Path(raw): Path<String>,
) -> Result<Json<PublicProfileResponse>, AppError> {
let not_found = || AppError::NotFound("no such account".into());
let id = Uuid::parse_str(&raw).map_err(|_| not_found())?;
let account = repo::find_by_id(&state.pool, id)
.await?
.ok_or_else(not_found)?;
let avatar_url = repo::avatar_ref(&state.pool, account.id)
.await?
.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()]
} else {
Vec::new()
};
Ok(Json(PublicProfileResponse {
id: account.id,
display_nick: account.display_nick,
avatar_url,
created_at: account.created_at,
badges,
}))
}