A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add a small authentication and validation library

oscar345 0d302c0c 15cdf1cc

+295 -2
+1 -1
cmd/bridge/main.go
··· 36 36 return errors.New("path is required") 37 37 } 38 38 39 - router := router.New(services.ArtistService{}, services.UserService{}, nil, &config.Config{}) 39 + router := router.New(services.ArtistService{}, services.UserService{}, nil, nil, &config.Config{}) 40 40 bridge.CreateRoutes(router.Router(), c.path) 41 41 42 42 return nil
+1
go.mod
··· 6 6 github.com/duckdb/duckdb-go/v2 v2.5.4 7 7 github.com/ggicci/httpin v0.20.2 8 8 github.com/go-chi/chi/v5 v5.2.3 9 + github.com/golang-jwt/jwt/v5 v5.3.0 9 10 github.com/gorilla/csrf v1.7.3 10 11 github.com/joho/godotenv v1.5.1 11 12 github.com/mattn/go-sqlite3 v1.14.33
+2
go.sum
··· 34 34 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 35 35 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 36 36 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 37 + github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 38 + github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 37 39 github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= 38 40 github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 39 41 github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
+4 -1
internal/server/server.go
··· 14 14 "github.com/oscar345/keeptrack/internal/repo/db" 15 15 "github.com/oscar345/keeptrack/internal/services" 16 16 "github.com/oscar345/keeptrack/internal/web/router" 17 + "github.com/oscar345/keeptrack/pkg/authentication" 17 18 "github.com/oscar345/keeptrack/pkg/database" 18 19 "github.com/oscar345/keeptrack/pkg/inertia" 19 20 storagesvc "github.com/oscar345/keeptrack/pkg/storage" ··· 54 55 55 56 inertia := setupInertia() 56 57 58 + authenticator := authentication.NewAuthenticatorJWT(s.config.Server.SecretKey) 59 + 57 60 router := router. 58 - New(services.Artist, services.User, inertia, s.config). 61 + New(services.Artist, services.User, inertia, authenticator, s.config). 59 62 Router() 60 63 61 64 server := http.Server{
+3
internal/web/handlers/authentication.go
··· 1 + package handlers 2 + 3 + type AuthenticationHandler struct{}
+45
internal/web/requests/authentication.go
··· 1 + package requests 2 + 3 + import "github.com/oscar345/keeptrack/pkg/validation" 4 + 5 + type LoginForm struct { 6 + Username string `json:"username"` 7 + Password string `json:"password"` 8 + } 9 + 10 + func (f *LoginForm) Validate() error { 11 + validator := validation.New() 12 + 13 + validator.Validate("username", map[string]bool{ 14 + validator.Required(): f.Username != "", 15 + validator.BetweenValue(3, 20): len(f.Username) >= 3 && len(f.Username) <= 20, 16 + }) 17 + 18 + validator.Validate("password", map[string]bool{ 19 + validator.Required(): f.Password != "", 20 + validator.BetweenValue(8, 20): len(f.Password) >= 8 && len(f.Password) <= 20, 21 + }) 22 + 23 + return validator.Run() 24 + } 25 + 26 + type RegisterForm struct { 27 + Username string `json:"username"` 28 + Password string `json:"password"` 29 + } 30 + 31 + func (f *RegisterForm) Validate() error { 32 + validator := validation.New() 33 + 34 + validator.Validate("username", map[string]bool{ 35 + validator.Required(): f.Username != "", 36 + validator.BetweenValue(3, 20): len(f.Username) >= 3 && len(f.Username) <= 20, 37 + }) 38 + 39 + validator.Validate("password", map[string]bool{ 40 + validator.Required(): f.Password != "", 41 + validator.BetweenValue(8, 20): len(f.Password) >= 8 && len(f.Password) <= 20, 42 + }) 43 + 44 + return validator.Run() 45 + }
+8
internal/web/router/router.go
··· 13 13 "github.com/oscar345/keeptrack/internal/web/handlers" 14 14 "github.com/oscar345/keeptrack/internal/web/requests" 15 15 "github.com/oscar345/keeptrack/internal/web/responses" 16 + "github.com/oscar345/keeptrack/pkg/authentication" 16 17 "github.com/oscar345/keeptrack/pkg/enum" 17 18 "github.com/oscar345/keeptrack/pkg/inertia" 18 19 "github.com/oscar345/keeptrack/pkg/pagination" ··· 23 24 artistService services.ArtistService 24 25 userService services.UserService 25 26 inertia *inertia.Inertia 27 + authenticator authentication.Authenticator 26 28 config *config.Config 27 29 } 28 30 ··· 30 32 artistService services.ArtistService, 31 33 userService services.UserService, 32 34 inertia *inertia.Inertia, 35 + authenticator authentication.Authenticator, 33 36 config *config.Config, 34 37 ) *Server { 35 38 return &Server{ 36 39 artistService: artistService, 37 40 userService: userService, 38 41 inertia: inertia, 42 + authenticator: authenticator, 39 43 config: config, 40 44 } 41 45 } ··· 74 78 s.inertia.Render(w, r, "Index", inertia.Props{ 75 79 "title": inertia.Always("Welcome"), 76 80 }) 81 + }) 82 + 83 + r.With(authentication.Middleware(s.authenticator)).Get("/test", func(w http.ResponseWriter, r *http.Request) { 84 + w.Write([]byte("Hello World")) 77 85 }) 78 86 79 87 r.Get("/library", func(w http.ResponseWriter, r *http.Request) {
+96
pkg/authentication/authentication.go
··· 1 + package authentication 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "github.com/golang-jwt/jwt/v5" 8 + ) 9 + 10 + const ( 11 + CookieName = "token" 12 + ) 13 + 14 + type Info struct { 15 + UserID string 16 + Role string 17 + } 18 + 19 + type Authenticator interface { 20 + Create(w http.ResponseWriter, info Info) error 21 + Get(r *http.Request) (Info, bool) 22 + } 23 + 24 + type AuthenticatorJWT struct { 25 + key string 26 + } 27 + 28 + var _ Authenticator = &AuthenticatorJWT{} 29 + 30 + func NewAuthenticatorJWT(key string) *AuthenticatorJWT { 31 + return &AuthenticatorJWT{ 32 + key: key, 33 + } 34 + } 35 + 36 + type claims struct { 37 + UserID string `json:"sub"` 38 + Role string `json:"role,omitempty"` 39 + jwt.RegisteredClaims 40 + } 41 + 42 + func newClaims(info Info) claims { 43 + return claims{ 44 + UserID: info.UserID, 45 + Role: info.Role, 46 + RegisteredClaims: jwt.RegisteredClaims{ 47 + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), 48 + IssuedAt: jwt.NewNumericDate(time.Now()), 49 + }, 50 + } 51 + } 52 + 53 + func (a *AuthenticatorJWT) Create(w http.ResponseWriter, info Info) error { 54 + token := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims(info)) 55 + 56 + signed, err := token.SignedString(a.key) 57 + if err != nil { 58 + return err 59 + } 60 + 61 + http.SetCookie(w, &http.Cookie{ 62 + Name: CookieName, 63 + Value: signed, 64 + Expires: time.Now().Add(24 * time.Hour), 65 + HttpOnly: true, 66 + Secure: true, 67 + SameSite: http.SameSiteStrictMode, 68 + }) 69 + 70 + return nil 71 + } 72 + 73 + func (a *AuthenticatorJWT) Get(r *http.Request) (Info, bool) { 74 + cookie, err := r.Cookie(CookieName) 75 + if err != nil { 76 + return Info{}, false 77 + } 78 + 79 + token, err := jwt.ParseWithClaims(cookie.Value, &claims{}, func(token *jwt.Token) (any, error) { 80 + if token.Method != jwt.SigningMethodHS256 { 81 + return nil, jwt.ErrSignatureInvalid 82 + } 83 + return a.key, nil 84 + }) 85 + 86 + if err != nil { 87 + return Info{}, false 88 + } 89 + 90 + claims, ok := token.Claims.(*claims) 91 + if !ok || !token.Valid { 92 + return Info{}, false 93 + } 94 + 95 + return Info{UserID: claims.UserID, Role: claims.Role}, true 96 + }
+38
pkg/authentication/middleware.go
··· 1 + package authentication 2 + 3 + import ( 4 + "net/http" 5 + ) 6 + 7 + type MiddlewareOptions struct { 8 + Role string 9 + } 10 + 11 + type MiddlewareOption func(*MiddlewareOptions) 12 + 13 + func WithMiddlewareRole(role string) MiddlewareOption { 14 + return func(o *MiddlewareOptions) { 15 + o.Role = role 16 + } 17 + } 18 + 19 + func Middleware(authenticator Authenticator, opts ...MiddlewareOption) func(http.Handler) http.Handler { 20 + options := &MiddlewareOptions{ 21 + Role: "", 22 + } 23 + 24 + for _, opt := range opts { 25 + opt(options) 26 + } 27 + 28 + return func(next http.Handler) http.Handler { 29 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 + if info, ok := authenticator.Get(r); !ok || info.Role != options.Role { 31 + http.Redirect(w, r, "/login", http.StatusUnauthorized) 32 + return 33 + } 34 + 35 + next.ServeHTTP(w, r) 36 + }) 37 + } 38 + }
+35
pkg/validation/message.go
··· 1 + package validation 2 + 3 + import "fmt" 4 + 5 + func (v *Validator) Required() string { 6 + return "Field is required" 7 + } 8 + 9 + func (v *Validator) MinLength(length int) string { 10 + return fmt.Sprintf("Field must be at least %d characters long", length) 11 + } 12 + 13 + func (v *Validator) MaxLength(length int) string { 14 + return fmt.Sprintf("Field must be at most %d characters long", length) 15 + } 16 + 17 + func (v *Validator) ExactLength(length int) string { 18 + return fmt.Sprintf("Field must be exactly %d characters long", length) 19 + } 20 + 21 + func (v *Validator) BetweenLength(min, max int) string { 22 + return fmt.Sprintf("Field must be between %d and %d characters long", min, max) 23 + } 24 + 25 + func (v *Validator) MinValue(value int) string { 26 + return fmt.Sprintf("Field must be at least %d", value) 27 + } 28 + 29 + func (v *Validator) MaxValue(value int) string { 30 + return fmt.Sprintf("Field must be at most %d", value) 31 + } 32 + 33 + func (v *Validator) BetweenValue(min, max int) string { 34 + return fmt.Sprintf("Field must be between %d and %d", min, max) 35 + }
+62
pkg/validation/validation.go
··· 1 + package validation 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type Options struct { 9 + FlatMap bool 10 + } 11 + 12 + type Option func(*Options) 13 + 14 + type Validator struct { 15 + errors map[string][]string 16 + options Options 17 + } 18 + 19 + func WithFlatMap() Option { 20 + return func(o *Options) { 21 + o.FlatMap = true 22 + } 23 + } 24 + 25 + func New(opts ...Option) *Validator { 26 + options := Options{ 27 + FlatMap: false, 28 + } 29 + 30 + for _, opt := range opts { 31 + opt(&options) 32 + } 33 + 34 + return &Validator{ 35 + errors: make(map[string][]string), 36 + options: options, 37 + } 38 + } 39 + 40 + func (v *Validator) Validate(key string, validations map[string]bool) { 41 + for message, isValid := range validations { 42 + if !isValid { 43 + v.errors[message] = append(v.errors[message], key) 44 + } 45 + } 46 + } 47 + 48 + func (e Validator) Error() string { 49 + return fmt.Sprintf("%v", e.errors) 50 + } 51 + 52 + func (e Validator) Run() error { 53 + if len(e.errors) > 0 { 54 + return e 55 + } 56 + 57 + return nil 58 + } 59 + 60 + func (v Validator) MarshalJSON() ([]byte, error) { 61 + return json.Marshal(v.errors) 62 + }