diff --git a/Dockerfile b/Dockerfile index 72ac4d5..a1e09b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY web . RUN yarn build # build app -FROM golang:1.17.6-alpine AS app-builder +FROM golang:1.17.9-alpine AS app-builder ARG VERSION=dev ARG REVISION=dev diff --git a/internal/action/qbittorrent.go b/internal/action/qbittorrent.go index 8e3d3d5..36edbb6 100644 --- a/internal/action/qbittorrent.go +++ b/internal/action/qbittorrent.go @@ -111,6 +111,13 @@ func (s *service) qbittorrentCheckRulesCanDownload(action domain.Action) (bool, TLSSkipVerify: client.TLSSkipVerify, } + // only set basic auth if enabled + if client.Settings.Basic.Auth { + qbtSettings.BasicAuth = client.Settings.Basic.Auth + qbtSettings.Basic.Username = client.Settings.Basic.Username + qbtSettings.Basic.Password = client.Settings.Basic.Password + } + qbt := qbittorrent.NewClient(qbtSettings) qbt.Name = client.Name // save cookies? diff --git a/internal/download_client/connection.go b/internal/download_client/connection.go index 07bb265..befd9e5 100644 --- a/internal/download_client/connection.go +++ b/internal/download_client/connection.go @@ -50,6 +50,13 @@ func (s *service) testQbittorrentConnection(client domain.DownloadClient) error TLSSkipVerify: client.TLSSkipVerify, } + // only set basic auth if enabled + if client.Settings.Basic.Auth { + qbtSettings.BasicAuth = client.Settings.Basic.Auth + qbtSettings.Basic.Username = client.Settings.Basic.Username + qbtSettings.Basic.Password = client.Settings.Basic.Password + } + qbt := qbittorrent.NewClient(qbtSettings) err := qbt.Login() if err != nil { diff --git a/pkg/qbittorrent/client.go b/pkg/qbittorrent/client.go index 7bfb646..8a1b008 100644 --- a/pkg/qbittorrent/client.go +++ b/pkg/qbittorrent/client.go @@ -10,6 +10,7 @@ import ( "net/http/cookiejar" "net/url" "os" + "path" "strings" "time" @@ -40,6 +41,13 @@ type Settings struct { TLS bool TLSSkipVerify bool protocol string + BasicAuth bool + Basic Basic +} + +type Basic struct { + Username string + Password string } func NewClient(s Settings) *Client { @@ -77,17 +85,21 @@ func NewClient(s Settings) *Client { } func (c *Client) get(endpoint string, opts map[string]string) (*http.Response, error) { - reqUrl := fmt.Sprintf("%v://%v:%v/api/v2/%v", c.settings.protocol, c.settings.Hostname, c.settings.Port, endpoint) - var err error var resp *http.Response + reqUrl := buildUrl(c.settings, endpoint) + req, err := http.NewRequest("GET", reqUrl, nil) if err != nil { log.Error().Err(err).Msgf("GET: error %v", reqUrl) return nil, err } + if c.settings.BasicAuth { + req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password) + } + // try request and if fail run 3 retries for i, backoff := range backoffSchedule { resp, err = c.http.Do(req) @@ -122,13 +134,18 @@ func (c *Client) post(endpoint string, opts map[string]string) (*http.Response, var err error var resp *http.Response - reqUrl := fmt.Sprintf("%v://%v:%v/api/v2/%v", c.settings.protocol, c.settings.Hostname, c.settings.Port, endpoint) + reqUrl := buildUrl(c.settings, endpoint) + req, err := http.NewRequest("POST", reqUrl, strings.NewReader(form.Encode())) if err != nil { log.Error().Err(err).Msgf("POST: req %v", reqUrl) return nil, err } + if c.settings.BasicAuth { + req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password) + } + // add the content-type so qbittorrent knows what to expect req.Header.Add("Content-Type", "application/x-www-form-urlencoded") @@ -154,6 +171,42 @@ func (c *Client) post(endpoint string, opts map[string]string) (*http.Response, return resp, nil } +func (c *Client) postBasic(endpoint string, opts map[string]string) (*http.Response, error) { + // add optional parameters that the user wants + form := url.Values{} + if opts != nil { + for k, v := range opts { + form.Add(k, v) + } + } + + var err error + var resp *http.Response + + reqUrl := buildUrl(c.settings, endpoint) + + req, err := http.NewRequest("POST", reqUrl, strings.NewReader(form.Encode())) + if err != nil { + log.Error().Err(err).Msgf("POST: req %v", reqUrl) + return nil, err + } + + if c.settings.BasicAuth { + req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password) + } + + // add the content-type so qbittorrent knows what to expect + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err = c.http.Do(req) + if err != nil { + log.Error().Err(err).Msgf("POST: do %v", reqUrl) + return nil, err + } + + return resp, nil +} + func (c *Client) postFile(endpoint string, fileName string, opts map[string]string) (*http.Response, error) { var err error var resp *http.Response @@ -206,13 +259,17 @@ func (c *Client) postFile(endpoint string, fileName string, opts map[string]stri // Close multipart writer multiPartWriter.Close() - reqUrl := fmt.Sprintf("%v://%v:%v/api/v2/%v", c.settings.protocol, c.settings.Hostname, c.settings.Port, endpoint) + reqUrl := buildUrl(c.settings, endpoint) req, err := http.NewRequest("POST", reqUrl, &requestBody) if err != nil { log.Error().Err(err).Msgf("POST file: could not create request object %v", fileName) return nil, err } + if c.settings.BasicAuth { + req.SetBasicAuth(c.settings.Basic.Username, c.settings.Basic.Password) + } + // Set correct content type req.Header.Set("Content-Type", multiPartWriter.FormDataContentType()) @@ -242,3 +299,50 @@ func (c *Client) setCookies(cookies []*http.Cookie) { cookieURL, _ := url.Parse(fmt.Sprintf("%v://%v:%v", c.settings.protocol, c.settings.Hostname, c.settings.Port)) c.http.Jar.SetCookies(cookieURL, cookies) } + +func buildUrl(settings Settings, endpoint string) string { + // parse url + u, _ := url.Parse(settings.Hostname) + + // reset Opaque + u.Opaque = "" + + // set scheme + scheme := "http" + if u.Scheme == "http" || u.Scheme == "https" { + if settings.TLS { + scheme = "https" + } + u.Scheme = scheme + } else { + if settings.TLS { + scheme = "https" + } + u.Scheme = scheme + } + + // if host is empty lets use one from settings + if u.Host == "" { + u.Host = settings.Hostname + } + + // reset Path + if u.Host == u.Path { + u.Path = "" + } + + // handle ports + if settings.Port > 0 { + if settings.Port == 80 || settings.Port == 443 { + // skip for regular http and https + } else { + u.Host = fmt.Sprintf("%v:%v", u.Host, settings.Port) + } + } + + // join path + u.Path = path.Join(u.Path, "/api/v2/", endpoint) + + // make into new string and return + return u.String() +} diff --git a/pkg/qbittorrent/client_test.go b/pkg/qbittorrent/client_test.go new file mode 100644 index 0000000..3bdc464 --- /dev/null +++ b/pkg/qbittorrent/client_test.go @@ -0,0 +1,135 @@ +package qbittorrent + +import "testing" + +func Test_buildUrl(t *testing.T) { + type args struct { + settings Settings + endpoint string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "build_url_1", + args: args{ + settings: Settings{ + Hostname: "https://qbit.domain.ltd", + Port: 0, + Username: "", + Password: "", + TLS: true, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "auth/login", + }, + want: "https://qbit.domain.ltd/api/v2/auth/login", + }, + { + name: "build_url_2", + args: args{ + settings: Settings{ + Hostname: "http://qbit.domain.ltd", + Port: 0, + Username: "", + Password: "", + TLS: false, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "http://qbit.domain.ltd/api/v2/auth/login", + }, + { + name: "build_url_3", + args: args{ + settings: Settings{ + Hostname: "https://qbit.domain.ltd:8080", + Port: 0, + Username: "", + Password: "", + TLS: true, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "https://qbit.domain.ltd:8080/api/v2/auth/login", + }, + { + name: "build_url_4", + args: args{ + settings: Settings{ + Hostname: "qbit.domain.ltd:8080", + Port: 0, + Username: "", + Password: "", + TLS: false, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "http://qbit.domain.ltd:8080/api/v2/auth/login", + }, + { + name: "build_url_5", + args: args{ + settings: Settings{ + Hostname: "qbit.domain.ltd", + Port: 8080, + Username: "", + Password: "", + TLS: false, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "http://qbit.domain.ltd:8080/api/v2/auth/login", + }, + { + name: "build_url_6", + args: args{ + settings: Settings{ + Hostname: "qbit.domain.ltd", + Port: 443, + Username: "", + Password: "", + TLS: true, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "https://qbit.domain.ltd/api/v2/auth/login", + }, + { + name: "build_url_6", + args: args{ + settings: Settings{ + Hostname: "qbit.domain.ltd", + Port: 10200, + Username: "", + Password: "", + TLS: false, + TLSSkipVerify: false, + protocol: "", + }, + endpoint: "/auth/login", + }, + want: "http://qbit.domain.ltd:10200/api/v2/auth/login", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildUrl(tt.args.settings, tt.args.endpoint); got != tt.want { + t.Errorf("buildUrl() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/qbittorrent/methods.go b/pkg/qbittorrent/methods.go index 9be388f..6b81b10 100644 --- a/pkg/qbittorrent/methods.go +++ b/pkg/qbittorrent/methods.go @@ -18,7 +18,7 @@ func (c *Client) Login() error { credentials["username"] = c.settings.Username credentials["password"] = c.settings.Password - resp, err := c.post("auth/login", credentials) + resp, err := c.postBasic("auth/login", credentials) if err != nil { log.Error().Err(err).Msg("login error") return err diff --git a/web/src/forms/settings/DownloadClientForms.tsx b/web/src/forms/settings/DownloadClientForms.tsx index 265ab0f..8478e5e 100644 --- a/web/src/forms/settings/DownloadClientForms.tsx +++ b/web/src/forms/settings/DownloadClientForms.tsx @@ -97,6 +97,46 @@ function FormFieldsArr() { ); } +function FormFieldsQbit() { + const { + values: { port, tls, settings } + } = useFormikContext(); + + return ( + + + + {port > 0 && ( + + )} + +
+ + + {tls && ( + + + + )} +
+ + + + +
+ +
+ + {settings.basic?.auth === true && ( + + + + + )} +
+ ); +} + export interface componentMapType { [key: string]: React.ReactElement; } @@ -104,7 +144,7 @@ export interface componentMapType { export const componentMap: componentMapType = { DELUGE_V1: , DELUGE_V2: , - QBITTORRENT: , + QBITTORRENT: , RADARR: , SONARR: , LIDARR: , @@ -356,7 +396,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) { type: "QBITTORRENT", enabled: true, host: "", - port: 10000, + port: 0, tls: false, tls_skip_verify: false, username: "",