From 2a15291f29d46f225b45f76e44641803573c45e2 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Tue, 8 Oct 2024 00:16:42 +0200 Subject: [PATCH 01/18] wip: spotify auth --- cmd/server/main.go | 32 ++++++- internal/config/config.go | 7 ++ internal/ui/handler/spotify.go | 51 ++++++++++ internal/ui/router/spotify_routes.go | 26 +++++ pkg/models/spotify.go | 21 ++++ pkg/service/spotify/spotify.go | 137 +++++++++++++++++++++++++++ pkg/util/random_string.go | 21 ++++ 7 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 internal/ui/handler/spotify.go create mode 100644 internal/ui/router/spotify_routes.go create mode 100644 pkg/models/spotify.go create mode 100644 pkg/service/spotify/spotify.go create mode 100644 pkg/util/random_string.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 1ff1f3c..b29fa65 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,12 +5,18 @@ import ( "beyerleinf/spotify-backup/internal/api/handler" "beyerleinf/spotify-backup/internal/api/router" "beyerleinf/spotify-backup/internal/config" + "beyerleinf/spotify-backup/internal/ui" + uiHandler "beyerleinf/spotify-backup/internal/ui/handler" + uiRouter "beyerleinf/spotify-backup/internal/ui/router" + uiTmpl "beyerleinf/spotify-backup/internal/ui/template" logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/pkg/service/spotify" "context" "fmt" "log" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" _ "github.com/lib/pq" ) @@ -25,7 +31,7 @@ func main() { slog.SetLogLevel(config.AppConfig.Server.LogLevel) - dburl := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", + dbUrl := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", config.AppConfig.Database.Host, config.AppConfig.Database.Port, config.AppConfig.Database.Username, @@ -33,7 +39,7 @@ func main() { config.AppConfig.Database.Password, ) - client, err := ent.Open("postgres", dburl) + client, err := ent.Open("postgres", dbUrl) if err != nil { slog.Fatal("Failed opening connection to postgres", "err", err) panic(err) @@ -53,13 +59,31 @@ func main() { e.HideBanner = true e.HidePort = true e.Use(logger.GetEchoLogger()) + e.Use(middleware.Recover()) - api := e.Group("/api") + apiBase := e.Group("/api") + uiBase := e.Group("/ui") - router.SetupRoutes(api, + router.SetupRoutes(apiBase, router.HealthRoutes(healthHandler), ) + renderer, err := uiTmpl.NewRenderer(ui.PublicFS) + if err != nil { + slog.Fatal("Failed to initialize renderer", "err", err) + } + + e.Renderer = renderer + e.StaticFS("/", ui.StaticFS) + + spotifyService := spotify.New(client) + + spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService) + + router.SetupRoutes(uiBase, + uiRouter.SpotifyRoutes(spotifyHandler), + ) + slog.Info(fmt.Sprintf("Starting server on [::]:%d", config.AppConfig.Server.Port)) e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", config.AppConfig.Server.Port))) } diff --git a/internal/config/config.go b/internal/config/config.go index e0b429d..7e31b34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type Config struct { Server ServerConfig `mapstructure:"server" env:"SERVER"` Database DatabaseConfig `mapstructure:"database" env:"DB"` + Spotify SpotifyConfig `mapstructure:"spotify" env:"SPOTIFY"` } type ServerConfig struct { @@ -27,6 +28,12 @@ type DatabaseConfig struct { DBName string `mapstructure:"db_name" env:"NAME"` } +type SpotifyConfig struct { + ClientId string `mapstructure:"client_id" env:"CLIENT_ID"` + ClientSecret string `mapstructure:"client_secret" env:"CLIENT_SECRET"` + RedirectUri string `mapstructure:"redirect_uri" env:"REDIRECT_URI"` +} + var AppConfig Config func LoadConfig() error { diff --git a/internal/ui/handler/spotify.go b/internal/ui/handler/spotify.go new file mode 100644 index 0000000..b2ba18f --- /dev/null +++ b/internal/ui/handler/spotify.go @@ -0,0 +1,51 @@ +package ui + +import ( + "beyerleinf/spotify-backup/internal/config" + logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/pkg/service/spotify" + "net/http" + + "github.com/labstack/echo/v4" +) + +type SpotifyHandler struct { + slogger *logger.Logger + spotifyService *spotify.SpotifyService +} + +func NewSpotifyHandler(spotifyService *spotify.SpotifyService) *SpotifyHandler { + return &SpotifyHandler{ + slogger: logger.New("spotify-ui", config.AppConfig.Server.LogLevel), + spotifyService: spotifyService, + } +} + +func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { + code := c.QueryParams().Get("code") + state := c.QueryParams().Get("state") + if code != "" && state != "" { + s.slogger.Verbose("Spotify Code", "code", code) + s.spotifyService.GetAuthToken(code, state) + } + + c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth") + return nil +} + +func (s *SpotifyHandler) SpotifyAuthPage(c echo.Context) error { + // profile, err := s.spotifyService.GetUserProfile() + // if err != nil { + // s.slogger.Error("Failed to load user profile. Not authenticated?", "err", err) + // } + + // s.slogger.Verbose("Profile", "profile", profile) + + spotifyAuthUrl := s.spotifyService.GetAuthUrl() + + return c.Render(http.StatusOK, "spotify_auth", map[string]any{ + "Title": "Spotify Settings | Spotify Backup", + "SpotifyAuthUrl": spotifyAuthUrl, + // "Profile": profile, + }) +} diff --git a/internal/ui/router/spotify_routes.go b/internal/ui/router/spotify_routes.go new file mode 100644 index 0000000..8e92964 --- /dev/null +++ b/internal/ui/router/spotify_routes.go @@ -0,0 +1,26 @@ +package ui + +import ( + "beyerleinf/spotify-backup/internal/api/router" + handler "beyerleinf/spotify-backup/internal/ui/handler" + + "github.com/labstack/echo/v4" +) + +func SpotifyRoutes(spotifyHandler *handler.SpotifyHandler) router.RouteGroup { + return router.RouteGroup{ + Prefix: "/spotify", + Routes: []router.Route{ + { + Method: echo.GET, + Path: "/auth", + Handler: spotifyHandler.SpotifyAuthPage, + }, + { + Method: echo.GET, + Path: "/callback", + Handler: spotifyHandler.SpotifyAuthCallbackPage, + }, + }, + } +} diff --git a/pkg/models/spotify.go b/pkg/models/spotify.go new file mode 100644 index 0000000..4f45a68 --- /dev/null +++ b/pkg/models/spotify.go @@ -0,0 +1,21 @@ +package models + +type SpotifyImage struct { + Url string `json:"url"` + Height string `json:"height"` + Width string `json:"width"` +} + +type SpotifyUserProfile struct { + Id string `json:"string"` + DisplayName string `json:"display_name"` + Images []SpotifyImage `json:"images"` +} + +type AuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token+type"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go new file mode 100644 index 0000000..83dcc25 --- /dev/null +++ b/pkg/service/spotify/spotify.go @@ -0,0 +1,137 @@ +package spotify + +import ( + "beyerleinf/spotify-backup/ent" + "beyerleinf/spotify-backup/internal/config" + logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/pkg/models" + util "beyerleinf/spotify-backup/pkg/util" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type SpotifyService struct { + slogger *logger.Logger + db *ent.Client + state string + redirectUri string +} + +func New(db *ent.Client) *SpotifyService { + return &SpotifyService{ + slogger: logger.New("spotify", config.AppConfig.Server.LogLevel.Level()), + db: db, + state: util.GenerateRandomString(16), + redirectUri: fmt.Sprintf("%s/ui/spotify/callback", config.AppConfig.Spotify.RedirectUri), + } +} + +func (s *SpotifyService) GetAuthUrl() string { + scope := url.QueryEscape("playlist-read-private user-read-private") + + return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", + config.AppConfig.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, + ) +} + +// TODO implement token storage in json file with encryption +// TODO also clean this mess up +func (s *SpotifyService) GetAuthToken(authCode string, returnedState string) (string, error) { + if returnedState != s.state { + return "", fmt.Errorf("state mismatch") + } + + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("code", authCode) + form.Add("redirect_uri", s.redirectUri) + + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) + if err != nil { + s.slogger.Error("Failed to construct user profile request", "err", err) + return "", err + } + + clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) + authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + s.slogger.Error("Failed to get user profile", "err", err) + return "", err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + s.slogger.Error("Failed to read response data", "err", err) + return "", err + } + + if res.StatusCode != http.StatusOK { + s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) + return "", fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) + } + + var tokenResponse models.AuthTokenResponse + err = json.Unmarshal(data, &tokenResponse) + if err != nil { + s.slogger.Error("Failed to unmarshal response", "err", err) + return "", err + } + + s.slogger.Verbose("token res", "token", tokenResponse) + + return tokenResponse.AccessToken, nil +} + +func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { + + url := "https://api.spotify.com/v1/me" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + s.slogger.Error("Failed to construct user profile request", "err", err) + return models.SpotifyUserProfile{}, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "token")) + s.slogger.Info("after req construct") + + res, err := http.DefaultClient.Do(req) + if err != nil { + s.slogger.Error("Failed to get user profile", "err", err) + return models.SpotifyUserProfile{}, err + } + + s.slogger.Info("after req") + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + s.slogger.Error("Failed to read response data", "err", err) + return models.SpotifyUserProfile{}, err + } + + s.slogger.Info("after read", "data", data) + + var profile models.SpotifyUserProfile + err = json.Unmarshal(data, &profile) + if err != nil { + s.slogger.Error("Failed to unmarshal response", "err", err) + return models.SpotifyUserProfile{}, err + } + + s.slogger.Info("Profile Name", "name", profile.DisplayName) + + return profile, nil +} diff --git a/pkg/util/random_string.go b/pkg/util/random_string.go new file mode 100644 index 0000000..a4394d9 --- /dev/null +++ b/pkg/util/random_string.go @@ -0,0 +1,21 @@ +package util + +import ( + "time" + + "golang.org/x/exp/rand" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func init() { + rand.Seed(uint64(time.Now().UnixNano())) +} + +func GenerateRandomString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} From b602869e25c1c7415a4389faa87ffbed75cb9e78 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Wed, 9 Oct 2024 01:16:00 +0200 Subject: [PATCH 02/18] wip: Finalize spotify auth --- cmd/server/main.go | 23 ++ internal/config/config.go | 7 +- internal/global/global.go | 3 + internal/ui/handler/spotify.go | 34 ++- internal/ui/public/assets/css/tailwind.css | 2 +- internal/ui/public/pages/spotify/auth.html | 26 +- pkg/log/log.go | 24 +- pkg/models/spotify.go | 14 +- pkg/service/spotify/auth.go | 293 +++++++++++++++++++++ pkg/service/spotify/errors.go | 7 + pkg/service/spotify/spotify.go | 78 +----- 11 files changed, 411 insertions(+), 100 deletions(-) create mode 100644 internal/global/global.go create mode 100644 pkg/service/spotify/auth.go create mode 100644 pkg/service/spotify/errors.go diff --git a/cmd/server/main.go b/cmd/server/main.go index b29fa65..5d5d066 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "beyerleinf/spotify-backup/internal/api/handler" "beyerleinf/spotify-backup/internal/api/router" "beyerleinf/spotify-backup/internal/config" + "beyerleinf/spotify-backup/internal/global" "beyerleinf/spotify-backup/internal/ui" uiHandler "beyerleinf/spotify-backup/internal/ui/handler" uiRouter "beyerleinf/spotify-backup/internal/ui/router" @@ -14,6 +15,8 @@ import ( "context" "fmt" "log" + "os" + "path/filepath" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -31,6 +34,8 @@ func main() { slog.SetLogLevel(config.AppConfig.Server.LogLevel) + createStorageDir(slog) + dbUrl := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", config.AppConfig.Database.Host, config.AppConfig.Database.Port, @@ -87,3 +92,21 @@ func main() { slog.Info(fmt.Sprintf("Starting server on [::]:%d", config.AppConfig.Server.Port)) e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", config.AppConfig.Server.Port))) } + +func createStorageDir(slogger *logger.Logger) { + homeDir, err := os.UserHomeDir() + if err != nil { + slogger.Fatal("Error getting home directory", "err", err) + panic(1) + } + + storageDir := filepath.Join(homeDir, global.StorageDir) + + err = os.MkdirAll(storageDir, 0755) + if err != nil { + slogger.Fatal("Failed to create storage dir", "err", err) + panic(1) + } + + slogger.Verbose(fmt.Sprintf("Storage directory at %s created/exists.", storageDir)) +} diff --git a/internal/config/config.go b/internal/config/config.go index 7e31b34..79c9af9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,9 +10,10 @@ import ( ) type Config struct { - Server ServerConfig `mapstructure:"server" env:"SERVER"` - Database DatabaseConfig `mapstructure:"database" env:"DB"` - Spotify SpotifyConfig `mapstructure:"spotify" env:"SPOTIFY"` + Server ServerConfig `mapstructure:"server" env:"SERVER"` + Database DatabaseConfig `mapstructure:"database" env:"DB"` + Spotify SpotifyConfig `mapstructure:"spotify" env:"SPOTIFY"` + EncryptionKey string `mapstructure:"encryption_key" env:"ENCRYPTION_KEY"` } type ServerConfig struct { diff --git a/internal/global/global.go b/internal/global/global.go new file mode 100644 index 0000000..3a1346f --- /dev/null +++ b/internal/global/global.go @@ -0,0 +1,3 @@ +package global + +const StorageDir = ".spotify-backup" diff --git a/internal/ui/handler/spotify.go b/internal/ui/handler/spotify.go index b2ba18f..940907b 100644 --- a/internal/ui/handler/spotify.go +++ b/internal/ui/handler/spotify.go @@ -24,9 +24,17 @@ func NewSpotifyHandler(spotifyService *spotify.SpotifyService) *SpotifyHandler { func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { code := c.QueryParams().Get("code") state := c.QueryParams().Get("state") - if code != "" && state != "" { - s.slogger.Verbose("Spotify Code", "code", code) - s.spotifyService.GetAuthToken(code, state) + + if code == "" || state == "" { + c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=code_or_state") + return nil + } + + err := s.spotifyService.HandleAuthCallback(code, state) + if err != nil { + s.slogger.Error("error handling auth callback", "err", err) + c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=get_access_token") + return nil } c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth") @@ -34,18 +42,24 @@ func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { } func (s *SpotifyHandler) SpotifyAuthPage(c echo.Context) error { - // profile, err := s.spotifyService.GetUserProfile() - // if err != nil { - // s.slogger.Error("Failed to load user profile. Not authenticated?", "err", err) - // } + spotifyAuthUrl := s.spotifyService.GetAuthUrl() + authError := c.QueryParams().Get("error") - // s.slogger.Verbose("Profile", "profile", profile) + profile, err := s.spotifyService.GetUserProfile() + if err != nil { + s.slogger.Error("Failed to load user profile. Not authenticated?", "err", err) - spotifyAuthUrl := s.spotifyService.GetAuthUrl() + return c.Render(http.StatusOK, "spotify_auth", map[string]any{ + "Title": "Spotify Settings | Spotify Backup", + "SpotifyAuthUrl": spotifyAuthUrl, + "HasError": authError, + }) + } return c.Render(http.StatusOK, "spotify_auth", map[string]any{ "Title": "Spotify Settings | Spotify Backup", "SpotifyAuthUrl": spotifyAuthUrl, - // "Profile": profile, + "HasError": authError, + "Profile": profile, }) } diff --git a/internal/ui/public/assets/css/tailwind.css b/internal/ui/public/assets/css/tailwind.css index adcd9ca..b78d713 100644 --- a/internal/ui/public/assets/css/tailwind.css +++ b/internal/ui/public/assets/css/tailwind.css @@ -1 +1 @@ -*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.frappe{--ctp-rosewater:242,213,207;--ctp-flamingo:238,190,190;--ctp-pink:244,184,228;--ctp-mauve:202,158,230;--ctp-red:231,130,132;--ctp-maroon:234,153,156;--ctp-peach:239,159,118;--ctp-yellow:229,200,144;--ctp-green:166,209,137;--ctp-teal:129,200,190;--ctp-sky:153,209,219;--ctp-sapphire:133,193,220;--ctp-blue:140,170,238;--ctp-lavender:186,187,241;--ctp-text:198,208,245;--ctp-subtext1:181,191,226;--ctp-subtext0:165,173,206;--ctp-overlay2:148,156,187;--ctp-overlay1:131,139,167;--ctp-overlay0:115,121,148;--ctp-surface2:98,104,128;--ctp-surface1:81,87,109;--ctp-surface0:65,69,89;--ctp-base:48,52,70;--ctp-mantle:41,44,60;--ctp-crust:35,38,52}:root{--ctp-rosewater:245,224,220;--ctp-flamingo:242,205,205;--ctp-pink:245,194,231;--ctp-mauve:203,166,247;--ctp-red:243,139,168;--ctp-maroon:235,160,172;--ctp-peach:250,179,135;--ctp-yellow:249,226,175;--ctp-green:166,227,161;--ctp-teal:148,226,213;--ctp-sky:137,220,235;--ctp-sapphire:116,199,236;--ctp-blue:137,180,250;--ctp-lavender:180,190,254;--ctp-text:205,214,244;--ctp-subtext1:186,194,222;--ctp-subtext0:166,173,200;--ctp-overlay2:147,153,178;--ctp-overlay1:127,132,156;--ctp-overlay0:108,112,134;--ctp-surface2:88,91,112;--ctp-surface1:69,71,90;--ctp-surface0:49,50,68;--ctp-base:30,30,46;--ctp-mantle:24,24,37;--ctp-crust:17,17,27}.mb-2{margin-bottom:.5rem}.mt-4{margin-top:1rem}.mt-2{margin-top:.5rem}.flex{display:flex}.table{display:table}.hidden{display:none}.resize{resize:both}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.rounded-md{border-radius:.375rem}.bg-lavender{--tw-bg-opacity:1;background-color:rgba(var(--ctp-lavender),var(--tw-bg-opacity))}.bg-mauve{--tw-bg-opacity:1;background-color:rgba(var(--ctp-mauve),var(--tw-bg-opacity))}.bg-base{--tw-bg-opacity:1;background-color:rgba(var(--ctp-base),var(--tw-bg-opacity))}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-surface0{--tw-text-opacity:1;color:rgba(var(--ctp-surface0),var(--tw-text-opacity))}.text-text{--tw-text-opacity:1;color:rgba(var(--ctp-text),var(--tw-text-opacity))}.hover\:bg-mauve:hover{--tw-bg-opacity:1;background-color:rgba(var(--ctp-mauve),var(--tw-bg-opacity))}.active\:bg-mauve\/75:active{background-color:rgba(var(--ctp-mauve),.75)} \ No newline at end of file +*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.frappe{--ctp-rosewater:242,213,207;--ctp-flamingo:238,190,190;--ctp-pink:244,184,228;--ctp-mauve:202,158,230;--ctp-red:231,130,132;--ctp-maroon:234,153,156;--ctp-peach:239,159,118;--ctp-yellow:229,200,144;--ctp-green:166,209,137;--ctp-teal:129,200,190;--ctp-sky:153,209,219;--ctp-sapphire:133,193,220;--ctp-blue:140,170,238;--ctp-lavender:186,187,241;--ctp-text:198,208,245;--ctp-subtext1:181,191,226;--ctp-subtext0:165,173,206;--ctp-overlay2:148,156,187;--ctp-overlay1:131,139,167;--ctp-overlay0:115,121,148;--ctp-surface2:98,104,128;--ctp-surface1:81,87,109;--ctp-surface0:65,69,89;--ctp-base:48,52,70;--ctp-mantle:41,44,60;--ctp-crust:35,38,52}:root{--ctp-rosewater:245,224,220;--ctp-flamingo:242,205,205;--ctp-pink:245,194,231;--ctp-mauve:203,166,247;--ctp-red:243,139,168;--ctp-maroon:235,160,172;--ctp-peach:250,179,135;--ctp-yellow:249,226,175;--ctp-green:166,227,161;--ctp-teal:148,226,213;--ctp-sky:137,220,235;--ctp-sapphire:116,199,236;--ctp-blue:137,180,250;--ctp-lavender:180,190,254;--ctp-text:205,214,244;--ctp-subtext1:186,194,222;--ctp-subtext0:166,173,200;--ctp-overlay2:147,153,178;--ctp-overlay1:127,132,156;--ctp-overlay0:108,112,134;--ctp-surface2:88,91,112;--ctp-surface1:69,71,90;--ctp-surface0:49,50,68;--ctp-base:30,30,46;--ctp-mantle:24,24,37;--ctp-crust:17,17,27}.mb-2{margin-bottom:.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mb-4{margin-bottom:1rem}.flex{display:flex}.table{display:table}.hidden{display:none}.resize{resize:both}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.items-center{align-items:center}.gap-2{gap:.5rem}.gap-4{gap:1rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.bg-base{--tw-bg-opacity:1;background-color:rgba(var(--ctp-base),var(--tw-bg-opacity))}.bg-lavender{--tw-bg-opacity:1;background-color:rgba(var(--ctp-lavender),var(--tw-bg-opacity))}.bg-mauve{--tw-bg-opacity:1;background-color:rgba(var(--ctp-mauve),var(--tw-bg-opacity))}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-surface0{--tw-text-opacity:1;color:rgba(var(--ctp-surface0),var(--tw-text-opacity))}.text-text{--tw-text-opacity:1;color:rgba(var(--ctp-text),var(--tw-text-opacity))}.hover\:bg-mauve:hover{--tw-bg-opacity:1;background-color:rgba(var(--ctp-mauve),var(--tw-bg-opacity))}.active\:bg-mauve\/75:active{background-color:rgba(var(--ctp-mauve),.75)} \ No newline at end of file diff --git a/internal/ui/public/pages/spotify/auth.html b/internal/ui/public/pages/spotify/auth.html index d415ee1..ec30b50 100644 --- a/internal/ui/public/pages/spotify/auth.html +++ b/internal/ui/public/pages/spotify/auth.html @@ -4,7 +4,7 @@ {{ template "header.html" . }} -

Spotify Auth

+

Spotify Settings

Spotify Auth Authenticate with Spotify - {{ if not (eq .Profile nil) }}
-

Profile

+ {{ if (eq .Profile nil) }} +
+ Not signed in +
+ {{ end }} -
- - {{ .Profile.DisplayName }} -
+ {{ if not (eq .Profile nil) }} +

Profile

+ +
+ Profile Picture + {{ .Profile.DisplayName }} +
+ {{ end }}
- {{ end }} {{ end }} diff --git a/pkg/log/log.go b/pkg/log/log.go index c74170a..f223e5c 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -3,7 +3,9 @@ package logger import ( "context" "log/slog" + "net/url" "os" + "slices" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -33,6 +35,11 @@ var LevelNames = map[slog.Leveler]string{ LevelFatal: "FATAL", } +var filteredQueryParams = [...]string{ + "code", + "state", +} + func New(area string, level slog.Level) *Logger { logLevel := new(slog.LevelVar) logLevel.Set(level) @@ -105,16 +112,29 @@ func GetEchoLogger() echo.MiddlewareFunc { LogError: true, HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { + parsedUrl, err := url.Parse(v.URI) + if err != nil { + return err + } + + query := parsedUrl.Query() + for key := range query { + if slices.Contains(filteredQueryParams[:], key) { + query[key] = []string{"REDACTED"} + } + } + parsedUrl.RawQuery = query.Encode() + if v.Error == nil { logger.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST", slog.String("method", v.Method), - slog.String("uri", v.URI), + slog.String("uri", parsedUrl.RequestURI()), slog.Int("status", v.Status), ) } else { logger.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR", slog.String("method", v.Method), - slog.String("uri", v.URI), + slog.String("uri", parsedUrl.RequestURI()), slog.Int("status", v.Status), slog.String("err", v.Error.Error()), ) diff --git a/pkg/models/spotify.go b/pkg/models/spotify.go index 4f45a68..8e7a7c1 100644 --- a/pkg/models/spotify.go +++ b/pkg/models/spotify.go @@ -1,9 +1,11 @@ package models +import "time" + type SpotifyImage struct { Url string `json:"url"` - Height string `json:"height"` - Width string `json:"width"` + Height int `json:"height"` + Width int `json:"width"` } type SpotifyUserProfile struct { @@ -14,8 +16,14 @@ type SpotifyUserProfile struct { type AuthTokenResponse struct { AccessToken string `json:"access_token"` - TokenType string `json:"token+type"` + TokenType string `json:"token_type"` Scope string `json:"scope"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` } + +type AuthToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` +} diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go new file mode 100644 index 0000000..9960862 --- /dev/null +++ b/pkg/service/spotify/auth.go @@ -0,0 +1,293 @@ +package spotify + +import ( + "beyerleinf/spotify-backup/internal/config" + "beyerleinf/spotify-backup/internal/global" + "beyerleinf/spotify-backup/pkg/models" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var tokenMutex sync.RWMutex + +const tokenFile = "token.bin" + +var authToken *models.AuthToken + +func (s *SpotifyService) GetAuthUrl() string { + scope := url.QueryEscape("playlist-read-private user-read-private") + + return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", + config.AppConfig.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, + ) +} + +// TODO create http api to make this nicer and less repetitive +func (s *SpotifyService) HandleAuthCallback(code string, state string) error { + if state != s.state { + return fmt.Errorf("state mismatch") + } + + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("code", code) + form.Add("redirect_uri", s.redirectUri) + + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) + if err != nil { + s.slogger.Error("Failed to construct user profile request", "err", err) + return err + } + + clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) + authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + s.slogger.Error("Failed to get user profile", "err", err) + return err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + s.slogger.Error("Failed to read response data", "err", err) + return err + } + + if res.StatusCode != http.StatusOK { + s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) + return fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) + } + + var tokenResponse models.AuthTokenResponse + err = json.Unmarshal(data, &tokenResponse) + if err != nil { + s.slogger.Error("Failed to unmarshal response", "err", err) + return err + } + + tokenMutex.Lock() + authToken = &models.AuthToken{ + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), + } + tokenMutex.Unlock() + + s.saveToken() + + s.slogger.Verbose("Successfully authenticated with Spotify!") + + return nil +} + +func (s *SpotifyService) GetAccessToken() (string, error) { + tokenMutex.RLock() + token := authToken + tokenMutex.RUnlock() + + if token == nil { + s.loadToken() + + tokenMutex.RLock() + token = authToken + tokenMutex.RUnlock() + } + + if token != nil && time.Now().Before(token.ExpiresAt) { + return token.AccessToken, nil + } + + if token != nil && time.Now().After(token.ExpiresAt) { + s.RefreshAccessToken(token.RefreshToken) + + tokenMutex.RLock() + token = authToken + tokenMutex.RUnlock() + + return token.AccessToken, nil + } + + if token == nil { + return "", &SpotifyUnauthenticatedError{} + } + + return "", fmt.Errorf("something went wrong") +} + +func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { + form := url.Values{} + form.Add("grant_type", "refresh_token") + form.Add("refresh_token", refreshToken) + form.Add("client_id", config.AppConfig.Spotify.ClientId) + + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) + if err != nil { + s.slogger.Error("Failed to construct user profile request", "err", err) + return err + } + + clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) + authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + s.slogger.Error("Failed to get user profile", "err", err) + return err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + s.slogger.Error("Failed to read response data", "err", err) + return err + } + + if res.StatusCode != http.StatusOK { + s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) + return fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) + } + + var tokenResponse models.AuthTokenResponse + err = json.Unmarshal(data, &tokenResponse) + if err != nil { + s.slogger.Error("Failed to unmarshal response", "err", err) + return err + } + + tokenMutex.Lock() + authToken = &models.AuthToken{ + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), + } + tokenMutex.Unlock() + + s.saveToken() + + return nil +} + +func (s *SpotifyService) saveToken() { + tokenMutex.RLock() + defer tokenMutex.RUnlock() + + jsonData, err := json.Marshal(authToken) + if err != nil { + s.slogger.Error("Error marshaling auth token", "err", err) + return + } + + encryptedData, err := s.encryptToken(jsonData) + if err != nil { + s.slogger.Error("Error encrypting auth token", "err", err) + return + } + + homeDir, err := os.UserHomeDir() + if err != nil { + s.slogger.Error("Error getting home directory", "err", err) + } + + tokenPath := filepath.Join(homeDir, global.StorageDir, tokenFile) + + err = os.WriteFile(tokenPath, encryptedData, 0600) + if err != nil { + s.slogger.Error("Error writing encrypted auth token", "err", err) + return + } +} + +func (s *SpotifyService) loadToken() { + homeDir, err := os.UserHomeDir() + if err != nil { + s.slogger.Error("Error getting home directory", "err", err) + } + + tokenPath := filepath.Join(homeDir, global.StorageDir, tokenFile) + + encryptedData, err := os.ReadFile(tokenPath) + if err != nil { + if !os.IsNotExist(err) { + s.slogger.Error("Error reading encrypted auth token", "err", err) + } + return + } + + decryptedData, err := s.decryptToken(encryptedData) + if err != nil { + s.slogger.Error("Error decrypting auth token", "err", err) + return + } + + var token models.AuthToken + err = json.Unmarshal(decryptedData, &token) + if err != nil { + s.slogger.Error("Error unmarshaling auth token", "err", err) + return + } + + tokenMutex.Lock() + authToken = &token + tokenMutex.Unlock() +} + +func (s *SpotifyService) encryptToken(data []byte) ([]byte, error) { + block, err := aes.NewCipher([]byte(config.AppConfig.EncryptionKey)) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, data, nil), nil +} + +func (s *SpotifyService) decryptToken(data []byte) ([]byte, error) { + block, err := aes.NewCipher([]byte(config.AppConfig.EncryptionKey)) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/pkg/service/spotify/errors.go b/pkg/service/spotify/errors.go new file mode 100644 index 0000000..c04ffcd --- /dev/null +++ b/pkg/service/spotify/errors.go @@ -0,0 +1,7 @@ +package spotify + +type SpotifyUnauthenticatedError struct{} + +func (e *SpotifyUnauthenticatedError) Error() string { + return "Authentication with Spotify failed! Try signing into your Account again." +} diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 83dcc25..8cb8d95 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -6,13 +6,10 @@ import ( logger "beyerleinf/spotify-backup/pkg/log" "beyerleinf/spotify-backup/pkg/models" util "beyerleinf/spotify-backup/pkg/util" - "encoding/base64" "encoding/json" "fmt" "io" "net/http" - "net/url" - "strings" ) type SpotifyService struct { @@ -31,71 +28,13 @@ func New(db *ent.Client) *SpotifyService { } } -func (s *SpotifyService) GetAuthUrl() string { - scope := url.QueryEscape("playlist-read-private user-read-private") - - return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", - config.AppConfig.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, - ) -} - -// TODO implement token storage in json file with encryption -// TODO also clean this mess up -func (s *SpotifyService) GetAuthToken(authCode string, returnedState string) (string, error) { - if returnedState != s.state { - return "", fmt.Errorf("state mismatch") - } - - form := url.Values{} - form.Add("grant_type", "authorization_code") - form.Add("code", authCode) - form.Add("redirect_uri", s.redirectUri) - - req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) - if err != nil { - s.slogger.Error("Failed to construct user profile request", "err", err) - return "", err - } - - clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) - authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) - - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - s.slogger.Error("Failed to get user profile", "err", err) - return "", err - } - - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) - if err != nil { - s.slogger.Error("Failed to read response data", "err", err) - return "", err - } - - if res.StatusCode != http.StatusOK { - s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) - return "", fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) - } - - var tokenResponse models.AuthTokenResponse - err = json.Unmarshal(data, &tokenResponse) +func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { + token, err := s.GetAccessToken() if err != nil { - s.slogger.Error("Failed to unmarshal response", "err", err) - return "", err + s.slogger.Error("Failed to get access token", "err", err) + return models.SpotifyUserProfile{}, err } - s.slogger.Verbose("token res", "token", tokenResponse) - - return tokenResponse.AccessToken, nil -} - -func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { - url := "https://api.spotify.com/v1/me" req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -103,8 +42,7 @@ func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { return models.SpotifyUserProfile{}, err } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "token")) - s.slogger.Info("after req construct") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) res, err := http.DefaultClient.Do(req) if err != nil { @@ -112,8 +50,6 @@ func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { return models.SpotifyUserProfile{}, err } - s.slogger.Info("after req") - defer res.Body.Close() data, err := io.ReadAll(res.Body) @@ -122,8 +58,6 @@ func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { return models.SpotifyUserProfile{}, err } - s.slogger.Info("after read", "data", data) - var profile models.SpotifyUserProfile err = json.Unmarshal(data, &profile) if err != nil { @@ -131,7 +65,5 @@ func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { return models.SpotifyUserProfile{}, err } - s.slogger.Info("Profile Name", "name", profile.DisplayName) - return profile, nil } From f113c04c1596973a60a3f2202f0ea501709c38ea Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Wed, 9 Oct 2024 01:30:55 +0200 Subject: [PATCH 03/18] wip: log.Trace resolved itself lmao --- pkg/log/log.go | 2 +- pkg/service/spotify/spotify.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/log/log.go b/pkg/log/log.go index f223e5c..e75999e 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -43,6 +43,7 @@ var filteredQueryParams = [...]string{ func New(area string, level slog.Level) *Logger { logLevel := new(slog.LevelVar) logLevel.Set(level) + slogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { @@ -87,7 +88,6 @@ func (l *Logger) Verbose(msg string, args ...any) { l.slogger.Log(ctx, LevelVerbose, msg, args...) } -// TODO fix issue with trace logging after config was loaded func (l *Logger) Trace(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelTrace, msg, args...) diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 8cb8d95..2c13b2d 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -31,6 +31,7 @@ func New(db *ent.Client) *SpotifyService { func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { token, err := s.GetAccessToken() if err != nil { + // TODO we are logging this twice, here and where the service is used. Do we need to log here? s.slogger.Error("Failed to get access token", "err", err) return models.SpotifyUserProfile{}, err } From 7633b6995f67458173296b076744e183dc57efe3 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Wed, 9 Oct 2024 01:48:34 +0200 Subject: [PATCH 04/18] wip: Create http wrapper --- pkg/http/http.go | 77 ++++++++++++++++++++++++++++++++++ pkg/service/spotify/auth.go | 53 +++++------------------ pkg/service/spotify/spotify.go | 26 ++---------- 3 files changed, 92 insertions(+), 64 deletions(-) create mode 100644 pkg/http/http.go diff --git a/pkg/http/http.go b/pkg/http/http.go new file mode 100644 index 0000000..503ddcb --- /dev/null +++ b/pkg/http/http.go @@ -0,0 +1,77 @@ +package http_utils + +import ( + "io" + "net/http" +) + +func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, 0, err + } + + req.Header = headers + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + + return data, res.StatusCode, nil +} + +func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, 0, err + } + + req.Header = headers + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + + return data, res.StatusCode, nil +} + +func Get(url string, headers map[string][]string) ([]byte, int, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, 0, err + } + + req.Header = headers + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + + return data, res.StatusCode, nil +} diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index 9960862..df59548 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -3,6 +3,7 @@ package spotify import ( "beyerleinf/spotify-backup/internal/config" "beyerleinf/spotify-backup/internal/global" + http_utils "beyerleinf/spotify-backup/pkg/http" "beyerleinf/spotify-backup/pkg/models" "crypto/aes" "crypto/cipher" @@ -34,7 +35,6 @@ func (s *SpotifyService) GetAuthUrl() string { ) } -// TODO create http api to make this nicer and less repetitive func (s *SpotifyService) HandleAuthCallback(code string, state string) error { if state != s.state { return fmt.Errorf("state mismatch") @@ -45,35 +45,20 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { form.Add("code", code) form.Add("redirect_uri", s.redirectUri) - req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) - if err != nil { - s.slogger.Error("Failed to construct user profile request", "err", err) - return err - } - clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - s.slogger.Error("Failed to get user profile", "err", err) - return err + headers := map[string][]string{ + "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, } - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) + data, status, err := http_utils.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { - s.slogger.Error("Failed to read response data", "err", err) return err } - if res.StatusCode != http.StatusOK { - s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) - return fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) + if status != http.StatusOK { + return fmt.Errorf("token request failed: %d - %s", status, string(data)) } var tokenResponse models.AuthTokenResponse @@ -138,41 +123,25 @@ func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { form.Add("refresh_token", refreshToken) form.Add("client_id", config.AppConfig.Spotify.ClientId) - req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode())) - if err != nil { - s.slogger.Error("Failed to construct user profile request", "err", err) - return err - } - clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", fmt.Sprintf("Basic %s", authHeaderValue)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - s.slogger.Error("Failed to get user profile", "err", err) - return err + headers := map[string][]string{ + "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, } - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) + data, status, err := http_utils.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { - s.slogger.Error("Failed to read response data", "err", err) return err } - if res.StatusCode != http.StatusOK { - s.slogger.Error("Token request failed", "status", res.Status, "body", string(data)) - return fmt.Errorf("token request failed: %s - %s", res.Status, string(data)) + if status != http.StatusOK { + return fmt.Errorf("token request failed: %d - %s", status, string(data)) } var tokenResponse models.AuthTokenResponse err = json.Unmarshal(data, &tokenResponse) if err != nil { - s.slogger.Error("Failed to unmarshal response", "err", err) return err } diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 2c13b2d..916415f 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -3,13 +3,12 @@ package spotify import ( "beyerleinf/spotify-backup/ent" "beyerleinf/spotify-backup/internal/config" + http_utils "beyerleinf/spotify-backup/pkg/http" logger "beyerleinf/spotify-backup/pkg/log" "beyerleinf/spotify-backup/pkg/models" util "beyerleinf/spotify-backup/pkg/util" "encoding/json" "fmt" - "io" - "net/http" ) type SpotifyService struct { @@ -31,38 +30,21 @@ func New(db *ent.Client) *SpotifyService { func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { token, err := s.GetAccessToken() if err != nil { - // TODO we are logging this twice, here and where the service is used. Do we need to log here? - s.slogger.Error("Failed to get access token", "err", err) return models.SpotifyUserProfile{}, err } - url := "https://api.spotify.com/v1/me" - req, err := http.NewRequest("GET", url, nil) - if err != nil { - s.slogger.Error("Failed to construct user profile request", "err", err) - return models.SpotifyUserProfile{}, err - } - - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) - - res, err := http.DefaultClient.Do(req) - if err != nil { - s.slogger.Error("Failed to get user profile", "err", err) - return models.SpotifyUserProfile{}, err + headers := map[string][]string{ + "Authorization": {fmt.Sprintf("Bearer %s", token)}, } - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) + data, _, err := http_utils.Get("https://api.spotify.com/v1/me", headers) if err != nil { - s.slogger.Error("Failed to read response data", "err", err) return models.SpotifyUserProfile{}, err } var profile models.SpotifyUserProfile err = json.Unmarshal(data, &profile) if err != nil { - s.slogger.Error("Failed to unmarshal response", "err", err) return models.SpotifyUserProfile{}, err } From c20502fcc29297c6bb1cdff167cd94199ff38654 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 21:55:31 +0200 Subject: [PATCH 05/18] chore(ci): Add CI workflow for PRs --- .github/workflows/build-pr.yml | 28 ++++++++++++++++++++++++++++ .gitignore | 1 + go.mod | 2 +- go.sum | 4 ++-- justfile | 5 ++++- pkg/http/http.go | 1 - 6 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/build-pr.yml diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..8b86eeb --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,28 @@ +name: "Build PR" +on: + pull_request: + branches: + - main +jobs: + build: + name: "Build" + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ["1.23"] + steps: + - name: "Checkout" + uses: actions/checkout@v4 + - name: "Setup Go ${{ matrix.go-version }}" + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: "Install dependencies" + run: | + go get . + - name: "Lint" + uses: golangci/golangci-lint-action@v6 + with: + version: v1.60 + - name: "Build" + run: "go build -o cmd/server/bin/server cmd/server/main.go" diff --git a/.gitignore b/.gitignore index e3bd625..574fd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.dll *.so *.dylib +bin/ # Test binary, built with `go test -c` *.test diff --git a/go.mod b/go.mod index ffb8a15..4c253a9 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/crypto v0.28.0 // indirect - golang.org/x/mod v0.20.0 // indirect + golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect diff --git a/go.sum b/go.sum index baa630a..2972a6d 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,8 @@ golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= diff --git a/justfile b/justfile index 39a4b63..119bb94 100644 --- a/justfile +++ b/justfile @@ -12,4 +12,7 @@ run: go run cmd/server/main.go tidy: - go mod tidy \ No newline at end of file + go mod tidy + +build APP: + go build -o cmd/{{APP}}/bin/{{APP}} cmd/{{APP}}/main.go \ No newline at end of file diff --git a/pkg/http/http.go b/pkg/http/http.go index 503ddcb..d73e649 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -29,7 +29,6 @@ func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, } func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { - req, err := http.NewRequest("POST", url, body) if err != nil { return nil, 0, err From d7a61299626a6d83937e04c4d15d99981a42bc9e Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 21:57:13 +0200 Subject: [PATCH 06/18] fix(ci): Fix install step --- .github/workflows/build-pr.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 8b86eeb..51ed8dd 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -18,8 +18,7 @@ jobs: with: go-version: ${{ matrix.go-version }} - name: "Install dependencies" - run: | - go get . + run: "go mod download" - name: "Lint" uses: golangci/golangci-lint-action@v6 with: From 4aeb561329ba34f3caec4da2f8902adc56122416 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 22:00:39 +0200 Subject: [PATCH 07/18] chore: Fix critical lint errors --- internal/ui/handler/spotify.go | 19 ++++++++++++++++--- pkg/service/spotify/auth.go | 5 ++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/ui/handler/spotify.go b/internal/ui/handler/spotify.go index 940907b..fc9a552 100644 --- a/internal/ui/handler/spotify.go +++ b/internal/ui/handler/spotify.go @@ -26,18 +26,31 @@ func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { state := c.QueryParams().Get("state") if code == "" || state == "" { - c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=code_or_state") + err := c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=code_or_state") + if err != nil { + return err + } + return nil } err := s.spotifyService.HandleAuthCallback(code, state) if err != nil { s.slogger.Error("error handling auth callback", "err", err) - c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=get_access_token") + + err = c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=get_access_token") + if err != nil { + return err + } + return nil } - c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth") + err = c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth") + if err != nil { + return err + } + return nil } diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index df59548..0ad8470 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -101,7 +101,10 @@ func (s *SpotifyService) GetAccessToken() (string, error) { } if token != nil && time.Now().After(token.ExpiresAt) { - s.RefreshAccessToken(token.RefreshToken) + err := s.RefreshAccessToken(token.RefreshToken) + if err != nil { + return "", err + } tokenMutex.RLock() token = authToken From 74388ade7ee55d64bdbdd510f70f9d297ec5e3c6 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 22:03:34 +0200 Subject: [PATCH 08/18] chore: Restructure Spotify package --- pkg/service/spotify/auth.go | 13 ++++++------- .../spotify.go => service/spotify/models.go} | 2 +- pkg/service/spotify/spotify.go | 11 +++++------ 3 files changed, 12 insertions(+), 14 deletions(-) rename pkg/{models/spotify.go => service/spotify/models.go} (97%) diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index 0ad8470..c3333ab 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -4,7 +4,6 @@ import ( "beyerleinf/spotify-backup/internal/config" "beyerleinf/spotify-backup/internal/global" http_utils "beyerleinf/spotify-backup/pkg/http" - "beyerleinf/spotify-backup/pkg/models" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -25,7 +24,7 @@ var tokenMutex sync.RWMutex const tokenFile = "token.bin" -var authToken *models.AuthToken +var authToken *AuthToken func (s *SpotifyService) GetAuthUrl() string { scope := url.QueryEscape("playlist-read-private user-read-private") @@ -61,7 +60,7 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { return fmt.Errorf("token request failed: %d - %s", status, string(data)) } - var tokenResponse models.AuthTokenResponse + var tokenResponse AuthTokenResponse err = json.Unmarshal(data, &tokenResponse) if err != nil { s.slogger.Error("Failed to unmarshal response", "err", err) @@ -69,7 +68,7 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { } tokenMutex.Lock() - authToken = &models.AuthToken{ + authToken = &AuthToken{ AccessToken: tokenResponse.AccessToken, RefreshToken: tokenResponse.RefreshToken, ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), @@ -142,14 +141,14 @@ func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { return fmt.Errorf("token request failed: %d - %s", status, string(data)) } - var tokenResponse models.AuthTokenResponse + var tokenResponse AuthTokenResponse err = json.Unmarshal(data, &tokenResponse) if err != nil { return err } tokenMutex.Lock() - authToken = &models.AuthToken{ + authToken = &AuthToken{ AccessToken: tokenResponse.AccessToken, RefreshToken: tokenResponse.RefreshToken, ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), @@ -213,7 +212,7 @@ func (s *SpotifyService) loadToken() { return } - var token models.AuthToken + var token AuthToken err = json.Unmarshal(decryptedData, &token) if err != nil { s.slogger.Error("Error unmarshaling auth token", "err", err) diff --git a/pkg/models/spotify.go b/pkg/service/spotify/models.go similarity index 97% rename from pkg/models/spotify.go rename to pkg/service/spotify/models.go index 8e7a7c1..7456c75 100644 --- a/pkg/models/spotify.go +++ b/pkg/service/spotify/models.go @@ -1,4 +1,4 @@ -package models +package spotify import "time" diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 916415f..c557ddb 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -5,7 +5,6 @@ import ( "beyerleinf/spotify-backup/internal/config" http_utils "beyerleinf/spotify-backup/pkg/http" logger "beyerleinf/spotify-backup/pkg/log" - "beyerleinf/spotify-backup/pkg/models" util "beyerleinf/spotify-backup/pkg/util" "encoding/json" "fmt" @@ -27,10 +26,10 @@ func New(db *ent.Client) *SpotifyService { } } -func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { +func (s *SpotifyService) GetUserProfile() (SpotifyUserProfile, error) { token, err := s.GetAccessToken() if err != nil { - return models.SpotifyUserProfile{}, err + return SpotifyUserProfile{}, err } headers := map[string][]string{ @@ -39,13 +38,13 @@ func (s *SpotifyService) GetUserProfile() (models.SpotifyUserProfile, error) { data, _, err := http_utils.Get("https://api.spotify.com/v1/me", headers) if err != nil { - return models.SpotifyUserProfile{}, err + return SpotifyUserProfile{}, err } - var profile models.SpotifyUserProfile + var profile SpotifyUserProfile err = json.Unmarshal(data, &profile) if err != nil { - return models.SpotifyUserProfile{}, err + return SpotifyUserProfile{}, err } return profile, nil From 1e37e47dc667bf43ab81a0c15cf1104d59146e67 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 22:15:45 +0200 Subject: [PATCH 09/18] chore: Move web files to correct directory --- cmd/server/main.go | 6 +++--- internal/ui/embed.go | 12 ------------ .../public/assets => web/static}/css/custom.css | 0 .../public/assets => web/static}/css/tailwind.css | 0 .../public/assets => web/static}/js/htmx.min.js | 0 .../templates}/fragments/header.html | 0 .../templates}/pages/spotify/auth.html | 0 web/web.go | 15 +++++++++++++++ 8 files changed, 18 insertions(+), 15 deletions(-) delete mode 100644 internal/ui/embed.go rename {internal/ui/public/assets => web/static}/css/custom.css (100%) rename {internal/ui/public/assets => web/static}/css/tailwind.css (100%) rename {internal/ui/public/assets => web/static}/js/htmx.min.js (100%) rename {internal/ui/public => web/templates}/fragments/header.html (100%) rename {internal/ui/public => web/templates}/pages/spotify/auth.html (100%) create mode 100644 web/web.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 5d5d066..ccb575b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,12 +6,12 @@ import ( "beyerleinf/spotify-backup/internal/api/router" "beyerleinf/spotify-backup/internal/config" "beyerleinf/spotify-backup/internal/global" - "beyerleinf/spotify-backup/internal/ui" uiHandler "beyerleinf/spotify-backup/internal/ui/handler" uiRouter "beyerleinf/spotify-backup/internal/ui/router" uiTmpl "beyerleinf/spotify-backup/internal/ui/template" logger "beyerleinf/spotify-backup/pkg/log" "beyerleinf/spotify-backup/pkg/service/spotify" + "beyerleinf/spotify-backup/web" "context" "fmt" "log" @@ -73,13 +73,13 @@ func main() { router.HealthRoutes(healthHandler), ) - renderer, err := uiTmpl.NewRenderer(ui.PublicFS) + renderer, err := uiTmpl.NewRenderer(web.TemplatesFS) if err != nil { slog.Fatal("Failed to initialize renderer", "err", err) } e.Renderer = renderer - e.StaticFS("/", ui.StaticFS) + e.StaticFS("/", web.StaticFS) spotifyService := spotify.New(client) diff --git a/internal/ui/embed.go b/internal/ui/embed.go deleted file mode 100644 index 9fb25ee..0000000 --- a/internal/ui/embed.go +++ /dev/null @@ -1,12 +0,0 @@ -package ui - -import ( - "embed" - - "github.com/labstack/echo/v4" -) - -//go:embed public/* -var public embed.FS -var PublicFS = echo.MustSubFS(public, "public") -var StaticFS = echo.MustSubFS(public, "public/assets") diff --git a/internal/ui/public/assets/css/custom.css b/web/static/css/custom.css similarity index 100% rename from internal/ui/public/assets/css/custom.css rename to web/static/css/custom.css diff --git a/internal/ui/public/assets/css/tailwind.css b/web/static/css/tailwind.css similarity index 100% rename from internal/ui/public/assets/css/tailwind.css rename to web/static/css/tailwind.css diff --git a/internal/ui/public/assets/js/htmx.min.js b/web/static/js/htmx.min.js similarity index 100% rename from internal/ui/public/assets/js/htmx.min.js rename to web/static/js/htmx.min.js diff --git a/internal/ui/public/fragments/header.html b/web/templates/fragments/header.html similarity index 100% rename from internal/ui/public/fragments/header.html rename to web/templates/fragments/header.html diff --git a/internal/ui/public/pages/spotify/auth.html b/web/templates/pages/spotify/auth.html similarity index 100% rename from internal/ui/public/pages/spotify/auth.html rename to web/templates/pages/spotify/auth.html diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..4a8c83b --- /dev/null +++ b/web/web.go @@ -0,0 +1,15 @@ +package web + +import ( + "embed" + + "github.com/labstack/echo/v4" +) + +//go:embed templates/* +var templates embed.FS +var TemplatesFS = echo.MustSubFS(templates, "templates") + +//go:embed static/* +var static embed.FS +var StaticFS = echo.MustSubFS(static, "static") From 9e1163448bf512fd671e90f4482f200efd97696f Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 22:23:52 +0200 Subject: [PATCH 10/18] chore: Refactor globals to be service arguments --- cmd/server/main.go | 27 ++++++++++++++++++--------- internal/global/global.go | 3 --- pkg/service/spotify/auth.go | 15 ++------------- pkg/service/spotify/spotify.go | 4 +++- 4 files changed, 23 insertions(+), 26 deletions(-) delete mode 100644 internal/global/global.go diff --git a/cmd/server/main.go b/cmd/server/main.go index ccb575b..22be4af 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,8 +23,10 @@ import ( _ "github.com/lib/pq" ) +const StorageDir = ".spotify-backup" + func main() { - slog := logger.New("main", logger.LevelInfo) + slogger := logger.New("main", logger.LevelInfo) err := config.LoadConfig() if err != nil { @@ -32,9 +34,9 @@ func main() { panic(err) } - slog.SetLogLevel(config.AppConfig.Server.LogLevel) + slogger.SetLogLevel(config.AppConfig.Server.LogLevel) - createStorageDir(slog) + createStorageDir(slogger) dbUrl := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", config.AppConfig.Database.Host, @@ -46,17 +48,24 @@ func main() { client, err := ent.Open("postgres", dbUrl) if err != nil { - slog.Fatal("Failed opening connection to postgres", "err", err) + slogger.Fatal("Failed opening connection to postgres", "err", err) panic(err) } defer client.Close() if err := client.Schema.Create(context.Background()); err != nil { - slog.Fatal("Failed creating schema resources", "err", err) + slogger.Fatal("Failed creating schema resources", "err", err) panic(err) } - slog.Info("Connected to database") + homeDir, err := os.UserHomeDir() + if err != nil { + slogger.Error("Error getting home directory", "err", err) + } + + storageDir := filepath.Join(homeDir, StorageDir) + + slogger.Info("Connected to database") healthHandler := handler.NewHealthHandler(client) @@ -75,13 +84,13 @@ func main() { renderer, err := uiTmpl.NewRenderer(web.TemplatesFS) if err != nil { - slog.Fatal("Failed to initialize renderer", "err", err) + slogger.Fatal("Failed to initialize renderer", "err", err) } e.Renderer = renderer e.StaticFS("/", web.StaticFS) - spotifyService := spotify.New(client) + spotifyService := spotify.New(client, storageDir) spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService) @@ -89,7 +98,7 @@ func main() { uiRouter.SpotifyRoutes(spotifyHandler), ) - slog.Info(fmt.Sprintf("Starting server on [::]:%d", config.AppConfig.Server.Port)) + slogger.Info(fmt.Sprintf("Starting server on [::]:%d", config.AppConfig.Server.Port)) e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", config.AppConfig.Server.Port))) } diff --git a/internal/global/global.go b/internal/global/global.go deleted file mode 100644 index 3a1346f..0000000 --- a/internal/global/global.go +++ /dev/null @@ -1,3 +0,0 @@ -package global - -const StorageDir = ".spotify-backup" diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index c3333ab..a64c46f 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -2,7 +2,6 @@ package spotify import ( "beyerleinf/spotify-backup/internal/config" - "beyerleinf/spotify-backup/internal/global" http_utils "beyerleinf/spotify-backup/pkg/http" "crypto/aes" "crypto/cipher" @@ -176,12 +175,7 @@ func (s *SpotifyService) saveToken() { return } - homeDir, err := os.UserHomeDir() - if err != nil { - s.slogger.Error("Error getting home directory", "err", err) - } - - tokenPath := filepath.Join(homeDir, global.StorageDir, tokenFile) + tokenPath := filepath.Join(s.storageDir, tokenFile) err = os.WriteFile(tokenPath, encryptedData, 0600) if err != nil { @@ -191,12 +185,7 @@ func (s *SpotifyService) saveToken() { } func (s *SpotifyService) loadToken() { - homeDir, err := os.UserHomeDir() - if err != nil { - s.slogger.Error("Error getting home directory", "err", err) - } - - tokenPath := filepath.Join(homeDir, global.StorageDir, tokenFile) + tokenPath := filepath.Join(s.storageDir, tokenFile) encryptedData, err := os.ReadFile(tokenPath) if err != nil { diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index c557ddb..3455821 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -15,14 +15,16 @@ type SpotifyService struct { db *ent.Client state string redirectUri string + storageDir string } -func New(db *ent.Client) *SpotifyService { +func New(db *ent.Client, storageDir string) *SpotifyService { return &SpotifyService{ slogger: logger.New("spotify", config.AppConfig.Server.LogLevel.Level()), db: db, state: util.GenerateRandomString(16), redirectUri: fmt.Sprintf("%s/ui/spotify/callback", config.AppConfig.Spotify.RedirectUri), + storageDir: storageDir, } } From c178102aee3c1b4e14fe8f5397ec38c127a28418 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Fri, 11 Oct 2024 22:40:43 +0200 Subject: [PATCH 11/18] chore: Restructure application code --- cmd/server/main.go | 66 +++++++++---------- internal/api/router/health_routes.go | 20 ------ internal/{ => server}/api/handler/health.go | 10 +-- internal/server/api/router/health_routes.go | 21 ++++++ internal/{ => server}/config/config.go | 17 +++-- internal/{ => server}/ui/handler/spotify.go | 18 +++-- .../{ => server}/ui/router/spotify_routes.go | 4 +- .../ui/template/template_renderer.go | 0 pkg/{log/log.go => logger/logger.go} | 0 {internal/api => pkg}/router/router.go | 0 pkg/service/spotify/auth.go | 13 ++-- pkg/service/spotify/spotify.go | 12 ++-- 12 files changed, 91 insertions(+), 90 deletions(-) delete mode 100644 internal/api/router/health_routes.go rename internal/{ => server}/api/handler/health.go (73%) create mode 100644 internal/server/api/router/health_routes.go rename internal/{ => server}/config/config.go (84%) rename internal/{ => server}/ui/handler/spotify.go (78%) rename internal/{ => server}/ui/router/spotify_routes.go (80%) rename internal/{ => server}/ui/template/template_renderer.go (100%) rename pkg/{log/log.go => logger/logger.go} (100%) rename {internal/api => pkg}/router/router.go (100%) diff --git a/cmd/server/main.go b/cmd/server/main.go index 22be4af..ddb53b5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,14 +2,14 @@ package main import ( "beyerleinf/spotify-backup/ent" - "beyerleinf/spotify-backup/internal/api/handler" - "beyerleinf/spotify-backup/internal/api/router" - "beyerleinf/spotify-backup/internal/config" - "beyerleinf/spotify-backup/internal/global" - uiHandler "beyerleinf/spotify-backup/internal/ui/handler" - uiRouter "beyerleinf/spotify-backup/internal/ui/router" - uiTmpl "beyerleinf/spotify-backup/internal/ui/template" - logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/internal/server/api/handler" + apiRouter "beyerleinf/spotify-backup/internal/server/api/router" + "beyerleinf/spotify-backup/internal/server/config" + uiHandler "beyerleinf/spotify-backup/internal/server/ui/handler" + uiRouter "beyerleinf/spotify-backup/internal/server/ui/router" + uiTmpl "beyerleinf/spotify-backup/internal/server/ui/template" + "beyerleinf/spotify-backup/pkg/logger" + "beyerleinf/spotify-backup/pkg/router" "beyerleinf/spotify-backup/pkg/service/spotify" "beyerleinf/spotify-backup/web" "context" @@ -28,25 +28,25 @@ const StorageDir = ".spotify-backup" func main() { slogger := logger.New("main", logger.LevelInfo) - err := config.LoadConfig() + cfg, err := config.LoadConfig() if err != nil { log.Fatalln("Failed to load config: %w", err) panic(err) } - slogger.SetLogLevel(config.AppConfig.Server.LogLevel) + slogger.SetLogLevel(cfg.Server.LogLevel) - createStorageDir(slogger) + storageDir := createStorageDir(slogger) - dbUrl := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", - config.AppConfig.Database.Host, - config.AppConfig.Database.Port, - config.AppConfig.Database.Username, - config.AppConfig.Database.DBName, - config.AppConfig.Database.Password, + dbURL := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", + cfg.Database.Host, + cfg.Database.Port, + cfg.Database.Username, + cfg.Database.DBName, + cfg.Database.Password, ) - client, err := ent.Open("postgres", dbUrl) + client, err := ent.Open("postgres", dbURL) if err != nil { slogger.Fatal("Failed opening connection to postgres", "err", err) panic(err) @@ -58,16 +58,9 @@ func main() { panic(err) } - homeDir, err := os.UserHomeDir() - if err != nil { - slogger.Error("Error getting home directory", "err", err) - } - - storageDir := filepath.Join(homeDir, StorageDir) - slogger.Info("Connected to database") - healthHandler := handler.NewHealthHandler(client) + healthHandler := handler.NewHealthHandler(client, cfg) e := echo.New() e.HideBanner = true @@ -79,7 +72,7 @@ func main() { uiBase := e.Group("/ui") router.SetupRoutes(apiBase, - router.HealthRoutes(healthHandler), + apiRouter.HealthRoutes(healthHandler), ) renderer, err := uiTmpl.NewRenderer(web.TemplatesFS) @@ -90,26 +83,25 @@ func main() { e.Renderer = renderer e.StaticFS("/", web.StaticFS) - spotifyService := spotify.New(client, storageDir) + spotifyService := spotify.New(client, cfg, storageDir) - spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService) + spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService, cfg) router.SetupRoutes(uiBase, uiRouter.SpotifyRoutes(spotifyHandler), ) - slogger.Info(fmt.Sprintf("Starting server on [::]:%d", config.AppConfig.Server.Port)) - e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", config.AppConfig.Server.Port))) + slogger.Info(fmt.Sprintf("Starting server on [::]:%d", cfg.Server.Port)) + e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", cfg.Server.Port))) } -func createStorageDir(slogger *logger.Logger) { +func createStorageDir(slogger *logger.Logger) string { homeDir, err := os.UserHomeDir() if err != nil { - slogger.Fatal("Error getting home directory", "err", err) - panic(1) + slogger.Error("Error getting home directory", "err", err) } - storageDir := filepath.Join(homeDir, global.StorageDir) + storageDir := filepath.Join(homeDir, StorageDir) err = os.MkdirAll(storageDir, 0755) if err != nil { @@ -117,5 +109,7 @@ func createStorageDir(slogger *logger.Logger) { panic(1) } - slogger.Verbose(fmt.Sprintf("Storage directory at %s created/exists.", storageDir)) + slogger.Verbose(fmt.Sprintf("Using storage directory at %s.", storageDir)) + + return storageDir } diff --git a/internal/api/router/health_routes.go b/internal/api/router/health_routes.go deleted file mode 100644 index 27d1c0c..0000000 --- a/internal/api/router/health_routes.go +++ /dev/null @@ -1,20 +0,0 @@ -package router - -import ( - "beyerleinf/spotify-backup/internal/api/handler" - - "github.com/labstack/echo/v4" -) - -func HealthRoutes(healthHandler *handler.HealthHandler) RouteGroup { - return RouteGroup{ - Prefix: "/health", - Routes: []Route{ - { - Method: echo.GET, - Path: "", - Handler: healthHandler.GetHealthStatus, - }, - }, - } -} \ No newline at end of file diff --git a/internal/api/handler/health.go b/internal/server/api/handler/health.go similarity index 73% rename from internal/api/handler/health.go rename to internal/server/api/handler/health.go index 940e829..836ef93 100644 --- a/internal/api/handler/health.go +++ b/internal/server/api/handler/health.go @@ -2,8 +2,8 @@ package handler import ( "beyerleinf/spotify-backup/ent" - "beyerleinf/spotify-backup/internal/config" - logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/internal/server/config" + "beyerleinf/spotify-backup/pkg/logger" "context" "fmt" "net/http" @@ -14,12 +14,14 @@ import ( type HealthHandler struct { slogger *logger.Logger db *ent.Client + config *config.Config } -func NewHealthHandler(db *ent.Client) *HealthHandler { +func NewHealthHandler(db *ent.Client, config *config.Config) *HealthHandler { return &HealthHandler{ - slogger: logger.New("health-check", config.AppConfig.Server.LogLevel), + slogger: logger.New("health-check", config.Server.LogLevel), db: db, + config: config, } } diff --git a/internal/server/api/router/health_routes.go b/internal/server/api/router/health_routes.go new file mode 100644 index 0000000..253dd8a --- /dev/null +++ b/internal/server/api/router/health_routes.go @@ -0,0 +1,21 @@ +package router + +import ( + "beyerleinf/spotify-backup/internal/server/api/handler" + "beyerleinf/spotify-backup/pkg/router" + + "github.com/labstack/echo/v4" +) + +func HealthRoutes(healthHandler *handler.HealthHandler) router.RouteGroup { + return router.RouteGroup{ + Prefix: "/health", + Routes: []router.Route{ + { + Method: echo.GET, + Path: "", + Handler: healthHandler.GetHealthStatus, + }, + }, + } +} diff --git a/internal/config/config.go b/internal/server/config/config.go similarity index 84% rename from internal/config/config.go rename to internal/server/config/config.go index 79c9af9..67fb1b3 100644 --- a/internal/config/config.go +++ b/internal/server/config/config.go @@ -1,7 +1,7 @@ package config import ( - logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/pkg/logger" "fmt" goslog "log/slog" "strings" @@ -35,9 +35,7 @@ type SpotifyConfig struct { RedirectUri string `mapstructure:"redirect_uri" env:"REDIRECT_URI"` } -var AppConfig Config - -func LoadConfig() error { +func LoadConfig() (*Config, error) { slog := logger.New("config", logger.LevelTrace) viper.SetConfigName("config") @@ -61,16 +59,17 @@ func LoadConfig() error { if _, ok := err.(viper.ConfigFileNotFoundError); ok { slog.Warn("No config file found. Using environment variables.") } else { - return fmt.Errorf("error reading config file: %w", err) + return &Config{}, fmt.Errorf("error reading config file: %w", err) } } - err := viper.Unmarshal(&AppConfig) + var config Config + err := viper.Unmarshal(&config) if err != nil { - return fmt.Errorf("unable to decode into struct: %w", err) + return &Config{}, fmt.Errorf("unable to decode into struct: %w", err) } - slog.Trace("Loaded config", "config", AppConfig) + slog.Trace("Loaded config", "config", config) - return nil + return &config, nil } diff --git a/internal/ui/handler/spotify.go b/internal/server/ui/handler/spotify.go similarity index 78% rename from internal/ui/handler/spotify.go rename to internal/server/ui/handler/spotify.go index fc9a552..baf5bab 100644 --- a/internal/ui/handler/spotify.go +++ b/internal/server/ui/handler/spotify.go @@ -1,8 +1,8 @@ -package ui +package handler import ( - "beyerleinf/spotify-backup/internal/config" - logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/internal/server/config" + "beyerleinf/spotify-backup/pkg/logger" "beyerleinf/spotify-backup/pkg/service/spotify" "net/http" @@ -12,12 +12,16 @@ import ( type SpotifyHandler struct { slogger *logger.Logger spotifyService *spotify.SpotifyService + config *config.Config } -func NewSpotifyHandler(spotifyService *spotify.SpotifyService) *SpotifyHandler { +const pageTitle = "Spotify Settings | Spotify Backup" + +func NewSpotifyHandler(spotifyService *spotify.SpotifyService, config *config.Config) *SpotifyHandler { return &SpotifyHandler{ - slogger: logger.New("spotify-ui", config.AppConfig.Server.LogLevel), + slogger: logger.New("spotify-ui", config.Server.LogLevel), spotifyService: spotifyService, + config: config, } } @@ -63,14 +67,14 @@ func (s *SpotifyHandler) SpotifyAuthPage(c echo.Context) error { s.slogger.Error("Failed to load user profile. Not authenticated?", "err", err) return c.Render(http.StatusOK, "spotify_auth", map[string]any{ - "Title": "Spotify Settings | Spotify Backup", + "Title": pageTitle, "SpotifyAuthUrl": spotifyAuthUrl, "HasError": authError, }) } return c.Render(http.StatusOK, "spotify_auth", map[string]any{ - "Title": "Spotify Settings | Spotify Backup", + "Title": pageTitle, "SpotifyAuthUrl": spotifyAuthUrl, "HasError": authError, "Profile": profile, diff --git a/internal/ui/router/spotify_routes.go b/internal/server/ui/router/spotify_routes.go similarity index 80% rename from internal/ui/router/spotify_routes.go rename to internal/server/ui/router/spotify_routes.go index 8e92964..461c62b 100644 --- a/internal/ui/router/spotify_routes.go +++ b/internal/server/ui/router/spotify_routes.go @@ -1,8 +1,8 @@ package ui import ( - "beyerleinf/spotify-backup/internal/api/router" - handler "beyerleinf/spotify-backup/internal/ui/handler" + "beyerleinf/spotify-backup/internal/server/ui/handler" + "beyerleinf/spotify-backup/pkg/router" "github.com/labstack/echo/v4" ) diff --git a/internal/ui/template/template_renderer.go b/internal/server/ui/template/template_renderer.go similarity index 100% rename from internal/ui/template/template_renderer.go rename to internal/server/ui/template/template_renderer.go diff --git a/pkg/log/log.go b/pkg/logger/logger.go similarity index 100% rename from pkg/log/log.go rename to pkg/logger/logger.go diff --git a/internal/api/router/router.go b/pkg/router/router.go similarity index 100% rename from internal/api/router/router.go rename to pkg/router/router.go diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index a64c46f..e850d49 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -1,7 +1,6 @@ package spotify import ( - "beyerleinf/spotify-backup/internal/config" http_utils "beyerleinf/spotify-backup/pkg/http" "crypto/aes" "crypto/cipher" @@ -29,7 +28,7 @@ func (s *SpotifyService) GetAuthUrl() string { scope := url.QueryEscape("playlist-read-private user-read-private") return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", - config.AppConfig.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, + s.config.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, ) } @@ -43,7 +42,7 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { form.Add("code", code) form.Add("redirect_uri", s.redirectUri) - clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) + clientIdAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientId, s.config.Spotify.ClientSecret) authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) headers := map[string][]string{ @@ -122,9 +121,9 @@ func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { form := url.Values{} form.Add("grant_type", "refresh_token") form.Add("refresh_token", refreshToken) - form.Add("client_id", config.AppConfig.Spotify.ClientId) + form.Add("client_id", s.config.Spotify.ClientId) - clientIdAndSecret := fmt.Sprintf("%s:%s", config.AppConfig.Spotify.ClientId, config.AppConfig.Spotify.ClientSecret) + clientIdAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientId, s.config.Spotify.ClientSecret) authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) headers := map[string][]string{ @@ -214,7 +213,7 @@ func (s *SpotifyService) loadToken() { } func (s *SpotifyService) encryptToken(data []byte) ([]byte, error) { - block, err := aes.NewCipher([]byte(config.AppConfig.EncryptionKey)) + block, err := aes.NewCipher([]byte(s.config.EncryptionKey)) if err != nil { return nil, err } @@ -233,7 +232,7 @@ func (s *SpotifyService) encryptToken(data []byte) ([]byte, error) { } func (s *SpotifyService) decryptToken(data []byte) ([]byte, error) { - block, err := aes.NewCipher([]byte(config.AppConfig.EncryptionKey)) + block, err := aes.NewCipher([]byte(s.config.EncryptionKey)) if err != nil { return nil, err } diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 3455821..7c234ae 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -2,9 +2,9 @@ package spotify import ( "beyerleinf/spotify-backup/ent" - "beyerleinf/spotify-backup/internal/config" + "beyerleinf/spotify-backup/internal/server/config" http_utils "beyerleinf/spotify-backup/pkg/http" - logger "beyerleinf/spotify-backup/pkg/log" + "beyerleinf/spotify-backup/pkg/logger" util "beyerleinf/spotify-backup/pkg/util" "encoding/json" "fmt" @@ -13,18 +13,20 @@ import ( type SpotifyService struct { slogger *logger.Logger db *ent.Client + config *config.Config state string redirectUri string storageDir string } -func New(db *ent.Client, storageDir string) *SpotifyService { +func New(db *ent.Client, config *config.Config, storageDir string) *SpotifyService { return &SpotifyService{ - slogger: logger.New("spotify", config.AppConfig.Server.LogLevel.Level()), + slogger: logger.New("spotify", config.Server.LogLevel.Level()), db: db, state: util.GenerateRandomString(16), - redirectUri: fmt.Sprintf("%s/ui/spotify/callback", config.AppConfig.Spotify.RedirectUri), + redirectUri: fmt.Sprintf("%s/ui/spotify/callback", config.Spotify.RedirectUri), storageDir: storageDir, + config: config, } } From a5b2033f7b76619a7ed6a902412d5fe8bd1f13f0 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 00:50:18 +0200 Subject: [PATCH 12/18] chore: Fix all (?) lint errors --- .golangci.yml | 5 ++ cmd/server/main.go | 10 ++-- internal/server/api/handler/health.go | 8 ++- internal/server/api/router/health_routes.go | 1 + internal/server/config/config.go | 21 +++++--- internal/server/ui/handler/spotify.go | 32 +++++++----- internal/server/ui/router/spotify_routes.go | 3 +- .../server/ui/template/template_renderer.go | 1 + pkg/logger/logger.go | 22 ++++++-- pkg/{http/http.go => request/request.go} | 5 +- pkg/router/router.go | 15 +++--- pkg/service/spotify/auth.go | 50 ++++++++++++------- pkg/service/spotify/errors.go | 6 ++- pkg/service/spotify/models.go | 16 +++--- pkg/service/spotify/spotify.go | 28 ++++++----- pkg/util/random_string.go | 2 + web/templates/pages/spotify/auth.html | 38 -------------- web/templates/pages/spotify/settings.html | 34 +++++++++++++ 18 files changed, 179 insertions(+), 118 deletions(-) create mode 100644 .golangci.yml rename pkg/{http/http.go => request/request.go} (89%) delete mode 100644 web/templates/pages/spotify/auth.html create mode 100644 web/templates/pages/spotify/settings.html diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..82156f3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,5 @@ +linters: + enable: + - misspell + - perfsprint + - noctx diff --git a/cmd/server/main.go b/cmd/server/main.go index ddb53b5..2add623 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,7 @@ import ( _ "github.com/lib/pq" ) -const StorageDir = ".spotify-backup" +const storageDir = ".spotify-backup" func main() { slogger := logger.New("main", logger.LevelInfo) @@ -101,15 +101,15 @@ func createStorageDir(slogger *logger.Logger) string { slogger.Error("Error getting home directory", "err", err) } - storageDir := filepath.Join(homeDir, StorageDir) + dir := filepath.Join(homeDir, storageDir) - err = os.MkdirAll(storageDir, 0755) + err = os.MkdirAll(dir, 0755) if err != nil { slogger.Fatal("Failed to create storage dir", "err", err) panic(1) } - slogger.Verbose(fmt.Sprintf("Using storage directory at %s.", storageDir)) + slogger.Verbose(fmt.Sprintf("Using storage directory at %s.", dir)) - return storageDir + return dir } diff --git a/internal/server/api/handler/health.go b/internal/server/api/handler/health.go index 836ef93..d8cfae4 100644 --- a/internal/server/api/handler/health.go +++ b/internal/server/api/handler/health.go @@ -11,12 +11,14 @@ import ( "github.com/labstack/echo/v4" ) +// A HealthHandler instance. type HealthHandler struct { slogger *logger.Logger db *ent.Client config *config.Config } +// NewHealthHandler creates a new instance of the [HealthHandler]. func NewHealthHandler(db *ent.Client, config *config.Config) *HealthHandler { return &HealthHandler{ slogger: logger.New("health-check", config.Server.LogLevel), @@ -25,14 +27,16 @@ func NewHealthHandler(db *ent.Client, config *config.Config) *HealthHandler { } } +// GetHealthStatus checks the health status of various components and +// returns an API response. func (h *HealthHandler) GetHealthStatus(c echo.Context) error { - db_err := h.testDBConnection() + dbErr := h.testDBConnection() res := map[string]string{ "status": "ok", } - if db_err != nil { + if dbErr != nil { res["database"] = "err" } else { res["database"] = "ok" diff --git a/internal/server/api/router/health_routes.go b/internal/server/api/router/health_routes.go index 253dd8a..b26a31e 100644 --- a/internal/server/api/router/health_routes.go +++ b/internal/server/api/router/health_routes.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" ) +// HealthRoutes returns all routes associated with the /health route. func HealthRoutes(healthHandler *handler.HealthHandler) router.RouteGroup { return router.RouteGroup{ Prefix: "/health", diff --git a/internal/server/config/config.go b/internal/server/config/config.go index 67fb1b3..6ac1c3a 100644 --- a/internal/server/config/config.go +++ b/internal/server/config/config.go @@ -3,12 +3,13 @@ package config import ( "beyerleinf/spotify-backup/pkg/logger" "fmt" - goslog "log/slog" + "log/slog" "strings" "github.com/spf13/viper" ) +// Config is the root level configuration struct. type Config struct { Server ServerConfig `mapstructure:"server" env:"SERVER"` Database DatabaseConfig `mapstructure:"database" env:"DB"` @@ -16,11 +17,13 @@ type Config struct { EncryptionKey string `mapstructure:"encryption_key" env:"ENCRYPTION_KEY"` } +// ServerConfig contains setting relating to the http server and the application in general. type ServerConfig struct { - Port int `mapstructure:"port" env:"PORT"` - LogLevel goslog.Level `mapstructure:"loglevel" env:"LOGLEVEL"` + Port int `mapstructure:"port" env:"PORT"` + LogLevel slog.Level `mapstructure:"loglevel" env:"LOGLEVEL"` } +// DatabaseConfig contains all database related settings. type DatabaseConfig struct { Host string `mapstructure:"host" env:"HOST"` Port int `mapstructure:"port" env:"PORT"` @@ -29,14 +32,16 @@ type DatabaseConfig struct { DBName string `mapstructure:"db_name" env:"NAME"` } +// SpotifyConfig contains all Spotify API related settings. type SpotifyConfig struct { - ClientId string `mapstructure:"client_id" env:"CLIENT_ID"` + ClientID string `mapstructure:"client_id" env:"CLIENT_ID"` ClientSecret string `mapstructure:"client_secret" env:"CLIENT_SECRET"` - RedirectUri string `mapstructure:"redirect_uri" env:"REDIRECT_URI"` + RedirectURI string `mapstructure:"redirect_uri" env:"REDIRECT_URI"` } +// LoadConfig uses viper to load the configuration file. func LoadConfig() (*Config, error) { - slog := logger.New("config", logger.LevelTrace) + slogger := logger.New("config", logger.LevelTrace) viper.SetConfigName("config") viper.SetConfigType("yaml") @@ -57,7 +62,7 @@ func LoadConfig() (*Config, error) { if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { - slog.Warn("No config file found. Using environment variables.") + slogger.Warn("No config file found. Using environment variables.") } else { return &Config{}, fmt.Errorf("error reading config file: %w", err) } @@ -69,7 +74,7 @@ func LoadConfig() (*Config, error) { return &Config{}, fmt.Errorf("unable to decode into struct: %w", err) } - slog.Trace("Loaded config", "config", config) + slogger.Trace("Loaded config", "config", config) return &config, nil } diff --git a/internal/server/ui/handler/spotify.go b/internal/server/ui/handler/spotify.go index baf5bab..04e995e 100644 --- a/internal/server/ui/handler/spotify.go +++ b/internal/server/ui/handler/spotify.go @@ -9,15 +9,17 @@ import ( "github.com/labstack/echo/v4" ) +// A SpotifyHandler instance. type SpotifyHandler struct { slogger *logger.Logger - spotifyService *spotify.SpotifyService + spotifyService *spotify.Service config *config.Config } const pageTitle = "Spotify Settings | Spotify Backup" -func NewSpotifyHandler(spotifyService *spotify.SpotifyService, config *config.Config) *SpotifyHandler { +// NewSpotifyHandler creates a new instance. +func NewSpotifyHandler(spotifyService *spotify.Service, config *config.Config) *SpotifyHandler { return &SpotifyHandler{ slogger: logger.New("spotify-ui", config.Server.LogLevel), spotifyService: spotifyService, @@ -25,6 +27,7 @@ func NewSpotifyHandler(spotifyService *spotify.SpotifyService, config *config.Co } } +// SpotifyAuthCallbackPage handles callback requests from Spotify's Authentication API. func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { code := c.QueryParams().Get("code") state := c.QueryParams().Get("state") @@ -58,25 +61,28 @@ func (s *SpotifyHandler) SpotifyAuthCallbackPage(c echo.Context) error { return nil } -func (s *SpotifyHandler) SpotifyAuthPage(c echo.Context) error { - spotifyAuthUrl := s.spotifyService.GetAuthUrl() +// SpotifySettingsPage serves the Spotify settings page. +func (s *SpotifyHandler) SpotifySettingsPage(c echo.Context) error { + const templateName = "spotify_settings" + + authURL := s.spotifyService.GetAuthURL() authError := c.QueryParams().Get("error") profile, err := s.spotifyService.GetUserProfile() if err != nil { s.slogger.Error("Failed to load user profile. Not authenticated?", "err", err) - return c.Render(http.StatusOK, "spotify_auth", map[string]any{ - "Title": pageTitle, - "SpotifyAuthUrl": spotifyAuthUrl, - "HasError": authError, + return c.Render(http.StatusOK, templateName, map[string]any{ + "Title": pageTitle, + "AuthURL": authURL, + "HasError": authError, }) } - return c.Render(http.StatusOK, "spotify_auth", map[string]any{ - "Title": pageTitle, - "SpotifyAuthUrl": spotifyAuthUrl, - "HasError": authError, - "Profile": profile, + return c.Render(http.StatusOK, templateName, map[string]any{ + "Title": pageTitle, + "AuthURL": authURL, + "HasError": authError, + "Profile": profile, }) } diff --git a/internal/server/ui/router/spotify_routes.go b/internal/server/ui/router/spotify_routes.go index 461c62b..f75dce2 100644 --- a/internal/server/ui/router/spotify_routes.go +++ b/internal/server/ui/router/spotify_routes.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" ) +// SpotifyRoutes returns all routes associated with the /spotify route. func SpotifyRoutes(spotifyHandler *handler.SpotifyHandler) router.RouteGroup { return router.RouteGroup{ Prefix: "/spotify", @@ -14,7 +15,7 @@ func SpotifyRoutes(spotifyHandler *handler.SpotifyHandler) router.RouteGroup { { Method: echo.GET, Path: "/auth", - Handler: spotifyHandler.SpotifyAuthPage, + Handler: spotifyHandler.SpotifySettingsPage, }, { Method: echo.GET, diff --git a/internal/server/ui/template/template_renderer.go b/internal/server/ui/template/template_renderer.go index c15c279..90f2cb8 100644 --- a/internal/server/ui/template/template_renderer.go +++ b/internal/server/ui/template/template_renderer.go @@ -9,6 +9,7 @@ import ( "github.com/labstack/echo/v4" ) +// A TemplateRenderer instance. type TemplateRenderer struct { Templates *template.Template BuildVersion string diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index e75999e..d5e091d 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -11,12 +11,14 @@ import ( "github.com/labstack/echo/v4/middleware" ) +// A Logger instance. type Logger struct { slogger *slog.Logger logLevel *slog.LevelVar area string } +// A log level. const ( LevelTrace = slog.Level(-8) LevelVerbose = slog.Level(-4) @@ -26,6 +28,7 @@ const ( LevelFatal = slog.Level(12) ) +// LevelNames contains printable names for each log level. var LevelNames = map[slog.Leveler]string{ LevelTrace: "TRACE", LevelVerbose: "VERBOSE", @@ -40,6 +43,7 @@ var filteredQueryParams = [...]string{ "state", } +// New creates a new logger instance. func New(area string, level slog.Level) *Logger { logLevel := new(slog.LevelVar) logLevel.Set(level) @@ -69,40 +73,48 @@ func New(area string, level slog.Level) *Logger { } } +// SetLogLevel changes the log level of this logger instance. func (l *Logger) SetLogLevel(level slog.Level) { l.logLevel.Set(level) } +// Info logs a INFO log message. func (l *Logger) Info(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelInfo, msg, args...) } +// Warn logs a WARN log message. func (l *Logger) Warn(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelWarn, msg, args...) } +// Verbose logs a VERBOSE log message. func (l *Logger) Verbose(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelVerbose, msg, args...) } +// Trace logs a TRACE log message. func (l *Logger) Trace(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelTrace, msg, args...) } +// Error logs a ERROR log message. func (l *Logger) Error(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelError, msg, args...) } +// Fatal logs a FATAL log message. func (l *Logger) Fatal(msg string, args ...any) { ctx := context.Background() l.slogger.Log(ctx, LevelFatal, msg, args...) } +// GetEchoLogger creates a logger middleware for use with the echo http server. func GetEchoLogger() echo.MiddlewareFunc { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) @@ -112,29 +124,29 @@ func GetEchoLogger() echo.MiddlewareFunc { LogError: true, HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error { - parsedUrl, err := url.Parse(v.URI) + parsedURL, err := url.Parse(v.URI) if err != nil { return err } - query := parsedUrl.Query() + query := parsedURL.Query() for key := range query { if slices.Contains(filteredQueryParams[:], key) { query[key] = []string{"REDACTED"} } } - parsedUrl.RawQuery = query.Encode() + parsedURL.RawQuery = query.Encode() if v.Error == nil { logger.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST", slog.String("method", v.Method), - slog.String("uri", parsedUrl.RequestURI()), + slog.String("uri", parsedURL.RequestURI()), slog.Int("status", v.Status), ) } else { logger.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR", slog.String("method", v.Method), - slog.String("uri", parsedUrl.RequestURI()), + slog.String("uri", parsedURL.RequestURI()), slog.Int("status", v.Status), slog.String("err", v.Error.Error()), ) diff --git a/pkg/http/http.go b/pkg/request/request.go similarity index 89% rename from pkg/http/http.go rename to pkg/request/request.go index d73e649..a8909ea 100644 --- a/pkg/http/http.go +++ b/pkg/request/request.go @@ -1,10 +1,11 @@ -package http_utils +package request import ( "io" "net/http" ) +// Post sends a POST request. func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { req, err := http.NewRequest("POST", url, body) if err != nil { @@ -28,6 +29,7 @@ func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, return data, res.StatusCode, nil } +// PostForm sends a POST request with a application/x-www-form-urlencoded body. func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { req, err := http.NewRequest("POST", url, body) if err != nil { @@ -52,6 +54,7 @@ func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, return data, res.StatusCode, nil } +// Get sends a GET request. func Get(url string, headers map[string][]string) ([]byte, int, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/pkg/router/router.go b/pkg/router/router.go index e43dac9..75066cc 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -4,6 +4,14 @@ import ( "github.com/labstack/echo/v4" ) +// A RouteGroup is a collection of routes under a common prefix like /foo/bar, /foo/baz, etc. +type RouteGroup struct { + Prefix string + Middlewares []echo.MiddlewareFunc + Routes []Route +} + +// A Route is a single endpoint like POST /bar and the associated handler and middlewares. type Route struct { Method string Path string @@ -11,12 +19,7 @@ type Route struct { Middlewares []echo.MiddlewareFunc } -type RouteGroup struct { - Prefix string - Middlewares []echo.MiddlewareFunc - Routes []Route -} - +// SetupRoutes adds routes to a echo group func SetupRoutes(root *echo.Group, groups ...RouteGroup) { for _, group := range groups { g := root.Group(group.Prefix, group.Middlewares...) diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index e850d49..e2d757c 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -1,7 +1,7 @@ package spotify import ( - http_utils "beyerleinf/spotify-backup/pkg/http" + "beyerleinf/spotify-backup/pkg/request" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -24,15 +24,20 @@ const tokenFile = "token.bin" var authToken *AuthToken -func (s *SpotifyService) GetAuthUrl() string { +// GetAuthURL returns a URL to redirect a user to sign in with Spotify. +func (s *Service) GetAuthURL() string { scope := url.QueryEscape("playlist-read-private user-read-private") return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", - s.config.Spotify.ClientId, scope, url.QueryEscape(s.redirectUri), s.state, + s.config.Spotify.ClientID, scope, url.QueryEscape(s.redirectURI), s.state, ) } -func (s *SpotifyService) HandleAuthCallback(code string, state string) error { +// HandleAuthCallback handles a callback request from Spotify's Auth API. +// It takes a code and the state used to initiate the authentication flow +// and follows Spotify's requirements to request an Access Token. +// [Spotify Authorization Code Flow]: https://developer.spotify.com/documentation/web-api/tutorials/code-flow +func (s *Service) HandleAuthCallback(code string, state string) error { if state != s.state { return fmt.Errorf("state mismatch") } @@ -40,16 +45,16 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { form := url.Values{} form.Add("grant_type", "authorization_code") form.Add("code", code) - form.Add("redirect_uri", s.redirectUri) + form.Add("redirect_uri", s.redirectURI) - clientIdAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientId, s.config.Spotify.ClientSecret) - authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) + clientIDAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientID, s.config.Spotify.ClientSecret) + authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIDAndSecret)) headers := map[string][]string{ "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, } - data, status, err := http_utils.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + data, status, err := request.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { return err } @@ -80,7 +85,10 @@ func (s *SpotifyService) HandleAuthCallback(code string, state string) error { return nil } -func (s *SpotifyService) GetAccessToken() (string, error) { +// GetAccessToken tries to read the current Access Token from an encrypted file +// on disk. If that fails or of the Access Token expired, it will request +// a new Access Token using [RefreshAccessToken]. +func (s *Service) GetAccessToken() (string, error) { tokenMutex.RLock() token := authToken tokenMutex.RUnlock() @@ -111,26 +119,30 @@ func (s *SpotifyService) GetAccessToken() (string, error) { } if token == nil { - return "", &SpotifyUnauthenticatedError{} + return "", &UnauthenticatedError{} } return "", fmt.Errorf("something went wrong") } -func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { +// RefreshAccessToken makes a call to Spotify's Authentication API using +// the Refresh Token obtained on the last authentication request. +// It will request a new Access Token using the Refresh Token. +// [Refreshing Tokens]: https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens +func (s *Service) RefreshAccessToken(refreshToken string) error { form := url.Values{} form.Add("grant_type", "refresh_token") form.Add("refresh_token", refreshToken) - form.Add("client_id", s.config.Spotify.ClientId) + form.Add("client_id", s.config.Spotify.ClientID) - clientIdAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientId, s.config.Spotify.ClientSecret) - authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIdAndSecret)) + clientIDAndSecret := fmt.Sprintf("%s:%s", s.config.Spotify.ClientID, s.config.Spotify.ClientSecret) + authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIDAndSecret)) headers := map[string][]string{ "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, } - data, status, err := http_utils.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + data, status, err := request.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { return err } @@ -158,7 +170,7 @@ func (s *SpotifyService) RefreshAccessToken(refreshToken string) error { return nil } -func (s *SpotifyService) saveToken() { +func (s *Service) saveToken() { tokenMutex.RLock() defer tokenMutex.RUnlock() @@ -183,7 +195,7 @@ func (s *SpotifyService) saveToken() { } } -func (s *SpotifyService) loadToken() { +func (s *Service) loadToken() { tokenPath := filepath.Join(s.storageDir, tokenFile) encryptedData, err := os.ReadFile(tokenPath) @@ -212,7 +224,7 @@ func (s *SpotifyService) loadToken() { tokenMutex.Unlock() } -func (s *SpotifyService) encryptToken(data []byte) ([]byte, error) { +func (s *Service) encryptToken(data []byte) ([]byte, error) { block, err := aes.NewCipher([]byte(s.config.EncryptionKey)) if err != nil { return nil, err @@ -231,7 +243,7 @@ func (s *SpotifyService) encryptToken(data []byte) ([]byte, error) { return gcm.Seal(nonce, nonce, data, nil), nil } -func (s *SpotifyService) decryptToken(data []byte) ([]byte, error) { +func (s *Service) decryptToken(data []byte) ([]byte, error) { block, err := aes.NewCipher([]byte(s.config.EncryptionKey)) if err != nil { return nil, err diff --git a/pkg/service/spotify/errors.go b/pkg/service/spotify/errors.go index c04ffcd..8afe4b7 100644 --- a/pkg/service/spotify/errors.go +++ b/pkg/service/spotify/errors.go @@ -1,7 +1,9 @@ package spotify -type SpotifyUnauthenticatedError struct{} +// A UnauthenticatedError is returned when Authentication +// with the Spotify API failed. +type UnauthenticatedError struct{} -func (e *SpotifyUnauthenticatedError) Error() string { +func (e *UnauthenticatedError) Error() string { return "Authentication with Spotify failed! Try signing into your Account again." } diff --git a/pkg/service/spotify/models.go b/pkg/service/spotify/models.go index 7456c75..3c93268 100644 --- a/pkg/service/spotify/models.go +++ b/pkg/service/spotify/models.go @@ -2,18 +2,21 @@ package spotify import "time" -type SpotifyImage struct { - Url string `json:"url"` +// Image is an image in a Spotify API response. +type Image struct { + URL string `json:"url"` Height int `json:"height"` Width int `json:"width"` } -type SpotifyUserProfile struct { - Id string `json:"string"` - DisplayName string `json:"display_name"` - Images []SpotifyImage `json:"images"` +// UserProfile represents the logged in users' Spotify profile. +type UserProfile struct { + ID string `json:"string"` + DisplayName string `json:"display_name"` + Images []Image `json:"images"` } +// AuthTokenResponse is the response from Spotify's Authentication API. type AuthTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` @@ -22,6 +25,7 @@ type AuthTokenResponse struct { RefreshToken string `json:"refresh_token"` } +// An AuthToken represents the current credentials. type AuthToken struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 7c234ae..fae9bca 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -3,52 +3,56 @@ package spotify import ( "beyerleinf/spotify-backup/ent" "beyerleinf/spotify-backup/internal/server/config" - http_utils "beyerleinf/spotify-backup/pkg/http" "beyerleinf/spotify-backup/pkg/logger" + "beyerleinf/spotify-backup/pkg/request" util "beyerleinf/spotify-backup/pkg/util" "encoding/json" "fmt" ) -type SpotifyService struct { +// A Service instance. +type Service struct { slogger *logger.Logger db *ent.Client config *config.Config state string - redirectUri string + redirectURI string storageDir string } -func New(db *ent.Client, config *config.Config, storageDir string) *SpotifyService { - return &SpotifyService{ +// New creates a [Service] instance. +func New(db *ent.Client, config *config.Config, storageDir string) *Service { + return &Service{ slogger: logger.New("spotify", config.Server.LogLevel.Level()), db: db, state: util.GenerateRandomString(16), - redirectUri: fmt.Sprintf("%s/ui/spotify/callback", config.Spotify.RedirectUri), + redirectURI: fmt.Sprintf("%s/ui/spotify/callback", config.Spotify.RedirectURI), storageDir: storageDir, config: config, } } -func (s *SpotifyService) GetUserProfile() (SpotifyUserProfile, error) { +// GetUserProfile returns a [UserProfile] from Spotify's API. +// [Get User Profile API]: https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile +func (s *Service) GetUserProfile() (UserProfile, error) { token, err := s.GetAccessToken() if err != nil { - return SpotifyUserProfile{}, err + return UserProfile{}, err } headers := map[string][]string{ "Authorization": {fmt.Sprintf("Bearer %s", token)}, } - data, _, err := http_utils.Get("https://api.spotify.com/v1/me", headers) + data, _, err := request.Get("https://api.spotify.com/v1/me", headers) if err != nil { - return SpotifyUserProfile{}, err + return UserProfile{}, err } - var profile SpotifyUserProfile + var profile UserProfile err = json.Unmarshal(data, &profile) if err != nil { - return SpotifyUserProfile{}, err + return UserProfile{}, err } return profile, nil diff --git a/pkg/util/random_string.go b/pkg/util/random_string.go index a4394d9..9cb1103 100644 --- a/pkg/util/random_string.go +++ b/pkg/util/random_string.go @@ -12,6 +12,8 @@ func init() { rand.Seed(uint64(time.Now().UnixNano())) } +// GenerateRandomString generates a random string in an efficient way. +// @see https://stackoverflow.com/a/31832326/6335286 func GenerateRandomString(n int) string { b := make([]byte, n) for i := range b { diff --git a/web/templates/pages/spotify/auth.html b/web/templates/pages/spotify/auth.html deleted file mode 100644 index ec30b50..0000000 --- a/web/templates/pages/spotify/auth.html +++ /dev/null @@ -1,38 +0,0 @@ -{{ define "spotify_auth" }} - - - {{ template "header.html" . }} - - -

Spotify Settings

- - Authenticate with Spotify - - -
- {{ if (eq .Profile nil) }} -
- Not signed in -
- {{ end }} - - {{ if not (eq .Profile nil) }} -

Profile

- -
- Profile Picture - {{ .Profile.DisplayName }} -
- {{ end }} -
- - -{{ end }} diff --git a/web/templates/pages/spotify/settings.html b/web/templates/pages/spotify/settings.html new file mode 100644 index 0000000..6591078 --- /dev/null +++ b/web/templates/pages/spotify/settings.html @@ -0,0 +1,34 @@ +{{ define "spotify_settings" }} + + + {{ template "header.html" . }} + + +

Spotify Settings

+ + Authenticate with Spotify + + +
+ {{ if (eq .Profile nil) }} +
Not signed in
+ {{ end }} {{ if not (eq .Profile nil) }} +

Profile

+ +
+ Profile Picture + {{ .Profile.DisplayName }} +
+ {{ end }} +
+ + +{{ end }} From 2c112afad6eed4ee941333011fb85c771ae92b4d Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 01:03:05 +0200 Subject: [PATCH 13/18] chore: Fix lint errors from new rules --- justfile | 5 ++++- pkg/request/request.go | 13 +++++++------ pkg/service/spotify/auth.go | 20 +++++++++++++------- pkg/service/spotify/spotify.go | 10 ++++++---- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/justfile b/justfile index 119bb94..994f265 100644 --- a/justfile +++ b/justfile @@ -15,4 +15,7 @@ tidy: go mod tidy build APP: - go build -o cmd/{{APP}}/bin/{{APP}} cmd/{{APP}}/main.go \ No newline at end of file + go build -o cmd/{{APP}}/bin/{{APP}} cmd/{{APP}}/main.go + +lint: + golangci-lint run \ No newline at end of file diff --git a/pkg/request/request.go b/pkg/request/request.go index a8909ea..39e57df 100644 --- a/pkg/request/request.go +++ b/pkg/request/request.go @@ -1,13 +1,14 @@ package request import ( + "context" "io" "net/http" ) // Post sends a POST request. -func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { - req, err := http.NewRequest("POST", url, body) +func Post(ctx context.Context, url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, body) if err != nil { return nil, 0, err } @@ -30,8 +31,8 @@ func Post(url string, body io.Reader, headers map[string][]string) ([]byte, int, } // PostForm sends a POST request with a application/x-www-form-urlencoded body. -func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { - req, err := http.NewRequest("POST", url, body) +func PostForm(ctx context.Context, url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, "POST", url, body) if err != nil { return nil, 0, err } @@ -55,8 +56,8 @@ func PostForm(url string, body io.Reader, headers map[string][]string) ([]byte, } // Get sends a GET request. -func Get(url string, headers map[string][]string) ([]byte, int, error) { - req, err := http.NewRequest("GET", url, nil) +func Get(ctx context.Context, url string, headers map[string][]string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, 0, err } diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index e2d757c..d187c47 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -2,11 +2,13 @@ package spotify import ( "beyerleinf/spotify-backup/pkg/request" + "context" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -38,8 +40,10 @@ func (s *Service) GetAuthURL() string { // and follows Spotify's requirements to request an Access Token. // [Spotify Authorization Code Flow]: https://developer.spotify.com/documentation/web-api/tutorials/code-flow func (s *Service) HandleAuthCallback(code string, state string) error { + ctx := context.Background() + if state != s.state { - return fmt.Errorf("state mismatch") + return errors.New("state mismatch") } form := url.Values{} @@ -51,10 +55,10 @@ func (s *Service) HandleAuthCallback(code string, state string) error { authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIDAndSecret)) headers := map[string][]string{ - "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, + "Authorization": {"Basic " + authHeaderValue}, } - data, status, err := request.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + data, status, err := request.PostForm(ctx, "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { return err } @@ -122,7 +126,7 @@ func (s *Service) GetAccessToken() (string, error) { return "", &UnauthenticatedError{} } - return "", fmt.Errorf("something went wrong") + return "", errors.New("something went wrong") } // RefreshAccessToken makes a call to Spotify's Authentication API using @@ -130,6 +134,8 @@ func (s *Service) GetAccessToken() (string, error) { // It will request a new Access Token using the Refresh Token. // [Refreshing Tokens]: https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens func (s *Service) RefreshAccessToken(refreshToken string) error { + ctx := context.Background() + form := url.Values{} form.Add("grant_type", "refresh_token") form.Add("refresh_token", refreshToken) @@ -139,10 +145,10 @@ func (s *Service) RefreshAccessToken(refreshToken string) error { authHeaderValue := base64.StdEncoding.EncodeToString([]byte(clientIDAndSecret)) headers := map[string][]string{ - "Authorization": {fmt.Sprintf("Basic %s", authHeaderValue)}, + "Authorization": {"Basic " + authHeaderValue}, } - data, status, err := request.PostForm("https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + data, status, err := request.PostForm(ctx, "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) if err != nil { return err } @@ -256,7 +262,7 @@ func (s *Service) decryptToken(data []byte) ([]byte, error) { nonceSize := gcm.NonceSize() if len(data) < nonceSize { - return nil, fmt.Errorf("ciphertext too short") + return nil, errors.New("ciphertext too short") } nonce, ciphertext := data[:nonceSize], data[nonceSize:] diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index fae9bca..287bb4e 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -6,8 +6,8 @@ import ( "beyerleinf/spotify-backup/pkg/logger" "beyerleinf/spotify-backup/pkg/request" util "beyerleinf/spotify-backup/pkg/util" + "context" "encoding/json" - "fmt" ) // A Service instance. @@ -26,7 +26,7 @@ func New(db *ent.Client, config *config.Config, storageDir string) *Service { slogger: logger.New("spotify", config.Server.LogLevel.Level()), db: db, state: util.GenerateRandomString(16), - redirectURI: fmt.Sprintf("%s/ui/spotify/callback", config.Spotify.RedirectURI), + redirectURI: config.Spotify.RedirectURI + "/ui/spotify/callback", storageDir: storageDir, config: config, } @@ -35,16 +35,18 @@ func New(db *ent.Client, config *config.Config, storageDir string) *Service { // GetUserProfile returns a [UserProfile] from Spotify's API. // [Get User Profile API]: https://developer.spotify.com/documentation/web-api/reference/get-current-users-profile func (s *Service) GetUserProfile() (UserProfile, error) { + ctx := context.Background() + token, err := s.GetAccessToken() if err != nil { return UserProfile{}, err } headers := map[string][]string{ - "Authorization": {fmt.Sprintf("Bearer %s", token)}, + "Authorization": {"Bearer " + token}, } - data, _, err := request.Get("https://api.spotify.com/v1/me", headers) + data, _, err := request.Get(ctx, "https://api.spotify.com/v1/me", headers) if err != nil { return UserProfile{}, err } From f5bc201c0bf3c38c42178d755cf5131d70d6891a Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 01:06:28 +0200 Subject: [PATCH 14/18] chore: Fix comment --- pkg/util/random_string.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/util/random_string.go b/pkg/util/random_string.go index 9cb1103..b78a978 100644 --- a/pkg/util/random_string.go +++ b/pkg/util/random_string.go @@ -13,7 +13,7 @@ func init() { } // GenerateRandomString generates a random string in an efficient way. -// @see https://stackoverflow.com/a/31832326/6335286 +// [Source]: https://stackoverflow.com/a/31832326/6335286 func GenerateRandomString(n int) string { b := make([]byte, n) for i := range b { From e283c58e48ad764b3097f53685e2db7527d72b47 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 23:31:39 +0200 Subject: [PATCH 15/18] chore(request): Make PostForm use Post --- pkg/request/request.go | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/pkg/request/request.go b/pkg/request/request.go index 39e57df..ba9fee0 100644 --- a/pkg/request/request.go +++ b/pkg/request/request.go @@ -32,27 +32,9 @@ func Post(ctx context.Context, url string, body io.Reader, headers map[string][] // PostForm sends a POST request with a application/x-www-form-urlencoded body. func PostForm(ctx context.Context, url string, body io.Reader, headers map[string][]string) ([]byte, int, error) { - req, err := http.NewRequestWithContext(ctx, "POST", url, body) - if err != nil { - return nil, 0, err - } - - req.Header = headers - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + headers["Content-Type"] = []string{"application/x-www-form-urlencoded"} - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, 0, err - } - - defer res.Body.Close() - - data, err := io.ReadAll(res.Body) - if err != nil { - return nil, 0, err - } - - return data, res.StatusCode, nil + return Post(ctx, url, body, headers) } // Get sends a GET request. From 5c8a2bb689e0b559219eced1c4cea9be75014d92 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 23:31:56 +0200 Subject: [PATCH 16/18] fix: Fix Spotify Settings template --- web/templates/pages/spotify/settings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/templates/pages/spotify/settings.html b/web/templates/pages/spotify/settings.html index 6591078..8e1dadc 100644 --- a/web/templates/pages/spotify/settings.html +++ b/web/templates/pages/spotify/settings.html @@ -21,7 +21,7 @@

Profile

Profile Picture From 1a9430980fbad4a7b82d38245e638dbc2a60fc3a Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sat, 12 Oct 2024 23:34:30 +0200 Subject: [PATCH 17/18] fix(spotify-auth): Fix refresh request I missed that Spotify does not always return a new refresh token when refreshing the access token which lead to the app saving an empty refresh token the first time it was used. --- pkg/assert/assert.go | 42 +++++++++++++++++++++++++++++++++ pkg/service/spotify/auth.go | 47 +++++++++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 pkg/assert/assert.go diff --git a/pkg/assert/assert.go b/pkg/assert/assert.go new file mode 100644 index 0000000..420904c --- /dev/null +++ b/pkg/assert/assert.go @@ -0,0 +1,42 @@ +package assert + +import ( + "fmt" + "runtime" +) + +// Assert checks if the given condition is true. +// If it's false, it panics with a message including the file and line number. +func Assert(condition bool, message string) { + if !condition { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("Assertion failed at %s:%d: %s", file, line, message)) + } +} + +// Equal checks if two values are equal. +// If they're not, it panics with a message including the file and line number. +func Equal(expected, actual interface{}, message string) { + if expected != actual { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("Assertion failed at %s:%d: %s. Expected \"%v\", got \"%v\"", file, line, message, expected, actual)) + } +} + +// NotEqual checks if two values are not equal. +// If they are, it panics with a message including the file and line number. +func NotEqual(expected, actual interface{}, message string) { + if expected == actual { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("Assertion failed at %s:%d: %s. Expected \"%v\" not to equal \"%v\"", file, line, message, actual, expected)) + } +} + +// NotNil checks if a value is not nil. +// If it is nil, it panics with a message including the file and line number. +func NotNil(value interface{}, message string) { + if value == nil { + _, file, line, _ := runtime.Caller(1) + panic(fmt.Sprintf("Assertion failed at %s:%d: %s. Value is nil", file, line, message)) + } +} diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index d187c47..97b4be1 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -1,6 +1,7 @@ package spotify import ( + "beyerleinf/spotify-backup/pkg/assert" "beyerleinf/spotify-backup/pkg/request" "context" "crypto/aes" @@ -78,7 +79,7 @@ func (s *Service) HandleAuthCallback(code string, state string) error { authToken = &AuthToken{ AccessToken: tokenResponse.AccessToken, RefreshToken: tokenResponse.RefreshToken, - ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), + ExpiresAt: s.calculateExpiresAt(tokenResponse.ExpiresIn), } tokenMutex.Unlock() @@ -106,10 +107,15 @@ func (s *Service) GetAccessToken() (string, error) { } if token != nil && time.Now().Before(token.ExpiresAt) { + assert.NotEqual("", token.AccessToken, "existing access token should not be an empty string") + assert.NotNil(token.AccessToken, "existing access token is nil") + return token.AccessToken, nil } if token != nil && time.Now().After(token.ExpiresAt) { + assert.NotEqual("", token.RefreshToken, "stored refresh token should not be an empty string") + err := s.RefreshAccessToken(token.RefreshToken) if err != nil { return "", err @@ -119,6 +125,9 @@ func (s *Service) GetAccessToken() (string, error) { token = authToken tokenMutex.RUnlock() + assert.NotEqual("", token.AccessToken, "new access token should not be an empty string") + assert.NotNil(token.AccessToken, "new access token is nil") + return token.AccessToken, nil } @@ -126,6 +135,7 @@ func (s *Service) GetAccessToken() (string, error) { return "", &UnauthenticatedError{} } + assert.Assert(true, "We should not have reached this") return "", errors.New("something went wrong") } @@ -134,6 +144,8 @@ func (s *Service) GetAccessToken() (string, error) { // It will request a new Access Token using the Refresh Token. // [Refreshing Tokens]: https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens func (s *Service) RefreshAccessToken(refreshToken string) error { + assert.NotEqual("", refreshToken, "RefreshToken should not be an empty string") + ctx := context.Background() form := url.Values{} @@ -163,12 +175,18 @@ func (s *Service) RefreshAccessToken(refreshToken string) error { return err } + assert.NotEqual("", tokenResponse.AccessToken, "AccessToken should not be empty") + assert.NotNil(tokenResponse.ExpiresIn, "ExpiresAt should not be nil") + tokenMutex.Lock() - authToken = &AuthToken{ - AccessToken: tokenResponse.AccessToken, - RefreshToken: tokenResponse.RefreshToken, - ExpiresAt: time.Now().Add(time.Second * time.Duration(tokenResponse.ExpiresIn)), + + authToken.AccessToken = tokenResponse.AccessToken + authToken.ExpiresAt = s.calculateExpiresAt(tokenResponse.ExpiresIn) + + if tokenResponse.RefreshToken != "" { + authToken.RefreshToken = tokenResponse.RefreshToken } + tokenMutex.Unlock() s.saveToken() @@ -180,6 +198,10 @@ func (s *Service) saveToken() { tokenMutex.RLock() defer tokenMutex.RUnlock() + assert.NotEqual("", authToken.AccessToken, "AccessToken should not be empty") + assert.NotNil(authToken.ExpiresAt, "ExpiresAt should not be nil") + assert.NotEqual("", authToken.RefreshToken, "RefreshToken should not be empty") + jsonData, err := json.Marshal(authToken) if err != nil { s.slogger.Error("Error marshaling auth token", "err", err) @@ -204,6 +226,11 @@ func (s *Service) saveToken() { func (s *Service) loadToken() { tokenPath := filepath.Join(s.storageDir, tokenFile) + _, err := os.Stat(tokenPath) + if err != nil { + return + } + encryptedData, err := os.ReadFile(tokenPath) if err != nil { if !os.IsNotExist(err) { @@ -212,6 +239,8 @@ func (s *Service) loadToken() { return } + assert.NotEqual(0, len(encryptedData), "token file exists but is empty") + decryptedData, err := s.decryptToken(encryptedData) if err != nil { s.slogger.Error("Error decrypting auth token", "err", err) @@ -225,6 +254,10 @@ func (s *Service) loadToken() { return } + assert.NotEqual("", token.AccessToken, "AccessToken should not be empty") + assert.NotNil(token.ExpiresAt, "ExpiresAt should not be nil") + assert.NotEqual("", token.RefreshToken, "RefreshToken should not be empty") + tokenMutex.Lock() authToken = &token tokenMutex.Unlock() @@ -268,3 +301,7 @@ func (s *Service) decryptToken(data []byte) ([]byte, error) { nonce, ciphertext := data[:nonceSize], data[nonceSize:] return gcm.Open(nil, nonce, ciphertext, nil) } + +func (s *Service) calculateExpiresAt(expiresIn int) time.Time { + return time.Now().Add(time.Second * time.Duration(expiresIn)) +} From db2bfb89550a5678d6cd6f19aa0e4e3fef1550c8 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sun, 13 Oct 2024 00:23:02 +0200 Subject: [PATCH 18/18] chore: Remove DB from spotify service --- cmd/server/main.go | 2 +- pkg/service/spotify/auth.go | 2 +- pkg/service/spotify/spotify.go | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 2add623..0415404 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,7 +83,7 @@ func main() { e.Renderer = renderer e.StaticFS("/", web.StaticFS) - spotifyService := spotify.New(client, cfg, storageDir) + spotifyService := spotify.New(cfg, storageDir) spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService, cfg) diff --git a/pkg/service/spotify/auth.go b/pkg/service/spotify/auth.go index 97b4be1..7405f92 100644 --- a/pkg/service/spotify/auth.go +++ b/pkg/service/spotify/auth.go @@ -29,7 +29,7 @@ var authToken *AuthToken // GetAuthURL returns a URL to redirect a user to sign in with Spotify. func (s *Service) GetAuthURL() string { - scope := url.QueryEscape("playlist-read-private user-read-private") + scope := url.QueryEscape("user-read-private playlist-read-private") return fmt.Sprintf("https://accounts.spotify.com/authorize?response_type=code&client_id=%s&scope=%s&redirect_uri=%s&state=%s", s.config.Spotify.ClientID, scope, url.QueryEscape(s.redirectURI), s.state, diff --git a/pkg/service/spotify/spotify.go b/pkg/service/spotify/spotify.go index 287bb4e..26be7df 100644 --- a/pkg/service/spotify/spotify.go +++ b/pkg/service/spotify/spotify.go @@ -1,7 +1,6 @@ package spotify import ( - "beyerleinf/spotify-backup/ent" "beyerleinf/spotify-backup/internal/server/config" "beyerleinf/spotify-backup/pkg/logger" "beyerleinf/spotify-backup/pkg/request" @@ -13,7 +12,6 @@ import ( // A Service instance. type Service struct { slogger *logger.Logger - db *ent.Client config *config.Config state string redirectURI string @@ -21,10 +19,9 @@ type Service struct { } // New creates a [Service] instance. -func New(db *ent.Client, config *config.Config, storageDir string) *Service { +func New(config *config.Config, storageDir string) *Service { return &Service{ slogger: logger.New("spotify", config.Server.LogLevel.Level()), - db: db, state: util.GenerateRandomString(16), redirectURI: config.Spotify.RedirectURI + "/ui/spotify/callback", storageDir: storageDir,