feat(auth): add /api/me endpoint, avatar upload, and profile picture support
Build and Deploy / build-and-push (push) Successful in 5m7s

- Add migration 005: nullable avatar_url column on users table
- Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug)
- Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif),
  writes to avatars/ dir, updates avatar_url in DB
- Serve /avatars via ServeDir so uploaded images are publicly accessible
- Update account.html: fetch username from /api/me instead of parsing JWT;
  add circular avatar display with initials fallback and click-to-upload
- Add SolitaireServerClient::fetch_me() for desktop/Android profile display
- Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None)
- Update sqlx offline query cache for new avatar_url queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-14 17:14:36 -07:00
parent eb906fe968
commit 407cae2040
11 changed files with 354 additions and 30 deletions
+3
View File
@@ -56,6 +56,9 @@ pub enum SyncBackend {
url: String,
/// The player's username on that server.
username: String,
/// Absolute URL of the user's avatar image, or `None` if not set.
#[serde(default, skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>,
// JWT tokens are stored in the OS keychain — not here.
},
+49 -1
View File
@@ -500,6 +500,53 @@ impl SolitaireServerClient {
})?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
/// Fetch the authenticated user's profile (`GET /api/me`).
///
/// Returns `(username, avatar_url)`. `avatar_url` is `None` when the user
/// has not set an avatar. Returns an error on network failure or if the
/// token is expired and refresh also fails.
pub async fn fetch_me(&self) -> Result<(String, Option<String>), SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/me", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.get(&url)
.bearer_auth(new_token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
return Self::extract_me_body(resp).await;
}
Self::extract_me_body(resp).await
}
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(SyncError::Network(format!("GET /api/me returned {status}")));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let username = body["username"].as_str().unwrap_or("").to_string();
let avatar_url = body["avatar_url"].as_str().map(str::to_string);
Ok((username, avatar_url))
}
}
// ---------------------------------------------------------------------------
@@ -575,7 +622,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result<SyncResponse, Sync
pub fn provider_for_backend(backend: &SyncBackend) -> Box<dyn SyncProvider + Send + Sync> {
match backend {
SyncBackend::Local => Box::new(LocalOnlyProvider),
SyncBackend::SolitaireServer { url, username } => {
SyncBackend::SolitaireServer { url, username, .. } => {
Box::new(SolitaireServerClient::new(url.clone(), username.clone()))
}
}
@@ -628,6 +675,7 @@ mod tests {
let provider = provider_for_backend(&SyncBackend::SolitaireServer {
url: "https://example.com".to_string(),
username: "bob".to_string(),
avatar_url: None,
});
assert_eq!(provider.backend_name(), "solitaire_server");
}