diff --git a/Cargo.lock b/Cargo.lock index 29fb11a..14f9ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 5c1d7ad..cbd02bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.5" +version = "0.1.6" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/README.md b/README.md index 4ad98a1..6ddfd4a 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Full OpenID Connect authorization code flow with PKCE: Provider metadata is cached for 1 hour and invalidated when OIDC config changes. -**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload. +**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload. **User provisioning order:** 1. Find existing `OidcLink` by issuer+sub → update claims, update role @@ -197,4 +197,5 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default. | `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* | | `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` | | `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* | +| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* | | `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` | diff --git a/src/admin/views.rs b/src/admin/views.rs index 4c34700..a999822 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -129,6 +129,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec, oidc_client_secret: Option, oidc_admin_groups: Option, + oidc_user_groups: Option, swagger_enabled: Option, agent_enabled: Option, agent_inbox_dir: Option, @@ -378,6 +388,7 @@ pub async fn settings_submit( let oidc_client_id = data.oidc_client_id.unwrap_or_default(); let oidc_client_secret = data.oidc_client_secret.unwrap_or_default(); let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default(); + let oidc_user_groups = data.oidc_user_groups.unwrap_or_default(); let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default(); let agent_storage_dir = data.agent_storage_dir.unwrap_or_default(); let agent_llm_url = data.agent_llm_url.unwrap_or_default(); @@ -386,7 +397,7 @@ pub async fn settings_submit( let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default(); let agent_context_limit = data.agent_context_limit.unwrap_or_default(); let agent_concurrency = data.agent_concurrency.unwrap_or_default(); - let fields: [(&str, &str); 17] = [ + let fields: [(&str, &str); 18] = [ ("auth_password_enabled", pw_enabled), ("auth_sso_enabled", sso_enabled), ("oidc_button_text", &oidc_button_text), @@ -394,6 +405,7 @@ pub async fn settings_submit( ("oidc_client_id", &oidc_client_id), ("oidc_client_secret", &oidc_client_secret), ("oidc_admin_groups", &oidc_admin_groups), + ("oidc_user_groups", &oidc_user_groups), ("swagger_enabled", swagger), ("agent_enabled", agent_en), ("agent_inbox_dir", &agent_inbox_dir), diff --git a/src/config.rs b/src/config.rs index 0675b3d..444cfa6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -122,6 +122,7 @@ pub struct ConfigSources { pub auth_sso_enabled: ConfigSource, pub oidc_button_text: ConfigSource, pub oidc_admin_groups: ConfigSource, + pub oidc_user_groups: ConfigSource, pub swagger_enabled: ConfigSource, pub agent_enabled: ConfigSource, pub agent_inbox_dir: ConfigSource, @@ -146,6 +147,7 @@ impl Default for ConfigSources { auth_sso_enabled: ConfigSource::Default, oidc_button_text: ConfigSource::Default, oidc_admin_groups: ConfigSource::Default, + oidc_user_groups: ConfigSource::Default, swagger_enabled: ConfigSource::Default, agent_enabled: ConfigSource::Default, agent_inbox_dir: ConfigSource::Default, @@ -238,6 +240,8 @@ pub struct AppConfig { pub oidc_button_text: String, /// Comma-separated list of OIDC group names that grant admin role. pub oidc_admin_groups: String, + /// Comma-separated list of OIDC group names that are allowed to use the service. + pub oidc_user_groups: String, /// Whether the Swagger UI is served at /swagger/. pub swagger_enabled: bool, /// Whether the AI agent background loop is enabled. @@ -272,6 +276,7 @@ impl Default for AppConfig { auth_sso_enabled: false, oidc_button_text: "Sign in with SSO".into(), oidc_admin_groups: String::new(), + oidc_user_groups: String::new(), swagger_enabled: false, agent_enabled: false, agent_inbox_dir: String::new(), @@ -297,6 +302,7 @@ impl_env_overrides!( auth_sso_enabled, oidc_button_text, oidc_admin_groups, + oidc_user_groups, swagger_enabled, agent_enabled, agent_inbox_dir, @@ -372,6 +378,7 @@ impl AppConfig { apply_db_field!(auth_sso_enabled); apply_db_field!(oidc_button_text); apply_db_field!(oidc_admin_groups); + apply_db_field!(oidc_user_groups); apply_db_field!(swagger_enabled); apply_db_field!(agent_enabled); apply_db_field!(agent_inbox_dir); diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index ed76e9f..c751760 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -70,6 +70,8 @@ translations! { settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)"; settings_oidc_admin_groups: "Admin groups" , "Группы администраторов"; settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)"; + settings_oidc_user_groups: "User groups" , "Группы пользователей"; + settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь."; // User management nav_users: "Users" , "Пользователи"; @@ -97,6 +99,7 @@ translations! { // OIDC login errors login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз."; login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен."; + login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору."; // Artist management nav_artists: "Artists" , "Артисты"; diff --git a/src/main.rs b/src/main.rs index ba16b5f..675cfd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -281,6 +281,7 @@ impl Project for FuruProject { " FURU_OIDC_CLIENT_SECRET OIDC client secret\n", " FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n", " FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n", + " FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n", "\n", " API:\n", " FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n", diff --git a/src/oidc.rs b/src/oidc.rs index af414c8..7581671 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -384,10 +384,24 @@ pub async fn oidc_callback_handler( .unwrap_or_default(); tracing::info!( - "OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}", + "OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}", config.oidc_admin_groups, + config.oidc_user_groups, ); + if !is_allowed_by_groups( + &groups, + &config.oidc_user_groups, + &config.oidc_admin_groups, + ) { + tracing::warn!( + "OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}", + config.oidc_user_groups, + config.oidc_admin_groups, + ); + return redirect_login_with_error(i18n.t.login_access_denied); + } + // User provisioning logic. let user = match provision_user( &db, @@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str { auth::Role::User.code() } +fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> { + groups + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect() +} + +fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool { + groups.iter().any(|g| allowed.contains(g.as_str())) +} + +fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool { + let user_set = parse_group_set(user_groups); + if user_set.is_empty() { + return true; + } + let admin_set = parse_group_set(admin_groups); + has_any_group(groups, &user_set) || has_any_group(groups, &admin_set) +} + async fn provision_user( db: &Database, issuer: &str, diff --git a/src/player/mod.rs b/src/player/mod.rs index 06e5186..dc66309 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -71,6 +71,7 @@ struct ArtistDetail { total_track_count: i64, total_play_count: i64, releases: Vec, + featured_tracks: Vec, } #[derive(Debug, Serialize, JsonSchema)] @@ -92,6 +93,19 @@ struct TrackItem { stream_url: String, } +#[derive(Debug, Serialize, JsonSchema)] +struct ArtistAppearanceTrack { + id: i64, + title: String, + release_id: i64, + release_title: String, + duration_seconds: f64, + artists: Vec, + featured_artists: Vec, + cover_url: Option, + stream_url: String, +} + #[derive(Debug, Serialize, JsonSchema)] struct ReleaseDetail { id: i64, @@ -357,6 +371,17 @@ struct PlaylistTrackRow { release_cover_file_id: Option, } +#[derive(sqlx::FromRow)] +struct AppearanceTrackRow { + id: i64, + title: String, + release_id: i64, + release_title: String, + duration_seconds: f64, + cover_file_id: Option, + release_cover_file_id: Option, +} + #[derive(sqlx::FromRow)] struct SearchArtistRow { id: i64, @@ -633,6 +658,86 @@ async fn artist_detail_handler( .await .map_err(|e| cot::Error::internal(e.to_string()))?; + let featured_rows = sqlx::query_as::<_, AppearanceTrackRow>( + r#"SELECT DISTINCT t.id, + t.title::text AS title, + r.id AS release_id, + r.title::text AS release_title, + t.duration_seconds, + t.cover_file_id, + r.cover_file_id AS release_cover_file_id + FROM furumusic__track_artist ta + JOIN furumusic__track t ON t.id = ta.track_id + JOIN furumusic__release r ON r.id = t.release_id + WHERE ta.artist_id = $1 + AND ta.role = 'featuring' + AND t.is_hidden = false + AND r.is_hidden = false + ORDER BY r.title::text, t.title::text"#, + ) + .bind(artist_id) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + let featured_track_ids: Vec = featured_rows.iter().map(|t| t.id).collect(); + let featured_track_artists = if featured_track_ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, TrackArtistRow>( + r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role + FROM furumusic__track_artist ta + JOIN furumusic__artist a ON a.id = ta.artist_id + WHERE ta.track_id = ANY($1) + ORDER BY ta.track_id, ta.position"#, + ) + .bind(&featured_track_ids) + .fetch_all(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + }; + + let mut featured_main_artists: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut featured_feat_artists: std::collections::HashMap> = + std::collections::HashMap::new(); + + for ta in &featured_track_artists { + let artist_ref = ArtistRef { + id: ta.artist_id, + name: ta.artist_name.clone(), + }; + if ta.role == "featuring" { + featured_feat_artists + .entry(ta.track_id) + .or_default() + .push(artist_ref); + } else { + featured_main_artists + .entry(ta.track_id) + .or_default() + .push(artist_ref); + } + } + + let featured_tracks: Vec = featured_rows + .into_iter() + .map(|t| { + let tid = t.id; + ArtistAppearanceTrack { + id: t.id, + title: t.title, + release_id: t.release_id, + release_title: t.release_title, + duration_seconds: t.duration_seconds, + artists: featured_main_artists.remove(&tid).unwrap_or_default(), + featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(), + cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id), + stream_url: format!("/api/player/stream/{tid}"), + } + }) + .collect(); + Json(ArtistDetail { id: artist.id, name: artist.name, @@ -640,6 +745,7 @@ async fn artist_detail_handler( total_track_count, total_play_count, releases: release_cards, + featured_tracks, }) .into_response() } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index b718e80..811ce83 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -67,6 +67,11 @@ {{ oidc_admin_groups_source }} + +
{{ t.settings_oidc_user_groups_help }} + + {{ oidc_user_groups_source }} +

{{ t.settings_api }}

diff --git a/templates/player.html b/templates/player.html index 8c7f5f0..db4bbfd 100644 --- a/templates/player.html +++ b/templates/player.html @@ -435,6 +435,17 @@ button.user-stat:hover { .release-meta .release-type { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); } .release-meta .release-title { font-size: 36px; font-weight: 900; line-height: 1.2; margin: 4px 0; } .release-meta .release-artists { font-size: 14px; color: var(--text-secondary); } + +.artist-link { + color: inherit; + cursor: pointer; + text-decoration: none; +} + +.artist-link:hover { + color: var(--text-primary); + text-decoration: underline; +} .release-meta .release-year { font-size: 14px; color: var(--text-subdued); margin-top: 4px; } /* Track list table */ @@ -2318,7 +2329,14 @@ button.user-stat:hover {
-
+
+ +
@@ -2433,6 +2451,59 @@ button.user-stat:hover {
+ @@ -2460,7 +2531,14 @@ button.user-stat:hover {
-
+
+ +
-
+
+ +
@@ -3308,7 +3414,7 @@ document.addEventListener('alpine:init', () => { Alpine.store('queue', { tracks: [], currentIndex: 0, - visible: true, + visible: false, _dragIdx: null, add(track) { @@ -3523,6 +3629,18 @@ document.addEventListener('alpine:init', () => { })); }, + trackArtistLinks(track) { + const main = (track?.artists || []).map(artist => ({ + id: artist.id, + label: artist.name, + })); + const featured = (track?.featured_artists || []).map(artist => ({ + id: artist.id, + label: 'ft. ' + artist.name, + })); + return [...main, ...featured]; + }, + async openRelease(id) { this.searchQuery = ''; this.searchResults = null;