From 4751e26d4d4745f284aa6de26277f7e165ac5681 Mon Sep 17 00:00:00 2001 From: Fabian Beyerlein Date: Sun, 13 Oct 2024 00:26:49 +0200 Subject: [PATCH] feat: Implement Spotify Authentication (#1) * wip: spotify auth * wip: Finalize spotify auth * wip: log.Trace resolved itself lmao * wip: Create http wrapper * chore(ci): Add CI workflow for PRs * fix(ci): Fix install step * chore: Fix critical lint errors * chore: Restructure Spotify package * chore: Move web files to correct directory * chore: Refactor globals to be service arguments * chore: Restructure application code * chore: Fix all (?) lint errors * chore: Fix lint errors from new rules * chore: Fix comment * chore(request): Make PostForm use Post * fix: Fix Spotify Settings template * 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. * chore: Remove DB from spotify service --- .github/workflows/build-pr.yml | 27 ++ .gitignore | 1 + .golangci.yml | 5 + cmd/server/main.go | 96 ++++-- go.mod | 2 +- go.sum | 4 +- internal/api/router/health_routes.go | 20 -- internal/config/config.go | 68 ---- internal/{ => server}/api/handler/health.go | 18 +- internal/server/api/router/health_routes.go | 22 ++ internal/server/config/config.go | 80 +++++ internal/server/ui/handler/spotify.go | 88 +++++ internal/server/ui/router/spotify_routes.go | 27 ++ .../ui/template/template_renderer.go | 1 + internal/ui/embed.go | 12 - internal/ui/public/pages/spotify/auth.html | 28 -- justfile | 8 +- pkg/assert/assert.go | 42 +++ pkg/{log/log.go => logger/logger.go} | 38 ++- pkg/request/request.go | 62 ++++ {internal/api => pkg}/router/router.go | 15 +- pkg/service/spotify/auth.go | 307 ++++++++++++++++++ pkg/service/spotify/errors.go | 9 + pkg/service/spotify/models.go | 33 ++ pkg/service/spotify/spotify.go | 58 ++++ pkg/util/random_string.go | 23 ++ .../assets => web/static}/css/custom.css | 0 .../assets => web/static}/css/tailwind.css | 2 +- .../assets => web/static}/js/htmx.min.js | 0 .../templates}/fragments/header.html | 0 web/templates/pages/spotify/settings.html | 34 ++ web/web.go | 15 + 32 files changed, 974 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/build-pr.yml create mode 100644 .golangci.yml delete mode 100644 internal/api/router/health_routes.go delete mode 100644 internal/config/config.go rename internal/{ => server}/api/handler/health.go (57%) create mode 100644 internal/server/api/router/health_routes.go create mode 100644 internal/server/config/config.go create mode 100644 internal/server/ui/handler/spotify.go create mode 100644 internal/server/ui/router/spotify_routes.go rename internal/{ => server}/ui/template/template_renderer.go (97%) delete mode 100644 internal/ui/embed.go delete mode 100644 internal/ui/public/pages/spotify/auth.html create mode 100644 pkg/assert/assert.go rename pkg/{log/log.go => logger/logger.go} (75%) create mode 100644 pkg/request/request.go rename {internal/api => pkg}/router/router.go (70%) create mode 100644 pkg/service/spotify/auth.go create mode 100644 pkg/service/spotify/errors.go create mode 100644 pkg/service/spotify/models.go create mode 100644 pkg/service/spotify/spotify.go create mode 100644 pkg/util/random_string.go rename {internal/ui/public/assets => web/static}/css/custom.css (100%) rename {internal/ui/public/assets => web/static}/css/tailwind.css (76%) rename {internal/ui/public/assets => web/static}/js/htmx.min.js (100%) rename {internal/ui/public => web/templates}/fragments/header.html (100%) create mode 100644 web/templates/pages/spotify/settings.html create mode 100644 web/web.go diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..51ed8dd --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,27 @@ +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 mod download" + - 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/.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 1ff1f3c..0415404 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,64 +2,114 @@ package main import ( "beyerleinf/spotify-backup/ent" - "beyerleinf/spotify-backup/internal/api/handler" - "beyerleinf/spotify-backup/internal/api/router" - "beyerleinf/spotify-backup/internal/config" - 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" "fmt" "log" + "os" + "path/filepath" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" _ "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() + cfg, err := config.LoadConfig() if err != nil { log.Fatalln("Failed to load config: %w", err) panic(err) } - slog.SetLogLevel(config.AppConfig.Server.LogLevel) + slogger.SetLogLevel(cfg.Server.LogLevel) + + 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 { - 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") + slogger.Info("Connected to database") - healthHandler := handler.NewHealthHandler(client) + healthHandler := handler.NewHealthHandler(client, cfg) e := echo.New() e.HideBanner = true e.HidePort = true e.Use(logger.GetEchoLogger()) + e.Use(middleware.Recover()) + + apiBase := e.Group("/api") + uiBase := e.Group("/ui") + + router.SetupRoutes(apiBase, + apiRouter.HealthRoutes(healthHandler), + ) + + renderer, err := uiTmpl.NewRenderer(web.TemplatesFS) + if err != nil { + slogger.Fatal("Failed to initialize renderer", "err", err) + } + + e.Renderer = renderer + e.StaticFS("/", web.StaticFS) + + spotifyService := spotify.New(cfg, storageDir) - api := e.Group("/api") + spotifyHandler := uiHandler.NewSpotifyHandler(spotifyService, cfg) - router.SetupRoutes(api, - router.HealthRoutes(healthHandler), + 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))) + 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) string { + homeDir, err := os.UserHomeDir() + if err != nil { + slogger.Error("Error getting home directory", "err", err) + } + + dir := filepath.Join(homeDir, storageDir) + + 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.", dir)) + + return dir } 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/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/config/config.go b/internal/config/config.go deleted file mode 100644 index e0b429d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,68 +0,0 @@ -package config - -import ( - logger "beyerleinf/spotify-backup/pkg/log" - "fmt" - goslog "log/slog" - "strings" - - "github.com/spf13/viper" -) - -type Config struct { - Server ServerConfig `mapstructure:"server" env:"SERVER"` - Database DatabaseConfig `mapstructure:"database" env:"DB"` -} - -type ServerConfig struct { - Port int `mapstructure:"port" env:"PORT"` - LogLevel goslog.Level `mapstructure:"loglevel" env:"LOGLEVEL"` -} - -type DatabaseConfig struct { - Host string `mapstructure:"host" env:"HOST"` - Port int `mapstructure:"port" env:"PORT"` - Username string `mapstructure:"username" env:"USERNAME"` - Password string `mapstructure:"password" env:"PASSWORD"` - DBName string `mapstructure:"db_name" env:"NAME"` -} - -var AppConfig Config - -func LoadConfig() error { - slog := logger.New("config", logger.LevelTrace) - - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - viper.AddConfigPath("./config") - - viper.SetEnvPrefix("app") - viper.AutomaticEnv() - viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - - viper.SetDefault("server.port", 8080) - viper.SetDefault("server.loglevel", 0) - viper.SetDefault("database.host", "localhost") - viper.SetDefault("database.port", 5432) - viper.SetDefault("database.username", "SpotifyBackup") - viper.SetDefault("database.password", "secret") - viper.SetDefault("database.db_name", "SpotifyBackup") - - if err := viper.ReadInConfig(); err != nil { - 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) - } - } - - err := viper.Unmarshal(&AppConfig) - if err != nil { - return fmt.Errorf("unable to decode into struct: %w", err) - } - - slog.Trace("Loaded config", "config", AppConfig) - - return nil -} diff --git a/internal/api/handler/health.go b/internal/server/api/handler/health.go similarity index 57% rename from internal/api/handler/health.go rename to internal/server/api/handler/health.go index 940e829..d8cfae4 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" @@ -11,26 +11,32 @@ import ( "github.com/labstack/echo/v4" ) +// A HealthHandler instance. type HealthHandler struct { slogger *logger.Logger db *ent.Client + config *config.Config } -func NewHealthHandler(db *ent.Client) *HealthHandler { +// 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.AppConfig.Server.LogLevel), + slogger: logger.New("health-check", config.Server.LogLevel), db: db, + config: config, } } +// 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 new file mode 100644 index 0000000..b26a31e --- /dev/null +++ b/internal/server/api/router/health_routes.go @@ -0,0 +1,22 @@ +package router + +import ( + "beyerleinf/spotify-backup/internal/server/api/handler" + "beyerleinf/spotify-backup/pkg/router" + + "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", + Routes: []router.Route{ + { + Method: echo.GET, + Path: "", + Handler: healthHandler.GetHealthStatus, + }, + }, + } +} diff --git a/internal/server/config/config.go b/internal/server/config/config.go new file mode 100644 index 0000000..6ac1c3a --- /dev/null +++ b/internal/server/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "beyerleinf/spotify-backup/pkg/logger" + "fmt" + "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"` + Spotify SpotifyConfig `mapstructure:"spotify" env:"SPOTIFY"` + 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 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"` + Username string `mapstructure:"username" env:"USERNAME"` + Password string `mapstructure:"password" env:"PASSWORD"` + 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"` + ClientSecret string `mapstructure:"client_secret" env:"CLIENT_SECRET"` + RedirectURI string `mapstructure:"redirect_uri" env:"REDIRECT_URI"` +} + +// LoadConfig uses viper to load the configuration file. +func LoadConfig() (*Config, error) { + slogger := logger.New("config", logger.LevelTrace) + + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + viper.SetEnvPrefix("app") + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + viper.SetDefault("server.port", 8080) + viper.SetDefault("server.loglevel", 0) + viper.SetDefault("database.host", "localhost") + viper.SetDefault("database.port", 5432) + viper.SetDefault("database.username", "SpotifyBackup") + viper.SetDefault("database.password", "secret") + viper.SetDefault("database.db_name", "SpotifyBackup") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + slogger.Warn("No config file found. Using environment variables.") + } else { + return &Config{}, fmt.Errorf("error reading config file: %w", err) + } + } + + var config Config + err := viper.Unmarshal(&config) + if err != nil { + return &Config{}, fmt.Errorf("unable to decode into struct: %w", err) + } + + 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 new file mode 100644 index 0000000..04e995e --- /dev/null +++ b/internal/server/ui/handler/spotify.go @@ -0,0 +1,88 @@ +package handler + +import ( + "beyerleinf/spotify-backup/internal/server/config" + "beyerleinf/spotify-backup/pkg/logger" + "beyerleinf/spotify-backup/pkg/service/spotify" + "net/http" + + "github.com/labstack/echo/v4" +) + +// A SpotifyHandler instance. +type SpotifyHandler struct { + slogger *logger.Logger + spotifyService *spotify.Service + config *config.Config +} + +const pageTitle = "Spotify Settings | Spotify Backup" + +// 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, + config: config, + } +} + +// 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") + + if code == "" || 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) + + err = c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth?error=get_access_token") + if err != nil { + return err + } + + return nil + } + + err = c.Redirect(http.StatusTemporaryRedirect, "/ui/spotify/auth") + if err != nil { + return err + } + + return nil +} + +// 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, templateName, map[string]any{ + "Title": pageTitle, + "AuthURL": authURL, + "HasError": authError, + }) + } + + 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 new file mode 100644 index 0000000..f75dce2 --- /dev/null +++ b/internal/server/ui/router/spotify_routes.go @@ -0,0 +1,27 @@ +package ui + +import ( + "beyerleinf/spotify-backup/internal/server/ui/handler" + "beyerleinf/spotify-backup/pkg/router" + + "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", + Routes: []router.Route{ + { + Method: echo.GET, + Path: "/auth", + Handler: spotifyHandler.SpotifySettingsPage, + }, + { + Method: echo.GET, + Path: "/callback", + Handler: spotifyHandler.SpotifyAuthCallbackPage, + }, + }, + } +} diff --git a/internal/ui/template/template_renderer.go b/internal/server/ui/template/template_renderer.go similarity index 97% rename from internal/ui/template/template_renderer.go rename to internal/server/ui/template/template_renderer.go index c15c279..90f2cb8 100644 --- a/internal/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/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/pages/spotify/auth.html b/internal/ui/public/pages/spotify/auth.html deleted file mode 100644 index d415ee1..0000000 --- a/internal/ui/public/pages/spotify/auth.html +++ /dev/null @@ -1,28 +0,0 @@ -{{ define "spotify_auth" }} - - - {{ template "header.html" . }} - - -

Spotify Auth

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

Profile

- -
- - {{ .Profile.DisplayName }} -
-
- {{ end }} - - -{{ end }} diff --git a/justfile b/justfile index 39a4b63..994f265 100644 --- a/justfile +++ b/justfile @@ -12,4 +12,10 @@ 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 + +lint: + golangci-lint run \ No newline at end of file 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/log/log.go b/pkg/logger/logger.go similarity index 75% rename from pkg/log/log.go rename to pkg/logger/logger.go index c74170a..d5e091d 100644 --- a/pkg/log/log.go +++ b/pkg/logger/logger.go @@ -3,18 +3,22 @@ package logger import ( "context" "log/slog" + "net/url" "os" + "slices" "github.com/labstack/echo/v4" "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) @@ -24,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", @@ -33,9 +38,16 @@ var LevelNames = map[slog.Leveler]string{ LevelFatal: "FATAL", } +var filteredQueryParams = [...]string{ + "code", + "state", +} + +// New creates a new logger instance. 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 { @@ -61,41 +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...) } -// TODO fix issue with trace logging after config was loaded +// 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)) @@ -105,16 +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) + 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/request/request.go b/pkg/request/request.go new file mode 100644 index 0000000..ba9fee0 --- /dev/null +++ b/pkg/request/request.go @@ -0,0 +1,62 @@ +package request + +import ( + "context" + "io" + "net/http" +) + +// Post sends a POST request. +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 + } + + 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 +} + +// 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) { + headers["Content-Type"] = []string{"application/x-www-form-urlencoded"} + + return Post(ctx, url, body, headers) +} + +// Get sends a GET request. +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 + } + + 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/internal/api/router/router.go b/pkg/router/router.go similarity index 70% rename from internal/api/router/router.go rename to pkg/router/router.go index e43dac9..75066cc 100644 --- a/internal/api/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 new file mode 100644 index 0000000..7405f92 --- /dev/null +++ b/pkg/service/spotify/auth.go @@ -0,0 +1,307 @@ +package spotify + +import ( + "beyerleinf/spotify-backup/pkg/assert" + "beyerleinf/spotify-backup/pkg/request" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +var tokenMutex sync.RWMutex + +const tokenFile = "token.bin" + +var authToken *AuthToken + +// GetAuthURL returns a URL to redirect a user to sign in with Spotify. +func (s *Service) GetAuthURL() string { + 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, + ) +} + +// 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 { + ctx := context.Background() + + if state != s.state { + return errors.New("state mismatch") + } + + form := url.Values{} + form.Add("grant_type", "authorization_code") + form.Add("code", code) + 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)) + + headers := map[string][]string{ + "Authorization": {"Basic " + authHeaderValue}, + } + + data, status, err := request.PostForm(ctx, "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("token request failed: %d - %s", status, string(data)) + } + + var tokenResponse AuthTokenResponse + err = json.Unmarshal(data, &tokenResponse) + if err != nil { + s.slogger.Error("Failed to unmarshal response", "err", err) + return err + } + + tokenMutex.Lock() + authToken = &AuthToken{ + AccessToken: tokenResponse.AccessToken, + RefreshToken: tokenResponse.RefreshToken, + ExpiresAt: s.calculateExpiresAt(tokenResponse.ExpiresIn), + } + tokenMutex.Unlock() + + s.saveToken() + + s.slogger.Verbose("Successfully authenticated with Spotify!") + + return nil +} + +// 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() + + if token == nil { + s.loadToken() + + tokenMutex.RLock() + token = authToken + tokenMutex.RUnlock() + } + + 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 + } + + tokenMutex.RLock() + 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 + } + + if token == nil { + return "", &UnauthenticatedError{} + } + + assert.Assert(true, "We should not have reached this") + return "", errors.New("something went wrong") +} + +// 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 { + assert.NotEqual("", refreshToken, "RefreshToken should not be an empty string") + + ctx := context.Background() + + form := url.Values{} + form.Add("grant_type", "refresh_token") + form.Add("refresh_token", refreshToken) + 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)) + + headers := map[string][]string{ + "Authorization": {"Basic " + authHeaderValue}, + } + + data, status, err := request.PostForm(ctx, "https://accounts.spotify.com/api/token", strings.NewReader(form.Encode()), headers) + if err != nil { + return err + } + + if status != http.StatusOK { + return fmt.Errorf("token request failed: %d - %s", status, string(data)) + } + + var tokenResponse AuthTokenResponse + err = json.Unmarshal(data, &tokenResponse) + if err != nil { + return err + } + + assert.NotEqual("", tokenResponse.AccessToken, "AccessToken should not be empty") + assert.NotNil(tokenResponse.ExpiresIn, "ExpiresAt should not be nil") + + tokenMutex.Lock() + + authToken.AccessToken = tokenResponse.AccessToken + authToken.ExpiresAt = s.calculateExpiresAt(tokenResponse.ExpiresIn) + + if tokenResponse.RefreshToken != "" { + authToken.RefreshToken = tokenResponse.RefreshToken + } + + tokenMutex.Unlock() + + s.saveToken() + + return nil +} + +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) + return + } + + encryptedData, err := s.encryptToken(jsonData) + if err != nil { + s.slogger.Error("Error encrypting auth token", "err", err) + return + } + + tokenPath := filepath.Join(s.storageDir, tokenFile) + + err = os.WriteFile(tokenPath, encryptedData, 0600) + if err != nil { + s.slogger.Error("Error writing encrypted auth token", "err", err) + return + } +} + +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) { + s.slogger.Error("Error reading encrypted auth token", "err", err) + } + 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) + return + } + + var token AuthToken + err = json.Unmarshal(decryptedData, &token) + if err != nil { + s.slogger.Error("Error unmarshaling auth token", "err", err) + 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() +} + +func (s *Service) encryptToken(data []byte) ([]byte, error) { + block, err := aes.NewCipher([]byte(s.config.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 *Service) decryptToken(data []byte) ([]byte, error) { + block, err := aes.NewCipher([]byte(s.config.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, errors.New("ciphertext too short") + } + + 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)) +} diff --git a/pkg/service/spotify/errors.go b/pkg/service/spotify/errors.go new file mode 100644 index 0000000..8afe4b7 --- /dev/null +++ b/pkg/service/spotify/errors.go @@ -0,0 +1,9 @@ +package spotify + +// A UnauthenticatedError is returned when Authentication +// with the Spotify API failed. +type UnauthenticatedError struct{} + +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 new file mode 100644 index 0000000..3c93268 --- /dev/null +++ b/pkg/service/spotify/models.go @@ -0,0 +1,33 @@ +package spotify + +import "time" + +// Image is an image in a Spotify API response. +type Image struct { + URL string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` +} + +// 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"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` +} + +// An AuthToken represents the current credentials. +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/spotify.go b/pkg/service/spotify/spotify.go new file mode 100644 index 0000000..26be7df --- /dev/null +++ b/pkg/service/spotify/spotify.go @@ -0,0 +1,58 @@ +package spotify + +import ( + "beyerleinf/spotify-backup/internal/server/config" + "beyerleinf/spotify-backup/pkg/logger" + "beyerleinf/spotify-backup/pkg/request" + util "beyerleinf/spotify-backup/pkg/util" + "context" + "encoding/json" +) + +// A Service instance. +type Service struct { + slogger *logger.Logger + config *config.Config + state string + redirectURI string + storageDir string +} + +// New creates a [Service] instance. +func New(config *config.Config, storageDir string) *Service { + return &Service{ + slogger: logger.New("spotify", config.Server.LogLevel.Level()), + state: util.GenerateRandomString(16), + redirectURI: config.Spotify.RedirectURI + "/ui/spotify/callback", + storageDir: storageDir, + config: config, + } +} + +// 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": {"Bearer " + token}, + } + + data, _, err := request.Get(ctx, "https://api.spotify.com/v1/me", headers) + if err != nil { + return UserProfile{}, err + } + + var profile UserProfile + err = json.Unmarshal(data, &profile) + if err != nil { + return UserProfile{}, err + } + + return profile, nil +} diff --git a/pkg/util/random_string.go b/pkg/util/random_string.go new file mode 100644 index 0000000..b78a978 --- /dev/null +++ b/pkg/util/random_string.go @@ -0,0 +1,23 @@ +package util + +import ( + "time" + + "golang.org/x/exp/rand" +) + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func init() { + rand.Seed(uint64(time.Now().UnixNano())) +} + +// GenerateRandomString generates a random string in an efficient way. +// [Source]: https://stackoverflow.com/a/31832326/6335286 +func GenerateRandomString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] + } + return string(b) +} 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 76% rename from internal/ui/public/assets/css/tailwind.css rename to web/static/css/tailwind.css index adcd9ca..b78d713 100644 --- a/internal/ui/public/assets/css/tailwind.css +++ b/web/static/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/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/web/templates/pages/spotify/settings.html b/web/templates/pages/spotify/settings.html new file mode 100644 index 0000000..8e1dadc --- /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 }} 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")