diff --git a/CHANGELOG.md b/CHANGELOG.md index 742064f78..2293a1969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ - Added extended debug logging for client requests and ID chain (request/action/virtual) to trace stream resolution. - Fixed xtream series/catchup lookups using the series-info virtual_id so episode requests now keep their own virtual_id/session. - Made cache storage more robust. Incomplete downloads will be deleted from cache. +- `kick_secs` added to config.yaml `web_ui` config. Default 30 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration. + This setting is also used for sleep-timed streams. # 3.2.0 (2025-11-14) - Added `name` attribute to Staged Input. diff --git a/Cargo.lock b/Cargo.lock index cceb3baf2..ba2adb119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.18" +version = "3.2.19" dependencies = [ "anyhow", "base64", @@ -3793,7 +3793,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.18" +version = "3.2.19" dependencies = [ "base64", "bitflags 2.10.0", @@ -4356,7 +4356,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.18" +version = "3.2.19" dependencies = [ "arc-swap", "async-compression", diff --git a/README.md b/README.md index 3071c6fc1..b73339c14 100644 --- a/README.md +++ b/README.md @@ -412,11 +412,13 @@ log: ``` ### 1.10 `web_ui` -- enabled: default is true, if set to false the web_ui is disabled -- user_ui_enabled, true or false, for user bouquet editor -- content_security_policy: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`. -- path is for web_ui path like `/ui` for reverse proxy integration if necessary. -- auth for authentication settings +- `enabled`: default is true, if set to false the web_ui is disabled +- `user_ui_enabled`: true or false, for user group editor +- `content_security_policy`: configure Content-Security-Policy headers. When `enabled` is true, the default directives `default-src 'self'`, `script-src 'self' 'wasm-unsafe-eval' 'nonce-{nonce_b64}'`, and `frame-ancestors 'none'` are applied. Additional directives can be added via `custom-attributes`. Enabling CSP may block external images/logos unless allowed via directives like `img-src`. +- `path` is for web_ui path like `/ui` for reverse proxy integration if necessary. +- `player_server` optional, if set the server setting is used for the web-ui-player. +- `kick_secs` default 30 seconds, if a user is kicked from the `web_ui`, they can't connect for this duration. This setting is also used for sleep-timed streams. +- `auth` for authentication settings - `enabled` can be deactivated if `enabled` is set to `false`. If not set default is `true`. - `issuer` - `secret` is used for jwt token generation. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ebb758e1b..80ab2d728 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.18" +version = "3.2.19" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 0d2dd0c8a..d355f2614 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -973,7 +973,7 @@ fn get_stream_throttle(app_state: &AppState) -> u64 { #[allow(clippy::too_many_arguments)] async fn try_shared_stream_response_if_any( - app_state: &AppState, + app_state: &Arc, stream_url: &str, fingerprint: &Fingerprint, user: &ProxyUserCredentials, diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 1f8a890b8..d7715b52f 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -24,6 +24,15 @@ use shared::model::{PlaylistItemType, StreamChannel, TargetType, UserConnectionP use shared::utils::{is_hls_url, replace_url_extension, sanitize_sensitive_info, CUSTOM_VIDEO_PREFIX, HLS_EXT}; use std::sync::Arc; +const PLAYLIST_TEMPLATE: &str = r"#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:10.0, +{url} +#EXT-X-ENDLIST +"; + #[derive(Debug, Deserialize)] struct HlsApiPathParams { username: String, @@ -52,6 +61,10 @@ pub(in crate::api) async fn handle_hls_stream_request( req_headers: &HeaderMap, connection_permission: UserConnectionPermission, ) -> impl IntoResponse + Send { + if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + let url = replace_url_extension(hls_url, HLS_EXT); let server_info = app_state.app_config.get_user_server_info(user); @@ -84,9 +97,8 @@ pub(in crate::api) async fn handle_hls_stream_request( Some(provider_cfg) => { let stream_url = get_stream_alternative_url(&url, input, &provider_cfg); debug_if_enabled!( - "API endpoint [HLS] create_session_fingerprint user={} virtual_id={} provider={} stream_url={}", + "API endpoint [HLS] create_session_fingerprint user={} virtual_id={virtual_id} provider={} stream_url={}", sanitize_sensitive_info(&user.username), - virtual_id, provider_cfg.name, sanitize_sensitive_info(&stream_url) ); @@ -151,15 +163,8 @@ pub(in crate::api) async fn handle_hls_stream_request( user.password, CustomVideoStreamType::ChannelUnavailable); - let playlist = format!(r"#EXTM3U -#EXT-X-VERSION:3 -#EXT-X-TARGETDURATION:10 -#EXT-X-MEDIA-SEQUENCE:0 -#EXTINF:10.0, -{url} -#EXT-X-ENDLIST -"); - hls_response(playlist.clone()).into_response() + let playlist = PLAYLIST_TEMPLATE.replace("{url}", &url); + hls_response(playlist).into_response() } else { axum::http::StatusCode::NOT_FOUND.into_response() } @@ -230,16 +235,10 @@ async fn hls_api_stream( let input = try_option_bad_request!( app_state.app_config.get_input_by_id(params.input_id), true, - format!( - "Cant find input {} for target {target_name}, stream_id {virtual_id}, hls", params.input_id - ) + format!("Cant find input {} for target {target_name}, stream_id {virtual_id}, hls", params.input_id) ); - debug_if_enabled!( - "ID chain for hls endpoint: request_stream_id={} -> virtual_id={}", - params.stream_id, - virtual_id - ); + debug_if_enabled!("ID chain for hls endpoint: request_stream_id={} -> virtual_id={virtual_id}", params.stream_id); let user_session_token = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); let mut user_session = app_state .active_users diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 40e61b85a..47220ddb0 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -118,6 +118,11 @@ async fn m3u_api_stream( format!("Failed to read m3u item for stream id {req_virtual_id}") ); let virtual_id = pli.virtual_id; + + if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + let input = try_option_bad_request!( app_state .app_config @@ -128,12 +133,8 @@ async fn m3u_api_stream( let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live); debug_if_enabled!( - "ID chain for m3u endpoint: request_stream_id={} -> action_stream_id={} -> req_virtual_id={} -> virtual_id={}", - stream_req.stream_id, - action_stream_id, - req_virtual_id, - virtual_id - ); + "ID chain for m3u endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}", + stream_req.stream_id); let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); let user_session = app_state .active_users diff --git a/backend/src/api/endpoints/websocket_api.rs b/backend/src/api/endpoints/websocket_api.rs index 7e93dcfcf..831406ffc 100644 --- a/backend/src/api/endpoints/websocket_api.rs +++ b/backend/src/api/endpoints/websocket_api.rs @@ -136,7 +136,7 @@ async fn handle_protocol_message( Ok(ProtocolMessage::UserAction(cmd)) => { if let Some(token) = mem.token.as_ref() { if !auth_required || verify_auth_admin_token(token, secret_key) { - Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd))) + Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd).await)) } else { Some(ProtocolMessage::UserActionResponse(false)) } @@ -314,8 +314,8 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_req } } -fn handle_user_action(app_state: &Arc, cmd: UserCommand) -> bool { +async fn handle_user_action(app_state: &Arc, cmd: UserCommand) -> bool { match cmd { - UserCommand::Kick(addr) => app_state.connection_manager.kick_connection(&addr), + UserCommand::Kick(addr, virtual_id, secs) => app_state.connection_manager.kick_connection(&addr, virtual_id, secs).await, } } diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 897f55d07..ed68c58d6 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -229,7 +229,6 @@ async fn xtream_player_api_stream( // debug!("{}", sanitize_sensitive_info(&message)); // } - let (user, target) = try_option_bad_request!( get_user_target_by_credentials( stream_req.username, stream_req.password, api_req, app_state), false, @@ -256,6 +255,10 @@ async fn xtream_player_api_stream( format!("Failed to read xtream item for stream id {req_virtual_id}") ); let virtual_id = pli.virtual_id; + if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + let input = try_option_bad_request!( app_state.app_config.get_input_by_name(pli.input_name.as_str()), true, @@ -267,14 +270,10 @@ async fn xtream_player_api_stream( } else { (pli.xtream_cluster, pli.item_type) }; - + debug_if_enabled!( - "ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={} -> req_virtual_id={} -> virtual_id={}", - stream_req.stream_id, - action_stream_id, - req_virtual_id, - virtual_id - ); + "ID chain for xtream endpoint: request_stream_id={} -> action_stream_id={action_stream_id} -> req_virtual_id={req_virtual_id} -> virtual_id={virtual_id}", + stream_req.stream_id); let session_key = create_session_fingerprint(&fingerprint.key, &user.username, virtual_id); let user_session = app_state .active_users diff --git a/backend/src/api/model/active_user_manager.rs b/backend/src/api/model/active_user_manager.rs index d21900cca..90ca91a3c 100644 --- a/backend/src/api/model/active_user_manager.rs +++ b/backend/src/api/model/active_user_manager.rs @@ -6,7 +6,7 @@ use crate::utils::GeoIp; use arc_swap::ArcSwapOption; use jsonwebtoken::get_current_timestamp; use log::{debug, info}; -use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission}; +use shared::model::{ActiveUserConnectionChange, StreamChannel, StreamInfo, UserConnectionPermission, VirtualId}; use shared::utils::{current_time_secs, default_grace_period_millis, default_grace_period_timeout_secs, sanitize_sensitive_info, strip_port}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; @@ -75,6 +75,7 @@ impl UserConnectionData { #[derive(Debug, Default)] struct UserConnections { + kicked: HashMap, by_key: HashMap, key_by_addr: HashMap, } @@ -124,7 +125,6 @@ impl ActiveUserManager { self.last_logged_user_count.store(last_user_count, Ordering::Relaxed); self.last_logged_user_connection_count.store(last_connection_count, Ordering::Relaxed); info!("Active Users: {user_count}, Active User Connections: {user_connection_count}"); - } } } @@ -496,6 +496,22 @@ impl ActiveUserManager { } } + pub async fn is_user_blocked_for_stream(&self, username: &str, virtual_id: VirtualId) -> bool { + let connections = self.connections.read().await; + let now = current_time_secs(); + matches!(connections.kicked.get(username), Some((expires_at, vid)) if *vid == virtual_id && *expires_at > now) + } + + pub async fn block_user_for_stream(&self, addr: &SocketAddr, virtual_id: VirtualId, blocked_secs: u64) { + let mut connections = self.connections.write().await; + let now = current_time_secs(); + connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); + if let Some(username) = connections.key_by_addr.get(addr).cloned() { + let expires_at = now + blocked_secs.clamp(1,86_400); // max 1 day + connections.kicked.insert(username, (expires_at, virtual_id)); + } + } + fn gc(&self) { if let Some(gc_ts) = &self.gc_ts { let ts = gc_ts.load(Ordering::Acquire); @@ -503,6 +519,7 @@ impl ActiveUserManager { if now - ts > USER_GC_TTL { if let Ok(mut user_connections) = self.connections.try_write() { + user_connections.kicked.retain(|_, (expires_at, _)| *expires_at > now); user_connections.by_key.retain(|_k, v| now - v.ts < USER_CON_TTL && v.connections > 0); for connection_data in user_connections.by_key.values_mut() { connection_data.sessions.retain(|s| now - s.ts < USER_CON_TTL); diff --git a/backend/src/api/model/connection_manager.rs b/backend/src/api/model/connection_manager.rs index 4dab2e5af..64277d851 100644 --- a/backend/src/api/model/connection_manager.rs +++ b/backend/src/api/model/connection_manager.rs @@ -3,7 +3,7 @@ use std::net::SocketAddr; use crate::api::model::{ActiveProviderManager, ActiveUserManager, CustomVideoStreamType, EventManager, EventMessage, ProviderHandle, SharedStreamManager}; use std::sync::Arc; use log::{debug, warn}; -use shared::model::{ActiveUserConnectionChange, StreamChannel}; +use shared::model::{ActiveUserConnectionChange, StreamChannel, VirtualId}; use crate::auth::Fingerprint; pub struct ConnectionManager { @@ -36,7 +36,10 @@ impl ConnectionManager { self.close_socket_signal_tx.subscribe() } - pub fn kick_connection(&self, addr: &SocketAddr) -> bool { + pub async fn kick_connection(&self, addr: &SocketAddr, virtual_id: VirtualId, block_secs: u64) -> bool { + if block_secs > 0 { + self.user_manager.block_user_for_stream(addr, virtual_id, block_secs).await; + } if let Err(e) = self.close_socket_signal_tx.send(*addr) { debug!("No active receivers for close signal ({addr}): {e:?}"); return false; @@ -74,7 +77,7 @@ impl ConnectionManager { self.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); } else { warn!("Failed to register connection for user {username} at {}; disconnecting client", fingerprint.addr); - let _ = self.kick_connection(&fingerprint.addr); + let _ = self.kick_connection(&fingerprint.addr,0, 0).await; } } diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index fea261151..fd6ed6b0c 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -37,7 +37,7 @@ impl ActiveClientStream { #[allow(clippy::too_many_arguments)] pub(crate) async fn new(mut stream_details: StreamDetails, - app_state: &AppState, + app_state: &Arc, user: &ProxyUserCredentials, connection_permission: UserConnectionPermission, fingerprint: &Fingerprint, @@ -53,6 +53,7 @@ impl ActiveClientStream { let user_agent = req_headers.get(USER_AGENT).map(|h| String::from_utf8_lossy(h.as_bytes())).unwrap_or_default(); + let virtual_id = stream_channel.virtual_id; app_state.connection_manager.update_connection(username, user.max_connections, fingerprint, &provider_name, stream_channel, user_agent, session_token).await; if let Some((_,_,_m_, Some(cvt))) = stream_details.stream_info.as_ref() { app_state.connection_manager.update_stream_detail(&fingerprint.addr, *cvt).await; @@ -76,12 +77,13 @@ impl ActiveClientStream { futures::stream::empty::>().boxed() } Some(stream) => { - match app_state.app_config.config.load().sleep_timer_mins { + let config = app_state.app_config.config.load(); + match config.sleep_timer_mins { None => stream, Some(mins) => { let secs = u32::try_from((u64::from(mins) * 60).min(u64::from(u32::MAX))).unwrap_or(0); if secs > 0 { - TimedClientStream::new(stream, secs).boxed() + TimedClientStream::new(app_state, stream, secs, fingerprint.addr, virtual_id).boxed() } else { stream } diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index d001649c4..4264b5d3a 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -1,7 +1,6 @@ use crate::api::api_utils::{get_headers_from_request, StreamOptions}; use crate::api::model::{get_response_headers, AppState, CustomVideoStreamType}; use crate::api::model::StreamError; -use crate::api::model::TimedClientStream; use crate::api::model::{create_channel_unavailable_stream, get_header_filter_for_item_type}; use crate::api::model::{BoxedProviderStream, ProviderStreamFactoryResponse}; use crate::model::{ReverseProxyDisabledHeaderConfig}; @@ -33,7 +32,6 @@ pub struct ProviderStreamFactoryOptions { addr: SocketAddr, // item_type: PlaylistItemType, reconnect_enabled: bool, - force_reconnect_secs: u32, buffer_enabled: bool, buffer_size: usize, share_stream: bool, @@ -77,7 +75,6 @@ impl ProviderStreamFactoryOptions { // item_type, addr, reconnect_enabled: stream_options.stream_retry, - force_reconnect_secs: stream_options.stream_force_retry_secs, pipe_stream: stream_options.pipe_provider_stream, buffer_enabled: stream_options.buffer_enabled, buffer_size, @@ -161,10 +158,6 @@ impl ProviderStreamFactoryOptions { self.reconnect_flag.is_active() } - #[inline] - pub fn get_reconnect_force_secs(&self) -> u32 { - self.force_reconnect_secs - } } fn get_request_range_start_bytes(req_headers: &HashMap>) -> Option { @@ -302,16 +295,7 @@ async fn provider_stream_request( StreamError::reqwest(&err) }) .boxed(); - let boxed_provider_stream = if stream_options.get_reconnect_force_secs() > 0 { - TimedClientStream::new( - provider_stream, - stream_options.get_reconnect_force_secs(), - ) - .boxed() - } else { - provider_stream - }; - return Ok(Some((boxed_provider_stream, response_info))); + return Ok(Some((provider_stream, response_info))); } if status.is_client_error() { diff --git a/backend/src/api/model/streams/timed_client_stream.rs b/backend/src/api/model/streams/timed_client_stream.rs index 51702e34d..278a53b15 100644 --- a/backend/src/api/model/streams/timed_client_stream.rs +++ b/backend/src/api/model/streams/timed_client_stream.rs @@ -1,20 +1,27 @@ +use std::net::SocketAddr; use crate::api::model::stream_error::StreamError; use bytes::Bytes; use futures::Stream; use std::pin::Pin; +use std::sync::Arc; use std::task::Poll; use std::time::{Duration, Instant}; -use crate::api::model::BoxedProviderStream; +use shared::model::VirtualId; +use shared::utils::default_kick_secs; +use crate::api::model::{AppState, BoxedProviderStream}; pub struct TimedClientStream { inner: BoxedProviderStream, deadline: Instant, + app_state: Arc, + addr: SocketAddr, + virtual_id: VirtualId, } impl TimedClientStream { - pub(crate) fn new(inner: BoxedProviderStream, duration: u32) -> Self { + pub(crate) fn new(app_state: &Arc, inner: BoxedProviderStream, duration: u32, addr: SocketAddr, virtual_id: VirtualId) -> Self { let deadline = Instant::now() + Duration::from_secs(u64::from(duration)); - Self { inner, deadline } + Self { inner, deadline, app_state: Arc::clone(app_state), addr, virtual_id } } } impl Stream for TimedClientStream { @@ -22,6 +29,13 @@ impl Stream for TimedClientStream { fn poll_next(mut self: Pin<&mut Self>,cx: &mut std::task::Context<'_>,) -> Poll> { if Instant::now() >= self.deadline { + let kick_secs = self.app_state.app_config.config.load().web_ui.as_ref().map_or_else(default_kick_secs, |wc| wc.kick_secs); + let user_manager = Arc::clone(&self.app_state.active_users); + let addr = self.addr; + let virtual_id = self.virtual_id; + tokio::spawn(async move { + user_manager.block_user_for_stream(&addr, virtual_id, kick_secs).await; + }); return Poll::Ready(None); } Pin::as_mut(&mut self.inner).poll_next(cx) diff --git a/backend/src/model/config/web_ui.rs b/backend/src/model/config/web_ui.rs index b061f5074..52737de75 100644 --- a/backend/src/model/config/web_ui.rs +++ b/backend/src/model/config/web_ui.rs @@ -1,5 +1,6 @@ use shared::error::TuliproxError; use shared::model::{ContentSecurityPolicyConfigDto, WebUiConfigDto}; +use shared::utils::default_kick_secs; use crate::model::{macros, WebAuthConfig}; #[derive(Debug, Clone)] @@ -16,6 +17,7 @@ pub struct WebUiConfig { pub path: Option, pub auth: Option, pub player_server: Option, + pub kick_secs: u64, } impl WebUiConfig { @@ -27,6 +29,9 @@ impl WebUiConfig { self.auth = None; } } + if self.kick_secs == 0 { + self.kick_secs = default_kick_secs(); + } Ok(()) } } @@ -61,6 +66,7 @@ impl From<&WebUiConfigDto> for WebUiConfig { path: dto.path.clone(), auth: dto.auth.as_ref().map(Into::into), player_server: dto.player_server.clone(), + kick_secs: dto.kick_secs, } } } @@ -73,6 +79,7 @@ impl From<&WebUiConfig> for WebUiConfigDto { path: instance.path.clone(), auth: instance.auth.as_ref().map(Into::into), player_server: instance.player_server.clone(), + kick_secs: instance.kick_secs, } } } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 4c1a20cd0..15855ada7 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "frontend" -version = "3.2.18" +version = "3.2.19" edition = "2021" [dependencies] -shared = { version = "3.2.18", path = "../shared" } +shared = { version = "3.2.19", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index a52e93860..725449cff 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -127,6 +127,7 @@ "SECRET": "Secret", "USERFILE": "Userfile", "PLAYER_SERVER": "Player Server", + "KICK_DURATION": "Kick duration (secs)", "VERSION": "Version", "SERVER_TIME": "Server time", "BUILD_TIME": "Build time", @@ -343,7 +344,8 @@ "EPG_LOGO_OVERRIDE": "Logo Override", "API_CONFIGURATION" : "API Configuration", "HEADER_NAME": "Header Name", - "HEADER_VALUE": "Header Value" + "HEADER_VALUE": "Header Value", + "TEMPLATES": "Templates" }, "TITLE": { "USER_BOUQUET_EDITOR": "Playlist Category Selection" diff --git a/frontend/public/config.json b/frontend/public/config.json index 2a055c157..e7bfcc794 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -7,7 +7,7 @@ "authUrl": "/auth/" }, "discord": "https://discord.gg/gkzCmWw9Tf", - "documentation": "https://github.com/euzu/tuliprox/blob/master/README.md", + "documentation": "https://euzu.github.io/tuliprox-docs/", "wsUrl" : "/ws", "protocolVersion": 1 } \ No newline at end of file diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 7367da42a..bf2dc35d8 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -81,3 +81,5 @@ @forward "components/source_editor/input_form"; @forward "components/cluster_flags"; @forward "components/titled_card"; +@forward "components/filter_input"; +@forward "components/filter_editor"; diff --git a/frontend/scss/app/components/_collapse_panel.scss b/frontend/scss/app/components/_collapse_panel.scss index fa7b86645..06901478e 100644 --- a/frontend/scss/app/components/_collapse_panel.scss +++ b/frontend/scss/app/components/_collapse_panel.scss @@ -13,6 +13,7 @@ cursor: pointer; box-sizing: border-box; width: 100%; + padding: var(--padding-small); &:hover { color: var(--menu-button-hover-color); @@ -23,6 +24,7 @@ flex: 1 1 auto; white-space: nowrap; text-overflow: ellipsis; + overflow: hidden; } span { pointer-events: none; diff --git a/frontend/scss/app/components/_filter.scss b/frontend/scss/app/components/_filter.scss index 729b0976e..3b0b6da03 100644 --- a/frontend/scss/app/components/_filter.scss +++ b/frontend/scss/app/components/_filter.scss @@ -61,6 +61,7 @@ .bracket-3 { color: var(--code-bracket-3-color); } } .tp__filter__inline { + overflow: hidden; .tp__filter__code { padding: 0; margin-block: 0; diff --git a/frontend/scss/app/components/_filter_editor.scss b/frontend/scss/app/components/_filter_editor.scss new file mode 100644 index 000000000..fb9897992 --- /dev/null +++ b/frontend/scss/app/components/_filter_editor.scss @@ -0,0 +1,86 @@ +.tp__filter-editor { + display: flex; + flex-flow: column; + overflow: hidden; + background-color: transparent; + box-sizing: border-box; + gap: var(--gap-default); + + &__templates { + display: flex; + flex-flow: column; + background-color: transparent; + box-sizing: border-box; + padding: var(--padding-default); + gap: var(--gap-default); + max-height: 400px; + overflow: auto; + + &-container { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + } + + &-content { + display: grid; + grid-template-columns: minmax(max-content, 100px) 1fr; + background-color: transparent; + box-sizing: border-box; + gap: var(--gap-default); + + } + + &-template { + display: flex; + flex-flow: row nowrap; + overflow: hidden; + box-sizing: border-box; + gap: var(--gap-default); + + &-name { + font-weight: bold; + color: var(--code-reserved-color); + &:after { + content: ':'; + } + } + + &-value { + color: var(--modest-text-color); + } + } + } + + &__editor { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--code-editor-background-color); + color: var(--code-editor-color); + + &-input { + height: 20rem; + width: 100%; + max-width: 100%; + } + } + + &__preview { + display: flex; + flex-flow: column; + border-radius: var(--border-radius); + min-height: 3rem; + max-height: 12rem; + } +} + +.tp__filter-editor-valid { + .tp__filter-editor__preview { + border: 1px solid var(--ok-color); + } +} + +.tp__filter-editor-invalid { + .tp__filter-editor__preview { + border: 1px solid var(--attention-color); + } +} \ No newline at end of file diff --git a/frontend/scss/app/components/_filter_input.scss b/frontend/scss/app/components/_filter_input.scss new file mode 100644 index 000000000..f63b1c10c --- /dev/null +++ b/frontend/scss/app/components/_filter_input.scss @@ -0,0 +1,28 @@ +.tp__filter-input { + display: flex; + cursor: pointer; + fill: var(--modest-text-color); + + .tp__input-wrapper { + height: 2.5rem; + padding: 0 var(--padding-mini); + display: flex; + align-items: center; + justify-content: space-between; + } + + &:hover { + fill: var(--text-color); + filter: brightness(1.2); + } + + &__preview { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-height: 2rem; + pointer-events: none; + } + +} \ No newline at end of file diff --git a/frontend/scss/app/components/_input.scss b/frontend/scss/app/components/_input.scss index 799cba9ed..7cc02f8e2 100644 --- a/frontend/scss/app/components/_input.scss +++ b/frontend/scss/app/components/_input.scss @@ -3,7 +3,7 @@ flex-flow: column; overflow: hidden; box-sizing: border-box; - gap: 6px; + gap: var(--gap-small); .tp__input-wrapper { display: flex; flex-flow: row nowrap; @@ -43,6 +43,7 @@ } } +textarea, input { background-clip: content-box; box-sizing: border-box; diff --git a/frontend/scss/app/components/_reveal_content.scss b/frontend/scss/app/components/_reveal_content.scss index bf78ff627..41c5ffe67 100644 --- a/frontend/scss/app/components/_reveal_content.scss +++ b/frontend/scss/app/components/_reveal_content.scss @@ -8,7 +8,6 @@ &:hover { fill: var(--text-color); - cursor: pointer; filter: brightness(1.2); } diff --git a/frontend/src/app/components/cluster_flags_input.rs b/frontend/src/app/components/cluster_flags_input.rs index b19e0c2b1..455370a11 100644 --- a/frontend/src/app/components/cluster_flags_input.rs +++ b/frontend/src/app/components/cluster_flags_input.rs @@ -2,6 +2,13 @@ use shared::model::{ClusterFlags}; use yew::prelude::*; use yew_i18n::use_translation; +#[derive(Clone, Copy, PartialEq, Debug, Default)] +pub enum ClusterFlagsInputMode { + #[default] + NoneIsAll, + NoneIsNone, +} + #[derive(Properties, Clone, PartialEq, Debug)] pub struct ClusterFlagsInputProps { pub name: String, @@ -9,17 +16,27 @@ pub struct ClusterFlagsInputProps { pub value: Option, #[prop_or_default] pub on_change: Callback<(String, Option)>, + #[prop_or_default] + pub mode: ClusterFlagsInputMode, } #[function_component] pub fn ClusterFlagsInput(props: &ClusterFlagsInputProps) -> Html { let translate = use_translation(); - let flags = use_state(|| props.value.unwrap_or_else(ClusterFlags::all)); + let flags = use_state(|| { + props.value.unwrap_or_else(|| match props.mode { + ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(), + ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(), + }) + }); { let set_flags = flags.clone(); - use_effect_with(props.value, move |val| { - set_flags.set((*val).unwrap_or_else(ClusterFlags::all)); + use_effect_with((props.value, props.mode), move |(val, cmode)| { + set_flags.set((*val).unwrap_or_else(|| match cmode { + ClusterFlagsInputMode::NoneIsAll => ClusterFlags::all(), + ClusterFlagsInputMode::NoneIsNone => ClusterFlags::empty(), + })); }); } diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index 38a1bf395..7d45f6bfb 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -2,7 +2,7 @@ use crate::app::components::{AppIcon, Card, Chip}; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; use crate::app::components::config::config_page::{ConfigForm, LABEL_WEB_UI_CONFIG}; -use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_text, edit_field_text_option, generate_form_reducer, html_if}; +use crate::{config_field, config_field_bool, config_field_child, config_field_hide, config_field_optional, edit_field_bool, edit_field_list_option, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer, html_if}; use yew::prelude::*; use yew_i18n::use_translation; use shared::model::{WebUiConfigDto, ContentSecurityPolicyConfigDto, WebAuthConfigDto}; @@ -15,6 +15,7 @@ const LABEL_SECRET: &str = "LABEL.SECRET"; const LABEL_TOKEN_TTL_MINS: &str = "LABEL.TOKEN_TTL_MINS"; const LABEL_USERFILE: &str = "LABEL.USERFILE"; const LABEL_PLAYER_SERVER: &str = "LABEL.PLAYER_SERVER"; +const LABEL_KICK_DURATION: &str = "LABEL.KICK_DURATION"; const LABEL_USER_UI_ENABLED: &str = "LABEL.USER_UI_ENABLED"; const LABEL_CONTENT_SECURITY_POLICY: &str = "LABEL.CONTENT_SECURITY_POLICY"; const LABEL_CONTENT_SECURITY_POLICY_CUSTOM_ATTRIBUTES: &str = "LABEL.CUSTOM_ATTRIBUTES"; @@ -29,6 +30,7 @@ generate_form_reducer!( UserUiEnabled => user_ui_enabled: bool, Path => path: Option, PlayerServer => player_server: Option, + KickSecs => kick_secs: u64, } ); @@ -126,6 +128,7 @@ pub fn WebUiConfigView() -> Html { { config_field_bool!(webui_state.form, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled) } { config_field_optional!(webui_state.form, translate.t(LABEL_PATH), path) } { config_field_optional!(webui_state.form, translate.t(LABEL_PLAYER_SERVER), player_server) } + { config_field!(webui_state.form, translate.t(LABEL_KICK_DURATION), kick_secs) }

{translate.t(LABEL_CONTENT_SECURITY_POLICY)}

{ config_field_bool!(csp_state.form, translate.t(LABEL_ENABLED), enabled) } @@ -163,6 +166,7 @@ pub fn WebUiConfigView() -> Html { { edit_field_bool!(webui_state, translate.t(LABEL_USER_UI_ENABLED), user_ui_enabled, WebUiConfigFormAction::UserUiEnabled) } { edit_field_text_option!(webui_state, translate.t(LABEL_PATH), path, WebUiConfigFormAction::Path) } { edit_field_text_option!(webui_state, translate.t(LABEL_PLAYER_SERVER), player_server, WebUiConfigFormAction::PlayerServer) } + { edit_field_number_u64!(webui_state, translate.t(LABEL_KICK_DURATION), kick_secs, WebUiConfigFormAction::KickSecs) }

{translate.t(LABEL_CONTENT_SECURITY_POLICY)}

{ edit_field_bool!(csp_state, translate.t(LABEL_ENABLED), enabled, CspConfigFormAction::Enabled) } diff --git a/frontend/src/app/components/confirm_dialog.rs b/frontend/src/app/components/confirm_dialog.rs index c0bb2d7c5..c778acc51 100644 --- a/frontend/src/app/components/confirm_dialog.rs +++ b/frontend/src/app/components/confirm_dialog.rs @@ -10,6 +10,8 @@ pub struct ConfirmDialogProps { pub ok_caption: String, pub cancel_caption: String, pub on_confirm: Callback, + #[prop_or(true)] + pub close_on_backdrop_click: bool, } #[function_component] @@ -45,7 +47,7 @@ pub fn ConfirmDialog(props: &ConfirmDialogProps) -> Html { open={*is_open} class="tp__confirm-dialog" modal=true - close_on_backdrop_click=true + close_on_backdrop_click={props.close_on_backdrop_click} on_close={Some(on_close)} >

{ &props.title }

diff --git a/frontend/src/app/components/content_dialog.rs b/frontend/src/app/components/content_dialog.rs index e26d67980..57fdf3324 100644 --- a/frontend/src/app/components/content_dialog.rs +++ b/frontend/src/app/components/content_dialog.rs @@ -9,6 +9,8 @@ pub struct ContentDialogProps { pub content: Html, pub actions: DialogActions, pub on_confirm: Callback, + #[prop_or(true)] + pub close_on_backdrop_click: bool, } #[function_component] @@ -68,7 +70,7 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html { open={*is_open} class="tp__content-dialog" modal=true - close_on_backdrop_click=true + close_on_backdrop_click={props.close_on_backdrop_click} on_close={on_close} > { props.content.clone() } diff --git a/frontend/src/app/components/custom_dialog.rs b/frontend/src/app/components/custom_dialog.rs index c7c49b1f2..78feaa69e 100644 --- a/frontend/src/app/components/custom_dialog.rs +++ b/frontend/src/app/components/custom_dialog.rs @@ -26,12 +26,12 @@ pub fn CustomDialog(props: &CustomDialogProps) -> Html { || () }); } - + // Handle backdrop click let on_backdrop_click = { let on_close = props.on_close.clone(); let close_on_backdrop = props.close_on_backdrop_click; - + Callback::from(move |_e: MouseEvent| { if close_on_backdrop { if let Some(on_close) = &on_close { diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index ba3171ff2..6f9aa6638 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -8,7 +8,7 @@ use gloo_timers::callback::Interval; use gloo_utils::window; use shared::error::{create_tuliprox_error_result, TuliproxError, TuliproxErrorKind}; use shared::model::{PlaylistItemType, ProtocolMessage, SortOrder, StreamChannel, StreamInfo, UserCommand}; -use shared::utils::{current_time_secs, strip_port}; +use shared::utils::{current_time_secs, default_kick_secs, strip_port}; use std::fmt::Display; use std::rc::Rc; use std::str::FromStr; @@ -84,7 +84,7 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { let popup_is_open = use_state(|| false); let selected_dto = use_state(|| None::>); - let headers = use_memo(config_ctx, |cfg| { + let headers = use_memo(config_ctx.clone(), |cfg| { let include_country = if let Some(app_cfg) = &cfg.config { app_cfg.config.is_geoip_enabled() } else { @@ -241,12 +241,15 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { let services = service_ctx.clone(); let selected_dto = selected_dto.clone(); let copy_to_clipboard = copy_to_clipboard.clone(); + let kick_secs = config_ctx.config.as_ref().and_then(|app_cfg| app_cfg.config.web_ui.as_ref()) + .map(|web_ui| web_ui.kick_secs) + .unwrap_or_else(default_kick_secs); Callback::from(move |(name, _): (String, _)| { if let Ok(action) = StreamsTableAction::from_str(&name) { match action { StreamsTableAction::Kick => { if let Some(dto) = (*selected_dto).as_ref() { - if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr))) { + if !services.websocket.send_message(ProtocolMessage::UserAction(UserCommand::Kick(dto.addr, dto.channel.virtual_id, kick_secs))) { services.toastr.error(translate.t("MESSAGES.FAILED_TO_KICK_USER_STREAM")); } } diff --git a/frontend/src/app/components/filter/filter_editor.rs b/frontend/src/app/components/filter/filter_editor.rs new file mode 100644 index 000000000..cc085908b --- /dev/null +++ b/frontend/src/app/components/filter/filter_editor.rs @@ -0,0 +1,118 @@ +use web_sys::InputEvent; +use crate::app::ConfigContext; +use shared::model::PatternTemplate; +use yew::{classes, function_component, html, use_context, use_effect_with, use_state, Callback, Html, Properties, TargetCast}; +use yew_i18n::use_translation; +use shared::foundation::filter::{get_filter}; +use crate::app::components::{CollapsePanel, FilterView}; + +#[derive(Properties, Clone, PartialEq, Debug)] +pub struct FilterEditorProps { + #[prop_or_default] + pub filter: Option, + #[prop_or_default] + pub on_filter_change: Callback>, + pub on_templates_change: Callback>>, +} + +#[function_component] +pub fn FilterEditor(props: &FilterEditorProps) -> Html { + let config_ctx = use_context::().expect("Config context not found"); + let translate = use_translation(); + + let templates_state = use_state(|| None); + let filter_state = use_state(|| None); + let parsed_filter_state = use_state(|| None); + let valid_filter_state = use_state(|| true); + + { + let templates = templates_state.clone(); + let cfg_templates = config_ctx.config.as_ref().and_then(|c| c.sources.templates.clone()); + use_effect_with(cfg_templates, move |templ| { + templates.set(templ.clone()); + }); + } + + { + let filter = filter_state.clone(); + use_effect_with(props.filter.clone(), move |flt| { + filter.set(flt.clone()); + }); + } + + { + let filter = filter_state.clone(); + let parsed_filter = parsed_filter_state.clone(); + let templates = templates_state.clone(); + let valid_filter = valid_filter_state.clone(); + use_effect_with(filter.clone(), move |flt| { + let parsed = if let Some(new_fltr) = flt.as_ref() { + match get_filter(new_fltr, (*templates).as_ref()) { + Ok(fltr) => { + valid_filter.set(true); + Some(fltr) + } + Err(_) => { + valid_filter.set(false); + None + } + } + } else { + valid_filter.set(true); + None + }; + parsed_filter.set(parsed); + }); + } + + let handle_filter_input = { + let filter = filter_state.clone(); + let on_filter_change = props.on_filter_change.clone(); + Callback::from(move |event: InputEvent| { + if let Some(input) = event.target_dyn_into::() { + let value = input.value(); + if value.is_empty() { + filter.set(None); + on_filter_change.emit(None); + } else { + filter.set(Some(value.clone())); + on_filter_change.emit(Some(value)); + } + } + }) + }; + + html! { +
+ +
+
+ { if let Some(templ_vec) = &*templates_state { + html! { + for templ_vec.iter().map(|templ| html! { + <> +
+ { &templ.name } +
+
+ { templ.value.to_string() } +
+ + }) + } + } else { + html! {} + } + } +
+
+
+
+