From 48db007031d5bf0fd2ce5768536cfedb95f9f84d Mon Sep 17 00:00:00 2001 From: Nguyen Marc Date: Tue, 20 Aug 2024 02:27:41 +0200 Subject: [PATCH] fix(credentials): use YAML and allow token-based auth --- .gitignore | 2 +- README.md | 47 +++++++---- cmd/download/download_command.go | 31 +++++--- cmd/logintest/logintest_command.go | 62 ++------------- config.yaml | 18 ++++- deployments/docker/config.yaml | 2 +- deployments/docker/credentials.txt | 1 - deployments/docker/credentials.yaml | 5 ++ deployments/docker/docker-compose.yaml | 3 +- deployments/kubernetes/configmap.yaml | 2 +- deployments/kubernetes/secret.yaml | 6 +- hls/hls_downloader.go | 24 +++--- hls/hls_downloader_test.go | 16 ++-- utils/secret/secret_reader.go | 71 +++++++++-------- withny/api/client.go | 104 +++++++++++++++++-------- withny/api/client_integration_test.go | 19 ++--- withny/livestream.go | 2 +- 17 files changed, 220 insertions(+), 195 deletions(-) delete mode 100644 deployments/docker/credentials.txt create mode 100644 deployments/docker/credentials.yaml diff --git a/.gitignore b/.gitignore index 690d274..96b6f61 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ target/ out/ *.local cookies.txt -credentials*.txt +credentials*.yaml .vscode/ diff --git a/README.md b/README.md index 3878168..882240e 100644 --- a/README.md +++ b/README.md @@ -164,19 +164,21 @@ Available format options: Streaming: - --credentials-file value Path to a credentials file. Format: 'usernameb64:passwordb64' with usernameb64 and passwordb64 encoded in base64 (like basic authentication). - --credentials.email value Email for withny login - --credentials.password value Password for withny login - --credentials.username value Username for withny login - --quality.audio-only Only download audio streams. (default: false) - --quality.max-bandwidth value Maximum inclusive bandwidth of the stream. (default: 0) - --quality.max-framerate value Maximum inclusive framerate of the stream. (default: 0) - --quality.max-height value Maximum inclusive height of the stream. (default: 0) - --quality.max-width value Maximum inclusive width of the stream. (default: 0) - --quality.min-bandwidth value Minimum inclusive bandwidth of the stream. (default: 0) - --quality.min-framerate value Minimum inclusive framerate of the stream. (default: 0) - --quality.min-height value Minimum inclusive height of the stream. (default: 0) - --quality.min-width value Minimum inclusive width of the stream. (default: 0) + --credentials-file value Path to a credentials file. Format is YAML and must contain 'username' and 'password' or 'access-token' and 'refresh-token'. + --credentials.access-token value Access token for withny login. You should also provide a refresh token. + --credentials.password value Password for withny login + --credentials.refresh-token value Refresh token for withny login. + --credentials.username value, --credentials.email value Username/email for withny login + --quality.audio-only Only download audio streams. (default: false) + --quality.max-bandwidth value Maximum inclusive bandwidth of the stream. (default: 0) + --quality.max-framerate value Maximum inclusive framerate of the stream. (default: 0) + --quality.max-height value Maximum inclusive height of the stream. (default: 0) + --quality.max-width value Maximum inclusive width of the stream. (default: 0) + --quality.min-bandwidth value Minimum inclusive bandwidth of the stream. (default: 0) + --quality.min-framerate value Minimum inclusive framerate of the stream. (default: 0) + --quality.min-height value Minimum inclusive height of the stream. (default: 0) + --quality.min-width value Minimum inclusive width of the stream. (default: 0) + ``` ### Download multiple live withny streams @@ -211,11 +213,22 @@ To configure the watcher, you must provide a configuration file. The configurati ```yaml --- -## [REQUIRED] Path to the file containing a "usernameB64:passwordB64" pair. +--- +## [REQUIRED] Path to the file containing the credentials. (default: '') +## +## Example of content: +## +## ```yaml +## # User/Password-based +## username: admin +## password: password +## +## # Token-based +## token: "ey..." +## refreshToken: "abc..." +## ``` ## -## usernameB64 is the base64 encoded username. -## passwordB64 is the base64 encoded password. -credentialsFile: '' +credentialsFile: 'credentials.yaml' defaultParams: ## Quality constraint to select the stream to download. diff --git a/cmd/download/download_command.go b/cmd/download/download_command.go index 362acd0..9ae598b 100644 --- a/cmd/download/download_command.go +++ b/cmd/download/download_command.go @@ -26,7 +26,7 @@ var ( loop bool credentialFile string - credentialsStatic secret.UserPasswordStatic + credentialsStatic secret.Static ) // Command is the command for downloading a live withny stream. @@ -181,21 +181,16 @@ Available format options: }, &cli.PathFlag{ Name: "credentials-file", - Usage: "Path to a credentials file. Format: 'usernameb64:passwordb64' with usernameb64 and passwordb64 encoded in base64 (like basic authentication).", + Usage: "Path to a credentials file. Format is YAML and must contain 'username' and 'password' or 'access-token' and 'refresh-token'.", Category: "Streaming:", Destination: &credentialFile, }, &cli.StringFlag{ Name: "credentials.username", - Usage: "Username for withny login", + Usage: "Username/email for withny login", Category: "Streaming:", - Destination: &credentialsStatic.Email, - }, - &cli.StringFlag{ - Name: "credentials.email", - Usage: "Email for withny login", - Category: "Streaming:", - Destination: &credentialsStatic.Email, + Aliases: []string{"credentials.email"}, + Destination: &credentialsStatic.Username, }, &cli.StringFlag{ Name: "credentials.password", @@ -203,6 +198,18 @@ Available format options: Category: "Streaming:", Destination: &credentialsStatic.Password, }, + &cli.StringFlag{ + Name: "credentials.access-token", + Usage: "Access token for withny login. You should also provide a refresh token.", + Category: "Streaming:", + Destination: &credentialsStatic.Token, + }, + &cli.StringFlag{ + Name: "credentials.refresh-token", + Usage: "Refresh token for withny login.", + Category: "Streaming:", + Destination: &credentialsStatic.RefreshToken, + }, &cli.BoolFlag{ Name: "no-wait", Value: false, @@ -259,8 +266,8 @@ Available format options: } hclient := &http.Client{Jar: jar, Timeout: time.Minute} - var reader api.UserPasswordReader - if credentialsStatic.Email != "" && credentialsStatic.Password != "" { + var reader api.CredentialsReader + if credentialsStatic.Username != "" || credentialsStatic.Token != "" { reader = &credentialsStatic } if credentialFile != "" { diff --git a/cmd/logintest/logintest_command.go b/cmd/logintest/logintest_command.go index fbb451d..af35f04 100644 --- a/cmd/logintest/logintest_command.go +++ b/cmd/logintest/logintest_command.go @@ -3,13 +3,10 @@ package logintest import ( "context" - "encoding/base64" "net/http" "net/http/cookiejar" "os" "os/signal" - "path/filepath" - "strconv" "syscall" "time" @@ -23,7 +20,7 @@ import ( // Command is the command for logging in and testing the login. var Command = &cli.Command{ Name: "login-test", - Usage: "Test the login and encode to base64.", + Usage: "Test the login.", Action: func(cCtx *cli.Context) error { ctx, cancel := context.WithCancel(cCtx.Context) @@ -54,9 +51,11 @@ var Command = &cli.Command{ return err } - client := api.NewClient(hclient, &secret.UserPasswordStatic{ - Email: username, - Password: password, + client := api.NewClient(hclient, &secret.Static{ + SavedCredentials: api.SavedCredentials{ + Username: username, + Password: password, + }, }) if err := client.Login(ctx); err != nil { log.Err(err). @@ -65,55 +64,6 @@ var Command = &cli.Command{ } log.Info().Msg("Login successful") - - usernameB64 := base64.StdEncoding.EncodeToString([]byte(username)) - passwordB64 := base64.StdEncoding.EncodeToString([]byte(password)) - - f, err := createFileWithRename("credentials.txt") - if err != nil { - log.Err(err).Msg("failed to create credentials.txt") - return err - } - defer f.Close() - if err := f.Chmod(0600); err != nil { - log.Err(err).Msg("failed to chmod credentials.txt") - return err - } - if _, err := f.WriteString(usernameB64 + ":" + passwordB64); err != nil { - log.Err(err).Msg("failed to write credentials.txt") - return err - } - - log.Info().Msg("Credentials written to credentials.txt") return nil }, } - -// Create a file, renaming it if there's a conflict -func createFileWithRename(path string) (*os.File, error) { - base := filepath.Base(path) - ext := filepath.Ext(path) - dir := filepath.Dir(path) - name := base[:len(base)-len(ext)] - - // Attempt to create the file, renaming it if necessary - for i := 0; ; i++ { - newPath := filepath.Join(dir, name+ext) - if i > 0 { - newPath = filepath.Join(dir, name+"_"+strconv.Itoa(i)+ext) - } - - // Attempt to open the file for creation - file, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) - if err == nil { - // Successfully created the file - return file, nil - } else if os.IsExist(err) { - // File already exists, continue renaming - continue - } else { - // Some other error occurred - return nil, err - } - } -} diff --git a/config.yaml b/config.yaml index 5858608..ac7ba46 100644 --- a/config.yaml +++ b/config.yaml @@ -1,9 +1,19 @@ --- -## [REQUIRED] Path to the file containing a "usernameB64:passwordB64" pair. +## [REQUIRED] Path to the file containing the credentials. (default: '') ## -## usernameB64 is the base64 encoded username. -## passwordB64 is the base64 encoded password. -credentialsFile: '' +## Example of content: +## +## ```yaml +## # User/Password-based +## username: admin +## password: password +## +## # Token-based +## token: "ey..." +## refreshToken: "abc..." +## ``` +## +credentialsFile: 'credentials.yaml' defaultParams: ## Quality constraint to select the stream to download. diff --git a/deployments/docker/config.yaml b/deployments/docker/config.yaml index ec2051f..48cd25c 100644 --- a/deployments/docker/config.yaml +++ b/deployments/docker/config.yaml @@ -1,4 +1,4 @@ -credentialsFile: '/secrets/credentials.txt' +credentialsFile: '/secrets/credentials.yaml' notifier: gotify: diff --git a/deployments/docker/credentials.txt b/deployments/docker/credentials.txt deleted file mode 100644 index 66c86b3..0000000 --- a/deployments/docker/credentials.txt +++ /dev/null @@ -1 +0,0 @@ -ZXhhbXBsZQo=:cGFzc3dvcmQK diff --git a/deployments/docker/credentials.yaml b/deployments/docker/credentials.yaml new file mode 100644 index 0000000..c02a5ef --- /dev/null +++ b/deployments/docker/credentials.yaml @@ -0,0 +1,5 @@ +username: admin +password: password +# Or, use token-based authentication: +# token: "ey..." +# refreshToken: "abc..." diff --git a/deployments/docker/docker-compose.yaml b/deployments/docker/docker-compose.yaml index 56b1b39..c383834 100644 --- a/deployments/docker/docker-compose.yaml +++ b/deployments/docker/docker-compose.yaml @@ -7,8 +7,7 @@ services: - 3000:3000 user: '${UID}:${GID}' volumes: - # credentials.txt contains "example" as username and "password" as password, in base64. - - ./credentials.txt:/secrets/credentials.txt:ro + - ./credentials.yaml:/secrets/credentials.yaml:ro - ./config.yaml:/config/config.yaml:ro - ./output:/output mem_reservation: 256m diff --git a/deployments/kubernetes/configmap.yaml b/deployments/kubernetes/configmap.yaml index 3a3be6e..dff0a7a 100644 --- a/deployments/kubernetes/configmap.yaml +++ b/deployments/kubernetes/configmap.yaml @@ -5,7 +5,7 @@ metadata: data: config.yaml: | - credentialsFile: '/secrets/credentials.txt' + credentialsFile: '/secrets/credentials.yaml' notifier: gotify: diff --git a/deployments/kubernetes/secret.yaml b/deployments/kubernetes/secret.yaml index d34da21..6268033 100644 --- a/deployments/kubernetes/secret.yaml +++ b/deployments/kubernetes/secret.yaml @@ -6,4 +6,8 @@ metadata: name: credentials-secret type: Opaque stringData: - credentials.txt: ZXhhbXBsZQo=:cGFzc3dvcmQK + credentials.yaml: | + username: admin + password: admin + # token: "ey..." + # refreshToken: "abc..." diff --git a/hls/hls_downloader.go b/hls/hls_downloader.go index 5e08460..47aece3 100644 --- a/hls/hls_downloader.go +++ b/hls/hls_downloader.go @@ -55,12 +55,12 @@ func NewDownloader( } // GetFragmentURLs fetches the fragment URLs from the HLS manifest. -func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) { +func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]Fragment, error) { ctx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() req, err := hls.NewAuthRequestWithContext(ctx, "GET", hls.url, nil) if err != nil { - return []fragment{}, err + return []Fragment{}, err } req.Header.Set( "Accept", @@ -71,7 +71,7 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) resp, err := hls.Client.Do(req) if err != nil { - return []fragment{}, err + return []Fragment{}, err } defer resp.Body.Close() @@ -88,7 +88,7 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) Str("method", "GET"). Msg("http error") metrics.Downloads.Errors.Add(ctx, 1) - return []fragment{}, ErrHLSForbidden + return []Fragment{}, ErrHLSForbidden case 404: hls.log.Warn(). Str("url", url.String()). @@ -96,7 +96,7 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) Str("response.body", string(body)). Str("method", "GET"). Msg("stream not ready") - return []fragment{}, nil + return []Fragment{}, nil default: hls.log.Error(). Str("url", url.String()). @@ -105,16 +105,16 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) Str("method", "GET"). Msg("http error") metrics.Downloads.Errors.Add(ctx, 1) - return []fragment{}, errors.New("http error") + return []Fragment{}, errors.New("http error") } } scanner := bufio.NewScanner(resp.Body) - fragments := make([]fragment, 0, 10) + fragments := make([]Fragment, 0, 10) exists := make(map[string]bool) // Avoid duplicates // URLs are supposedly sorted. - var currentFragment fragment + var currentFragment Fragment for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -139,7 +139,7 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) continue } currentFragment.URL = line - fragments = append(fragments, fragment{ + fragments = append(fragments, Fragment{ URL: currentFragment.URL, Time: currentFragment.Time, }) @@ -157,7 +157,7 @@ func (hls *Downloader) GetFragmentURLs(ctx context.Context) ([]fragment, error) // fillQueue continuously fetches fragments url until stream end func (hls *Downloader) fillQueue( ctx context.Context, - fragChan chan<- fragment, + fragChan chan<- Fragment, ) (err error) { hls.log.Debug().Msg("started to fill queue") ctx, span := otel.Tracer(tracerName).Start(ctx, "hls.fillQueue") @@ -300,7 +300,7 @@ func (hls *Downloader) download( return err } -type fragment struct { +type Fragment struct { URL string Time time.Time } @@ -326,7 +326,7 @@ func (hls *Downloader) Read( errChan := make(chan error) // Blocking channel is used to wait for fillQueue to finish. defer close(errChan) - fragChan := make(chan fragment, 10) + fragChan := make(chan Fragment, 10) defer close(fragChan) go func() { diff --git a/hls/hls_downloader_test.go b/hls/hls_downloader_test.go index fc2b281..fd33e85 100644 --- a/hls/hls_downloader_test.go +++ b/hls/hls_downloader_test.go @@ -26,7 +26,7 @@ func timeMustParse(value string) time.Time { return t } -var expectedFragments = []fragment{ +var expectedFragments = []Fragment{ { Time: timeMustParse("2024-08-19T23:23:47.099Z"), URL: "https://video-edge-d9161e.cdg02.hls.live-video.net/v1/segment/CqMGzqY93tj7dQxK2OfyYyv8G8p8JRTMEyKaPmwSpn_VM-NWly_6to7NFIcNktxPUJef-II4CkbfdE4CWTXSBqsux2AVRw2gQOS_bKzkVTJH3Y50z25iOeBwiOJTH5Quw5O_t8TrrHfOmHda_ahKnDgbMY0586bDEe5_sJX6an9iocxyyjdFDLHXIO3ZD-zHslTaYKdsPILxjZL40YR9DmRyie-Xv3UAmcmkMD-cbr0qKLUGhsDqvzzk0gc4IKygjy7IT68PQz7AO9YaS1K8KD9Zd0lSu03H1g9iF6icvd0SgYwahtBrtE9w9_HHfXJGLQIl__k8RHf4eAUnZ_1qe4iq5fs7wSLc1eFaOW88K93806cf74sHhA4OtocMy5e9DCPUJU2wfbsym3GIHG2MYZlaFuLg4IzE0L8xT3tf4AomGmOVlvTl-sjJzUJ-Yr59GDmoMMdfsAGxLB2nYbHLh6bKDeIi-twDy1OZ4VoWqKv0uXJuZvk9gk593XfJKFvl5Yzi_TAAPJd3rnDMn9E90JVs2sbvcHDDZqDSRHcQtvaFbsT-5jhPXtgVLChDDFZbVyf9IPIhGY7fY7zmYETOSruxqgVcPzp_2xvSZUORv6j32OqeTEqWoUDfIGgNyT1N5rWeO2rdIGTIrXBHnpPnlJuJynKUI23khs5wk5wuD3gnn3Vnx9jeIT7giH9zDw-QboRvq972TL3RhMLk4kUDB36GwqXz76BoibM9V4ra-dLFN0m4_LiFpBMpUHBbWDNZy_jxtkyyx3KZIYVwopbGwcZpxbwCgPka7vBflXnFEPRdJ6ERr_Uhp4ywR_zbqYj3A1_3u531wf7HnErUhLg6jWqoZTb2rElIdLQ-6QRRtX0_kVMHRC7YZtVXg1X6W8Cq3J14l1aLjMuSDtfcB3KsAnsUrIapUcJMnvTTsaq8gu-uLCwElbKOJ4pAAexfYJavbBmM02e5maoTAEKgUJi-cpecJv09xHjKRNCXzAMinXjuL1E8JMJbxNQjqSP7jBnRRMAE1STpk8WDcXCSKjEF17ssAoBYrYa44JOyGZeGPe3KuH44ziUaDGI0k2Fghbjb2b8fByABKglldS13ZXN0LTEwjQY.ts", @@ -92,7 +92,7 @@ var expectedFragments = []fragment{ //go:embed fixtures/playlist2.txt var fixture2 []byte -var expectedFragments2 = []fragment{ +var expectedFragments2 = []Fragment{ { Time: timeMustParse("2024-08-19T23:23:49.099Z"), URL: "https://video-edge-d9161e.cdg02.hls.live-video.net/v1/segment/Cq4G6a0ObZECekl0Rm8uUrnpZ_VKg7TQ3IkXZ3O0SRVAV5UviqLrQFjvLe1-PFp60a-Da7ZPkiQbkbIm6K-XauhyJ_PtjoU7iGj00y_igZ0ysYxsA-94DL8lA1MVZn9ODzJUEJYDyksChPy7CBULd5Ij17fIo-ukKrmY76jRV4AuHtCtdk3QnrxwBOEzXxcFqOgoVcuRypH6t3msZ8eWY3zYVHd7tL-AnG6F9N4eTOOkK4l0TzGlB5-6tQbrDXuEyFVm4JHRyTJdef3uqHaS0QORRB0yWw-EaRRquDggs-Pq0QEAF1bVv29R-ZtPjcRNhkni29k0QhtkdaMdYhXuxHspdTZJZIt_woKuBDA_fgwntlfQdUuR6z20WGN-hBpcBMlRYengtvSFpfO-89ANrYhYOxdu3KWYXoMF17-8EYu_-9oJDyLsCEbHqTG3j0FmUJhJIPM_wdd8ousGrnm7oZv31DOD-e2SoGEXxBek50llMdmHZK8PaWmJoHB9O0AXMU8sZCoU0WRYFOwh_GHGYzfDgLMmDeeLsL6sm3_1o7jLHnwcvg6D-rknktIYr7hBgHzdjbGm-7eqTIYmJrZP8867n2FkVHWibeDZBf9C4tr9EOW2Z6mdTbElkBoRGKEJ-z5WmcBGdKnCXw2hwH7nro84z2wmub3n0l9KiEjdEk_ubcNHvBghtTdLI7ZWYV59vSLGSLeerm-jhzY7PszwwOjyQTuHFAUNU7Cs0Gben7MrX19UdP3E3TiIXme6XnAMA6nVI846_g9R40XuvTZmbo8xm_NK8bmBqKkX8blsDHwBAheQknDZ0DyKijLpvJFRTbHuBkOeuw4A8oxLxMsy7Fjfwzjs2Eji7S4YKHM8zEJA2NBW1ck1iP-UDvTy6g2sur8sVzUtEpqKb0uE0fgaAFPCaRF7PyJASYostAaKj0Y-VhZ98H-uddYqtg4KPwULeFfuUX02HtCSLosI-lz2tQJLoqbe_OdRn1xLFxZ6AO56cLPoZ5h3GYPe3w6WsvirhYxYCZT7xxcf3fusn6tgu5K7_qGmIoAn2LQeYQGIg5_ik0Ibb53WaDLlhR1GVURDWhoMPX5JHOzdBKTC7SzZIAEqCWV1LXdlc3QtMTCNBg.ts", @@ -163,7 +163,7 @@ var combinedExpectedFragments = append( //go:embed fixtures/playlist_no_ts.txt var fixture1NoTS []byte -var expectedFragments1NoTS = []fragment{ +var expectedFragments1NoTS = []Fragment{ { URL: "https://video-edge-d9161e.cdg02.hls.live-video.net/v1/segment/CqMGzqY93tj7dQxK2OfyYyv8G8p8JRTMEyKaPmwSpn_VM-NWly_6to7NFIcNktxPUJef-II4CkbfdE4CWTXSBqsux2AVRw2gQOS_bKzkVTJH3Y50z25iOeBwiOJTH5Quw5O_t8TrrHfOmHda_ahKnDgbMY0586bDEe5_sJX6an9iocxyyjdFDLHXIO3ZD-zHslTaYKdsPILxjZL40YR9DmRyie-Xv3UAmcmkMD-cbr0qKLUGhsDqvzzk0gc4IKygjy7IT68PQz7AO9YaS1K8KD9Zd0lSu03H1g9iF6icvd0SgYwahtBrtE9w9_HHfXJGLQIl__k8RHf4eAUnZ_1qe4iq5fs7wSLc1eFaOW88K93806cf74sHhA4OtocMy5e9DCPUJU2wfbsym3GIHG2MYZlaFuLg4IzE0L8xT3tf4AomGmOVlvTl-sjJzUJ-Yr59GDmoMMdfsAGxLB2nYbHLh6bKDeIi-twDy1OZ4VoWqKv0uXJuZvk9gk593XfJKFvl5Yzi_TAAPJd3rnDMn9E90JVs2sbvcHDDZqDSRHcQtvaFbsT-5jhPXtgVLChDDFZbVyf9IPIhGY7fY7zmYETOSruxqgVcPzp_2xvSZUORv6j32OqeTEqWoUDfIGgNyT1N5rWeO2rdIGTIrXBHnpPnlJuJynKUI23khs5wk5wuD3gnn3Vnx9jeIT7giH9zDw-QboRvq972TL3RhMLk4kUDB36GwqXz76BoibM9V4ra-dLFN0m4_LiFpBMpUHBbWDNZy_jxtkyyx3KZIYVwopbGwcZpxbwCgPka7vBflXnFEPRdJ6ERr_Uhp4ywR_zbqYj3A1_3u531wf7HnErUhLg6jWqoZTb2rElIdLQ-6QRRtX0_kVMHRC7YZtVXg1X6W8Cq3J14l1aLjMuSDtfcB3KsAnsUrIapUcJMnvTTsaq8gu-uLCwElbKOJ4pAAexfYJavbBmM02e5maoTAEKgUJi-cpecJv09xHjKRNCXzAMinXjuL1E8JMJbxNQjqSP7jBnRRMAE1STpk8WDcXCSKjEF17ssAoBYrYa44JOyGZeGPe3KuH44ziUaDGI0k2Fghbjb2b8fByABKglldS13ZXN0LTEwjQY.ts", }, @@ -214,7 +214,7 @@ var expectedFragments1NoTS = []fragment{ //go:embed fixtures/playlist2_no_ts.txt var fixture2NoTS []byte -var expectedFragments2NoTS = []fragment{ +var expectedFragments2NoTS = []Fragment{ { URL: "https://video-edge-d9161e.cdg02.hls.live-video.net/v1/segment/Cq4G6a0ObZECekl0Rm8uUrnpZ_VKg7TQ3IkXZ3O0SRVAV5UviqLrQFjvLe1-PFp60a-Da7ZPkiQbkbIm6K-XauhyJ_PtjoU7iGj00y_igZ0ysYxsA-94DL8lA1MVZn9ODzJUEJYDyksChPy7CBULd5Ij17fIo-ukKrmY76jRV4AuHtCtdk3QnrxwBOEzXxcFqOgoVcuRypH6t3msZ8eWY3zYVHd7tL-AnG6F9N4eTOOkK4l0TzGlB5-6tQbrDXuEyFVm4JHRyTJdef3uqHaS0QORRB0yWw-EaRRquDggs-Pq0QEAF1bVv29R-ZtPjcRNhkni29k0QhtkdaMdYhXuxHspdTZJZIt_woKuBDA_fgwntlfQdUuR6z20WGN-hBpcBMlRYengtvSFpfO-89ANrYhYOxdu3KWYXoMF17-8EYu_-9oJDyLsCEbHqTG3j0FmUJhJIPM_wdd8ousGrnm7oZv31DOD-e2SoGEXxBek50llMdmHZK8PaWmJoHB9O0AXMU8sZCoU0WRYFOwh_GHGYzfDgLMmDeeLsL6sm3_1o7jLHnwcvg6D-rknktIYr7hBgHzdjbGm-7eqTIYmJrZP8867n2FkVHWibeDZBf9C4tr9EOW2Z6mdTbElkBoRGKEJ-z5WmcBGdKnCXw2hwH7nro84z2wmub3n0l9KiEjdEk_ubcNHvBghtTdLI7ZWYV59vSLGSLeerm-jhzY7PszwwOjyQTuHFAUNU7Cs0Gben7MrX19UdP3E3TiIXme6XnAMA6nVI846_g9R40XuvTZmbo8xm_NK8bmBqKkX8blsDHwBAheQknDZ0DyKijLpvJFRTbHuBkOeuw4A8oxLxMsy7Fjfwzjs2Eji7S4YKHM8zEJA2NBW1ck1iP-UDvTy6g2sur8sVzUtEpqKb0uE0fgaAFPCaRF7PyJASYostAaKj0Y-VhZ98H-uddYqtg4KPwULeFfuUX02HtCSLosI-lz2tQJLoqbe_OdRn1xLFxZ6AO56cLPoZ5h3GYPe3w6WsvirhYxYCZT7xxcf3fusn6tgu5K7_qGmIoAn2LQeYQGIg5_ik0Ibb53WaDLlhR1GVURDWhoMPX5JHOzdBKTC7SzZIAEqCWV1LXdlc3QtMTCNBg.ts", }, @@ -312,8 +312,8 @@ func (suite *DownloaderTestSuite) TestGetFragmentURLs() { func (suite *DownloaderTestSuite) TestFillQueue() { // Arrange - frags := make([]fragment, 0, 11) - fragChan := make(chan fragment) + frags := make([]Fragment, 0, 11) + fragChan := make(chan Fragment) ctx, cancel := context.WithCancel(context.Background()) errChan := make(chan error, 1) @@ -389,8 +389,8 @@ func (suite *DownloaderTestSuiteNoTS) TestGetFragmentURLs() { func (suite *DownloaderTestSuiteNoTS) TestFillQueue() { // Arrange - frags := make([]fragment, 0, 11) - fragChan := make(chan fragment) + frags := make([]Fragment, 0, 11) + fragChan := make(chan Fragment) ctx, cancel := context.WithCancel(context.Background()) errChan := make(chan error, 1) diff --git a/utils/secret/secret_reader.go b/utils/secret/secret_reader.go index 224e8ed..054aabd 100644 --- a/utils/secret/secret_reader.go +++ b/utils/secret/secret_reader.go @@ -2,10 +2,11 @@ package secret import ( - "encoding/base64" "errors" "os" - "strings" + + "github.com/Darkness4/withny-dl/withny/api" + "gopkg.in/yaml.v3" ) var ( @@ -13,6 +14,19 @@ var ( ErrInvalidSecret = errors.New("invalid secret") ) +var _ api.CredentialsReader = (*Reader)(nil) + +// ReadCredentialFile reads the credentials from a file. +func ReadCredentialFile(path string) (api.SavedCredentials, error) { + var cf api.SavedCredentials + b, err := os.ReadFile(path) + if err != nil { + return cf, err + } + err = yaml.Unmarshal(b, &cf) + return cf, err +} + // Reader is a secret reader from a file. type Reader struct { FilePath string @@ -26,49 +40,34 @@ func NewReader(filePath string) *Reader { } // Read reads the username and password from the file. -// -// Format is 'usernameb64:passwordb64' with usernameb64 and passwordb64 encoded in base64 (like basic authentication). -func (s *Reader) Read() (username string, password string, err error) { - b, err := os.ReadFile(s.FilePath) - if err != nil { - return "", "", err - } - - usernameB64, passwordB64, found := strings.Cut(strings.TrimSpace(string(b)), ":") - if !found { - return "", "", ErrInvalidSecret - } - - usernameB, err := base64.StdEncoding.DecodeString(usernameB64) - if err != nil { - return "", "", err - } - - passwordB, err := base64.StdEncoding.DecodeString(passwordB64) - if err != nil { - return "", "", err - } - - return string(usernameB), string(passwordB), nil +func (s *Reader) Read() (api.SavedCredentials, error) { + creds, err := ReadCredentialFile(s.FilePath) + return creds, err } +var _ api.CredentialsReader = (*UserPasswordFromEnv)(nil) + // UserPasswordFromEnv is a user password reader from the environment. type UserPasswordFromEnv struct{} // Read returns the email and password from the environment. -func (UserPasswordFromEnv) Read() (email, password string, err error) { - email = os.Getenv("WITHNY_EMAIL") - password = os.Getenv("WITHNY_PASSWORD") - return +func (UserPasswordFromEnv) Read() (api.SavedCredentials, error) { + return api.SavedCredentials{ + Username: os.Getenv("WITHNY_USERNAME"), + Password: os.Getenv("WITHNY_PASSWORD"), + Token: os.Getenv("WITHNY_ACCESS_TOKEN"), + RefreshToken: os.Getenv("WITHNY_REFRESH_TOKEN"), + }, nil } -// UserPasswordStatic is a static user password reader. -type UserPasswordStatic struct { - Email string - Password string +var _ api.CredentialsReader = (*Static)(nil) + +// Static is a static user password reader. +type Static struct { + api.SavedCredentials } // Read returns the email and password. -func (u UserPasswordStatic) Read() (email, password string, err error) { - return u.Email, u.Password, nil +func (u Static) Read() (api.SavedCredentials, error) { + return u.SavedCredentials, nil } diff --git a/withny/api/client.go b/withny/api/client.go index 9d6e14e..95cfd3b 100644 --- a/withny/api/client.go +++ b/withny/api/client.go @@ -38,24 +38,56 @@ type Credentials struct { LoginResponse } -// UserPasswordReader is an interface for reading user and password. -type UserPasswordReader interface { - Read() (email, password string, err error) +// SavedCredentials is the saved credentials given by the user for the withny API. +type SavedCredentials struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` +} + +// CredentialsReader is an interface for reading saved credentials. +type CredentialsReader interface { + Read() (SavedCredentials, error) } // Client is a withny API client. type Client struct { *http.Client - userPasswordReader UserPasswordReader - credentials Credentials + credentialsReader CredentialsReader + credentials Credentials +} + +// SetCredentials sets the credentials for the client. +func (c *Client) SetCredentials(creds Credentials) { + c.credentials = creds } // NewClient creates a new withny API client. -func NewClient(client *http.Client, reader UserPasswordReader) *Client { +func NewClient(client *http.Client, reader CredentialsReader) *Client { + if reader == nil { + log.Warn().Msg("no user and password provided") + } return &Client{ - Client: client, - userPasswordReader: reader, + Client: client, + credentialsReader: reader, + } +} + +// NewAuthRequestWithContext creates a new authenticated request with the given context. +func (c *Client) NewAuthRequestWithContext( + ctx context.Context, + method, url string, + body io.Reader, +) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err } + if c.credentials.TokenType != "" { + req.Header.Set("Authorization", c.credentials.TokenType+" "+c.credentials.Token) + } + return req, nil } // Login will login to withny and store the credentials in the client. @@ -63,21 +95,36 @@ func (c *Client) Login(ctx context.Context) (err error) { var creds Credentials switch { case c.credentials.RefreshToken != "": - creds, err = c.loginWithRefreshToken(ctx, c.credentials.RefreshToken) + creds, err = c.LoginWithRefreshToken(ctx, c.credentials.RefreshToken) if err != nil { - log.Err(err).Msg("failed to refresh token, will use user and password") - email, password, rerr := c.userPasswordReader.Read() + if c.credentialsReader == nil { + log.Err(err).Msg("failed to refresh token") + return fmt.Errorf("no credentials provided") + } + + log.Err(err).Msg("failed to refresh token, will use saved credentials") + saved, rerr := c.credentialsReader.Read() if rerr != nil { return rerr } - creds, err = c.loginWithCredentials(ctx, email, password) + if saved.Username != "" { + creds, err = c.LoginWithUserPassword(ctx, saved.Username, saved.Password) + } else if saved.Token != "" { + creds.Token = saved.Token + creds, err = c.LoginWithRefreshToken(ctx, saved.RefreshToken) + } } - case c.userPasswordReader != nil: - email, password, rerr := c.userPasswordReader.Read() + case c.credentialsReader != nil: + saved, rerr := c.credentialsReader.Read() if rerr != nil { return rerr } - creds, err = c.loginWithCredentials(ctx, email, password) + if saved.Username != "" { + creds, err = c.LoginWithUserPassword(ctx, saved.Username, saved.Password) + } else if saved.Token != "" { + creds.Token = saved.Token + creds, err = c.LoginWithRefreshToken(ctx, saved.RefreshToken) + } default: return fmt.Errorf("no credentials provided") } @@ -97,7 +144,7 @@ func (c *Client) GetUser(ctx context.Context, channelID string) (GetUserResponse q := u.Query() q.Set("username", channelID) u.RawQuery = q.Encode() - req, err := http.NewRequestWithContext( + req, err := c.NewAuthRequestWithContext( ctx, http.MethodGet, u.String(), @@ -106,9 +153,6 @@ func (c *Client) GetUser(ctx context.Context, channelID string) (GetUserResponse if err != nil { return GetUserResponse{}, err } - if c.credentials.TokenType != "" { - req.Header.Set("Authorization", c.credentials.TokenType+" "+c.credentials.Token) - } res, err := c.Do(req) if err != nil { @@ -139,7 +183,7 @@ func (c *Client) GetStreams(ctx context.Context, channelID string) (GetStreamsRe q := u.Query() q.Set("username", channelID) u.RawQuery = q.Encode() - req, err := http.NewRequestWithContext( + req, err := c.NewAuthRequestWithContext( ctx, http.MethodGet, u.String(), @@ -148,9 +192,6 @@ func (c *Client) GetStreams(ctx context.Context, channelID string) (GetStreamsRe if err != nil { return GetStreamsResponse{}, err } - if c.credentials.TokenType != "" { - req.Header.Set("Authorization", c.credentials.TokenType+" "+c.credentials.Token) - } res, err := c.Do(req) if err != nil { @@ -172,7 +213,8 @@ func (c *Client) GetStreams(ctx context.Context, channelID string) (GetStreamsRe return parsed, err } -func (c *Client) loginWithRefreshToken( +// LoginWithRefreshToken will login with the given refreshToken. +func (c *Client) LoginWithRefreshToken( ctx context.Context, refreshToken string, ) (Credentials, error) { @@ -184,7 +226,7 @@ func (c *Client) loginWithRefreshToken( panic(err) } - req, err := http.NewRequestWithContext( + req, err := c.NewAuthRequestWithContext( ctx, http.MethodPost, refreshURL, @@ -194,9 +236,6 @@ func (c *Client) loginWithRefreshToken( panic(err) } req.Header.Set("Content-Type", "application/json") - if c.credentials.TokenType != "" { - req.Header.Set("Authorization", c.credentials.TokenType+" "+c.credentials.Token) - } res, err := c.Do(req) if err != nil { @@ -221,7 +260,8 @@ func (c *Client) loginWithRefreshToken( return lr, err } -func (c *Client) loginWithCredentials( +// LoginWithUserPassword will login with the given email and password. +func (c *Client) LoginWithUserPassword( ctx context.Context, email, password string, ) (Credentials, error) { @@ -274,7 +314,7 @@ func (c *Client) GetStreamPlaybackURL(ctx context.Context, streamID string) (str if err != nil { panic(err) } - req, err := http.NewRequestWithContext( + req, err := c.NewAuthRequestWithContext( ctx, http.MethodGet, u.String(), @@ -283,9 +323,6 @@ func (c *Client) GetStreamPlaybackURL(ctx context.Context, streamID string) (str if err != nil { return "", err } - if c.credentials.TokenType != "" { - req.Header.Set("Authorization", c.credentials.TokenType+" "+c.credentials.Token) - } res, err := c.Do(req) if err != nil { @@ -309,6 +346,7 @@ func (c *Client) GetStreamPlaybackURL(ctx context.Context, streamID string) (str // GetPlaylists will fetch the playlists from the given playbackURL. func (c *Client) GetPlaylists(ctx context.Context, playbackURL string) ([]Playlist, error) { + // No need for auth request. Token is included in the playback URL. req, err := http.NewRequestWithContext( ctx, http.MethodGet, diff --git a/withny/api/client_integration_test.go b/withny/api/client_integration_test.go index 9db5ea4..44dd3ec 100644 --- a/withny/api/client_integration_test.go +++ b/withny/api/client_integration_test.go @@ -1,6 +1,6 @@ //go:build integration -package api +package api_test import ( "context" @@ -12,6 +12,7 @@ import ( "time" "github.com/Darkness4/withny-dl/utils/secret" + "github.com/Darkness4/withny-dl/withny/api" "github.com/joho/godotenv" "github.com/stretchr/testify/require" ) @@ -22,13 +23,13 @@ func TestLogin(t *testing.T) { require.NoError(t, err) hclient := &http.Client{Jar: jar, Timeout: time.Minute} credReader := &secret.UserPasswordFromEnv{} - email, password, _ := credReader.Read() - client := NewClient(hclient, credReader) + saved, _ := credReader.Read() + client := api.NewClient(hclient, credReader) t.Run("Login with credentials", func(t *testing.T) { - res, err := client.loginWithCredentials( + res, err := client.LoginWithUserPassword( context.Background(), - email, password, + saved.Username, saved.Password, ) // Assert @@ -45,14 +46,14 @@ func TestLogin(t *testing.T) { t.Run("Login with refresh token", func(t *testing.T) { // Act - res, err := client.loginWithCredentials( + res, err := client.LoginWithUserPassword( context.Background(), - email, password, + saved.Username, saved.Password, ) require.NoError(t, err) - client.credentials = res + client.SetCredentials(res) time.Sleep(2 * time.Second) - res2, err := client.loginWithRefreshToken( + res2, err := client.LoginWithRefreshToken( context.Background(), res.RefreshToken, ) diff --git a/withny/livestream.go b/withny/livestream.go index e46a526..9da75e6 100644 --- a/withny/livestream.go +++ b/withny/livestream.go @@ -71,7 +71,7 @@ func DownloadLiveStream(ctx context.Context, client *api.Client, ls LiveStream) ) downloader := hls.NewDownloader( - client.Client, + client, &log.Logger, ls.Params.PacketLossMax, playlist.URL,