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
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT username FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "username",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE users SET avatar_url = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT username, avatar_url FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "username",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "avatar_url",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45"
}
+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");
}
@@ -424,6 +424,7 @@ fn poll_auth_task(
settings.0.sync_backend = SyncBackend::SolitaireServer {
url: url.clone(),
username: username.clone(),
avatar_url: None,
};
if let Some(path) = &settings_path.0
&& let Err(e) = save_settings_to(path, &settings.0)
@@ -0,0 +1,4 @@
-- Migration 005: user avatar
-- Adds a nullable avatar_url column to users.
-- Existing rows receive NULL (no avatar set).
ALTER TABLE users ADD COLUMN avatar_url TEXT;
+117 -2
View File
@@ -1,6 +1,12 @@
//! Authentication handlers: register, login, refresh, delete account.
//! Authentication handlers: register, login, refresh, delete account,
//! current-user profile, and avatar upload.
use axum::{extract::State, Json};
use axum::{
body::Bytes,
extract::State,
http::HeaderMap,
Json,
};
use bcrypt::{hash, verify};
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
@@ -37,6 +43,14 @@ pub struct AuthResponse {
pub refresh_token: String,
}
/// Response for `GET /api/me`.
#[derive(Debug, Serialize)]
pub struct MeResponse {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
}
/// Successful refresh response — contains the new access token and the rotated
/// refresh token. The refresh token is always rotated: the client must store
/// the new value and discard the old one.
@@ -302,6 +316,107 @@ pub async fn delete_account(
Ok(Json(serde_json::json!({ "ok": true })))
}
/// `GET /api/me` — return the authenticated user's id, username, and avatar URL.
pub async fn get_me(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<MeResponse>, AppError> {
struct Row {
username: Option<String>,
avatar_url: Option<String>,
}
let row = sqlx::query_as!(
Row,
"SELECT username, avatar_url FROM users WHERE id = ?",
user.user_id
)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("user not found".into()))?;
Ok(Json(MeResponse {
id: user.user_id,
username: row.username.unwrap_or_default(),
avatar_url: row.avatar_url,
}))
}
/// Allowed MIME types for uploaded avatars.
const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"];
/// Maximum avatar upload size in bytes (1 MB).
const AVATAR_MAX_BYTES: usize = 1024 * 1024;
/// `PUT /api/me/avatar` — upload a new avatar image (raw bytes, ≤ 1 MB).
///
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
pub async fn upload_avatar(
State(state): State<AppState>,
user: AuthenticatedUser,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<MeResponse>, AppError> {
let mime = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let ext = if mime.contains("jpeg") || mime.contains("jpg") {
"jpg"
} else if mime.contains("png") {
"png"
} else if mime.contains("webp") {
"webp"
} else if mime.contains("gif") {
"gif"
} else {
return Err(AppError::BadRequest(
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
));
};
if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) {
return Err(AppError::BadRequest("unsupported image type".into()));
}
if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
}
// Write to avatars/ directory, replacing any previous file for this user.
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
let filename = format!("{}.{}", user.user_id, ext);
let path = std::path::Path::new("avatars").join(&filename);
// Remove stale files with other extensions first.
for old_ext in &["jpg", "png", "webp", "gif"] {
let _ = std::fs::remove_file(
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
);
}
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
let avatar_url = format!("/avatars/{filename}");
sqlx::query!(
"UPDATE users SET avatar_url = ? WHERE id = ?",
avatar_url,
user.user_id
)
.execute(&state.pool)
.await?;
let username: Option<String> = sqlx::query_scalar!(
"SELECT username FROM users WHERE id = ?",
user.user_id
)
.fetch_optional(&state.pool)
.await?;
Ok(Json(MeResponse {
id: user.user_id,
username: username.unwrap_or_default(),
avatar_url: Some(avatar_url),
}))
}
// ---------------------------------------------------------------------------
// Admin helpers (CLI use only — not exposed via HTTP)
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -19,9 +19,10 @@ use axum::{
http::{HeaderValue, Request},
middleware as axum_middleware,
response::{Html, Response},
routing::{delete, get, post},
routing::{delete, get, post, put},
Router,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use sqlx::SqlitePool;
use std::sync::Arc;
@@ -143,6 +144,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/leaderboard/opt-in", post(leaderboard::opt_in))
.route("/api/leaderboard/opt-in", delete(leaderboard::opt_out))
.route("/api/account", delete(auth::delete_account))
.route("/api/me", get(auth::get_me))
.route("/api/me/avatar", put(auth::upload_avatar))
.layer(axum_middleware::from_fn_with_state(
state.clone(),
middleware::require_auth,
@@ -228,6 +231,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
)
.nest_service("/web", ServeDir::new("solitaire_server/web"))
.nest_service("/assets", ServeDir::new("assets"))
.nest_service("/avatars", ServeDir::new("avatars"))
.layer(axum_middleware::from_fn(security_headers));
Router::new()
+117 -14
View File
@@ -80,14 +80,14 @@
button[type="submit"]:disabled { opacity: 0.4; cursor: default; }
/* ── Signed-in state ── */
#signed-in { display: none; flex-direction: column; gap: 16px; }
#signed-in { display: none; flex-direction: column; gap: 16px; align-items: center; }
.username-display {
font-size: 20px; font-weight: 700; text-align: center;
}
.signed-in-detail {
font-size: 13px; color: var(--text-muted); text-align: center;
}
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; }
.signed-in-actions { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.btn-secondary {
background: var(--panel-hi); color: var(--text);
border: 1px solid var(--border); border-radius: 6px;
@@ -103,6 +103,40 @@
cursor: pointer; transition: background 120ms;
}
.btn-danger:hover { background: rgba(165, 66, 66, 0.15); }
/* ── Avatar ── */
.avatar-wrap {
position: relative; width: 96px; height: 96px; cursor: pointer;
border-radius: 50%; overflow: hidden;
border: 2px solid var(--border);
flex-shrink: 0;
}
.avatar-img {
width: 100%; height: 100%; object-fit: cover;
border-radius: 50%;
}
.avatar-initials {
width: 100%; height: 100%; border-radius: 50%;
background: var(--panel-hi); display: flex;
align-items: center; justify-content: center;
font-size: 32px; font-weight: 700; color: var(--text-muted);
user-select: none;
}
.avatar-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(0,0,0,0.55); display: flex;
align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: #fff;
letter-spacing: 0.06em; opacity: 0;
transition: opacity 120ms;
}
.avatar-wrap:hover .avatar-overlay { opacity: 1; }
.avatar-status {
font-size: 11px; color: var(--text-muted); text-align: center;
min-height: 16px;
}
.avatar-status.ok { color: var(--success); }
.avatar-status.err { color: var(--accent-hi); }
</style>
<!-- Matomo -->
<script>
@@ -128,6 +162,13 @@
<div class="card">
<!-- Signed-in view -->
<div id="signed-in">
<div class="avatar-wrap" id="avatar-wrap" title="Click to change avatar">
<img id="avatar-img" class="avatar-img" src="" alt="" style="display:none">
<div id="avatar-initials" class="avatar-initials"></div>
<div class="avatar-overlay">Upload</div>
<input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp,image/gif" style="display:none">
</div>
<div class="avatar-status" id="avatar-status"></div>
<div class="signed-in-detail">Signed in as</div>
<div class="username-display" id="display-username"></div>
<div class="signed-in-actions">
@@ -152,7 +193,7 @@
</div>
<div>
<label for="si-pass">Password</label>
<input type="password" id="si-pass" placeholder="••••••••" autocomplete="current-password">
<input type="password" id="si-pass" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;" autocomplete="current-password">
</div>
<div class="error-msg" id="si-error"></div>
<button type="submit">Sign In</button>
@@ -164,17 +205,17 @@
<label for="su-user">Username</label>
<input type="text" id="su-user" placeholder="your_username" autocomplete="username"
minlength="3" maxlength="32">
<div class="hint" style="margin-top:4px">332 characters, letters, digits, underscores</div>
<div class="hint" style="margin-top:4px">3&ndash;32 characters, letters, digits, underscores</div>
</div>
<div>
<label for="su-pass">Password</label>
<input type="password" id="su-pass" placeholder="••••••••" autocomplete="new-password"
<input type="password" id="su-pass" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;" autocomplete="new-password"
minlength="8">
<div class="hint" style="margin-top:4px">Minimum 8 characters</div>
</div>
<div>
<label for="su-pass2">Confirm Password</label>
<input type="password" id="su-pass2" placeholder="••••••••" autocomplete="new-password">
<input type="password" id="su-pass2" placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;" autocomplete="new-password">
</div>
<div class="error-msg" id="su-error"></div>
<div class="success-msg" id="su-success"></div>
@@ -186,18 +227,40 @@
<script>
const TOKEN_KEY = 'fs_token';
function getUsername(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub_name ?? payload.username ?? payload.sub ?? null;
} catch { return null; }
async function fetchMe(token) {
const res = await fetch('/api/me', {
headers: { 'Authorization': 'Bearer ' + token },
});
if (!res.ok) return null;
return res.json();
}
function showSignedIn(token) {
const username = getUsername(token);
document.getElementById('display-username').textContent = username ?? 'Player';
function applyProfile(me) {
document.getElementById('display-username').textContent = me.username || 'Player';
const img = document.getElementById('avatar-img');
const initials = document.getElementById('avatar-initials');
if (me.avatar_url) {
img.src = me.avatar_url;
img.style.display = 'block';
initials.style.display = 'none';
} else {
img.style.display = 'none';
initials.style.display = 'flex';
initials.textContent = (me.username || 'P')[0].toUpperCase();
}
}
async function showSignedIn(token) {
document.getElementById('signed-in').style.display = 'flex';
document.getElementById('auth-section').style.display = 'none';
const me = await fetchMe(token);
if (me) {
applyProfile(me);
} else {
// Token is stale — kick back to auth
localStorage.removeItem(TOKEN_KEY);
showAuth();
}
}
function showAuth() {
@@ -279,6 +342,46 @@
showAuth();
});
// Avatar: click wrap → trigger file picker
document.getElementById('avatar-wrap').addEventListener('click', () => {
document.getElementById('avatar-file').click();
});
document.getElementById('avatar-file').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const status = document.getElementById('avatar-status');
status.className = 'avatar-status';
status.textContent = 'Uploading...';
const token = localStorage.getItem(TOKEN_KEY);
try {
const res = await fetch('/api/me/avatar', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': file.type,
},
body: file,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
status.className = 'avatar-status err';
status.textContent = body.message ?? 'Upload failed.';
return;
}
const me = await res.json();
applyProfile(me);
status.className = 'avatar-status ok';
status.textContent = 'Avatar updated.';
setTimeout(() => { status.textContent = ''; }, 3000);
} catch {
status.className = 'avatar-status err';
status.textContent = 'Network error.';
}
// Reset so re-selecting the same file triggers change again
e.target.value = '';
});
// Initial state
const token = localStorage.getItem(TOKEN_KEY);
if (token) { showSignedIn(token); } else { showAuth(); }