From 952d11e6f560ed6f08a9e327b56f799c5a3459d7 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 5 Jun 2026 03:37:51 +0300 Subject: [PATCH] added mobile auth support --- Cargo.lock | 2 +- src/admin/mod.rs | 3 + src/api/mod.rs | 536 +++++++++++++++++++++++++++++- src/auth.rs | 556 ++++++++++++++++++++++++++++++- src/config.rs | 42 +++ src/main.rs | 19 ++ src/oidc.rs | 412 ++++++++++++++++++++++- src/player/mod.rs | 821 ++++++++++++++++++++++++++++++---------------- 8 files changed, 2091 insertions(+), 300 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbd6848..cf4b9ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.2.15" +version = "0.3.1" dependencies = [ "anyhow", "async-trait", diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 73d9b8f..a0a58a7 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1324,6 +1324,9 @@ impl App for AdminApp { all.extend(cot::db::migrations::wrap_migrations( crate::scheduler::db_migrations::MIGRATIONS, )); + all.extend(cot::db::migrations::wrap_migrations( + crate::auth::db_migrations::MIGRATIONS, + )); all } } diff --git a/src/api/mod.rs b/src/api/mod.rs index b5e6094..beec398 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,14 +1,27 @@ +use std::marker::PhantomData; + +use cot::aide::openapi::{ + MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject, + StatusCode as OpenApiStatusCode, +}; +use cot::auth::PasswordVerificationResult; +use cot::common_types::Password; use cot::db::Database; +use cot::http::StatusCode; +use cot::http::header::CONTENT_TYPE; use cot::json::Json; +use cot::openapi::{AsApiOperation, RouteContext}; use cot::response::IntoResponse; -use cot::router::method::openapi::api_get; +use cot::router::method::openapi::{api_get, api_post}; use cot::router::{Route, Router}; use cot::session::Session; -use cot::{App, Body}; -use schemars::JsonSchema; -use serde::Serialize; +use cot::{App, Body, RequestHandler}; +use schemars::{JsonSchema, SchemaGenerator}; +use serde::{Deserialize, Serialize}; use crate::auth; +use crate::config::AppConfig; +use crate::user::User; // --------------------------------------------------------------------------- // JSON error helper @@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re .expect("valid response") } +#[derive(Clone, Copy)] +struct DocumentedJsonHandler { + handler: H, + summary: &'static str, + _marker: PhantomData Res>, +} + +#[derive(Clone, Copy)] +struct DocumentedResponseHandler { + handler: H, + summary: &'static str, + _marker: PhantomData Res>, +} + +fn documented_json_handler( + handler: H, + summary: &'static str, +) -> DocumentedJsonHandler { + DocumentedJsonHandler { + handler, + summary, + _marker: PhantomData, + } +} + +fn documented_response_handler( + handler: H, + summary: &'static str, +) -> DocumentedResponseHandler { + DocumentedResponseHandler { + handler, + summary, + _marker: PhantomData, + } +} + +impl RequestHandler + for DocumentedJsonHandler +where + H: RequestHandler + Clone + Send + Sync + 'static, +{ + async fn handle(&self, request: cot::request::Request) -> cot::Result { + self.handler.handle(request).await + } +} + +impl RequestHandler for DocumentedResponseHandler +where + H: RequestHandler + Clone + Send + Sync + 'static, +{ + async fn handle(&self, request: cot::request::Request) -> cot::Result { + self.handler.handle(request).await + } +} + +impl AsApiOperation for DocumentedJsonHandler +where + Req: JsonSchema, + Res: JsonSchema, +{ + fn as_api_operation( + &self, + _route_context: &RouteContext<'_>, + schema_generator: &mut SchemaGenerator, + ) -> Option { + let mut operation = Operation { + summary: Some(self.summary.to_owned()), + ..Default::default() + }; + + let mut request_body = RequestBody { + required: true, + ..Default::default() + }; + request_body.content.insert( + "application/json".to_owned(), + MediaType { + schema: Some(SchemaObject { + json_schema: Req::json_schema(schema_generator), + external_docs: None, + example: None, + }), + ..Default::default() + }, + ); + operation.request_body = Some(ReferenceOr::Item(request_body)); + + let responses = operation.responses.get_or_insert_default(); + let mut ok = OpenApiResponse { + description: "OK".to_owned(), + ..Default::default() + }; + ok.content.insert( + "application/json".to_owned(), + MediaType { + schema: Some(SchemaObject { + json_schema: Res::json_schema(schema_generator), + external_docs: None, + example: None, + }), + ..Default::default() + }, + ); + responses + .responses + .insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok)); + + Some(operation) + } +} + +impl AsApiOperation for DocumentedResponseHandler +where + Res: JsonSchema, +{ + fn as_api_operation( + &self, + _route_context: &RouteContext<'_>, + schema_generator: &mut SchemaGenerator, + ) -> Option { + let mut operation = Operation { + summary: Some(self.summary.to_owned()), + ..Default::default() + }; + add_json_response::(&mut operation, schema_generator); + Some(operation) + } +} + +fn add_json_response( + operation: &mut Operation, + schema_generator: &mut SchemaGenerator, +) { + let responses = operation.responses.get_or_insert_default(); + let mut ok = OpenApiResponse { + description: "OK".to_owned(), + ..Default::default() + }; + ok.content.insert( + "application/json".to_owned(), + MediaType { + schema: Some(SchemaObject { + json_schema: Res::json_schema(schema_generator), + external_docs: None, + example: None, + }), + ..Default::default() + }, + ); + responses + .responses + .insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok)); +} + +fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + +async fn parse_json_request( + request: cot::request::Request, +) -> cot::Result> +where + T: for<'de> Deserialize<'de>, +{ + let content_type = request + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if !is_json_content_type(content_type) { + return Ok(Err(json_error( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "expected application/json", + ))); + } + + let bytes = request.into_body().into_bytes().await?; + let body = match serde_json::from_slice::(&bytes) { + Ok(body) => body, + Err(_) => { + return Ok(Err(json_error( + StatusCode::BAD_REQUEST, + "invalid JSON body", + ))); + } + }; + Ok(Ok(body)) +} + // --------------------------------------------------------------------------- // GET /api/me // --------------------------------------------------------------------------- @@ -34,8 +240,85 @@ struct MeResponse { role: String, } -async fn me_handler(session: Session, db: Database) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { +#[derive(Debug, Serialize, JsonSchema)] +struct AuthUserResponse { + id: i64, + name: String, + role: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AuthTokenResponse { + access_token: String, + refresh_token: String, + token_type: String, + expires_in_seconds: i64, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AuthLoginResponse { + user: AuthUserResponse, + tokens: AuthTokenResponse, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct PasswordLoginRequest { + username: String, + password: String, + device_name: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct RefreshRequest { + refresh_token: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct SsoExchangeRequest { + code: String, + device_name: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct LogoutRequest { + refresh_token: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct LogoutResponse { + revoked: bool, +} + +fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse { + AuthUserResponse { + id: user.id, + name: user.name, + role: user.role.code().to_owned(), + } +} + +fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse { + AuthTokenResponse { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: tokens.token_type.to_owned(), + expires_in_seconds: tokens.expires_in_seconds, + } +} + +fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse { + AuthLoginResponse { + user: user_response(user), + tokens: token_response(tokens), + } +} + +async fn me_handler( + auth_ctx: auth::AuthContext, + session: Session, + db: Database, +) -> cot::Result { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error( cot::http::StatusCode::UNAUTHORIZED, "not authenticated", @@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result cot::Result { + let request = match parse_json_request::(raw_request).await? { + Ok(request) => request, + Err(response) => return Ok(response), + }; + + let (config, _) = AppConfig::load_with_db(&db).await; + if !config.auth_password_enabled { + crate::metrics::record_auth_attempt("api_password", "failure", "disabled"); + return Ok(json_error( + StatusCode::FORBIDDEN, + "password login is disabled", + )); + } + + let user = match User::get_by_username(&db, request.username.trim()).await { + Ok(Some(user)) if user.is_active() => user, + _ => { + crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials"); + return Ok(json_error( + StatusCode::UNAUTHORIZED, + "invalid username or password", + )); + } + }; + + let Some(hash) = user.password_ref() else { + crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials"); + return Ok(json_error( + StatusCode::UNAUTHORIZED, + "invalid username or password", + )); + }; + + match hash.verify(&Password::new(&request.password)) { + PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => { + let auth_user = auth::AuthenticatedUser { + id: user.id_val(), + name: { + let display = user.display_name_str(); + if display.is_empty() { + user.username_str().to_owned() + } else { + display + } + }, + role: user.role(), + }; + let tokens = + auth::create_api_session(&db, user.id_val(), request.device_name.as_deref()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + crate::metrics::record_auth_attempt("api_password", "success", "ok"); + crate::metrics::record_session_created("api_password"); + Json(login_response(auth_user, tokens)).into_response() + } + PasswordVerificationResult::Invalid => { + crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials"); + Ok(json_error( + StatusCode::UNAUTHORIZED, + "invalid username or password", + )) + } + } +} + +async fn refresh_handler( + db: Database, + raw_request: cot::request::Request, +) -> cot::Result { + let request = match parse_json_request::(raw_request).await? { + Ok(request) => request, + Err(response) => return Ok(response), + }; + + match auth::refresh_api_session(&db, request.refresh_token.trim()).await { + Ok(Some(tokens)) => Json(token_response(tokens)).into_response(), + Ok(None) => Ok(json_error( + StatusCode::UNAUTHORIZED, + "invalid refresh token", + )), + Err(err) => Err(cot::Error::internal(err.to_string())), + } +} + +async fn sso_exchange_handler( + db: Database, + raw_request: cot::request::Request, +) -> cot::Result { + let request = match parse_json_request::(raw_request).await? { + Ok(request) => request, + Err(response) => return Ok(response), + }; + + match auth::exchange_mobile_code_for_api_session( + &db, + request.code.trim(), + request.device_name.as_deref(), + ) + .await + { + Ok(Some((user, tokens))) => { + crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok"); + crate::metrics::record_session_created("api_sso_exchange"); + Json(login_response(user, tokens)).into_response() + } + Ok(None) => { + crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code"); + Ok(json_error( + StatusCode::UNAUTHORIZED, + "invalid SSO exchange code", + )) + } + Err(err) => Err(cot::Error::internal(err.to_string())), + } +} + +async fn logout_handler( + auth_ctx: auth::AuthContext, + db: Database, + raw_request: cot::request::Request, +) -> cot::Result { + let request = match parse_json_request::(raw_request).await? { + Ok(request) => request, + Err(response) => return Ok(response), + }; + + let revoked = auth::revoke_api_session( + &db, + auth_ctx.bearer_token(), + request.refresh_token.as_deref().map(str::trim), + ) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(LogoutResponse { revoked }).into_response() +} + // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -62,10 +485,101 @@ impl App for ApiApp { } fn router(&self) -> Router { - Router::with_urls([Route::with_api_handler_and_name( - "/me", - api_get(me_handler), - "api_me", - )]) + Router::with_urls([ + Route::with_api_handler_and_name( + "/me", + api_get(documented_response_handler::( + me_handler, + "Get the current authenticated user", + )), + "api_me", + ), + Route::with_api_handler_and_name( + "/auth/password", + api_post(documented_json_handler::< + PasswordLoginRequest, + AuthLoginResponse, + _, + >( + password_login_handler, + "Log in with username and password", + )), + "api_auth_password", + ), + Route::with_api_handler_and_name( + "/auth/refresh", + api_post(documented_json_handler::< + RefreshRequest, + AuthTokenResponse, + _, + >( + refresh_handler, "Refresh an API token pair" + )), + "api_auth_refresh", + ), + Route::with_api_handler_and_name( + "/auth/sso/exchange", + api_post(documented_json_handler::< + SsoExchangeRequest, + AuthLoginResponse, + _, + >( + sso_exchange_handler, + "Exchange a mobile SSO code for API tokens", + )), + "api_auth_sso_exchange", + ), + Route::with_api_handler_and_name( + "/auth/logout", + api_post(documented_json_handler::( + logout_handler, + "Revoke an API session", + )), + "api_auth_logout", + ), + ]) + } +} + +#[cfg(test)] +mod tests { + use cot::aide::openapi::{PathItem, ReferenceOr}; + + use super::*; + + fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) { + assert!(matches!( + paths.paths.get(path), + Some(ReferenceOr::Item(PathItem { get: Some(_), .. })) + )); + } + + fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) { + assert!(matches!( + paths.paths.get(path), + Some(ReferenceOr::Item(PathItem { post: Some(_), .. })) + )); + } + + #[test] + fn openapi_includes_auth_routes() { + let openapi = ApiApp.router().as_api(); + let paths = openapi.paths.expect("OpenAPI paths"); + + assert_get_path(&paths, "/me"); + assert_post_path(&paths, "/auth/password"); + assert_post_path(&paths, "/auth/refresh"); + assert_post_path(&paths, "/auth/sso/exchange"); + assert_post_path(&paths, "/auth/logout"); + + let Some(ReferenceOr::Item(PathItem { + post: Some(operation), + .. + })) = paths.paths.get("/auth/password") + else { + panic!("password auth path should be documented as POST"); + }; + assert!(operation.request_body.is_some()); + assert!(operation.responses.is_some()); } } diff --git a/src/auth.rs b/src/auth.rs index d0fa26c..70557a5 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,13 @@ +use chrono::{Duration, Utc}; use cot::Body; -use cot::db::Database; +use cot::db::{Auto, Database, LimitedString, Model}; +use cot::http::header::AUTHORIZATION; +use cot::request::RequestHead; +use cot::request::extractors::FromRequestHead; use cot::response::IntoResponse; use cot::session::Session; +use serde::Serialize; +use sha2::{Digest, Sha256}; use crate::user::User; @@ -46,11 +52,7 @@ pub struct AuthenticatedUser { pub role: Role, } -/// Read `user_id` from the session, fetch the `User` from DB, return -/// `AuthenticatedUser` if the user exists and is active. -pub async fn get_session_user(session: &Session, db: &Database) -> Option { - let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??; - let user = User::get_by_id(db, user_id).await.ok()??; +fn authenticated_user_from_user(user: User) -> Option { if !user.is_active() { return None; } @@ -70,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option Option { + let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??; + let user = User::get_by_id(db, user_id).await.ok()??; + authenticated_user_from_user(user) +} + +// --------------------------------------------------------------------------- +// API bearer-token auth +// --------------------------------------------------------------------------- + +const ACCESS_TOKEN_PREFIX: &str = "furu_at_"; +const REFRESH_TOKEN_PREFIX: &str = "furu_rt_"; +const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_"; +const ACCESS_TOKEN_TTL_MINUTES: i64 = 15; +const REFRESH_TOKEN_TTL_DAYS: i64 = 60; +const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3; + +#[derive(Debug, Clone, Default)] +pub struct AuthContext { + bearer_token: Option, +} + +impl AuthContext { + pub fn bearer_token(&self) -> Option<&str> { + self.bearer_token.as_deref() + } +} + +impl FromRequestHead for AuthContext { + async fn from_request_head(head: &RequestHead) -> cot::Result { + let bearer_token = head + .headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(parse_bearer_token) + .map(str::to_owned); + Ok(Self { bearer_token }) + } +} + +fn parse_bearer_token(header: &str) -> Option<&str> { + let header = header.trim(); + let (scheme, token) = header.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("Bearer") { + return None; + } + let token = token.trim(); + if token.is_empty() || token.len() > 512 { + return None; + } + Some(token) +} + +#[derive(Debug, Serialize)] +pub struct ApiTokenPair { + pub access_token: String, + pub refresh_token: String, + pub token_type: &'static str, + pub expires_in_seconds: i64, +} + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct ApiSession { + #[model(primary_key)] + id: Auto, + user_id: i64, + device_name: Option, + access_token_hash: LimitedString<128>, + refresh_token_hash: LimitedString<128>, + access_expires_at: String, + refresh_expires_at: String, + created_at: String, + last_used_at: Option, + revoked_at: Option, +} + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct MobileExchangeCode { + #[model(primary_key)] + id: Auto, + code_hash: LimitedString<128>, + user_id: i64, + created_at: String, + expires_at: String, + consumed_at: Option, +} + +impl ApiSession { + pub async fn create_for_user( + db: &Database, + user_id: i64, + device_name: Option<&str>, + ) -> cot::db::Result { + let tokens = fresh_token_pair(); + let now = now_iso(); + let mut session = Self { + id: Auto::auto(), + user_id, + device_name: device_name.and_then(normalize_device_name), + access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(), + refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(), + access_expires_at: access_expires_at(), + refresh_expires_at: refresh_expires_at(), + created_at: now.clone(), + last_used_at: Some(now), + revoked_at: None, + }; + session.insert(db).await?; + Ok(tokens) + } + + async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result> { + let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else { + return Ok(None); + }; + cot::db::query!(ApiSession, $access_token_hash == hash) + .get(db) + .await + } + + async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result> { + let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else { + return Ok(None); + }; + cot::db::query!(ApiSession, $refresh_token_hash == hash) + .get(db) + .await + } + + fn is_revoked(&self) -> bool { + self.revoked_at.is_some() + } + + fn access_token_valid(&self) -> bool { + !self.is_revoked() && self.access_expires_at > now_iso() + } + + fn refresh_token_valid(&self) -> bool { + !self.is_revoked() && self.refresh_expires_at > now_iso() + } + + async fn rotate(&mut self, db: &Database) -> cot::db::Result { + let tokens = fresh_token_pair(); + self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap(); + self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(); + self.access_expires_at = access_expires_at(); + self.refresh_expires_at = refresh_expires_at(); + self.last_used_at = Some(now_iso()); + self.save(db).await?; + Ok(tokens) + } + + async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> { + if self.revoked_at.is_none() { + self.revoked_at = Some(now_iso()); + self.save(db).await?; + } + Ok(()) + } +} + +pub async fn create_api_session( + db: &Database, + user_id: i64, + device_name: Option<&str>, +) -> cot::db::Result { + ApiSession::create_for_user(db, user_id, device_name).await +} + +pub async fn get_bearer_user(db: &Database, token: &str) -> Option { + let session = ApiSession::find_by_access_token(db, token).await.ok()??; + if !session.access_token_valid() { + return None; + } + let user = User::get_by_id(db, session.user_id).await.ok()??; + authenticated_user_from_user(user) +} + +pub async fn get_request_user( + auth: &AuthContext, + session: &Session, + db: &Database, +) -> Option { + if let Some(token) = auth.bearer_token() { + return get_bearer_user(db, token).await; + } + get_session_user(session, db).await +} + +pub async fn refresh_api_session( + db: &Database, + refresh_token: &str, +) -> cot::db::Result> { + let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else { + return Ok(None); + }; + if !session.refresh_token_valid() { + session.revoke(db).await?; + return Ok(None); + } + let Some(user) = User::get_by_id(db, session.user_id).await? else { + session.revoke(db).await?; + return Ok(None); + }; + if !user.is_active() { + session.revoke(db).await?; + return Ok(None); + } + Ok(Some(session.rotate(db).await?)) +} + +pub async fn revoke_api_session( + db: &Database, + access_token: Option<&str>, + refresh_token: Option<&str>, +) -> cot::db::Result { + let mut session = if let Some(token) = access_token { + ApiSession::find_by_access_token(db, token).await? + } else { + None + }; + if session.is_none() { + if let Some(token) = refresh_token { + session = ApiSession::find_by_refresh_token(db, token).await?; + } + } + let Some(mut session) = session else { + return Ok(false); + }; + session.revoke(db).await?; + Ok(true) +} + +impl MobileExchangeCode { + pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result { + let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX); + let now = now_iso(); + let mut row = Self { + id: Auto::auto(), + code_hash: LimitedString::new(&token_hash(&code)).unwrap(), + user_id, + created_at: now, + expires_at: mobile_exchange_code_expires_at(), + consumed_at: None, + }; + row.insert(db).await?; + Ok(code) + } + + async fn find_by_code(db: &Database, code: &str) -> cot::db::Result> { + let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else { + return Ok(None); + }; + cot::db::query!(MobileExchangeCode, $code_hash == hash) + .get(db) + .await + } + + fn is_valid(&self) -> bool { + self.consumed_at.is_none() && self.expires_at > now_iso() + } + + async fn consume(&mut self, db: &Database) -> cot::db::Result<()> { + self.consumed_at = Some(now_iso()); + self.save(db).await + } +} + +pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result { + MobileExchangeCode::create_for_user(db, user_id).await +} + +pub async fn exchange_mobile_code_for_api_session( + db: &Database, + code: &str, + device_name: Option<&str>, +) -> cot::db::Result> { + let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else { + return Ok(None); + }; + if !exchange_code.is_valid() { + return Ok(None); + } + let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else { + exchange_code.consume(db).await?; + return Ok(None); + }; + let Some(auth_user) = authenticated_user_from_user(user) else { + exchange_code.consume(db).await?; + return Ok(None); + }; + exchange_code.consume(db).await?; + let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?; + Ok(Some((auth_user, tokens))) +} + +fn fresh_token_pair() -> ApiTokenPair { + ApiTokenPair { + access_token: random_token(ACCESS_TOKEN_PREFIX), + refresh_token: random_token(REFRESH_TOKEN_PREFIX), + token_type: "Bearer", + expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60, + } +} + +fn random_token(prefix: &str) -> String { + format!( + "{prefix}{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ) +} + +fn token_hash(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out +} + +fn normalize_device_name(name: &str) -> Option { + let trimmed = name.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(255).collect()) +} + +fn now_iso() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +fn access_expires_at() -> String { + (Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() +} + +fn refresh_expires_at() -> String { + (Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() +} + +fn mobile_exchange_code_expires_at() -> String { + (Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES)) + .format("%Y-%m-%dT%H:%M:%SZ") + .to_string() +} + /// Return `Ok(user)` if the session belongs to an active admin, otherwise /// `Err(response)` — a redirect to `/login` or a 403. pub async fn require_admin_or_redirect( @@ -159,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response { .expect("valid response") } +// --------------------------------------------------------------------------- +// Migrations +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; + use cot::db::{DatabaseField, Identifier, LimitedString}; + + #[derive(Debug, Copy, Clone)] + pub struct M0038CreateApiSession; + + impl migrations::Migration for M0038CreateApiSession { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0038_create_api_session"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0003_create_user", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__api_session")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new( + Identifier::new("device_name"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("access_token_hash"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("refresh_token_hash"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("access_expires_at"), + ::TYPE, + ), + Field::new( + Identifier::new("refresh_expires_at"), + ::TYPE, + ), + Field::new( + Identifier::new("created_at"), + ::TYPE, + ), + Field::new( + Identifier::new("last_used_at"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("revoked_at"), + ::TYPE, + ) + .set_null(true), + ]) + .build()]; + } + + #[cot::db::migrations::migration_op] + async fn create_api_session_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_api_session_access_token_hash \ + ON furumusic__api_session (access_token_hash)", + ) + .await?; + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \ + ON furumusic__api_session (refresh_token_hash)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX idx_api_session_user_id \ + ON furumusic__api_session (user_id)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0039CreateApiSessionIndexes; + + impl migrations::Migration for M0039CreateApiSessionIndexes { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0038_create_api_session", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_api_session_indexes).build()]; + } + + #[derive(Debug, Copy, Clone)] + pub struct M0040CreateMobileExchangeCode; + + impl migrations::Migration for M0040CreateMobileExchangeCode { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0039_create_api_session_indexes", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("furumusic__mobile_exchange_code")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("code_hash"), + as DatabaseField>::TYPE, + ), + Field::new(Identifier::new("user_id"), ::TYPE), + Field::new( + Identifier::new("created_at"), + ::TYPE, + ), + Field::new( + Identifier::new("expires_at"), + ::TYPE, + ), + Field::new( + Identifier::new("consumed_at"), + ::TYPE, + ) + .set_null(true), + ]) + .build()]; + } + + #[cot::db::migrations::migration_op] + async fn create_mobile_exchange_code_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \ + ON furumusic__mobile_exchange_code (code_hash)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX idx_mobile_exchange_code_user_id \ + ON furumusic__mobile_exchange_code (user_id)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0041CreateMobileExchangeCodeIndexes; + + impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "furumusic", + "m_0040_create_mobile_exchange_code", + )]; + const OPERATIONS: &'static [Operation] = + &[Operation::custom(create_mobile_exchange_code_indexes).build()]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[ + &M0038CreateApiSession, + &M0039CreateApiSessionIndexes, + &M0040CreateMobileExchangeCode, + &M0041CreateMobileExchangeCodeIndexes, + ]; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src/config.rs b/src/config.rs index e3dc22a..ceb596a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -338,10 +338,52 @@ impl AppConfig { pub fn load() -> Self { let mut cfg = Self::default(); cfg.apply_env_overrides(); + cfg.apply_startup_db_overrides(); + cfg.apply_env_overrides(); cfg.normalize_host_paths(); cfg } + fn apply_startup_db_overrides(&mut self) { + if self.database_url.is_empty() { + return; + } + if tokio::runtime::Handle::try_current().is_ok() { + tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime"); + return; + } + + let database_url = self.database_url.clone(); + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + tracing::warn!("failed to create runtime for startup DB config load"); + return; + }; + + let result = runtime.block_on(async move { + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await?; + sqlx::query_scalar::<_, String>( + "SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'", + ) + .fetch_optional(&pool) + .await + }); + + match result { + Ok(Some(value)) => match value.parse::() { + Ok(value) => self.swagger_enabled = value, + Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"), + }, + Ok(None) => {} + Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"), + } + } + /// Build config with full 3-layer resolution (default → DB → env) and /// track the source of each field. pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) { diff --git a/src/main.rs b/src/main.rs index fd79d02..b6ccb99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -378,6 +378,15 @@ impl App for FuruApp { } }; + let (live_config, _) = AppConfig::load_with_db(&db).await; + if !live_config.auth_password_enabled { + metrics::record_auth_attempt("password", "failure", "disabled"); + let msg = i18n.t.login_disabled.to_owned(); + return login_page_handler(i18n, &config, db, msg) + .await? + .into_response(); + } + // Try to authenticate if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await { @@ -425,6 +434,16 @@ impl App for FuruApp { get(oidc::oidc_callback_handler), "oidc_callback", ), + Route::with_handler_and_name( + "/auth/mobile/oidc/start", + get(oidc::oidc_mobile_start_handler), + "mobile_oidc_start", + ), + Route::with_handler_and_name( + "/auth/mobile/oidc/callback", + get(oidc::oidc_mobile_callback_handler), + "mobile_oidc_callback", + ), ]) } } diff --git a/src/oidc.rs b/src/oidc.rs index f584a0d..c23c3d7 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use std::time::Instant; use cot::db::Database; +use cot::request::extractors::UrlQuery; use cot::session::Session; use openidconnect::core::{CoreClient, CoreProviderMetadata}; use openidconnect::{ @@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce"; const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier"; const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri"; +const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state"; +const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce"; +const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier"; +const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri"; +const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri"; +const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback"; + // --------------------------------------------------------------------------- // Provider cache // --------------------------------------------------------------------------- @@ -247,13 +255,23 @@ pub struct OidcCallbackQuery { state: String, } +#[derive(Deserialize)] +pub struct MobileOidcStartQuery { + redirect_uri: Option, +} + +#[derive(Deserialize)] +pub struct MobileOidcCallbackQuery { + code: Option, + state: Option, + error: Option, +} + pub async fn oidc_callback_handler( i18n: I18n, db: Database, session: Session, - cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery< - OidcCallbackQuery, - >, + UrlQuery(query): UrlQuery, ) -> cot::Result { let (config, _) = AppConfig::load_with_db(&db).await; @@ -461,6 +479,296 @@ pub async fn oidc_callback_handler( Ok(auth::redirect(&redirect_to)) } +// --------------------------------------------------------------------------- +// Mobile OIDC flow +// --------------------------------------------------------------------------- + +pub async fn oidc_mobile_start_handler( + origin: RequestOrigin, + db: Database, + session: Session, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri"); + return Ok(text_response( + cot::http::StatusCode::BAD_REQUEST, + "invalid mobile redirect_uri", + )); + }; + + let (config, _) = AppConfig::load_with_db(&db).await; + + if !config.auth_sso_enabled + || config.oidc_issuer.is_empty() + || config.oidc_client_id.is_empty() + || config.oidc_client_secret.is_empty() + { + tracing::warn!("Mobile OIDC start requested but SSO is not configured"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured"); + return Ok(mobile_redirect_error( + &app_redirect_uri, + "sso_not_configured", + )); + } + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Mobile OIDC provider error: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error"); + return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error")); + } + }; + + let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0); + let redirect_url = RedirectUrl::new(provider_redirect_uri.clone()) + .map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?; + let client = client.set_redirect_uri(redirect_url); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf_state, nonce) = client + .authorize_url( + openidconnect::AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + session + .insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_MOBILE_NONCE, nonce.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert( + SESSION_MOBILE_PROVIDER_REDIRECT_URI, + provider_redirect_uri.clone(), + ) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + tracing::info!( + auth_url = %auth_url, + provider_redirect_uri = %provider_redirect_uri, + "Mobile OIDC start: redirecting to provider", + ); + + Ok(auth::redirect(auth_url.as_str())) +} + +pub async fn oidc_mobile_callback_handler( + db: Database, + session: Session, + UrlQuery(query): UrlQuery, +) -> cot::Result { + let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?; + + if query.error.is_some() { + tracing::warn!("Mobile OIDC callback returned provider error"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied")); + } + + let Some(code) = query.code else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code")); + }; + let Some(state) = query.state else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state")); + }; + + let saved_csrf: Option = session + .get(SESSION_MOBILE_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_nonce: Option = session + .get(SESSION_MOBILE_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_pkce: Option = session + .get(SESSION_MOBILE_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let provider_redirect_uri: Option = session + .get(SESSION_MOBILE_PROVIDER_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let Some(saved_csrf) = saved_csrf else { + tracing::warn!("Mobile OIDC callback: no CSRF state in session"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state")); + }; + if state != saved_csrf { + tracing::warn!("Mobile OIDC callback: CSRF state mismatch"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "csrf")); + } + + let Some(nonce_str) = saved_nonce else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce")); + }; + let Some(pkce_str) = saved_pkce else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce")); + }; + let Some(provider_redirect_uri) = provider_redirect_uri else { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error( + &app_redirect_uri, + "missing_redirect_uri", + )); + }; + + let (config, _) = AppConfig::load_with_db(&db).await; + if !config.auth_sso_enabled + || config.oidc_issuer.is_empty() + || config.oidc_client_id.is_empty() + || config.oidc_client_secret.is_empty() + { + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error( + &app_redirect_uri, + "sso_not_configured", + )); + } + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("Mobile OIDC provider error during callback: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error")); + } + }; + let redirect_url = RedirectUrl::new(provider_redirect_uri) + .map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?; + let client = client.set_redirect_uri(redirect_url); + + let token_request = match client.exchange_code(AuthorizationCode::new(code)) { + Ok(req) => req, + Err(e) => { + tracing::error!("Mobile OIDC token endpoint not configured: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error")); + } + }; + let token_response = token_request + .set_pkce_verifier(PkceCodeVerifier::new(pkce_str)) + .request_async(&http) + .await; + let token_response = match token_response { + Ok(t) => t, + Err(e) => { + tracing::error!("Mobile OIDC token exchange failed: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error")); + } + }; + + use openidconnect::TokenResponse; + let id_token = match token_response.id_token() { + Some(t) => t, + None => { + tracing::error!("Mobile OIDC response missing ID token"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error")); + } + }; + + let nonce = Nonce::new(nonce_str); + let claims = match id_token.claims(&client.id_token_verifier(), &nonce) { + Ok(c) => c, + Err(e) => { + tracing::error!("Mobile OIDC ID token verification failed: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error")); + } + }; + + let sub = claims.subject().to_string(); + let issuer = claims.issuer().to_string(); + let email = claims.email().map(|e| e.to_string()); + let name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.to_string()); + let groups = extract_groups_from_jwt(&id_token.to_string()); + + if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) { + tracing::warn!( + "Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}", + config.oidc_user_groups, + config.oidc_admin_groups, + ); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied")); + } + + let user = match provision_user( + &db, + &issuer, + &sub, + email.as_deref(), + name.as_deref(), + &groups, + &config.oidc_admin_groups, + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!("Mobile OIDC user provisioning failed: {e}"); + crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning"); + clear_mobile_oidc_session(&session).await?; + return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error")); + } + }; + + let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + clear_mobile_oidc_session(&session).await?; + + crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok"); + crate::metrics::record_session_created("mobile_oidc"); + Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code)) +} + // --------------------------------------------------------------------------- // User provisioning // --------------------------------------------------------------------------- @@ -616,6 +924,104 @@ fn redirect_login_with_error(message: &str) -> cot::Result cot::response::Response { + cot::http::Response::builder() + .status(status) + .body(cot::Body::fixed(message.to_owned())) + .expect("valid response") +} + +async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result { + let saved: Option = session + .get(SESSION_MOBILE_APP_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(safe_mobile_redirect_uri(saved.as_deref()) + .unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned())) +} + +async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> { + let _: Option = session + .remove(SESSION_MOBILE_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_MOBILE_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_MOBILE_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_MOBILE_APP_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option { + let value = raw + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_MOBILE_REDIRECT_URI); + if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) { + return None; + } + + let lower = value.to_ascii_lowercase(); + if lower.starts_with("furumi://") || lower.starts_with("furumusic://") { + return Some(value.to_owned()); + } + None +} + +fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response { + auth::redirect(&append_query_param(app_redirect_uri, "code", code)) +} + +fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response { + auth::redirect(&append_query_param(app_redirect_uri, "error", error)) +} + +fn append_query_param(uri: &str, key: &str, value: &str) -> String { + let (base, fragment) = uri.split_once('#').unwrap_or((uri, "")); + let separator = if base.contains('?') { '&' } else { '?' }; + let mut out = format!("{base}{separator}{key}={}", urlencoded(value)); + if !fragment.is_empty() { + out.push('#'); + out.push_str(fragment); + } + out +} + +fn extract_groups_from_jwt(token: &str) -> Vec { + use base64::Engine; + + let Some(payload_b64) = token.split('.').nth(1) else { + return Vec::new(); + }; + let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_slice::(&payload_bytes) else { + return Vec::new(); + }; + let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect() +} + /// Minimal percent-encoding for query parameter values. fn urlencoded(s: &str) -> String { let mut out = String::with_capacity(s.len() * 2); diff --git a/src/player/mod.rs b/src/player/mod.rs index c1bf466..5ef8277 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -894,11 +894,12 @@ pub struct PlayerPageTemplate { // --------------------------------------------------------------------------- async fn me_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -972,11 +973,12 @@ fn request_origin(request: &cot::request::Request) -> Option { } async fn lastfm_status_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let (config, _) = AppConfig::load_with_db(&db).await; @@ -1007,12 +1009,13 @@ async fn lastfm_status_handler( } async fn lastfm_connect_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, request: cot::request::Request, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(redirect_response("/login")); }; let (config, _) = AppConfig::load_with_db(&db).await; @@ -1055,12 +1058,13 @@ async fn lastfm_connect_handler( } async fn lastfm_callback_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, query: cot::request::extractors::UrlQuery, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(redirect_response("/login")); }; let Some(token) = query @@ -1134,11 +1138,12 @@ async fn lastfm_callback_handler( } async fn lastfm_disconnect_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); @@ -1353,12 +1358,13 @@ async fn enqueue_lastfm_scrobble( } async fn lastfm_now_playing_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(entry): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let (config, _) = AppConfig::load_with_db(&db).await; @@ -1425,12 +1431,13 @@ async fn lastfm_now_playing_handler( } async fn lastfm_scrobble_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(entry): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let (config, _) = AppConfig::load_with_db(&db).await; @@ -1453,11 +1460,12 @@ async fn lastfm_scrobble_handler( // --------------------------------------------------------------------------- async fn agent_queue_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -1483,12 +1491,13 @@ async fn agent_queue_handler( // --------------------------------------------------------------------------- async fn user_uploads_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, UrlQuery(query): UrlQuery, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let limit = query.limit.unwrap_or(120).clamp(1, 500); @@ -1497,13 +1506,14 @@ async fn user_uploads_handler( } async fn user_upload_track_update_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let track_id = path.0.track_id; @@ -1680,13 +1690,14 @@ async fn user_upload_track_update_handler( } async fn user_upload_release_update_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let release_id = path.0.id; @@ -1790,12 +1801,13 @@ async fn user_upload_release_update_handler( } async fn user_upload_tracks_bulk_update_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let mut track_ids = body @@ -1932,13 +1944,14 @@ async fn user_upload_tracks_bulk_update_handler( } async fn user_upload_review_save_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let review_id = path.0.id; @@ -1962,12 +1975,13 @@ async fn user_upload_review_save_handler( } async fn user_upload_review_delete_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let review_id = path.0.id; @@ -1992,13 +2006,14 @@ async fn user_upload_review_delete_handler( } async fn user_upload_review_approve_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let review_id = path.0.id; @@ -2769,12 +2784,13 @@ fn input_path_filename(path: Option<&str>) -> String { // --------------------------------------------------------------------------- async fn artists_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, query: cot::request::extractors::UrlQuery, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -2923,12 +2939,13 @@ async fn artists_handler( // --------------------------------------------------------------------------- async fn artist_detail_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3167,12 +3184,13 @@ async fn artist_detail_handler( // --------------------------------------------------------------------------- async fn release_detail_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3263,11 +3281,12 @@ async fn release_detail_handler( // --------------------------------------------------------------------------- async fn playlists_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3336,12 +3355,13 @@ async fn playlists_handler( // --------------------------------------------------------------------------- async fn playlist_detail_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3550,12 +3570,13 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul // --------------------------------------------------------------------------- async fn create_playlist_share_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3618,12 +3639,13 @@ async fn create_playlist_share_handler( // --------------------------------------------------------------------------- async fn playlist_share_detail_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3711,6 +3733,7 @@ async fn likes_playlist_handler( // --------------------------------------------------------------------------- async fn stream_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, @@ -3718,7 +3741,7 @@ async fn stream_handler( request: &cot::http::Request, path: Path, ) -> cot::Result> { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3795,13 +3818,14 @@ async fn stream_handler( } async fn local_upload_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, config: AppConfig, scheduler_handle: Arc>>, request: cot::request::Request, ) -> cot::Result> { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -3984,16 +4008,27 @@ async fn read_file_range(path: &std::path::Path, start: u64, length: u64) -> cot // --------------------------------------------------------------------------- async fn cover_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, config: &AppConfig, path: Path, ) -> cot::Result> { - cover_response(session, db, pool, config, path.0.media_file_id, None).await + cover_response( + auth_ctx, + session, + db, + pool, + config, + path.0.media_file_id, + None, + ) + .await } async fn cover_variant_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, @@ -4001,6 +4036,7 @@ async fn cover_variant_handler( path: Path, ) -> cot::Result> { cover_response( + auth_ctx, session, db, pool, @@ -4012,6 +4048,7 @@ async fn cover_variant_handler( } async fn cover_response( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, @@ -4019,7 +4056,7 @@ async fn cover_response( media_file_id: i64, variant_name: Option<&str>, ) -> cot::Result> { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4074,12 +4111,13 @@ async fn cover_response( // --------------------------------------------------------------------------- async fn devices_heartbeat_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(device_id) = normalize_device_id(&dto.device_id) else { @@ -4100,12 +4138,13 @@ async fn devices_heartbeat_handler( } async fn devices_poll_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(device_id) = normalize_device_id(&dto.device_id) else { @@ -4126,12 +4165,13 @@ async fn devices_poll_handler( } async fn devices_select_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(target_device_id) = normalize_device_id(&dto.device_id) else { @@ -4153,12 +4193,13 @@ async fn devices_select_handler( } async fn devices_command_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let command = dto.command.trim(); @@ -4227,12 +4268,13 @@ fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_na } async fn jam_users_search_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, UrlQuery(query): UrlQuery, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4286,13 +4328,14 @@ async fn jam_users_search_handler( } async fn jam_create_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(device_id) = normalize_device_id(&dto.device_id) else { @@ -4348,12 +4391,13 @@ async fn load_jam_invitees( } async fn jam_join_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(jam_id) = normalize_device_id(&dto.jam_id) else { @@ -4370,13 +4414,14 @@ async fn jam_join_handler( } async fn jam_invite_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(jam_id) = normalize_device_id(&dto.jam_id) else { @@ -4394,12 +4439,13 @@ async fn jam_invite_handler( } async fn jam_leave_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, hub: Arc, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let Some(jam_id) = normalize_device_id(&dto.jam_id) else { @@ -4420,11 +4466,12 @@ async fn jam_leave_handler( // --------------------------------------------------------------------------- async fn get_state_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4469,12 +4516,13 @@ async fn get_state_handler( // --------------------------------------------------------------------------- async fn put_state_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(dto): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4511,12 +4559,13 @@ async fn put_state_handler( // --------------------------------------------------------------------------- async fn history_list_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, query: cot::request::extractors::UrlQuery, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4664,12 +4713,13 @@ async fn history_list_handler( } async fn history_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(entry): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -4736,12 +4786,13 @@ async fn history_handler( // --------------------------------------------------------------------------- async fn search_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, query: cot::request::extractors::UrlQuery, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -5033,12 +5084,13 @@ async fn search_handler( // --------------------------------------------------------------------------- async fn create_playlist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let title = body.title.trim().to_string(); @@ -5075,13 +5127,14 @@ async fn create_playlist_handler( // --------------------------------------------------------------------------- async fn update_playlist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; @@ -5130,12 +5183,13 @@ async fn update_playlist_handler( // --------------------------------------------------------------------------- async fn delete_playlist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; @@ -5174,13 +5228,14 @@ async fn delete_playlist_handler( // --------------------------------------------------------------------------- async fn add_tracks_to_playlist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; @@ -5240,13 +5295,14 @@ async fn add_tracks_to_playlist_handler( // --------------------------------------------------------------------------- async fn remove_track_from_playlist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, Json(body): Json, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let playlist_id = path.0.id; @@ -5293,12 +5349,13 @@ async fn remove_track_from_playlist_handler( // --------------------------------------------------------------------------- async fn toggle_like_track_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let track_id = path.0.track_id; @@ -5339,12 +5396,13 @@ async fn toggle_like_track_handler( // --------------------------------------------------------------------------- async fn like_release_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let release_id = path.0.id; @@ -5411,11 +5469,12 @@ async fn like_release_handler( // --------------------------------------------------------------------------- async fn liked_ids_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let rows: Vec<(i64,)> = @@ -5436,11 +5495,12 @@ async fn liked_ids_handler( // --------------------------------------------------------------------------- async fn followed_artists_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -5492,12 +5552,13 @@ async fn followed_artists_handler( // --------------------------------------------------------------------------- async fn toggle_follow_artist_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; let artist_id = path.0.id; @@ -5992,12 +6053,13 @@ async fn build_release_radio_ids( } async fn radio_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, path: Path, ) -> cot::Result { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -6021,12 +6083,13 @@ async fn radio_handler( // --------------------------------------------------------------------------- async fn tracks_by_ids_handler( + auth_ctx: auth::AuthContext, session: Session, db: Database, pool: &sqlx::PgPool, Json(body): Json, ) -> cot::Result { - let Some(_user) = auth::get_session_user(&session, &db).await else { + let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else { return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); }; @@ -6081,22 +6144,24 @@ impl App for PlayerApp { { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - get(move |session: Session, db: Database| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - me_handler(session, db, pg_pool).await - } - }) + get( + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + me_handler(auth_ctx, session, db, pg_pool).await + } + }, + ) }, "player_me", ), @@ -6105,7 +6170,7 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6118,7 +6183,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_status_handler(session, db, pg_pool).await + lastfm_status_handler(auth_ctx, session, db, pg_pool).await } } }), @@ -6129,7 +6194,10 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, request: cot::request::Request| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + request: cot::request::Request| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6142,7 +6210,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_connect_handler(session, db, pg_pool, request).await + lastfm_connect_handler(auth_ctx, session, db, pg_pool, request).await } } }), @@ -6153,7 +6221,8 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, query: cot::request::extractors::UrlQuery| { let pool = Arc::clone(&pool); @@ -6168,7 +6237,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_callback_handler(session, db, pg_pool, query).await + lastfm_callback_handler(auth_ctx, session, db, pg_pool, query).await } } }), @@ -6179,7 +6248,7 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6192,7 +6261,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_disconnect_handler(session, db, pg_pool).await + lastfm_disconnect_handler(auth_ctx, session, db, pg_pool).await } } }), @@ -6203,7 +6272,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6216,7 +6288,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_now_playing_handler(session, db, pg_pool, json).await + lastfm_now_playing_handler(auth_ctx, session, db, pg_pool, json).await } } }), @@ -6227,7 +6299,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6240,7 +6315,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - lastfm_scrobble_handler(session, db, pg_pool, json).await + lastfm_scrobble_handler(auth_ctx, session, db, pg_pool, json).await } } }), @@ -6251,22 +6326,24 @@ impl App for PlayerApp { { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - get(move |session: Session, db: Database| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - agent_queue_handler(session, db, pg_pool).await - } - }) + get( + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + agent_queue_handler(auth_ctx, session, db, pg_pool).await + } + }, + ) }, "player_agent_queue", ), @@ -6278,40 +6355,44 @@ impl App for PlayerApp { let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); - get(move |session: Session, db: Database| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - let torrent_service = Arc::clone(&torrent_service); - let scheduler_handle = Arc::clone(&scheduler_handle); - async move { - let Some(user) = auth::get_session_user(&session, &db).await else { - return Ok(json_error( - StatusCode::UNAUTHORIZED, - "not authenticated", - )); - }; - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - let service = torrent_service - .get_or_init(|| async { - Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) - }) - .await; - match service.list(pg_pool, user.id).await { - Ok(items) => Json(items).into_response(), - Err(err) => { - Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) + get( + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + let torrent_service = Arc::clone(&torrent_service); + let scheduler_handle = Arc::clone(&scheduler_handle); + async move { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { + return Ok(json_error( + StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + let service = torrent_service + .get_or_init(|| async { + Arc::new(TorrentService::new(Arc::clone(&scheduler_handle))) + }) + .await; + match service.list(pg_pool, user.id).await { + Ok(items) => Json(items).into_response(), + Err(err) => { + Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string())) + } } } - } - }) + }, + ) }, "player_torrent_list", ), @@ -6327,13 +6408,18 @@ impl App for PlayerApp { let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6363,13 +6449,18 @@ impl App for PlayerApp { } }) .delete( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6411,13 +6502,18 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); post( - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6454,11 +6550,15 @@ impl App for PlayerApp { { let scheduler_handle = Arc::clone(&self.scheduler_handle); post( - move |session: Session, db: Database, request: cot::request::Request| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + request: cot::request::Request| { let scheduler_handle = Arc::clone(&scheduler_handle); async move { let (live_config, _) = AppConfig::load_with_db(&db).await; local_upload_handler( + auth_ctx, session, db, live_config, @@ -6477,7 +6577,10 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, query: UrlQuery| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + query: UrlQuery| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6490,7 +6593,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_uploads_handler(session, db, pg_pool, query).await + user_uploads_handler(auth_ctx, session, db, pg_pool, query).await } } }), @@ -6501,7 +6604,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -6517,7 +6621,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_track_update_handler(session, db, pg_pool, path, json).await + user_upload_track_update_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }), @@ -6528,7 +6635,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, json: Json| { let pool = Arc::clone(&pool); @@ -6543,7 +6651,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_tracks_bulk_update_handler(session, db, pg_pool, json).await + user_upload_tracks_bulk_update_handler( + auth_ctx, session, db, pg_pool, json, + ) + .await } } }), @@ -6554,7 +6665,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -6570,8 +6682,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_release_update_handler(session, db, pg_pool, path, json) - .await + user_upload_release_update_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }), @@ -6582,7 +6696,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -6598,7 +6713,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_review_save_handler(session, db, pg_pool, path, json).await + user_upload_review_save_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }), @@ -6609,7 +6727,10 @@ impl App for PlayerApp { delete({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6622,7 +6743,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_review_delete_handler(session, db, pg_pool, path).await + user_upload_review_delete_handler(auth_ctx, session, db, pg_pool, path) + .await } } }), @@ -6633,7 +6755,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -6649,8 +6772,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - user_upload_review_approve_handler(session, db, pg_pool, path, json) - .await + user_upload_review_approve_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }), @@ -6664,7 +6789,8 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); post( - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -6673,7 +6799,9 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6723,13 +6851,18 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); post( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6769,13 +6902,18 @@ impl App for PlayerApp { let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&self.scheduler_handle); get( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let torrent_service = Arc::clone(&torrent_service); let scheduler_handle = Arc::clone(&scheduler_handle); async move { - let Some(user) = auth::get_session_user(&session, &db).await else { + let Some(user) = + auth::get_request_user(&auth_ctx, &session, &db).await + else { return Ok(json_error( StatusCode::UNAUTHORIZED, "not authenticated", @@ -6813,7 +6951,9 @@ impl App for PlayerApp { { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - get(move |session: Session, db: Database, + get(move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, query: cot::request::extractors::UrlQuery| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); @@ -6827,7 +6967,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - artists_handler(session, db, pg_pool, query).await + artists_handler(auth_ctx, session, db, pg_pool, query).await } }) }, @@ -6836,60 +6976,14 @@ impl App for PlayerApp { // -- Artist detail -- Route::with_handler_and_name( "/artists/{id}", - { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - get(move |session: Session, db: Database, path: Path| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - artist_detail_handler(session, db, pg_pool, path).await - } - }) - }, - "player_artist_detail", - ), - // -- Release detail -- - Route::with_handler_and_name( - "/releases/{id}", - { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - get(move |session: Session, db: Database, path: Path| { - let pool = Arc::clone(&pool); - let pool_config = Arc::clone(&pool_config); - async move { - let pg_pool = pool - .get_or_init(|| async { - sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&pool_config.database_url) - .await - .expect("player pool") - }) - .await; - release_detail_handler(session, db, pg_pool, path).await - } - }) - }, - "player_release_detail", - ), - Route::with_handler_and_name( - "/radio/{kind}/{id}", { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); get( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6902,7 +6996,66 @@ impl App for PlayerApp { .expect("player pool") }) .await; - radio_handler(session, db, pg_pool, path).await + artist_detail_handler(auth_ctx, session, db, pg_pool, path).await + } + }, + ) + }, + "player_artist_detail", + ), + // -- Release detail -- + Route::with_handler_and_name( + "/releases/{id}", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get( + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + release_detail_handler(auth_ctx, session, db, pg_pool, path).await + } + }, + ) + }, + "player_release_detail", + ), + Route::with_handler_and_name( + "/radio/{kind}/{id}", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get( + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + async move { + let pg_pool = pool + .get_or_init(|| async { + sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&pool_config.database_url) + .await + .expect("player pool") + }) + .await; + radio_handler(auth_ctx, session, db, pg_pool, path).await } }, ) @@ -6915,7 +7068,7 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6928,14 +7081,17 @@ impl App for PlayerApp { .expect("player pool") }) .await; - playlists_handler(session, db, pg_pool).await + playlists_handler(auth_ctx, session, db, pg_pool).await } } }) .post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6948,7 +7104,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - create_playlist_handler(session, db, pg_pool, json).await + create_playlist_handler(auth_ctx, session, db, pg_pool, json).await } } }), @@ -6960,7 +7116,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6973,7 +7132,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - create_playlist_share_handler(session, db, pg_pool, json).await + create_playlist_share_handler(auth_ctx, session, db, pg_pool, json) + .await } } }), @@ -6984,7 +7144,10 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -6997,7 +7160,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - playlist_share_detail_handler(session, db, pg_pool, path).await + playlist_share_detail_handler(auth_ctx, session, db, pg_pool, path) + .await } } }), @@ -7009,7 +7173,10 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7022,14 +7189,15 @@ impl App for PlayerApp { .expect("player pool") }) .await; - playlist_detail_handler(session, db, pg_pool, path).await + playlist_detail_handler(auth_ctx, session, db, pg_pool, path).await } } }) .put({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -7045,14 +7213,18 @@ impl App for PlayerApp { .expect("player pool") }) .await; - update_playlist_handler(session, db, pg_pool, path, json).await + update_playlist_handler(auth_ctx, session, db, pg_pool, path, json) + .await } } }) .delete({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7065,7 +7237,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - delete_playlist_handler(session, db, pg_pool, path).await + delete_playlist_handler(auth_ctx, session, db, pg_pool, path).await } } }), @@ -7077,7 +7249,8 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -7093,14 +7266,18 @@ impl App for PlayerApp { .expect("player pool") }) .await; - add_tracks_to_playlist_handler(session, db, pg_pool, path, json).await + add_tracks_to_playlist_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }) .delete({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, json: Json| { @@ -7116,8 +7293,10 @@ impl App for PlayerApp { .expect("player pool") }) .await; - remove_track_from_playlist_handler(session, db, pg_pool, path, json) - .await + remove_track_from_playlist_handler( + auth_ctx, session, db, pg_pool, path, json, + ) + .await } } }), @@ -7129,7 +7308,7 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7142,7 +7321,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - liked_ids_handler(session, db, pg_pool).await + liked_ids_handler(auth_ctx, session, db, pg_pool).await } } }), @@ -7154,7 +7333,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7167,7 +7349,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - toggle_like_track_handler(session, db, pg_pool, path).await + toggle_like_track_handler(auth_ctx, session, db, pg_pool, path).await } } }), @@ -7179,7 +7361,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7192,7 +7377,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - like_release_handler(session, db, pg_pool, path).await + like_release_handler(auth_ctx, session, db, pg_pool, path).await } } }), @@ -7204,7 +7389,7 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7217,7 +7402,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - followed_artists_handler(session, db, pg_pool).await + followed_artists_handler(auth_ctx, session, db, pg_pool).await } } }), @@ -7229,7 +7414,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7242,7 +7430,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - toggle_follow_artist_handler(session, db, pg_pool, path).await + toggle_follow_artist_handler(auth_ctx, session, db, pg_pool, path).await } } }), @@ -7255,7 +7443,8 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); get( - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, path: Path, request: cot::request::Request| { @@ -7272,8 +7461,16 @@ impl App for PlayerApp { }) .await; let (live_config, _) = AppConfig::load_with_db(&db).await; - stream_handler(session, db, pg_pool, &live_config, &request, path) - .await + stream_handler( + auth_ctx, + session, + db, + pg_pool, + &live_config, + &request, + path, + ) + .await } }, ) @@ -7287,7 +7484,10 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); get( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7301,8 +7501,15 @@ impl App for PlayerApp { }) .await; let (live_config, _) = AppConfig::load_with_db(&db).await; - cover_variant_handler(session, db, pg_pool, &live_config, path) - .await + cover_variant_handler( + auth_ctx, + session, + db, + pg_pool, + &live_config, + path, + ) + .await } }, ) @@ -7315,7 +7522,10 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); get( - move |session: Session, db: Database, path: Path| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + path: Path| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7329,7 +7539,8 @@ impl App for PlayerApp { }) .await; let (live_config, _) = AppConfig::load_with_db(&db).await; - cover_handler(session, db, pg_pool, &live_config, path).await + cover_handler(auth_ctx, session, db, pg_pool, &live_config, path) + .await } }, ) @@ -7341,9 +7552,14 @@ impl App for PlayerApp { "/devices/heartbeat", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { devices_heartbeat_handler(session, db, device_hub, json).await } + async move { + devices_heartbeat_handler(auth_ctx, session, db, device_hub, json).await + } } }), "player_devices_heartbeat", @@ -7352,9 +7568,14 @@ impl App for PlayerApp { "/devices/poll", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { devices_poll_handler(session, db, device_hub, json).await } + async move { + devices_poll_handler(auth_ctx, session, db, device_hub, json).await + } } }), "player_devices_poll", @@ -7363,9 +7584,14 @@ impl App for PlayerApp { "/devices/active", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { devices_select_handler(session, db, device_hub, json).await } + async move { + devices_select_handler(auth_ctx, session, db, device_hub, json).await + } } }), "player_devices_active", @@ -7374,9 +7600,14 @@ impl App for PlayerApp { "/devices/command", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { devices_command_handler(session, db, device_hub, json).await } + async move { + devices_command_handler(auth_ctx, session, db, device_hub, json).await + } } }), "player_devices_command", @@ -7386,7 +7617,10 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, query: UrlQuery| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + query: UrlQuery| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7399,7 +7633,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - jam_users_search_handler(session, db, pg_pool, query).await + jam_users_search_handler(auth_ctx, session, db, pg_pool, query).await } } }), @@ -7411,7 +7645,10 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let device_hub = Arc::clone(&device_hub); @@ -7425,7 +7662,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - jam_create_handler(session, db, pg_pool, device_hub, json).await + jam_create_handler(auth_ctx, session, db, pg_pool, device_hub, json) + .await } } }), @@ -7435,9 +7673,12 @@ impl App for PlayerApp { "/jams/join", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { jam_join_handler(session, db, device_hub, json).await } + async move { jam_join_handler(auth_ctx, session, db, device_hub, json).await } } }), "player_jams_join", @@ -7448,7 +7689,10 @@ impl App for PlayerApp { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); let device_hub = Arc::clone(&device_hub); @@ -7462,7 +7706,8 @@ impl App for PlayerApp { .expect("player pool") }) .await; - jam_invite_handler(session, db, pg_pool, device_hub, json).await + jam_invite_handler(auth_ctx, session, db, pg_pool, device_hub, json) + .await } } }), @@ -7472,9 +7717,12 @@ impl App for PlayerApp { "/jams/leave", post({ let device_hub = Arc::clone(&device_hub); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let device_hub = Arc::clone(&device_hub); - async move { jam_leave_handler(session, db, device_hub, json).await } + async move { jam_leave_handler(auth_ctx, session, db, device_hub, json).await } } }), "player_jams_leave", @@ -7485,7 +7733,7 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database| { + move |auth_ctx: auth::AuthContext, session: Session, db: Database| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7498,14 +7746,17 @@ impl App for PlayerApp { .expect("player pool") }) .await; - get_state_handler(session, db, pg_pool).await + get_state_handler(auth_ctx, session, db, pg_pool).await } } }) .put({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7518,7 +7769,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - put_state_handler(session, db, pg_pool, json).await + put_state_handler(auth_ctx, session, db, pg_pool, json).await } } }) @@ -7526,7 +7777,10 @@ impl App for PlayerApp { // POST handler for sendBeacon (used on page unload) let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7539,7 +7793,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - put_state_handler(session, db, pg_pool, json).await + put_state_handler(auth_ctx, session, db, pg_pool, json).await } } }), @@ -7551,7 +7805,8 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, + move |auth_ctx: auth::AuthContext, + session: Session, db: Database, query: cot::request::extractors::UrlQuery| { let pool = Arc::clone(&pool); @@ -7566,14 +7821,17 @@ impl App for PlayerApp { .expect("player pool") }) .await; - history_list_handler(session, db, pg_pool, query).await + history_list_handler(auth_ctx, session, db, pg_pool, query).await } } }) .post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7586,7 +7844,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - history_handler(session, db, pg_pool, json).await + history_handler(auth_ctx, session, db, pg_pool, json).await } } }), @@ -7598,7 +7856,9 @@ impl App for PlayerApp { get({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, query: cot::request::extractors::UrlQuery| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); @@ -7612,7 +7872,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - search_handler(session, db, pg_pool, query).await + search_handler(auth_ctx, session, db, pg_pool, query).await } } }), @@ -7624,7 +7884,10 @@ impl App for PlayerApp { post({ let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); - move |session: Session, db: Database, json: Json| { + move |auth_ctx: auth::AuthContext, + session: Session, + db: Database, + json: Json| { let pool = Arc::clone(&pool); let pool_config = Arc::clone(&pool_config); async move { @@ -7637,7 +7900,7 @@ impl App for PlayerApp { .expect("player pool") }) .await; - tracks_by_ids_handler(session, db, pg_pool, json).await + tracks_by_ids_handler(auth_ctx, session, db, pg_pool, json).await } } }),