Skip to main content

xui_rs/
client.rs

1//! Main panel client, authentication selection, and transport configuration.
2
3use std::{sync::Arc, time::Duration};
4
5use reqwest::{
6    Method, RequestBuilder, StatusCode,
7    cookie::{CookieStore, Jar},
8    header,
9};
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Serialize, de::DeserializeOwned};
12use tokio::sync::Mutex;
13use url::Url;
14
15use crate::{
16    AuthApi, Error, ProxyConfig, Result,
17    response::ApiResponse,
18    transport::{self, ErrorUrlPolicy},
19};
20
21const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
23const DEFAULT_USER_AGENT: &str = concat!("xui-rs/", env!("CARGO_PKG_VERSION"));
24
25/// Default maximum size of an ordinary panel API response body: 64 MiB.
26pub const DEFAULT_API_RESPONSE_BODY_LIMIT: usize = 64 * 1024 * 1024;
27
28/// Default maximum size of an explicit database or migration download: 512 MiB.
29pub const DEFAULT_DOWNLOAD_RESPONSE_BODY_LIMIT: usize = 512 * 1024 * 1024;
30
31#[derive(Clone, Copy)]
32pub(crate) enum AuthenticationScope {
33    Session,
34    #[allow(dead_code)]
35    PanelApi,
36}
37
38/// Authentication mechanism used for protected panel endpoints.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40#[non_exhaustive]
41pub enum AuthenticationKind {
42    /// Signed session cookies obtained through `/login`.
43    Session,
44    /// An API token sent in the `Authorization` header.
45    BearerToken,
46}
47
48/// Builder for [`Client`].
49#[must_use]
50pub struct ClientBuilder {
51    base_url: Url,
52    bearer_token: Option<SecretString>,
53    timeout: Duration,
54    connect_timeout: Duration,
55    user_agent: String,
56    accept_invalid_certs: bool,
57    proxy: Option<ProxyConfig>,
58    response_body_limit: usize,
59    download_body_limit: usize,
60}
61
62impl std::fmt::Debug for ClientBuilder {
63    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        formatter
65            .debug_struct("ClientBuilder")
66            .field("server_origin", &origin(&self.base_url))
67            .field(
68                "authentication",
69                &self
70                    .bearer_token
71                    .as_ref()
72                    .map(|_| AuthenticationKind::BearerToken),
73            )
74            .field("timeout", &self.timeout)
75            .field("connect_timeout", &self.connect_timeout)
76            .field("user_agent", &self.user_agent)
77            .field("accept_invalid_certs", &self.accept_invalid_certs)
78            .field("proxy", &self.proxy.as_ref().map(ProxyConfig::scheme))
79            .field("response_body_limit", &self.response_body_limit)
80            .field("download_body_limit", &self.download_body_limit)
81            .finish()
82    }
83}
84
85impl ClientBuilder {
86    pub(crate) fn new(base_url: impl AsRef<str>) -> Result<Self> {
87        Ok(Self {
88            base_url: normalize_base_url(base_url.as_ref())?,
89            bearer_token: None,
90            timeout: DEFAULT_TIMEOUT,
91            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
92            user_agent: DEFAULT_USER_AGENT.to_owned(),
93            accept_invalid_certs: false,
94            proxy: None,
95            response_body_limit: DEFAULT_API_RESPONSE_BODY_LIMIT,
96            download_body_limit: DEFAULT_DOWNLOAD_RESPONSE_BODY_LIMIT,
97        })
98    }
99
100    /// Uses a 3x-ui API token for protected API endpoints.
101    pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
102        self.bearer_token = Some(SecretString::from(token.into()));
103        self
104    }
105
106    /// Sets the total timeout for one HTTP request.
107    pub const fn timeout(mut self, timeout: Duration) -> Self {
108        self.timeout = timeout;
109        self
110    }
111
112    /// Sets the timeout for establishing a connection.
113    pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
114        self.connect_timeout = timeout;
115        self
116    }
117
118    /// Overrides the user-agent sent by the SDK.
119    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
120        self.user_agent = user_agent.into();
121        self
122    }
123
124    /// Accepts invalid TLS certificates.
125    ///
126    /// Use this only for an explicitly trusted self-signed deployment. It
127    /// weakens transport security for every request made by this client.
128    pub const fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
129        self.accept_invalid_certs = accept;
130        self
131    }
132
133    /// Routes panel HTTP requests and WebSocket connections through an
134    /// explicit proxy.
135    ///
136    /// Environment proxy variables are never consulted. Use
137    /// [`ProxyScheme::Socks5h`](crate::ProxyScheme::Socks5h) when target DNS
138    /// resolution must happen on the proxy.
139    pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
140        self.proxy = Some(proxy);
141        self
142    }
143
144    /// Parses and configures a credential-free proxy URL.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`Error::Configuration`] for an invalid proxy URL. Configure
149    /// credentials with [`ProxyConfig::with_basic_auth`] and [`Self::proxy`].
150    pub fn proxy_url(self, proxy_url: impl AsRef<str>) -> Result<Self> {
151        Ok(self.proxy(ProxyConfig::new(proxy_url)?))
152    }
153
154    /// Removes a previously configured explicit proxy.
155    pub fn no_proxy(mut self) -> Self {
156        self.proxy = None;
157        self
158    }
159
160    /// Sets the maximum in-memory size of an ordinary panel API response.
161    ///
162    /// Both declared `Content-Length` and actually received chunked or
163    /// decompressed bytes are checked. Set a deliberately larger value for
164    /// deployments whose client lists, logs, or runtime `OpenAPI` exceed the
165    /// safe 64 MiB default.
166    pub const fn response_body_limit(mut self, limit: usize) -> Self {
167        self.response_body_limit = limit;
168        self
169    }
170
171    /// Sets the maximum in-memory size of explicit database and migration
172    /// downloads.
173    ///
174    /// This is separate from [`Self::response_body_limit`] because backups are
175    /// expected to be substantially larger than structured API responses.
176    pub const fn download_body_limit(mut self, limit: usize) -> Self {
177        self.download_body_limit = limit;
178        self
179    }
180
181    /// Builds the configured client.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`Error::Configuration`] when the HTTP client configuration is
186    /// invalid.
187    pub fn build(self) -> Result<Client> {
188        if self
189            .bearer_token
190            .as_ref()
191            .is_some_and(|token| token.expose_secret().trim().is_empty())
192        {
193            return Err(Error::Configuration(
194                "bearer token must not be empty".to_owned(),
195            ));
196        }
197        let mut headers = header::HeaderMap::new();
198        headers.insert(
199            "x-requested-with",
200            header::HeaderValue::from_static("XMLHttpRequest"),
201        );
202
203        let cookie_jar = Arc::new(Jar::default());
204        let mut http_builder = reqwest::Client::builder()
205            .no_proxy()
206            .cookie_provider(Arc::clone(&cookie_jar))
207            .default_headers(headers)
208            .timeout(self.timeout)
209            .connect_timeout(self.connect_timeout)
210            .user_agent(self.user_agent.clone())
211            .danger_accept_invalid_certs(self.accept_invalid_certs);
212        if let Some(proxy) = &self.proxy {
213            http_builder = http_builder.proxy(proxy.reqwest_proxy()?);
214        }
215        let http = http_builder
216            .build()
217            .map_err(|error| Error::Configuration(error.to_string()))?;
218
219        Ok(Client {
220            inner: Arc::new(Inner {
221                http,
222                base_url: self.base_url,
223                bearer_token: self.bearer_token,
224                cookie_jar,
225                csrf_token: Mutex::new(None),
226                connect_timeout: self.connect_timeout,
227                user_agent: self.user_agent,
228                accept_invalid_certs: self.accept_invalid_certs,
229                proxy: self.proxy,
230                response_body_limit: self.response_body_limit,
231                download_body_limit: self.download_body_limit,
232            }),
233        })
234    }
235}
236
237/// A cheap-to-clone, concurrency-safe client for a 3x-ui panel.
238#[derive(Clone)]
239pub struct Client {
240    inner: Arc<Inner>,
241}
242
243impl std::fmt::Debug for Client {
244    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        formatter
246            .debug_struct("Client")
247            .field("server_origin", &origin(&self.inner.base_url))
248            .field("authentication", &self.authentication_kind())
249            .field("proxy", &self.inner.proxy.as_ref().map(ProxyConfig::scheme))
250            .field("response_body_limit", &self.inner.response_body_limit)
251            .field("download_body_limit", &self.inner.download_body_limit)
252            .finish_non_exhaustive()
253    }
254}
255
256struct Inner {
257    http: reqwest::Client,
258    base_url: Url,
259    bearer_token: Option<SecretString>,
260    cookie_jar: Arc<Jar>,
261    csrf_token: Mutex<Option<SecretString>>,
262    connect_timeout: Duration,
263    user_agent: String,
264    accept_invalid_certs: bool,
265    proxy: Option<ProxyConfig>,
266    response_body_limit: usize,
267    download_body_limit: usize,
268}
269
270impl Client {
271    /// Creates a cookie-session client with default settings.
272    ///
273    /// # Errors
274    ///
275    /// Returns [`Error::Configuration`] when the base URL or HTTP client
276    /// configuration is invalid.
277    pub fn new(base_url: impl AsRef<str>) -> Result<Self> {
278        Self::builder(base_url)?.build()
279    }
280
281    /// Starts configuring a client.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`Error::Configuration`] when `base_url` is not an HTTP(S) URL.
286    pub fn builder(base_url: impl AsRef<str>) -> Result<ClientBuilder> {
287        ClientBuilder::new(base_url)
288    }
289
290    /// Returns the normalized panel base URL.
291    pub fn base_url(&self) -> &Url {
292        &self.inner.base_url
293    }
294
295    /// Returns the authentication mechanism for protected panel endpoints.
296    pub fn authentication_kind(&self) -> AuthenticationKind {
297        if self.inner.bearer_token.is_some() {
298            AuthenticationKind::BearerToken
299        } else {
300            AuthenticationKind::Session
301        }
302    }
303
304    /// Returns the configured maximum ordinary API response body size.
305    pub fn response_body_limit(&self) -> usize {
306        self.inner.response_body_limit
307    }
308
309    /// Returns the configured maximum database/migration download size.
310    pub fn download_body_limit(&self) -> usize {
311        self.inner.download_body_limit
312    }
313
314    /// Accesses login, logout, 2FA, and CSRF operations.
315    pub const fn auth(&self) -> AuthApi<'_> {
316        AuthApi::new(self)
317    }
318
319    /// Accesses inbound management operations.
320    pub const fn inbounds(&self) -> crate::InboundsApi<'_> {
321        crate::InboundsApi::new(self)
322    }
323
324    /// Accesses client and client-group management operations.
325    pub const fn clients(&self) -> crate::ClientsApi<'_> {
326        crate::ClientsApi::new(self)
327    }
328
329    /// Accesses server status, Xray lifecycle, maintenance, and utility operations.
330    pub const fn server(&self) -> crate::ServerApi<'_> {
331        crate::ServerApi::new(self)
332    }
333
334    /// Accesses panel settings, credentials, notifications, and API tokens.
335    pub const fn settings(&self) -> crate::SettingsApi<'_> {
336        crate::SettingsApi::new(self)
337    }
338
339    /// Accesses Xray settings, integrations, tests, and outbound subscriptions.
340    pub const fn xray_settings(&self) -> crate::XraySettingsApi<'_> {
341        crate::XraySettingsApi::new(self)
342    }
343
344    /// Accesses per-inbound subscription host override operations.
345    pub const fn hosts(&self) -> crate::HostsApi<'_> {
346        crate::HostsApi::new(self)
347    }
348
349    /// Accesses remote node registration, health, discovery, and mTLS operations.
350    pub const fn nodes(&self) -> crate::NodesApi<'_> {
351        crate::NodesApi::new(self)
352    }
353
354    /// Accesses panel-wide metadata and backup operations.
355    pub const fn panel(&self) -> crate::PanelApi<'_> {
356        crate::PanelApi::new(self)
357    }
358
359    /// Accesses the authenticated real-time event stream.
360    pub const fn events(&self) -> crate::EventsApi<'_> {
361        crate::EventsApi::new(self)
362    }
363
364    /// Accesses JSON-subscription balancer management operations.
365    pub const fn subscription_balancers(&self) -> crate::SubscriptionBalancersApi<'_> {
366        crate::SubscriptionBalancersApi::new(self)
367    }
368
369    pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
370        self.inner
371            .base_url
372            .join(path.trim_start_matches('/'))
373            .map_err(|error| Error::Configuration(format!("invalid endpoint path: {error}")))
374    }
375
376    pub(crate) fn cookie_header(&self, url: &Url) -> Option<header::HeaderValue> {
377        self.inner.cookie_jar.cookies(url)
378    }
379
380    pub(crate) fn connect_timeout(&self) -> Duration {
381        self.inner.connect_timeout
382    }
383
384    pub(crate) fn user_agent(&self) -> &str {
385        &self.inner.user_agent
386    }
387
388    pub(crate) fn accepts_invalid_certs(&self) -> bool {
389        self.inner.accept_invalid_certs
390    }
391
392    pub(crate) fn proxy(&self) -> Option<&ProxyConfig> {
393        self.inner.proxy.as_ref()
394    }
395
396    pub(crate) async fn execute<T, B>(
397        &self,
398        method: Method,
399        path: &str,
400        body: Option<&B>,
401        scope: AuthenticationScope,
402    ) -> Result<ApiResponse<T>>
403    where
404        T: DeserializeOwned,
405        B: Serialize + ?Sized,
406    {
407        self.execute_with(method, path, scope, |request| match body {
408            Some(body) => request.json(body),
409            None => request,
410        })
411        .await
412    }
413
414    pub(crate) async fn execute_form<T, B>(
415        &self,
416        method: Method,
417        path: &str,
418        body: &B,
419        scope: AuthenticationScope,
420    ) -> Result<ApiResponse<T>>
421    where
422        T: DeserializeOwned,
423        B: Serialize + ?Sized,
424    {
425        self.execute_with(method, path, scope, |request| request.form(body))
426            .await
427    }
428
429    pub(crate) async fn execute_response<T, B>(
430        &self,
431        method: Method,
432        path: &str,
433        body: Option<&B>,
434        scope: AuthenticationScope,
435    ) -> Result<T>
436    where
437        T: DeserializeOwned,
438        B: Serialize + ?Sized,
439    {
440        self.execute_response_with(method, path, scope, |request| match body {
441            Some(body) => request.json(body),
442            None => request,
443        })
444        .await
445    }
446
447    pub(crate) async fn execute_empty<B>(
448        &self,
449        method: Method,
450        path: &str,
451        body: Option<&B>,
452        scope: AuthenticationScope,
453    ) -> Result<()>
454    where
455        B: Serialize + ?Sized,
456    {
457        self.execute_configured(method, path, scope, |request| match body {
458            Some(body) => request.json(body),
459            None => request,
460        })
461        .await?;
462        Ok(())
463    }
464
465    pub(crate) async fn execute_query<T, Q>(
466        &self,
467        method: Method,
468        path: &str,
469        query: &Q,
470        scope: AuthenticationScope,
471    ) -> Result<ApiResponse<T>>
472    where
473        T: DeserializeOwned,
474        Q: Serialize + ?Sized,
475    {
476        self.execute_with(method, path, scope, |request| request.query(query))
477            .await
478    }
479
480    pub(crate) async fn execute_query_json<T, Q, B>(
481        &self,
482        method: Method,
483        path: &str,
484        query: &Q,
485        body: &B,
486        scope: AuthenticationScope,
487    ) -> Result<ApiResponse<T>>
488    where
489        T: DeserializeOwned,
490        Q: Serialize + ?Sized,
491        B: Serialize + ?Sized,
492    {
493        self.execute_with(method, path, scope, |request| {
494            request.query(query).json(body)
495        })
496        .await
497    }
498
499    pub(crate) async fn execute_multipart<T>(
500        &self,
501        method: Method,
502        path: &str,
503        field: &'static str,
504        filename: &str,
505        bytes: &[u8],
506        scope: AuthenticationScope,
507    ) -> Result<ApiResponse<T>>
508    where
509        T: DeserializeOwned,
510    {
511        self.execute_with(method, path, scope, |request| {
512            let part = reqwest::multipart::Part::bytes(bytes.to_vec())
513                .file_name(filename.to_owned())
514                .mime_str("application/octet-stream")
515                .expect("static MIME type is valid");
516            request.multipart(reqwest::multipart::Form::new().part(field, part))
517        })
518        .await
519    }
520
521    pub(crate) async fn execute_bytes(
522        &self,
523        method: Method,
524        path: &str,
525        scope: AuthenticationScope,
526    ) -> Result<(header::HeaderMap, Vec<u8>)> {
527        let url = self.endpoint(path)?;
528        let response = self
529            .execute_raw_once(method.clone(), url.clone(), scope, false, |request| request)
530            .await?;
531        let headers = response.headers().clone();
532        let bytes = transport::read_response_body(
533            response,
534            &method,
535            &url,
536            self.inner.download_body_limit,
537            ErrorUrlPolicy::Preserve,
538        )
539        .await?;
540        Ok((headers, bytes))
541    }
542
543    async fn execute_with<T, F>(
544        &self,
545        method: Method,
546        path: &str,
547        scope: AuthenticationScope,
548        configure: F,
549    ) -> Result<ApiResponse<T>>
550    where
551        T: DeserializeOwned,
552        F: Fn(RequestBuilder) -> RequestBuilder,
553    {
554        let (method, url, bytes) = self
555            .execute_configured(method, path, scope, configure)
556            .await?;
557        let envelope: ApiResponse<T> =
558            serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
559                method: method.clone(),
560                url: Box::new(url.clone()),
561                source,
562            })?;
563        if !envelope.success {
564            return Err(Error::Api {
565                method,
566                url: Box::new(url),
567                message: if envelope.msg.is_empty() {
568                    "unknown panel error".to_owned()
569                } else {
570                    envelope.msg
571                },
572            });
573        }
574        Ok(envelope)
575    }
576
577    async fn execute_response_with<T, F>(
578        &self,
579        method: Method,
580        path: &str,
581        scope: AuthenticationScope,
582        configure: F,
583    ) -> Result<T>
584    where
585        T: DeserializeOwned,
586        F: Fn(RequestBuilder) -> RequestBuilder,
587    {
588        let (method, url, bytes) = self
589            .execute_configured(method, path, scope, configure)
590            .await?;
591        serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
592            method,
593            url: Box::new(url),
594            source,
595        })
596    }
597
598    async fn execute_configured<F>(
599        &self,
600        method: Method,
601        path: &str,
602        scope: AuthenticationScope,
603        configure: F,
604    ) -> Result<(Method, Url, Vec<u8>)>
605    where
606        F: Fn(RequestBuilder) -> RequestBuilder,
607    {
608        let url = self.endpoint(path)?;
609        let is_unsafe = !matches!(
610            method,
611            Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE
612        );
613        let unsafe_session_request = is_unsafe
614            && match scope {
615                AuthenticationScope::Session => true,
616                AuthenticationScope::PanelApi => self.inner.bearer_token.is_none(),
617            };
618        let first = self
619            .execute_raw_once(
620                method.clone(),
621                url.clone(),
622                scope,
623                unsafe_session_request,
624                &configure,
625            )
626            .await;
627        let response = if unsafe_session_request && matches!(first, Err(Error::Forbidden { .. })) {
628            self.clear_csrf_token().await;
629            self.execute_raw_once(
630                method.clone(),
631                url.clone(),
632                scope,
633                unsafe_session_request,
634                &configure,
635            )
636            .await?
637        } else {
638            first?
639        };
640        let bytes = transport::read_response_body(
641            response,
642            &method,
643            &url,
644            self.inner.response_body_limit,
645            ErrorUrlPolicy::Preserve,
646        )
647        .await?;
648        Ok((method, url, bytes))
649    }
650
651    async fn execute_raw_once<F>(
652        &self,
653        method: Method,
654        url: Url,
655        scope: AuthenticationScope,
656        csrf_required: bool,
657        configure: F,
658    ) -> Result<reqwest::Response>
659    where
660        F: FnOnce(RequestBuilder) -> RequestBuilder,
661    {
662        let mut request = self.inner.http.request(method.clone(), url.clone());
663        if matches!(scope, AuthenticationScope::PanelApi) {
664            request = self.authenticate(request);
665        }
666        if csrf_required {
667            let token = self.ensure_csrf_token().await?;
668            request = request.header("x-csrf-token", token.expose_secret());
669        }
670        request = configure(request);
671
672        let response =
673            transport::send_request(request, &method, &url, ErrorUrlPolicy::Preserve).await?;
674        let status = response.status();
675        match status {
676            StatusCode::UNAUTHORIZED => Err(Error::Unauthorized {
677                method,
678                url: Box::new(url),
679            }),
680            StatusCode::FORBIDDEN => Err(Error::Forbidden {
681                method,
682                url: Box::new(url),
683            }),
684            _ if !status.is_success() => Err(Error::HttpStatus {
685                method,
686                url: Box::new(url),
687                status,
688            }),
689            _ => Ok(response),
690        }
691    }
692
693    fn authenticate(&self, request: RequestBuilder) -> RequestBuilder {
694        match self.inner.bearer_token.as_ref() {
695            Some(token) => request.bearer_auth(token.expose_secret()),
696            None => request,
697        }
698    }
699
700    pub(crate) async fn ensure_csrf_token(&self) -> Result<SecretString> {
701        let mut current = self.inner.csrf_token.lock().await;
702        if let Some(token) = current.as_ref() {
703            return Ok(token.clone());
704        }
705
706        let method = Method::GET;
707        let url = self.endpoint("csrf-token")?;
708        let response = transport::send_request(
709            self.inner.http.get(url.clone()),
710            &method,
711            &url,
712            ErrorUrlPolicy::Preserve,
713        )
714        .await?;
715        let status = response.status();
716        if !status.is_success() {
717            return Err(Error::HttpStatus {
718                method,
719                url: Box::new(url),
720                status,
721            });
722        }
723        let bytes = transport::read_response_body(
724            response,
725            &method,
726            &url,
727            self.inner.response_body_limit,
728            ErrorUrlPolicy::Preserve,
729        )
730        .await?;
731        let envelope: ApiResponse<String> =
732            serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
733                method: method.clone(),
734                url: Box::new(url.clone()),
735                source,
736            })?;
737        if !envelope.success {
738            return Err(Error::Api {
739                method,
740                url: Box::new(url),
741                message: envelope.msg,
742            });
743        }
744        let token = SecretString::from(envelope.obj.ok_or_else(|| Error::MissingObject {
745            method,
746            url: Box::new(url),
747        })?);
748        *current = Some(token.clone());
749        Ok(token)
750    }
751
752    pub(crate) async fn clear_csrf_token(&self) {
753        *self.inner.csrf_token.lock().await = None;
754    }
755}
756
757fn normalize_base_url(value: &str) -> Result<Url> {
758    let mut url = Url::parse(value)
759        .map_err(|error| Error::Configuration(format!("invalid panel URL: {error}")))?;
760    if !matches!(url.scheme(), "http" | "https") {
761        return Err(Error::Configuration(
762            "panel URL scheme must be http or https".to_owned(),
763        ));
764    }
765    if url.host_str().is_none() {
766        return Err(Error::Configuration(
767            "panel URL must contain a host".to_owned(),
768        ));
769    }
770    if !url.username().is_empty() || url.password().is_some() {
771        return Err(Error::Configuration(
772            "panel URL must not contain credentials".to_owned(),
773        ));
774    }
775    url.set_query(None);
776    url.set_fragment(None);
777    if !url.path().ends_with('/') {
778        url.set_path(&format!("{}/", url.path()));
779    }
780    Ok(url)
781}
782
783fn origin(url: &Url) -> String {
784    let mut value = url.clone();
785    value.set_path("");
786    value.set_query(None);
787    value.set_fragment(None);
788    value.to_string().trim_end_matches('/').to_owned()
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
795
796    #[test]
797    fn normalizes_base_url_without_losing_path() {
798        let client = Client::new("https://panel.example.com/secret?ignored=1#fragment").unwrap();
799        assert_eq!(
800            client.base_url().as_str(),
801            "https://panel.example.com/secret/"
802        );
803        assert_eq!(
804            client.endpoint("panel/api/server/status").unwrap().as_str(),
805            "https://panel.example.com/secret/panel/api/server/status"
806        );
807    }
808
809    #[test]
810    fn rejects_non_http_urls() {
811        assert!(matches!(
812            Client::new("ftp://panel.example.com").unwrap_err(),
813            Error::Configuration(_)
814        ));
815    }
816
817    #[test]
818    fn rejects_url_credentials_and_empty_bearer_tokens() {
819        assert!(matches!(
820            Client::new("https://admin:password@panel.example.com").unwrap_err(),
821            Error::Configuration(_)
822        ));
823        assert!(matches!(
824            Client::builder("https://panel.example.com")
825                .unwrap()
826                .bearer_token("  ")
827                .build()
828                .unwrap_err(),
829            Error::Configuration(_)
830        ));
831    }
832
833    #[test]
834    fn debug_output_never_contains_bearer_token_or_secret_base_path() {
835        let builder = Client::builder("https://panel.example.com/private-panel-path")
836            .unwrap()
837            .bearer_token("super-secret-token");
838        let builder_debug = format!("{builder:?}");
839        assert!(!builder_debug.contains("super-secret-token"));
840        assert!(!builder_debug.contains("private-panel-path"));
841        assert!(builder_debug.contains("panel.example.com"));
842        assert!(builder_debug.contains("BearerToken"));
843
844        let client = builder.build().unwrap();
845        let debug = format!("{client:?}");
846        assert!(!debug.contains("super-secret-token"));
847        assert!(!debug.contains("private-panel-path"));
848        assert!(debug.contains("panel.example.com"));
849        assert!(debug.contains("BearerToken"));
850    }
851
852    #[tokio::test]
853    async fn bearer_api_request_skips_csrf() {
854        let server = MockServer::start().await;
855        Mock::given(matchers::method("GET"))
856            .and(matchers::path("/panel/api/example"))
857            .and(matchers::header("authorization", "Bearer api-secret"))
858            .and(matchers::header("x-requested-with", "XMLHttpRequest"))
859            .respond_with(
860                ResponseTemplate::new(200)
861                    .set_body_json(serde_json::json!({ "success": true, "obj": 42 })),
862            )
863            .expect(1)
864            .mount(&server)
865            .await;
866        let client = Client::builder(server.uri())
867            .unwrap()
868            .bearer_token("api-secret")
869            .build()
870            .unwrap();
871
872        let response = client
873            .execute::<u32, ()>(
874                Method::GET,
875                "panel/api/example",
876                None,
877                AuthenticationScope::PanelApi,
878            )
879            .await
880            .unwrap();
881
882        assert_eq!(response.obj, Some(42));
883    }
884}