diff --git a/furumi-agent/migrations/0005_users_and_play_events.sql b/furumi-agent/migrations/0005_users_and_play_events.sql new file mode 100644 index 0000000..cae6b92 --- /dev/null +++ b/furumi-agent/migrations/0005_users_and_play_events.sql @@ -0,0 +1,20 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT, + email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE play_events ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id BIGINT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + played_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_play_events_user_id ON play_events(user_id); +CREATE INDEX idx_play_events_track_id ON play_events(track_id); +CREATE INDEX idx_play_events_user_track ON play_events(user_id, track_id); +CREATE INDEX idx_play_events_played_at ON play_events(played_at DESC); diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 82ac92a..34d51a3 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -57,7 +57,7 @@ function App() { // Authenticated — render player immediately if (!loading && user) { - return + return } // Loading — show spinner (no login form flash) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 64d3410..c2572e5 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi' +import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' import type { Track } from './types' -export function FurumiPlayer() { +export type UserProfile = { + sub: string + name?: string + email?: string +} + +export function FurumiPlayer({ user }: { user: UserProfile }) { const dispatch = useAppDispatch() const artistsLoading = useAppSelector((s) => s.artists.loading) const artistsError = useAppSelector((s) => s.artists.error) @@ -293,6 +299,7 @@ export function FurumiPlayer() { dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] void playback.loadStreamForTrack(track.slug) + void recordPlay(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -513,6 +520,7 @@ export function FurumiPlayer() { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + user={user} /> void + user: UserInfo +} + +function UserMenu({ user }: { user: UserInfo }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const initials = (user.name ?? user.sub) + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + + return ( +
+ + {open && ( +
+
+
{user.name ?? user.sub}
+ {user.email &&
{user.email}
} +
+ Sign out +
+ )} +
+ ) } export function Header({ searchOpen, searchResults, onSearchSelect, + user, }: HeaderProps) { return (
@@ -40,6 +86,7 @@ export function Header({ onSelect={onSearchSelect} /> +
) diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 3777ab4..02af660 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -32,4 +32,80 @@ margin-left: 0.25rem; font-weight: 500; text-decoration: none; +} + +/* User menu */ + +.userMenu { + position: relative; +} + +.userAvatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.userAvatar:hover { + background: var(--accent-dim); +} + +.userDropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.userInfo { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.userName { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.userEmail { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.userLogout { + display: block; + padding: 10px 16px; + color: var(--danger); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s; +} + +.userLogout:hover { + background: var(--bg-hover); } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index fa447c7..eb9aa4e 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,10 @@ export async function getTrackInfo(trackSlug: string): Promise { + await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) +} + export async function preloadStream(trackSlug: string) { return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index f33fac6..0fad353 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -82,6 +82,50 @@ pub struct SearchResult { pub detail: Option, // artist name for albums/tracks } +// --- User management --- + +pub async fn upsert_user( + pool: &PgPool, + id: &str, + username: &str, + display_name: Option<&str>, + email: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#"INSERT INTO users (id, username, display_name, email, last_seen_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (id) DO UPDATE SET + username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + email = EXCLUDED.email, + last_seen_at = NOW()"# + ) + .bind(id) + .bind(username) + .bind(display_name) + .bind(email) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn record_play_event( + pool: &PgPool, + user_id: &str, + track_slug: &str, +) -> Result { + let result = sqlx::query( + r#"INSERT INTO play_events (user_id, track_id, played_at) + SELECT $1, t.id, NOW() + FROM tracks t WHERE t.slug = $2"# + ) + .bind(user_id) + .bind(track_slug) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + // --- Queries --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index e4fe88a..7357702 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -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; @@ -291,6 +293,20 @@ pub async fn search(State(state): State, Query(q): Query) -> imp } } +// --- Play tracking --- + +pub async fn record_play( + State(state): State, + Path(slug): Path, + Extension(user): Extension, +) -> 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 { diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 98574f7..8f61b86 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -109,12 +109,23 @@ impl OidcState { } } +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: String, + pub username: String, + pub display_name: Option, + pub email: Option, +} + #[derive(Debug, serde::Deserialize)] struct BearerClaims { sub: String, + preferred_username: Option, + name: Option, + email: Option, } -async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { 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 validation.validate_aud = false; let data = decode::(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 { } /// 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>, - req: Request, + mut req: Request, next: Next, ) -> Response { + let mut auth_user: Option = 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)] diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index c8d95b9..d35a8a1 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -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) -> 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) -> 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));