feat: add user support with play event tracking
Backend (Rust API): - Add users and play_events tables (migration 0005) - Extract full user identity from JWT (sub, username, email, name) and pass AuthUser via request extensions to all handlers - Auto-upsert user in background on every authenticated request - POST /api/tracks/:slug/play endpoint to record play events - Allow POST method in CORS Frontend (Node player): - Call recordPlay() when a track starts playing - Add user profile avatar with dropdown menu (name, email, sign out) - Pass user info from App through FurumiPlayer to Header Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,10 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
use axum::Extension;
|
||||
use crate::db;
|
||||
use super::AppState;
|
||||
use super::auth::AuthUser;
|
||||
|
||||
type S = Arc<AppState>;
|
||||
|
||||
@@ -291,6 +293,20 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
|
||||
}
|
||||
}
|
||||
|
||||
// --- Play tracking ---
|
||||
|
||||
pub async fn record_play(
|
||||
State(state): State<S>,
|
||||
Path(slug): Path<String>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> impl IntoResponse {
|
||||
match db::record_play_event(&state.pool, &user.id, &slug).await {
|
||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(false) => error_json(StatusCode::NOT_FOUND, "track not found"),
|
||||
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn error_json(status: StatusCode, message: &str) -> Response {
|
||||
|
||||
@@ -109,12 +109,23 @@ impl OidcState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BearerClaims {
|
||||
sub: String,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
|
||||
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<AuthUser> {
|
||||
let header = decode_header(token).ok()?;
|
||||
let kid = header.kid.as_ref()?;
|
||||
|
||||
@@ -134,7 +145,13 @@ async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String>
|
||||
validation.validate_aud = false;
|
||||
|
||||
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
|
||||
Some(data.claims.sub)
|
||||
let c = data.claims;
|
||||
Some(AuthUser {
|
||||
id: c.sub.clone(),
|
||||
username: c.preferred_username.unwrap_or(c.sub),
|
||||
display_name: c.name,
|
||||
email: c.email,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
||||
@@ -164,11 +181,14 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
|
||||
/// Inserts AuthUser into request extensions and upserts user in DB.
|
||||
pub async fn require_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let mut auth_user: Option<AuthUser> = None;
|
||||
|
||||
// 1. Check Bearer token — JWT from OIDC provider
|
||||
if let Some(ref oidc) = state.oidc {
|
||||
if let Some(token) = req
|
||||
@@ -177,32 +197,54 @@ pub async fn require_auth(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
{
|
||||
if let Some(user_id) = validate_bearer_token(oidc, token).await {
|
||||
tracing::debug!("Bearer auth OK for user: {}", user_id);
|
||||
return next.run(req).await;
|
||||
}
|
||||
auth_user = validate_bearer_token(oidc, token).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check SSO session cookie (if OIDC configured)
|
||||
if let Some(ref oidc) = state.oidc {
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if auth_user.is_none() {
|
||||
if let Some(ref oidc) = state.oidc {
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
for c in cookies.split(';') {
|
||||
let c = c.trim();
|
||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
||||
return next.run(req).await;
|
||||
for c in cookies.split(';') {
|
||||
let c = c.trim();
|
||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
|
||||
auth_user = Some(AuthUser {
|
||||
id: user_id.clone(),
|
||||
username: user_id,
|
||||
display_name: None,
|
||||
email: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||
match auth_user {
|
||||
Some(user) => {
|
||||
tracing::debug!("Auth OK for user: {}", user.username);
|
||||
// Upsert user in background
|
||||
let pool = state.pool.clone();
|
||||
let u = user.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::db::upsert_user(
|
||||
&pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(),
|
||||
).await {
|
||||
tracing::warn!("Failed to upsert user: {}", e);
|
||||
}
|
||||
});
|
||||
req.extensions_mut().insert(user);
|
||||
next.run(req).await
|
||||
}
|
||||
None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{Router, routing::get, middleware};
|
||||
use axum::{Router, routing::{get, post}, middleware};
|
||||
use axum::http::{header, Method};
|
||||
use sqlx::PgPool;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
@@ -29,7 +29,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/tracks/:slug", get(api::get_track_detail))
|
||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||
.route("/stream/:slug", get(api::stream_track))
|
||||
.route("/search", get(api::search));
|
||||
.route("/search", get(api::search))
|
||||
.route("/tracks/:slug/play", post(api::record_play));
|
||||
|
||||
let api = Router::new()
|
||||
.nest("/api", library);
|
||||
@@ -44,7 +45,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
|
||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||
.max_age(Duration::from_secs(600));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user