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.

Make the authentication system based on sessions (instead of JWT). Furthermore create interfaces for session stores for both the auth and inertia that are then implemented using gorilla sessions.

oscar345 33b8e5eb 747545ab

+523 -219
+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, nil, nil, &config.Config{}) 39 + router := router.New(services.ArtistService{}, services.UserService{}, services.AuthenticationService{}, nil, nil, nil, &config.Config{}) 40 40 bridge.CreateRoutes(router.Router(), c.path) 41 41 42 42 return nil
+13 -1
go.mod
··· 15 15 ) 16 16 17 17 require ( 18 + github.com/dustin/go-humanize v1.0.1 // indirect 19 + github.com/mattn/go-isatty v0.0.20 // indirect 20 + github.com/ncruces/go-strftime v1.0.0 // indirect 21 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 22 + modernc.org/libc v1.67.6 // indirect 23 + modernc.org/mathutil v1.7.1 // indirect 24 + modernc.org/memory v1.11.0 // indirect 25 + ) 26 + 27 + require ( 18 28 github.com/apache/arrow-go/v18 v18.4.1 // indirect 19 29 github.com/duckdb/duckdb-go-bindings v0.1.24 // indirect 20 30 github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.24 // indirect ··· 35 45 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 36 46 github.com/pierrec/lz4/v4 v4.1.22 // indirect 37 47 github.com/zeebo/xxh3 v1.0.2 // indirect 48 + golang.org/x/crypto v0.47.0 38 49 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect 39 50 golang.org/x/mod v0.31.0 // indirect 40 - golang.org/x/sys v0.39.0 // indirect 51 + golang.org/x/sys v0.40.0 // indirect 41 52 golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect 42 53 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 54 + modernc.org/sqlite v1.44.1 43 55 )
+21
go.sum
··· 24 24 github.com/duckdb/duckdb-go/mapping v0.0.27/go.mod h1:7C4QWJWG6UOV9b0iWanfF5ML1ivJPX45Kz+VmlvRlTA= 25 25 github.com/duckdb/duckdb-go/v2 v2.5.4 h1:+ip+wPCwf7Eu/dXxp19aLCxwpLUaeOy2UV/peBphXK0= 26 26 github.com/duckdb/duckdb-go/v2 v2.5.4/go.mod h1:CeobOFmWpf7MTDb+MW08/zIWP8TQ2jbPbMgGo5761tY= 27 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 28 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 27 29 github.com/ggicci/httpin v0.20.2 h1:SmXSM/jg58H2W4+fIcF+6bo4JXQW/f8oeHYHYmwecmk= 28 30 github.com/ggicci/httpin v0.20.2/go.mod h1:lQaLWTYNcs4eo8WoESBqqT4fUc9dgdIKeHweZMj17No= 29 31 github.com/ggicci/owl v0.8.2 h1:og+lhqpzSMPDdEB+NJfzoAJARP7qCG3f8uUC3xvGukA= ··· 62 64 github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 63 65 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 64 66 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 67 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 68 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 65 69 github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= 66 70 github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 67 71 github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= 68 72 github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= 69 73 github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= 70 74 github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= 75 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 76 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 71 77 github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= 72 78 github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 73 79 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 74 80 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 81 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 82 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 75 83 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 76 84 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 77 85 github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 78 86 github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 79 87 github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 80 88 github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 89 + golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= 90 + golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 81 91 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= 82 92 golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= 83 93 golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 84 94 golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 85 95 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 86 96 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 97 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 98 golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 88 99 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 100 + golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 101 + golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 89 102 golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 h1:H52Mhyrc44wBgLTGzq6+0cmuVuF3LURCSXsLMOqfFos= 90 103 golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523/go.mod h1:ArQvPJS723nJQietgilmZA+shuB3CZxH1n2iXq9VSfs= 91 104 golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= ··· 96 109 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 97 110 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 98 111 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 112 + modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= 113 + modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= 114 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 115 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 116 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 117 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 118 + modernc.org/sqlite v1.44.1 h1:qybx/rNpfQipX/t47OxbHmkkJuv2JWifCMH8SVUiDas= 119 + modernc.org/sqlite v1.44.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+17
internal/repo/db/user.go
··· 47 47 return user, nil 48 48 }) 49 49 } 50 + 51 + func (repo *UserRepoDB) GetByEmail(ctx context.Context, email string) (models.User, error) { 52 + const statement = /*sql*/ ` 53 + SELECT 54 + users.id, users.name, users.email, users.password 55 + FROM users 56 + WHERE users.email = ? 57 + ` 58 + 59 + return database.QueryOne(ctx, repo.db, statement, []any{email}, func(r *sql.Rows) (models.User, error) { 60 + var user models.User 61 + if err := r.Scan(&user.ID, &user.Name, &user.Email, &user.Password); err != nil { 62 + return models.User{}, err 63 + } 64 + return user, nil 65 + }) 66 + }
+1
internal/repo/repo.go
··· 43 43 type UserRepo interface { 44 44 Create(ctx context.Context, user models.User) (int, error) 45 45 GetByID(ctx context.Context, id string) (models.User, error) 46 + GetByEmail(ctx context.Context, email string) (models.User, error) 46 47 } 47 48 48 49 type UserFollowRepo interface {
+38
internal/server/inertia.go
··· 1 + package server 2 + 3 + import ( 4 + "html/template" 5 + "log" 6 + 7 + "github.com/gorilla/sessions" 8 + "github.com/oscar345/keeptrack/internal/web/sessionstore" 9 + "github.com/oscar345/keeptrack/pkg/inertia" 10 + ) 11 + 12 + func setupInertia(store sessions.Store) *inertia.Inertia { 13 + tmpl, err := template.New("root").Parse(root) 14 + if err != nil { 15 + log.Fatal(err) 16 + } 17 + return inertia.New(tmpl, sessionstore.NewInertia(store)) 18 + } 19 + 20 + const root = /*html*/ ` 21 + <!DOCTYPE html> 22 + <html lang="en"> 23 + <head> 24 + <meta charset="UTF-8"> 25 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 26 + <meta name="referrer" content="same-origin"> 27 + <title>Document</title> 28 + <script src="/assets/app.js" defer type="module"></script> 29 + <link rel="stylesheet" href="/assets/app.css"> 30 + <link rel="stylesheet" href="/assets/styles.css"> 31 + <script type="application/json" data-page="app"> 32 + {{ .Data }} 33 + </script> 34 + </head> 35 + <body id="app"> 36 + </body> 37 + </html> 38 + `
+26 -90
internal/server/server.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/gob" 6 - "errors" 7 - "html/template" 8 5 "log" 9 6 "net/http" 10 - "time" 11 7 12 8 _ "github.com/duckdb/duckdb-go/v2" 13 9 "github.com/gorilla/sessions" 14 - _ "github.com/mattn/go-sqlite3" 15 10 "github.com/oscar345/keeptrack/internal/config" 16 11 "github.com/oscar345/keeptrack/internal/image" 17 12 "github.com/oscar345/keeptrack/internal/providers" 18 13 "github.com/oscar345/keeptrack/internal/repo/db" 19 14 "github.com/oscar345/keeptrack/internal/services" 20 15 "github.com/oscar345/keeptrack/internal/web/router" 16 + "github.com/oscar345/keeptrack/internal/web/sessionstore" 21 17 "github.com/oscar345/keeptrack/pkg/authentication" 22 18 "github.com/oscar345/keeptrack/pkg/database" 23 - "github.com/oscar345/keeptrack/pkg/inertia" 24 19 storagesvc "github.com/oscar345/keeptrack/pkg/storage" 25 20 "github.com/oscar345/keeptrack/pkg/utilities" 21 + _ "modernc.org/sqlite" 26 22 ) 27 23 28 24 type Server struct { ··· 40 36 } 41 37 42 38 func (s *Server) Start() error { 43 - now := time.Now() 44 - generalDB := database.Open("sqlite3", s.config.MusicbrainzDatabase.Path, func(db *sql.DB) { 39 + generalDB := database.Open("sqlite", s.config.MusicbrainzDatabase.Path, func(db *sql.DB) { 45 40 db.Exec("PRAGMA journal_mode = WAL") 46 41 db.Exec("PRAGMA synchronous = NORMAL") 47 42 }) 48 - defer generalDB.Close() 49 - 50 - log.Printf("Database opened in %s", time.Since(now)) 51 - 52 43 database.AddAttachments(generalDB, map[string]string{ 53 44 "app": s.config.AppDatabase.Path, 54 45 }) 55 - 56 - log.Printf("Database opened in %s", time.Since(now)) 46 + defer generalDB.Close() 57 47 58 48 statisticsDB := database.Open("duckdb", s.config.StatisticsDatabase.Path, func(d *sql.DB) { 59 49 d.Exec("SET threads TO 4") 60 50 }) 61 51 defer statisticsDB.Close() 62 52 63 - log.Printf("Statistics database opened in %s", time.Since(now)) 64 - 53 + store := NewSessionStore(s.config) 65 54 services := s.services(generalDB, statisticsDB) 66 - store := sessions.NewCookieStore([]byte(s.config.Server.SecretKey)) 67 55 56 + auth := authentication.New(sessionstore.NewAuthentication(store)) 68 57 inertia := setupInertia(store) 69 58 70 - authenticator := authentication.NewAuthenticatorJWT(s.config.Server.SecretKey) 71 - 72 59 router := router. 73 - New(services.Artist, services.User, inertia, authenticator, store, s.config). 60 + New(services.Artist, services.User, services.Auth, inertia, auth, store, s.config). 74 61 Router() 75 62 76 - log.Printf("Router setup in %s", time.Since(now)) 77 - 78 - server := http.Server{ 79 - Addr: s.address, 80 - Handler: router, 81 - } 82 63 log.Printf("Listening on http://%s\n", s.address) 83 - 84 - log.Printf("Server started in %s", time.Since(now)) 85 - 64 + server := http.Server{Addr: s.address, Handler: router} 86 65 return server.ListenAndServe() 87 66 } 88 67 89 68 type Services struct { 90 69 Artist services.ArtistService 91 70 User services.UserService 71 + Auth services.AuthenticationService 92 72 } 93 73 94 - func (s *Server) services(musicbrainzDB *sql.DB, statisticsDB *sql.DB) Services { 95 - artistRepo := db.NewArtistRepoDB(musicbrainzDB) 74 + func (s *Server) services(generalDB *sql.DB, statisticsDB *sql.DB) Services { 75 + artistRepo := db.NewArtistRepoDB(generalDB) 96 76 artistScrobbleRepo := db.NewArtistScrobbleRepoDB(statisticsDB) 97 77 artistImageFetcher := image.NewArtistImageFetcherFanArtTV(s.config.Services.FanartTV.APIKey) 98 78 99 - userRepo := db.NewUserRepoDB(musicbrainzDB) 100 - userFollowRepo := db.NewUserFollowRepoDB(musicbrainzDB) 79 + userRepo := db.NewUserRepoDB(generalDB) 80 + userFollowRepo := db.NewUserFollowRepoDB(generalDB) 101 81 102 82 releaseImageFetcher := image.NewReleaseImageFetcherCoverArtArchive() 103 83 ··· 112 92 User: services.NewUserService( 113 93 userRepo, userFollowRepo, artistRepo, artistScrobbleRepo, mediaProvider, 114 94 ), 115 - } 116 - } 117 - 118 - type InertiaSessionStore struct { 119 - store *sessions.CookieStore 120 - } 121 - 122 - func NewInertiaSessionStore(store *sessions.CookieStore) *InertiaSessionStore { 123 - gob.Register(inertia.SessionData{}) 124 - 125 - return &InertiaSessionStore{ 126 - store: store, 95 + Auth: services.NewAuthenticationService(userRepo), 127 96 } 128 97 } 129 98 130 - func (is *InertiaSessionStore) Set(w http.ResponseWriter, r *http.Request, key string, value inertia.SessionData) error { 131 - session, err := is.store.Get(r, key) 132 - if err != nil { 133 - return err 134 - } 135 - session.Values["data"] = value 136 - return session.Save(r, w) 137 - } 99 + func NewSessionStore(cfg *config.Config) sessions.Store { 100 + store := sessions.NewCookieStore( 101 + utilities.DeriveKey(cfg.Server.SecretKey, "authentication"), 102 + utilities.DeriveKey(cfg.Server.SecretKey, "encryption"), 103 + ) 138 104 139 - func (is *InertiaSessionStore) Get(r *http.Request, key string) (inertia.SessionData, error) { 140 - session, err := is.store.Get(r, key) 141 - if err != nil { 142 - return inertia.SessionData{}, err 143 - } 144 - data, ok := session.Values["data"].(inertia.SessionData) 145 - if !ok { 146 - return inertia.SessionData{}, errors.New("invalid session data type") 105 + store.Options = &sessions.Options{ 106 + Path: "/", 107 + MaxAge: 60 * 60 * 24 * 7, // 7 days 108 + Secure: cfg.Environment == config.Production, 109 + SameSite: http.SameSiteLaxMode, 110 + HttpOnly: true, 147 111 } 148 - return data, nil 149 - } 150 112 151 - func setupInertia(store *sessions.CookieStore) *inertia.Inertia { 152 - tmpl, err := template.New("root").Parse(root) 153 - if err != nil { 154 - log.Fatal(err) 155 - } 156 - return inertia.New(tmpl, NewInertiaSessionStore(store)) 113 + return store 157 114 } 158 - 159 - const root = /*html*/ ` 160 - <!DOCTYPE html> 161 - <html lang="en"> 162 - <head> 163 - <meta charset="UTF-8"> 164 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 165 - <meta name="referrer" content="same-origin"> 166 - <title>Document</title> 167 - <script src="/assets/app.js" defer type="module"></script> 168 - <link rel="stylesheet" href="/assets/app.css"> 169 - <link rel="stylesheet" href="/assets/styles.css"> 170 - <script type="application/json" data-page="app"> 171 - {{ .Data }} 172 - </script> 173 - </head> 174 - <body id="app"> 175 - 176 - </body> 177 - </html> 178 - `
+52
internal/services/authentication.go
··· 1 + package services 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + 7 + "github.com/oscar345/keeptrack/internal/models" 8 + "github.com/oscar345/keeptrack/internal/repo" 9 + "github.com/oscar345/keeptrack/pkg/authentication" 10 + ) 11 + 12 + type AuthenticationService struct { 13 + userRepo repo.UserRepo 14 + } 15 + 16 + func NewAuthenticationService(userRepo repo.UserRepo) AuthenticationService { 17 + return AuthenticationService{ 18 + userRepo: userRepo, 19 + } 20 + } 21 + 22 + func (as *AuthenticationService) Login(ctx context.Context, email, password string) (models.User, error) { 23 + validUser := false 24 + 25 + user, err := as.userRepo.GetByEmail(ctx, email) 26 + if err == nil { 27 + validUser = true 28 + } 29 + 30 + ok := authentication.ComparePasswordAndHash(validUser, password, user.Password) 31 + if !ok { 32 + return models.User{}, errors.New("invalid credentials") 33 + } 34 + 35 + return user, nil 36 + } 37 + 38 + func (as *AuthenticationService) Register(ctx context.Context, user models.User) (models.User, error) { 39 + hashedPassword, err := authentication.HashPassword(user.Password) 40 + if err != nil { 41 + return models.User{}, err 42 + } 43 + 44 + user.Password = hashedPassword 45 + userID, err := as.userRepo.Create(ctx, user) 46 + if err != nil { 47 + return models.User{}, err 48 + } 49 + 50 + user.ID = userID 51 + return user, nil 52 + }
+8 -8
internal/web/requests/authentication.go
··· 3 3 import "github.com/oscar345/keeptrack/pkg/validation" 4 4 5 5 type LoginForm struct { 6 - Username string `json:"username"` 6 + Email string `json:"email"` 7 7 Password string `json:"password"` 8 8 } 9 9 10 10 func (f *LoginForm) Validate() error { 11 11 validator := validation.New() 12 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, 13 + validator.Validate("email", map[string]bool{ 14 + validator.Required(): f.Email != "", 15 + validator.BetweenValue(3, 20): len(f.Email) >= 3 && len(f.Email) <= 20, 16 16 }) 17 17 18 18 validator.Validate("password", map[string]bool{ ··· 24 24 } 25 25 26 26 type RegisterForm struct { 27 - Username string `json:"username"` 27 + Email string `json:"email"` 28 28 Password string `json:"password"` 29 29 } 30 30 31 31 func (f *RegisterForm) Validate() error { 32 32 validator := validation.New() 33 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, 34 + validator.Validate("email", map[string]bool{ 35 + validator.Required(): f.Email != "", 36 + validator.BetweenValue(3, 20): len(f.Email) >= 3 && len(f.Email) <= 20, 37 37 }) 38 38 39 39 validator.Validate("password", map[string]bool{
+34 -10
internal/web/router/router.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "net/http" 6 7 7 8 "github.com/ggicci/httpin" ··· 20 21 "github.com/oscar345/keeptrack/pkg/enum" 21 22 "github.com/oscar345/keeptrack/pkg/inertia" 22 23 "github.com/oscar345/keeptrack/pkg/pagination" 24 + "github.com/oscar345/keeptrack/pkg/utilities" 23 25 "github.com/oscar345/keeptrack/pkg/validation" 24 26 "github.com/oscar345/keeptrack/private" 25 27 ) ··· 27 29 type Server struct { 28 30 artistService services.ArtistService 29 31 userService services.UserService 32 + authService services.AuthenticationService 30 33 inertia *inertia.Inertia 31 - authenticator authentication.Authenticator 34 + authProvider *authentication.Provider 32 35 config *config.Config 33 - store *sessions.CookieStore 36 + store sessions.Store 34 37 } 35 38 36 39 func New( 37 40 artistService services.ArtistService, 38 41 userService services.UserService, 42 + authService services.AuthenticationService, 39 43 inertia *inertia.Inertia, 40 - authenticator authentication.Authenticator, 41 - store *sessions.CookieStore, 44 + authProvider *authentication.Provider, 45 + store sessions.Store, 42 46 config *config.Config, 43 47 ) *Server { 44 48 return &Server{ 45 49 artistService: artistService, 46 50 userService: userService, 51 + authService: authService, 47 52 inertia: inertia, 48 - authenticator: authenticator, 53 + authProvider: authProvider, 49 54 store: store, 50 55 config: config, 51 56 } ··· 65 70 chimiddleware.Logger, 66 71 chimiddleware.RequestID, 67 72 chimiddleware.CleanPath, 68 - s.inertia.Middleware, 69 73 csrf.Protect( 70 - []byte(s.config.Server.SecretKey), 74 + utilities.DeriveKey(s.config.Server.SecretKey, "csrf_token"), 71 75 csrf.Secure(s.config.Environment == config.Production), 72 76 csrf.TrustedOrigins([]string{"127.0.0.1:3000", "localhost:3000"}), 73 77 ), 78 + s.inertia.Middleware, 74 79 middleware.InertiaCSRFToken, 75 80 ) 76 81 ··· 83 88 } 84 89 85 90 func (s *Server) index() func(chi.Router) { 86 - handler := handlers.NewIndexHandler(s.artistService) 91 + handler := handlers.NewIndexHandler(s.inertia) 87 92 88 93 return func(r chi.Router) { 89 94 r.Get("/", func(w http.ResponseWriter, r *http.Request) { ··· 92 97 }) 93 98 }) 94 99 95 - r.With(authentication.Middleware(s.authenticator)).Get("/test", func(w http.ResponseWriter, r *http.Request) { 100 + r.With(s.authProvider.Middleware).Get("/test", func(w http.ResponseWriter, r *http.Request) { 96 101 w.Write([]byte("Hello World")) 97 102 }) 98 103 ··· 100 105 s.inertia.Render(w, r, "authentication/Login", inertia.Props{}) 101 106 }) 102 107 108 + r.Get("/authentication/register", func(w http.ResponseWriter, r *http.Request) { 109 + s.inertia.Render(w, r, "authentication/Register", inertia.Props{}) 110 + }) 111 + 103 112 r.Post("/authentication/login", func(w http.ResponseWriter, r *http.Request) { 113 + fmt.Println("login") 104 114 var form requests.LoginForm 105 115 if err := json.NewDecoder(r.Body).Decode(&form); err != nil { 106 116 http.Error(w, err.Error(), http.StatusBadRequest) ··· 113 123 return 114 124 } 115 125 116 - w.Write([]byte("Hello World")) 126 + user, err := s.authService.Login(r.Context(), form.Email, form.Password) 127 + if err != nil { 128 + s.inertia.SetErrors(w, r, map[string][]string{ 129 + "username": []string{"Invalid credentials"}, 130 + }) 131 + http.Redirect(w, r, "/authentication/login", 302) 132 + return 133 + } 134 + 135 + if err := s.authProvider.CreateSession(w, r, user.ID); err != nil { 136 + http.Error(w, err.Error(), http.StatusInternalServerError) 137 + return 138 + } 139 + 140 + http.Redirect(w, r, "/", 302) 117 141 }) 118 142 119 143 r.Get("/library", func(w http.ResponseWriter, r *http.Request) {
+58
internal/web/sessionstore/authentication.go
··· 1 + package sessionstore 2 + 3 + import ( 4 + "encoding/gob" 5 + "errors" 6 + "net/http" 7 + 8 + "github.com/gorilla/sessions" 9 + "github.com/oscar345/keeptrack/pkg/authentication" 10 + ) 11 + 12 + type Authentication struct { 13 + store sessions.Store 14 + } 15 + 16 + var _ authentication.SessionStore = (*Authentication)(nil) 17 + 18 + func NewAuthentication(store sessions.Store) *Authentication { 19 + gob.Register(authentication.Session{}) 20 + 21 + return &Authentication{ 22 + store: store, 23 + } 24 + } 25 + 26 + func (s *Authentication) Create(w http.ResponseWriter, r *http.Request, key string, authSession authentication.Session) error { 27 + session, err := s.store.Get(r, key) 28 + if err != nil { 29 + return err 30 + } 31 + session.Values["data"] = authSession 32 + return session.Save(r, w) 33 + } 34 + 35 + func (s *Authentication) Get(r *http.Request, key string) (authentication.Session, error) { 36 + session, err := s.store.Get(r, key) 37 + if err != nil { 38 + return authentication.Session{}, err 39 + } 40 + 41 + result, ok := session.Values["data"].(authentication.Session) 42 + if !ok { 43 + return authentication.Session{}, errors.New("invalid session data") 44 + } 45 + 46 + return result, nil 47 + } 48 + 49 + func (s *Authentication) Delete(w http.ResponseWriter, r *http.Request, token string) error { 50 + session, err := s.store.Get(r, token) 51 + if err != nil { 52 + return err 53 + } 54 + 55 + session.Options.MaxAge = -1 56 + session.Values["data"] = nil 57 + return session.Save(r, w) 58 + }
+49
internal/web/sessionstore/inertia.go
··· 1 + package sessionstore 2 + 3 + import ( 4 + "encoding/gob" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/gorilla/sessions" 10 + "github.com/oscar345/keeptrack/pkg/inertia" 11 + ) 12 + 13 + // Setting up inertia 14 + 15 + type Inertia struct { 16 + store sessions.Store 17 + } 18 + 19 + func NewInertia(store sessions.Store) *Inertia { 20 + gob.Register(inertia.SessionData{}) 21 + 22 + return &Inertia{ 23 + store: store, 24 + } 25 + } 26 + 27 + func (is *Inertia) Set(w http.ResponseWriter, r *http.Request, key string, value inertia.SessionData) error { 28 + session, err := is.store.Get(r, key) 29 + if err != nil { 30 + if session, err = is.store.New(r, key); err != nil { 31 + return err 32 + } 33 + } 34 + session.Values["data"] = value 35 + return session.Save(r, w) 36 + } 37 + 38 + func (is *Inertia) Get(r *http.Request, key string) (inertia.SessionData, error) { 39 + session, err := is.store.Get(r, key) 40 + if err != nil { 41 + fmt.Println("Error getting session:", err) 42 + return inertia.SessionData{}, err 43 + } 44 + data, ok := session.Values["data"].(inertia.SessionData) 45 + if !ok { 46 + return inertia.SessionData{}, errors.New("invalid session data type") 47 + } 48 + return data, nil 49 + }
+94 -60
pkg/authentication/authentication.go
··· 1 1 package authentication 2 2 3 3 import ( 4 + "context" 5 + "errors" 4 6 "net/http" 5 - "time" 6 7 7 - "github.com/golang-jwt/jwt/v5" 8 + "golang.org/x/crypto/bcrypt" 8 9 ) 9 10 10 - const ( 11 - CookieName = "token" 12 - ) 11 + const CookieSessionKey = "authentication" 12 + const CtxUserKey = "user" 13 + const CtxSessionKey = "userID" 13 14 14 - type Info struct { 15 - UserID string 16 - Role string 15 + // Helper function to set the user in the context for later use in the handlers 16 + func SetUser[T any](r *http.Request, user T) { 17 + r.WithContext(context.WithValue(r.Context(), CtxUserKey, user)) 17 18 } 18 19 19 - type Authenticator interface { 20 - Create(w http.ResponseWriter, info Info) error 21 - Get(r *http.Request) (Info, bool) 20 + // Sets the session in the context for later use in the handlers. Session gets automatically set in 21 + // the middleware. 22 + func SetSession(r *http.Request, session Session) { 23 + r.WithContext(context.WithValue(r.Context(), CtxSessionKey, session)) 22 24 } 23 25 24 - type AuthenticatorJWT struct { 25 - key string 26 + // Helper function to get the user from the context 27 + func GetUser[T any](r *http.Request) (T, error) { 28 + user, ok := r.Context().Value(CtxUserKey).(T) 29 + if !ok { 30 + return user, errors.New("user not found") 31 + } 32 + return user, nil 26 33 } 27 34 28 - var _ Authenticator = &AuthenticatorJWT{} 29 - 30 - func NewAuthenticatorJWT(key string) *AuthenticatorJWT { 31 - return &AuthenticatorJWT{ 32 - key: key, 35 + // Helper function to get the session from the context 36 + func GetSession(r *http.Request) (Session, error) { 37 + session, ok := r.Context().Value(CtxSessionKey).(Session) 38 + if !ok { 39 + return session, errors.New("session not found") 33 40 } 41 + return session, nil 34 42 } 35 43 36 - type claims struct { 37 - UserID string `json:"sub"` 38 - Role string `json:"role,omitempty"` 39 - jwt.RegisteredClaims 44 + type SessionStore interface { 45 + Create(w http.ResponseWriter, r *http.Request, key string, session Session) error 46 + Get(r *http.Request, key string) (Session, error) 47 + Delete(w http.ResponseWriter, r *http.Request, key string) error 40 48 } 41 49 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 - } 50 + type Session struct { 51 + userID int 51 52 } 52 53 53 - func (a *AuthenticatorJWT) Create(w http.ResponseWriter, info Info) error { 54 - token := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims(info)) 54 + type Provider struct { 55 + store SessionStore 56 + } 55 57 56 - signed, err := token.SignedString(a.key) 57 - if err != nil { 58 - return err 58 + func New(store SessionStore) *Provider { 59 + return &Provider{ 60 + store: store, 59 61 } 62 + } 60 63 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 - }) 64 + func (p *Provider) CreateSession(w http.ResponseWriter, r *http.Request, userID int) error { 65 + session := Session{ 66 + userID: userID, 67 + } 69 68 70 - return nil 69 + return p.store.Create(w, r, CookieSessionKey, session) 71 70 } 72 71 73 - func (a *AuthenticatorJWT) Get(r *http.Request) (Info, bool) { 74 - cookie, err := r.Cookie(CookieName) 72 + func (p *Provider) GetUserID(r *http.Request) (int, error) { 73 + session, err := p.store.Get(r, CookieSessionKey) 74 + 75 75 if err != nil { 76 - return Info{}, false 76 + return 0, err 77 77 } 78 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 79 + return session.userID, nil 80 + } 81 + 82 + func (p *Provider) DeleteSession(w http.ResponseWriter, r *http.Request) error { 83 + return p.store.Delete(w, r, CookieSessionKey) 84 + } 85 + 86 + func (p *Provider) Middleware(next http.Handler) http.Handler { 87 + middleware := p.MiddlewareWithRule(func(_ int) bool { 88 + return true 84 89 }) 85 90 86 - if err != nil { 87 - return Info{}, false 88 - } 91 + return middleware(next) 92 + } 93 + 94 + func (p *Provider) MiddlewareWithRule(ruleFN func(userID int) bool) func(next http.Handler) http.Handler { 95 + return func(next http.Handler) http.Handler { 96 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 + session, err := p.store.Get(r, CookieSessionKey) 98 + if err != nil { 99 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 100 + return 101 + } 102 + 103 + SetSession(r, session) 104 + 105 + if !ruleFN(session.userID) { 106 + http.Error(w, "Forbidden", http.StatusForbidden) 107 + return 108 + } 89 109 90 - claims, ok := token.Claims.(*claims) 91 - if !ok || !token.Valid { 92 - return Info{}, false 110 + next.ServeHTTP(w, r) 111 + }) 93 112 } 113 + } 94 114 95 - return Info{UserID: claims.UserID, Role: claims.Role}, true 115 + // HashPassword hashes a password using bcrypt. 116 + func HashPassword(password string) (string, error) { 117 + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14) 118 + return string(bytes), err 119 + } 120 + 121 + // ComparePasswordAndHash compares a password with a hash. If invalidUser is true, it will compare 122 + // with an invalid hash, which will always fail. This is used to prevent timing attacks. 123 + func ComparePasswordAndHash(invalidUser bool, password, hash string) bool { 124 + if invalidUser { 125 + _ = bcrypt.CompareHashAndPassword([]byte("invalid-user"), []byte(password)) 126 + return false 127 + } 128 + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 129 + return err == nil 96 130 }
-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 - }
+8
pkg/utilities/utilities.go
··· 1 1 package utilities 2 2 3 3 import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 4 6 "fmt" 5 7 "log" 6 8 "os" ··· 32 34 } 33 35 return *item 34 36 } 37 + 38 + func DeriveKey(key, purpose string) []byte { 39 + h := hmac.New(sha256.New, []byte(key)) 40 + h.Write([]byte(purpose)) 41 + return h.Sum(nil) 42 + }
+26 -11
web/views/authentication/Login.svelte
··· 3 3 import Field from "$components/interaction/Field.svelte"; 4 4 import { default as Base } from "$components/layouts/Layout.svelte"; 5 5 import { default as Authentication } from "$components/layouts/authentication/Layout.svelte"; 6 - import { POST_AuthenticationLogin } from "$routes"; 7 - import { useForm } from "@inertiajs/svelte"; 6 + import { 7 + GET_AuthenticationRegister, 8 + POST_AuthenticationLogin, 9 + } from "$routes"; 10 + import { Link, useForm } from "@inertiajs/svelte"; 8 11 9 12 export const layout = [Base, Authentication]; 10 13 </script> 11 14 12 15 <script lang="ts"> 16 + import Back from "$components/interaction/Back.svelte"; 17 + import { GET_Index } from "$routes"; 18 + import type { LoginForm } from "$schemas/requests"; 19 + 13 20 type Props = {}; 14 21 15 22 let {}: Props = $props(); 16 23 17 24 const form = useForm({ 18 - username: "", 25 + email: "", 19 26 password: "", 20 - }); 27 + } as LoginForm); 21 28 22 29 function submit(e?: SubmitEvent) { 23 30 e?.preventDefault(); ··· 29 36 <hgroup> 30 37 <h1 class="h1">Login</h1> 31 38 <p class="subtitle"> 32 - Lorem ipsum dolor sit, amet consectetur adipisicing elit. 39 + Don't have an account yet? Go to the <Link 40 + href={GET_AuthenticationRegister()}>register</Link 41 + > page to create an account. 33 42 </p> 34 43 </hgroup> 35 44 </header> 36 45 46 + <Back href={GET_Index()} label="Go back home" /> 47 + 37 48 <form onsubmit={submit}> 38 49 <Field 39 50 as="input" 40 51 type="text" 41 - label="Username" 42 - bind:value={$form.username} 43 - description="Enter your username" 44 - errors={$form.errors.username} 52 + label="Email" 53 + bind:value={$form.email} 54 + description="Enter your email" 55 + errors={$form.errors["email"]} 45 56 /> 46 57 <Field 47 58 as="input" ··· 51 62 description="Enter your password" 52 63 errors={$form.errors.password} 53 64 /> 54 - <Field as="checkbox" label="Remember me" /> 55 - <Button scheme="primary">submit</Button> 65 + <Field 66 + as="checkbox" 67 + label="Remember me" 68 + description="We will keep you logged in for 30 days" 69 + /> 70 + <Button scheme="primary">Submit</Button> 56 71 </form> 57 72 58 73 <style>
+77
web/views/authentication/Register.svelte
··· 1 + <script module lang="ts"> 2 + import Button from "$components/interaction/Button.svelte"; 3 + import Field from "$components/interaction/Field.svelte"; 4 + import { default as Base } from "$components/layouts/Layout.svelte"; 5 + import { default as Authentication } from "$components/layouts/authentication/Layout.svelte"; 6 + import { 7 + GET_AuthenticationLogin, 8 + GET_AuthenticationRegister, 9 + POST_AuthenticationLogin, 10 + } from "$routes"; 11 + import { Link, useForm } from "@inertiajs/svelte"; 12 + 13 + export const layout = [Base, Authentication]; 14 + </script> 15 + 1 16 <script lang="ts"> 17 + import Back from "$components/interaction/Back.svelte"; 18 + import { GET_Index } from "$routes"; 19 + 2 20 type Props = {}; 3 21 4 22 let {}: Props = $props(); 23 + 24 + const form = useForm({ 25 + username: "", 26 + password: "", 27 + confirm_password: "", 28 + }); 29 + 30 + function submit(e?: SubmitEvent) { 31 + e?.preventDefault(); 32 + $form.submit(POST_AuthenticationLogin(), {}); 33 + } 5 34 </script> 35 + 36 + <header class="header"> 37 + <hgroup> 38 + <h1 class="h1">Register</h1> 39 + <p class="subtitle"> 40 + Create a new account to start using the application. Already have an 41 + account? <Link href={GET_AuthenticationLogin()}>Login</Link> 42 + </p> 43 + </hgroup> 44 + </header> 45 + 46 + <Back href={GET_Index()} label="Go back home" /> 47 + 48 + <form onsubmit={submit}> 49 + <Field 50 + as="input" 51 + type="text" 52 + label="Username" 53 + bind:value={$form.username} 54 + description="Enter your username" 55 + errors={$form.errors.username} 56 + /> 57 + <Field 58 + as="input" 59 + type="password" 60 + label="Password" 61 + bind:value={$form.password} 62 + description="Enter your password" 63 + errors={$form.errors.password} 64 + /> 65 + <Field 66 + as="input" 67 + type="password" 68 + label="Confirm password" 69 + bind:value={$form.confirm_password} 70 + description="Confirm your password" 71 + errors={$form.errors.confirmPassword} 72 + /> 73 + <Button scheme="primary">submit</Button> 74 + </form> 75 + 76 + <style> 77 + form { 78 + display: flex; 79 + flex-direction: column; 80 + gap: var(--spacing-4); 81 + } 82 + </style>