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.

potential kratos

oscar345 8f6351f6 f9826de2

+1259 -122
+1 -1
cmd/seeduser/main.go
··· 9 9 "github.com/oscar345/keeptrack/internal/config" 10 10 "github.com/oscar345/keeptrack/internal/models" 11 11 "github.com/oscar345/keeptrack/internal/repo/db" 12 - "github.com/oscar345/keeptrack/pkg/authentication" 12 + authentication "github.com/oscar345/keeptrack/pkg/authentication" 13 13 "github.com/oscar345/keeptrack/pkg/database" 14 14 _ "modernc.org/sqlite" 15 15 )
+438
combined.txt
··· 1 + package authentication 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + "golang.org/x/crypto/bcrypt" 9 + ) 10 + 11 + type User struct { 12 + ID int 13 + Email string 14 + HashedPassword string 15 + ConfirmedAt time.Time 16 + } 17 + 18 + type Repo interface { 19 + GetUserByEmail(email string) (User, error) 20 + GetUserByID(id int) (User, error) 21 + GetUserByTokenHash(hash string) (User, Token, error) 22 + CreateUser(user User) (int, error) 23 + UpdateUser(user User) error 24 + 25 + GetTokenByHash(hash string) (Token, error) 26 + ListTokenByUserID(userID int) ([]Token, error) 27 + CreateToken(token Token) error 28 + DeleteToken(hash string) error 29 + } 30 + 31 + type Authenticator struct { 32 + repo Repo 33 + invalidHashPassword []byte 34 + store SessionStore 35 + } 36 + 37 + func NewAuthenticator(repo Repo) *Authenticator { 38 + invalidHashPassword, err := bcrypt.GenerateFromPassword([]byte("invalid"), bcrypt.DefaultCost) 39 + if err != nil { 40 + log.Panicln(err) 41 + } 42 + 43 + return &Authenticator{ 44 + repo: repo, 45 + invalidHashPassword: invalidHashPassword, 46 + } 47 + } 48 + 49 + func (a *Authenticator) GetUserByEmailAndPassword(email, password string) (User, error) { 50 + user, err := a.repo.GetUserByEmail(email) 51 + 52 + if ok := a.IsPasswordCorrect(user.HashedPassword, password, err == nil); !ok { 53 + return User{}, ErrInvalidCredentials 54 + } 55 + 56 + return user, nil 57 + } 58 + 59 + func (a *Authenticator) IsPasswordCorrect(current string, input string, isValidUser bool) bool { 60 + if !isValidUser { 61 + _ = bcrypt.CompareHashAndPassword(a.invalidHashPassword, []byte(input)) 62 + return false 63 + } 64 + err := bcrypt.CompareHashAndPassword([]byte(current), []byte(input)) 65 + return err == nil 66 + } 67 + 68 + func (a *Authenticator) UpdateUserEmail(userID int, hash string) error { 69 + user, err := a.repo.GetUserByID(userID) 70 + if err != nil { 71 + return err 72 + } 73 + 74 + raw, err := GenerateHashFromToken(hash) 75 + if err != nil { 76 + return err 77 + } 78 + 79 + token, err := a.repo.GetTokenByHash(string(raw)) 80 + if err != nil { 81 + return err 82 + } 83 + 84 + if err := token.VerifyByPurpose(CreateChangeTokenPurpose(user.Email)); err != nil { 85 + return err 86 + } 87 + 88 + user.Email = token.SentTo 89 + if err := a.repo.UpdateUser(user); err != nil { 90 + return err 91 + } 92 + 93 + return a.repo.DeleteToken(token.Hash) 94 + } 95 + 96 + func (a *Authenticator) UpdateUserPassword(userID int, current string, replacement string) error { 97 + user, err := a.repo.GetUserByID(userID) 98 + if err != nil { 99 + return err 100 + } 101 + 102 + if !a.IsPasswordCorrect(user.HashedPassword, current, true) { 103 + return ErrInvalidCredentials 104 + } 105 + 106 + hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 107 + if err != nil { 108 + return err 109 + } 110 + 111 + user.HashedPassword = string(hashed) 112 + return a.repo.UpdateUser(user) 113 + } 114 + 115 + func (a *Authenticator) ConfirmUser(hash string) error { 116 + user, token, err := a.repo.GetUserByTokenHash(hash) 117 + if err != nil { 118 + return err 119 + } 120 + 121 + if err := token.VerifyByPurpose(TokenPurposeConfirm); err != nil { 122 + return err 123 + } 124 + 125 + user.ConfirmedAt = time.Now() 126 + if err := a.repo.UpdateUser(user); err != nil { 127 + return err 128 + } 129 + 130 + return a.repo.DeleteToken(token.Hash) 131 + } 132 + 133 + func (a *Authenticator) ResetUserPassword(hash string, replacement string) error { 134 + user, token, err := a.repo.GetUserByTokenHash(hash) 135 + if err != nil { 136 + return err 137 + } 138 + 139 + if err := token.VerifyByPurpose(TokenPurposePasswordReset); err != nil { 140 + return err 141 + } 142 + 143 + hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 144 + if err != nil { 145 + return err 146 + } 147 + 148 + user.HashedPassword = string(hashed) 149 + if err := a.repo.UpdateUser(user); err != nil { 150 + return err 151 + } 152 + 153 + return a.repo.DeleteToken(token.Hash) 154 + } 155 + 156 + func (a *Authenticator) LogIn(w http.ResponseWriter, r *http.Request, user User) error { 157 + data, err := a.store.Get(r) 158 + if err != nil { 159 + return err 160 + } 161 + 162 + if err := RenewSession(w, r, a.store); err != nil { 163 + return err 164 + } 165 + 166 + token, raw, err := CreateSessionToken(user.ID) 167 + if err != nil { 168 + return err 169 + } 170 + 171 + if err := a.repo.CreateToken(token); err != nil { 172 + return err 173 + } 174 + 175 + if err := a.store.Set(w, r, SessionData{Token: raw}); err != nil { 176 + return err 177 + } 178 + 179 + returnTo := PathLogin 180 + if data.ReturnTo != "" { 181 + returnTo = data.ReturnTo 182 + } 183 + 184 + http.Redirect(w, r, returnTo, http.StatusFound) 185 + return nil 186 + } 187 + 188 + func (a *Authenticator) LogOut(w http.ResponseWriter, r *http.Request) error { 189 + data, err := a.store.Get(r) 190 + if err != nil { 191 + return err 192 + } 193 + 194 + if data.Token == "" { 195 + return nil 196 + } 197 + 198 + hash, err := GenerateHashFromToken(data.Token) 199 + if err != nil { 200 + return err 201 + } 202 + 203 + if err := a.repo.DeleteToken(string(hash)); err != nil { 204 + return err 205 + } 206 + 207 + if err := RenewSession(w, r, a.store); err != nil { 208 + _ = a.store.Delete(w, r) 209 + return err 210 + } 211 + 212 + http.Redirect(w, r, PathSignedOut, http.StatusFound) 213 + return nil 214 + } 215 + package authentication 216 + 217 + import "errors" 218 + 219 + var ( 220 + ErrSessionExpired = errors.New("session expired") 221 + ErrWrongPurpose = errors.New("wrong purpose") 222 + ErrInvalidCredentials = errors.New("invalid credentials") 223 + ErrNoUserInContext = errors.New("no user in context") 224 + ) 225 + package authentication 226 + 227 + import ( 228 + "context" 229 + "net/http" 230 + ) 231 + 232 + type SessionData struct { 233 + Token string 234 + ReturnTo string 235 + } 236 + 237 + type SessionStore interface { 238 + Get(r *http.Request) (SessionData, error) 239 + Set(w http.ResponseWriter, r *http.Request, data SessionData) error 240 + Delete(w http.ResponseWriter, r *http.Request) error 241 + } 242 + 243 + func RenewSession(w http.ResponseWriter, r *http.Request, store SessionStore) error { 244 + if err := store.Delete(w, r); err != nil { 245 + return err 246 + } 247 + 248 + if err := store.Set(w, r, SessionData{}); err != nil { 249 + _ = store.Delete(w, r) 250 + return err 251 + } 252 + 253 + return nil 254 + } 255 + 256 + type Middleware struct { 257 + repo Repo 258 + store SessionStore 259 + } 260 + 261 + func NewMiddleware(repo Repo, store SessionStore) *Middleware { 262 + return &Middleware{ 263 + repo: repo, 264 + store: store, 265 + } 266 + } 267 + 268 + func (m *Middleware) FetchUser(next http.Handler) http.Handler { 269 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 270 + data, err := m.store.Get(r) 271 + if err != nil { 272 + next.ServeHTTP(w, r) 273 + return 274 + } 275 + 276 + if data.Token == "" { 277 + next.ServeHTTP(w, r) 278 + return 279 + } 280 + 281 + hash, err := GenerateHashFromToken(data.Token) 282 + if err != nil { 283 + next.ServeHTTP(w, r) 284 + return 285 + } 286 + 287 + user, token, err := m.repo.GetUserByTokenHash(string(hash)) 288 + if err != nil { 289 + next.ServeHTTP(w, r) 290 + } 291 + 292 + if err := token.VerifyByPurpose(TokenPurposeSession); err != nil { 293 + next.ServeHTTP(w, r) 294 + } 295 + 296 + r = r.WithContext(context.WithValue(r.Context(), ContextKeyUser, user)) 297 + 298 + next.ServeHTTP(w, r) 299 + }) 300 + } 301 + 302 + func (m *Middleware) RequireUser(next http.Handler) http.Handler { 303 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 304 + _, err := GetUserFromRequest(r) 305 + if err != nil { 306 + _ = m.store.Set(w, r, SessionData{ReturnTo: r.URL.Path}) 307 + http.Redirect(w, r, PathLogin, http.StatusFound) 308 + return 309 + } 310 + 311 + next.ServeHTTP(w, r) 312 + }) 313 + } 314 + 315 + func (m *Middleware) RequireNoUser(next http.Handler) http.Handler { 316 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 317 + _, err := GetUserFromRequest(r) 318 + if err == nil { 319 + http.Redirect(w, r, PathSignedIn, http.StatusFound) 320 + return 321 + } 322 + 323 + next.ServeHTTP(w, r) 324 + }) 325 + } 326 + 327 + const ContextKeyUser = "authentication-user" 328 + 329 + func GetUserFromRequest(r *http.Request) (User, error) { 330 + val := r.Context().Value(ContextKeyUser) 331 + if val == nil { 332 + return User{}, ErrNoUserInContext 333 + } 334 + 335 + user, ok := val.(User) 336 + if !ok { 337 + return User{}, ErrNoUserInContext 338 + } 339 + 340 + return user, nil 341 + } 342 + package authentication 343 + 344 + import "time" 345 + 346 + const ( 347 + SessionValidityDuration = 7 * 24 * time.Hour 348 + ) 349 + 350 + const ( 351 + PathLogin = "/login" 352 + PathSignedIn = "/" 353 + PathSignedOut = "/" 354 + ) 355 + package authentication 356 + 357 + import ( 358 + "crypto/rand" 359 + "crypto/sha256" 360 + "crypto/subtle" 361 + "encoding/base64" 362 + "time" 363 + ) 364 + 365 + type Token struct { 366 + Hash string 367 + UserID int 368 + Purpose TokenPurpose 369 + ExpiresAt time.Time 370 + SentTo string 371 + } 372 + 373 + func (t *Token) VerifyExpired() error { 374 + if time.Now().After(t.ExpiresAt) { 375 + return ErrSessionExpired 376 + } 377 + return nil 378 + } 379 + 380 + // VerifyByPurpose verifies if the session is valid for the given purpose and not expired. 381 + func (t *Token) VerifyByPurpose(purpose TokenPurpose) error { 382 + if subtle.ConstantTimeCompare([]byte(t.Purpose), []byte(purpose)) != 1 { 383 + return ErrWrongPurpose 384 + } 385 + return t.VerifyExpired() 386 + } 387 + 388 + type TokenPurpose string 389 + 390 + const ( 391 + TokenPurposeSession TokenPurpose = "session" 392 + TokenPurposePasswordReset TokenPurpose = "password-reset" 393 + TokenPurposeConfirm TokenPurpose = "confirm" 394 + ) 395 + 396 + func CreateChangeTokenPurpose(value string) TokenPurpose { 397 + return TokenPurpose("change:" + value) 398 + } 399 + 400 + // Create a token model. The function will return that model, its raw token value and an error if 401 + // any. 402 + func CreateSessionToken(userID int) (Token, string, error) { 403 + raw, hash, err := CreateTokenHash() 404 + if err != nil { 405 + return Token{}, "", err 406 + } 407 + 408 + token := Token{ 409 + UserID: userID, 410 + Purpose: TokenPurposeSession, 411 + ExpiresAt: time.Now().Add(SessionValidityDuration), 412 + Hash: string(hash), 413 + } 414 + 415 + return token, raw, nil 416 + } 417 + 418 + // Generate a random token, and return its raw value (for the user), its hash and a possible error 419 + func CreateTokenHash() (string, []byte, error) { 420 + token := make([]byte, 32) 421 + _, err := rand.Read(token) 422 + if err != nil { 423 + return "", nil, err 424 + } 425 + hash := sha256.Sum256(token) 426 + return base64.StdEncoding.EncodeToString(token), hash[:], nil 427 + } 428 + 429 + func GenerateHashFromToken(token string) ([]byte, error) { 430 + // Always perform the hash operation, even if decode fails 431 + raw, err := base64.StdEncoding.DecodeString(token) 432 + if err != nil { 433 + // Continue with dummy data to maintain constant time 434 + raw = make([]byte, 32) 435 + } 436 + hash := sha256.Sum256(raw) 437 + return hash[:], nil 438 + }
docker/docker-compose.dev.yml

This is a binary file and will not be displayed.

+45
internal/repo/db/authentication_repo.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + 6 + "github.com/oscar345/keeptrack/pkg/authentication" 7 + ) 8 + 9 + var _ authentication.Repo = (*AuthenticationRepoDB)(nil) 10 + 11 + type AuthenticationRepoDB struct { 12 + db *sql.DB 13 + } 14 + 15 + func NewAuthenticationRepoDB(db *sql.DB) *AuthenticationRepoDB { 16 + return &AuthenticationRepoDB{db: db} 17 + } 18 + 19 + func (repo *AuthenticationRepoDB) GetUserByEmail(email string) (authentication.User, error) { 20 + return authentication.User{}, nil 21 + } 22 + func (repo *AuthenticationRepoDB) GetUserByID(id int) (authentication.User, error) { 23 + return authentication.User{}, nil 24 + } 25 + func (repo *AuthenticationRepoDB) GetUserByTokenHash(hash string) (authentication.User, authentication.Token, error) { 26 + return authentication.User{}, authentication.Token{}, nil 27 + } 28 + func (repo *AuthenticationRepoDB) CreateUser(user authentication.User) (int, error) { 29 + return 0, nil 30 + } 31 + func (repo *AuthenticationRepoDB) UpdateUser(user authentication.User) error { 32 + return nil 33 + } 34 + func (repo *AuthenticationRepoDB) GetTokenByHash(hash string) (authentication.Token, error) { 35 + return authentication.Token{}, nil 36 + } 37 + func (repo *AuthenticationRepoDB) ListTokenByUserID(userID int) ([]authentication.Token, error) { 38 + return []authentication.Token{}, nil 39 + } 40 + func (repo *AuthenticationRepoDB) CreateToken(token authentication.Token) error { 41 + return nil 42 + } 43 + func (repo *AuthenticationRepoDB) DeleteToken(hash string) error { 44 + return nil 45 + }
+4 -5
internal/server/server.go
··· 13 13 "github.com/oscar345/keeptrack/internal/repo/db" 14 14 "github.com/oscar345/keeptrack/internal/services" 15 15 "github.com/oscar345/keeptrack/internal/web/router" 16 - "github.com/oscar345/keeptrack/internal/web/sessionstore" 17 16 "github.com/oscar345/keeptrack/pkg/authentication" 18 17 "github.com/oscar345/keeptrack/pkg/database" 19 18 storagesvc "github.com/oscar345/keeptrack/pkg/storage" ··· 50 49 }) 51 50 defer statisticsDB.Close() 52 51 52 + auth := authentication.New(db.NewAuthenticationRepoDB(generalDB)) 53 53 store := NewSessionStore(s.config) 54 - services := s.services(generalDB, statisticsDB) 54 + services := s.services(generalDB, statisticsDB, auth) 55 55 56 - auth := authentication.New(sessionstore.NewAuthentication(store)) 57 56 inertia := setupInertia(store) 58 57 59 58 router := router. ··· 71 70 Auth services.AuthenticationService 72 71 } 73 72 74 - func (s *Server) services(generalDB *sql.DB, statisticsDB *sql.DB) Services { 73 + func (s *Server) services(generalDB *sql.DB, statisticsDB *sql.DB, auth *authentication.Authenticator) Services { 75 74 artistRepo := db.NewArtistRepoDB(generalDB) 76 75 artistScrobbleRepo := db.NewArtistScrobbleRepoDB(statisticsDB) 77 76 artistImageFetcher := image.NewArtistImageFetcherFanArtTV(s.config.Services.FanartTV.APIKey) ··· 92 91 User: services.NewUserService( 93 92 userRepo, userFollowRepo, artistRepo, artistScrobbleRepo, mediaProvider, 94 93 ), 95 - Auth: services.NewAuthenticationService(userRepo), 94 + Auth: services.NewAuthenticationService(userRepo, auth), 96 95 } 97 96 } 98 97
+7 -18
internal/services/authentication.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "errors" 6 5 7 6 "github.com/oscar345/keeptrack/internal/models" 8 7 "github.com/oscar345/keeptrack/internal/repo" ··· 10 9 ) 11 10 12 11 type AuthenticationService struct { 13 - userRepo repo.UserRepo 12 + userRepo repo.UserRepo 13 + authenticator *authentication.Authenticator 14 14 } 15 15 16 - func NewAuthenticationService(userRepo repo.UserRepo) AuthenticationService { 16 + func NewAuthenticationService(userRepo repo.UserRepo, authenticator *authentication.Authenticator) AuthenticationService { 17 17 return AuthenticationService{ 18 - userRepo: userRepo, 18 + userRepo: userRepo, 19 + authenticator: authenticator, 19 20 } 20 21 } 21 22 22 - func (as *AuthenticationService) Login(ctx context.Context, email, password string) (models.User, error) { 23 - invalidUser := true 24 - 25 - user, err := as.userRepo.GetByEmail(ctx, email) 26 - if err == nil { 27 - invalidUser = false 28 - } 29 - 30 - ok := authentication.ComparePasswordAndHash(invalidUser, password, user.Password) 31 - if !ok { 32 - return models.User{}, errors.New("invalid credentials") 33 - } 34 - 35 - return user, nil 23 + func (as *AuthenticationService) Login(ctx context.Context, email, password string) (authentication.User, error) { 24 + return as.authenticator.GetUserByEmailAndPassword(email, password) 36 25 } 37 26 38 27 func (as *AuthenticationService) Register(ctx context.Context, user models.User) (models.User, error) {
+9 -1
internal/web/router/router.go
··· 18 18 "github.com/oscar345/keeptrack/internal/web/middleware" 19 19 "github.com/oscar345/keeptrack/internal/web/requests" 20 20 "github.com/oscar345/keeptrack/internal/web/responses" 21 - "github.com/oscar345/keeptrack/pkg/authentication" 21 + authentication "github.com/oscar345/keeptrack/pkg/authentication" 22 22 "github.com/oscar345/keeptrack/pkg/enum" 23 23 "github.com/oscar345/keeptrack/pkg/inertia" 24 24 "github.com/oscar345/keeptrack/pkg/pagination" ··· 108 108 109 109 r.Get("/authentication/register", func(w http.ResponseWriter, r *http.Request) { 110 110 s.inertia.Render(w, r, "authentication/Register", inertia.Props{}) 111 + }) 112 + 113 + r.Get("/settings", func(w http.ResponseWriter, r *http.Request) { 114 + s.inertia.Render(w, r, "settings/Index", inertia.Props{}) 115 + }) 116 + 117 + r.Get("/settings/services", func(w http.ResponseWriter, r *http.Request) { 118 + s.inertia.Render(w, r, "settings/Services", inertia.Props{}) 111 119 }) 112 120 113 121 r.Post("/authentication/register", func(w http.ResponseWriter, r *http.Request) {
+1 -1
internal/web/sessionstore/authentication.go
··· 7 7 "net/http" 8 8 9 9 "github.com/gorilla/sessions" 10 - "github.com/oscar345/keeptrack/pkg/authentication" 10 + authentication "github.com/oscar345/keeptrack/pkg/authentication" 11 11 ) 12 12 13 13 type Authentication struct {
+1
pkg/auth/authentication.go
··· 1 + package authentication
+49
pkg/auth/config/kratos.toml
··· 1 + #:schema https://raw.githubusercontent.com/ory/kratos/v1.3.0/.schemastore/config.schema.json 2 + 3 + dsn = "sqlite:///var/lib/sqlite/users.db?_fk=true&mode=rwc" 4 + 5 + [identity] 6 + default_schema_id = "user" 7 + 8 + [[identity.schemas]] 9 + id = "user" 10 + url = "file://path/to/identity.traits.schema.json" 11 + 12 + [selfservice] 13 + default_browser_return_url = "http://localhost:3000" 14 + 15 + [selfservice.methods] 16 + password.enabled = true 17 + 18 + [selfservice.flows] 19 + verification.enabled = false 20 + recovery.enabled = false 21 + 22 + [selfservice.flows.settings] 23 + ui_url = "http://localhost:3000/settings" 24 + lifespan = "15m" 25 + privileged_session_max_age = "15m" 26 + required_aal = "highest_available" 27 + 28 + [selfservice.flows.login] 29 + ui_url = "http://localhost:3000/login" 30 + lifespan = "10m" 31 + 32 + [selfservice.flows.registration] 33 + ui_url = "http://localhost:3000/registration" 34 + lifespan = "10m" 35 + after.password.hooks = [{ hook = "session" }] 36 + 37 + [selfservice.flows.logout] 38 + after.default_browser_return_url = "http://localhost:3000" 39 + 40 + [courier.smtp] 41 + connection_uri = "smtps://test:test@mailslurper:1025/?skip_ssl_verify=true" 42 + 43 + [secrets] 44 + cookie = ["very-long-very-secret-value"] 45 + cipher = ["even-longer-even-more-secret-val"] 46 + 47 + [hashers] 48 + algorithm = "bcrypt" 49 + bcrypt.cost = 12
+25
pkg/auth/config/user.schema.json
··· 1 + { 2 + "$id": "https://example.com/example.json", 3 + "$schema": "http://json-schema.org/draft-07/schema#", 4 + "title": "Person", 5 + "type": "object", 6 + "properties": { 7 + "traits": { 8 + "type": "object", 9 + "properties": { 10 + "username": { 11 + "type": "string", 12 + "ory.sh/kratos": { 13 + "credentials": { 14 + "password": { 15 + "identifier": true 16 + } 17 + } 18 + } 19 + } 20 + }, 21 + "required": ["username"], 22 + "additionalProperties": false 23 + } 24 + } 25 + }
+3
pkg/auth/docker-compose.yml
··· 1 + services: 2 + kratos: 3 + image:
+172 -80
pkg/authentication/authentication.go
··· 1 1 package authentication 2 2 3 3 import ( 4 - "context" 5 - "errors" 4 + "log" 6 5 "net/http" 6 + "time" 7 7 8 8 "golang.org/x/crypto/bcrypt" 9 9 ) 10 10 11 - const CookieSessionKey = "authentication" 12 - const CtxUserKey = "user" 13 - const CtxSessionKey = "user_session" 11 + type User struct { 12 + ID int 13 + Email string 14 + HashedPassword string 15 + ConfirmedAt time.Time 16 + } 14 17 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)) 18 + type Repo interface { 19 + GetUserByEmail(email string) (User, error) 20 + GetUserByID(id int) (User, error) 21 + GetUserByTokenHash(hash string) (User, Token, error) 22 + CreateUser(user User) (int, error) 23 + UpdateUser(user User) error 24 + 25 + GetTokenByHash(hash string) (Token, error) 26 + ListTokenByUserID(userID int) ([]Token, error) 27 + CreateToken(token Token) error 28 + DeleteToken(hash string) error 18 29 } 19 30 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)) 31 + type Authenticator struct { 32 + repo Repo 33 + invalidHashPassword []byte 34 + store SessionStore 24 35 } 25 36 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") 37 + func GenerateHashPassword(password string) (string, error) { 38 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 39 + if err != nil { 40 + return "", err 31 41 } 32 - return user, nil 42 + return string(hashedPassword), nil 33 43 } 34 44 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") 45 + func New(repo Repo) *Authenticator { 46 + invalidHashPassword, err := GenerateHashPassword("invalid") 47 + if err != nil { 48 + log.Panicln(err) 40 49 } 41 - return session, nil 42 - } 43 50 44 - type SessionStore interface { 45 - Set(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 51 + return &Authenticator{ 52 + repo: repo, 53 + invalidHashPassword: []byte(invalidHashPassword), 54 + } 48 55 } 49 56 50 - type Session struct { 51 - UserID int 57 + func (a *Authenticator) GetUserByEmailAndPassword(email, password string) (User, error) { 58 + user, err := a.repo.GetUserByEmail(email) 59 + 60 + if ok := a.IsPasswordCorrect(user.HashedPassword, password, err == nil); !ok { 61 + return User{}, ErrInvalidCredentials 62 + } 63 + 64 + return user, nil 52 65 } 53 66 54 - type Provider struct { 55 - store SessionStore 67 + func (a *Authenticator) IsPasswordCorrect(current string, input string, isValidUser bool) bool { 68 + if !isValidUser { 69 + _ = bcrypt.CompareHashAndPassword(a.invalidHashPassword, []byte(input)) 70 + return false 71 + } 72 + err := bcrypt.CompareHashAndPassword([]byte(current), []byte(input)) 73 + return err == nil 56 74 } 57 75 58 - func New(store SessionStore) *Provider { 59 - return &Provider{ 60 - store: store, 76 + func (a *Authenticator) UpdateUserEmail(userID int, hash string) error { 77 + user, err := a.repo.GetUserByID(userID) 78 + if err != nil { 79 + return err 80 + } 81 + 82 + raw, err := GenerateHashFromToken(hash) 83 + if err != nil { 84 + return err 85 + } 86 + 87 + token, err := a.repo.GetTokenByHash(string(raw)) 88 + if err != nil { 89 + return err 61 90 } 62 - } 63 91 64 - func (p *Provider) CreateSession(w http.ResponseWriter, r *http.Request, userID int) error { 65 - session := Session{ 66 - UserID: userID, 92 + if err := token.VerifyByPurpose(CreateChangeTokenPurpose(user.Email)); err != nil { 93 + return err 94 + } 95 + 96 + user.Email = token.SentTo 97 + if err := a.repo.UpdateUser(user); err != nil { 98 + return err 67 99 } 68 100 69 - return p.store.Set(w, r, CookieSessionKey, session) 101 + return a.repo.DeleteToken(token.Hash) 70 102 } 71 103 72 - func (p *Provider) GetUserID(r *http.Request) (int, error) { 73 - session, err := p.store.Get(r, CookieSessionKey) 104 + func (a *Authenticator) UpdateUserPassword(userID int, current string, replacement string) error { 105 + user, err := a.repo.GetUserByID(userID) 106 + if err != nil { 107 + return err 108 + } 109 + 110 + if !a.IsPasswordCorrect(user.HashedPassword, current, true) { 111 + return ErrInvalidCredentials 112 + } 74 113 114 + hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 75 115 if err != nil { 76 - return 0, err 116 + return err 77 117 } 78 118 79 - return session.UserID, nil 119 + user.HashedPassword = string(hashed) 120 + return a.repo.UpdateUser(user) 80 121 } 81 122 82 - func (p *Provider) DeleteSession(w http.ResponseWriter, r *http.Request) error { 83 - return p.store.Delete(w, r, CookieSessionKey) 84 - } 123 + func (a *Authenticator) ConfirmUser(hash string) error { 124 + user, token, err := a.repo.GetUserByTokenHash(hash) 125 + if err != nil { 126 + return err 127 + } 85 128 86 - func (p *Provider) Middleware(next http.Handler) http.Handler { 87 - middleware := p.MiddlewareWithRule(func(_ int) bool { 88 - return true 89 - }) 129 + if err := token.VerifyByPurpose(TokenPurposeConfirm); err != nil { 130 + return err 131 + } 90 132 91 - return middleware(next) 133 + user.ConfirmedAt = time.Now() 134 + if err := a.repo.UpdateUser(user); err != nil { 135 + return err 136 + } 137 + 138 + return a.repo.DeleteToken(token.Hash) 92 139 } 93 140 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 - } 141 + func (a *Authenticator) ResetUserPassword(hash string, replacement string) error { 142 + user, token, err := a.repo.GetUserByTokenHash(hash) 143 + if err != nil { 144 + return err 145 + } 102 146 103 - SetSession(r, session) 147 + if err := token.VerifyByPurpose(TokenPurposePasswordReset); err != nil { 148 + return err 149 + } 104 150 105 - if !ruleFN(session.UserID) { 106 - http.Error(w, "Forbidden", http.StatusForbidden) 107 - return 108 - } 151 + hashed, err := bcrypt.GenerateFromPassword([]byte(replacement), bcrypt.DefaultCost) 152 + if err != nil { 153 + return err 154 + } 109 155 110 - next.ServeHTTP(w, r) 111 - }) 156 + user.HashedPassword = string(hashed) 157 + if err := a.repo.UpdateUser(user); err != nil { 158 + return err 112 159 } 160 + 161 + return a.repo.DeleteToken(token.Hash) 113 162 } 114 163 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 164 + func (a *Authenticator) LogIn(w http.ResponseWriter, r *http.Request, user User) error { 165 + data, err := a.store.Get(r) 166 + if err != nil { 167 + return err 168 + } 169 + 170 + if err := RenewSession(w, r, a.store); err != nil { 171 + return err 172 + } 173 + 174 + token, raw, err := CreateSessionToken(user.ID) 175 + if err != nil { 176 + return err 177 + } 178 + 179 + if err := a.repo.CreateToken(token); err != nil { 180 + return err 181 + } 182 + 183 + if err := a.store.Set(w, r, SessionData{Token: raw}); err != nil { 184 + return err 185 + } 186 + 187 + returnTo := PathLogin 188 + if data.ReturnTo != "" { 189 + returnTo = data.ReturnTo 190 + } 191 + 192 + http.Redirect(w, r, returnTo, http.StatusFound) 193 + return nil 119 194 } 120 195 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 196 + func (a *Authenticator) LogOut(w http.ResponseWriter, r *http.Request) error { 197 + data, err := a.store.Get(r) 198 + if err != nil { 199 + return err 200 + } 201 + 202 + if data.Token == "" { 203 + return nil 204 + } 205 + 206 + hash, err := GenerateHashFromToken(data.Token) 207 + if err != nil { 208 + return err 209 + } 210 + 211 + if err := a.repo.DeleteToken(string(hash)); err != nil { 212 + return err 213 + } 214 + 215 + if err := RenewSession(w, r, a.store); err != nil { 216 + _ = a.store.Delete(w, r) 217 + return err 127 218 } 128 - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 129 - return err == nil 219 + 220 + http.Redirect(w, r, PathSignedOut, http.StatusFound) 221 + return nil 130 222 }
+10
pkg/authentication/errors.go
··· 1 + package authentication 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrSessionExpired = errors.New("session expired") 7 + ErrWrongPurpose = errors.New("wrong purpose") 8 + ErrInvalidCredentials = errors.New("invalid credentials") 9 + ErrNoUserInContext = errors.New("no user in context") 10 + )
+172
pkg/authentication/middleware.go
··· 1 + package authentication 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + 7 + "github.com/gorilla/sessions" 8 + ) 9 + 10 + type SessionData struct { 11 + Token string 12 + ReturnTo string 13 + } 14 + 15 + const CookieNameSessionData = "_app_session_data" 16 + 17 + type SessionStore interface { 18 + Get(r *http.Request) (SessionData, error) 19 + Set(w http.ResponseWriter, r *http.Request, data SessionData) error 20 + Delete(w http.ResponseWriter, r *http.Request) error 21 + } 22 + 23 + type GorillaSessionStore struct { 24 + store sessions.Store 25 + options sessions.Options 26 + } 27 + 28 + func NewGorillaSessionStore(store sessions.Store, options sessions.Options) *GorillaSessionStore { 29 + return &GorillaSessionStore{ 30 + store: store, 31 + options: options, 32 + } 33 + } 34 + 35 + func (s *GorillaSessionStore) Get(r *http.Request) (SessionData, error) { 36 + session, err := s.store.Get(r, CookieNameSessionData) 37 + if err != nil { 38 + return SessionData{}, err 39 + } 40 + 41 + token, ok := session.Values["data"].(SessionData) 42 + if !ok { 43 + return SessionData{}, nil 44 + } 45 + 46 + return token, nil 47 + } 48 + 49 + func (s *GorillaSessionStore) Set(w http.ResponseWriter, r *http.Request, data SessionData) error { 50 + session, err := s.store.New(r, CookieNameSessionData) 51 + if err != nil { 52 + return err 53 + } 54 + 55 + session.Values["data"] = data 56 + session.Options = &s.options 57 + 58 + return session.Save(r, w) 59 + } 60 + 61 + func (s *GorillaSessionStore) Delete(w http.ResponseWriter, r *http.Request) error { 62 + session, err := s.store.Get(r, CookieNameSessionData) 63 + if err != nil { 64 + return err 65 + } 66 + 67 + session.Options.MaxAge = -1 68 + 69 + return session.Save(r, w) 70 + } 71 + 72 + func RenewSession(w http.ResponseWriter, r *http.Request, store SessionStore) error { 73 + if err := store.Delete(w, r); err != nil { 74 + return err 75 + } 76 + 77 + if err := store.Set(w, r, SessionData{}); err != nil { 78 + _ = store.Delete(w, r) 79 + return err 80 + } 81 + 82 + return nil 83 + } 84 + 85 + type Middleware struct { 86 + repo Repo 87 + store SessionStore 88 + } 89 + 90 + func NewMiddleware(repo Repo, store SessionStore) *Middleware { 91 + return &Middleware{ 92 + repo: repo, 93 + store: store, 94 + } 95 + } 96 + 97 + func (m *Middleware) FetchUser(next http.Handler) http.Handler { 98 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 99 + data, err := m.store.Get(r) 100 + if err != nil { 101 + next.ServeHTTP(w, r) 102 + return 103 + } 104 + 105 + if data.Token == "" { 106 + next.ServeHTTP(w, r) 107 + return 108 + } 109 + 110 + hash, err := GenerateHashFromToken(data.Token) 111 + if err != nil { 112 + next.ServeHTTP(w, r) 113 + return 114 + } 115 + 116 + user, token, err := m.repo.GetUserByTokenHash(string(hash)) 117 + if err != nil { 118 + next.ServeHTTP(w, r) 119 + return 120 + } 121 + 122 + if err := token.VerifyByPurpose(TokenPurposeSession); err != nil { 123 + next.ServeHTTP(w, r) 124 + return 125 + } 126 + 127 + r = r.WithContext(context.WithValue(r.Context(), ContextKeyUser, user)) 128 + 129 + next.ServeHTTP(w, r) 130 + }) 131 + } 132 + 133 + func (m *Middleware) RequireUser(next http.Handler) http.Handler { 134 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 135 + _, err := GetUserFromRequest(r) 136 + if err != nil { 137 + _ = m.store.Set(w, r, SessionData{ReturnTo: r.URL.Path}) 138 + http.Redirect(w, r, PathLogin, http.StatusFound) 139 + return 140 + } 141 + 142 + next.ServeHTTP(w, r) 143 + }) 144 + } 145 + 146 + func (m *Middleware) RequireNoUser(next http.Handler) http.Handler { 147 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 + _, err := GetUserFromRequest(r) 149 + if err == nil { 150 + http.Redirect(w, r, PathSignedIn, http.StatusFound) 151 + return 152 + } 153 + 154 + next.ServeHTTP(w, r) 155 + }) 156 + } 157 + 158 + const ContextKeyUser = "authentication-user" 159 + 160 + func GetUserFromRequest(r *http.Request) (User, error) { 161 + val := r.Context().Value(ContextKeyUser) 162 + if val == nil { 163 + return User{}, ErrNoUserInContext 164 + } 165 + 166 + user, ok := val.(User) 167 + if !ok { 168 + return User{}, ErrNoUserInContext 169 + } 170 + 171 + return user, nil 172 + }
+13
pkg/authentication/settings.go
··· 1 + package authentication 2 + 3 + import "time" 4 + 5 + const ( 6 + SessionValidityDuration = 7 * 24 * time.Hour 7 + ) 8 + 9 + const ( 10 + PathLogin = "/login" 11 + PathSignedIn = "/" 12 + PathSignedOut = "/" 13 + )
+84
pkg/authentication/token.go
··· 1 + package authentication 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "crypto/subtle" 7 + "encoding/base64" 8 + "time" 9 + ) 10 + 11 + type Token struct { 12 + Hash string 13 + UserID int 14 + Purpose TokenPurpose 15 + ExpiresAt time.Time 16 + SentTo string 17 + } 18 + 19 + func (t *Token) VerifyExpired() error { 20 + if time.Now().After(t.ExpiresAt) { 21 + return ErrSessionExpired 22 + } 23 + return nil 24 + } 25 + 26 + // VerifyByPurpose verifies if the session is valid for the given purpose and not expired. 27 + func (t *Token) VerifyByPurpose(purpose TokenPurpose) error { 28 + if subtle.ConstantTimeCompare([]byte(t.Purpose), []byte(purpose)) != 1 { 29 + return ErrWrongPurpose 30 + } 31 + return t.VerifyExpired() 32 + } 33 + 34 + type TokenPurpose string 35 + 36 + const ( 37 + TokenPurposeSession TokenPurpose = "session" 38 + TokenPurposePasswordReset TokenPurpose = "password-reset" 39 + TokenPurposeConfirm TokenPurpose = "confirm" 40 + ) 41 + 42 + func CreateChangeTokenPurpose(value string) TokenPurpose { 43 + return TokenPurpose("change:" + value) 44 + } 45 + 46 + // Create a token model. The function will return that model, its raw token value and an error if 47 + // any. 48 + func CreateSessionToken(userID int) (Token, string, error) { 49 + raw, hash, err := CreateTokenHash() 50 + if err != nil { 51 + return Token{}, "", err 52 + } 53 + 54 + token := Token{ 55 + UserID: userID, 56 + Purpose: TokenPurposeSession, 57 + ExpiresAt: time.Now().Add(SessionValidityDuration), 58 + Hash: string(hash), 59 + } 60 + 61 + return token, raw, nil 62 + } 63 + 64 + // Generate a random token, and return its raw value (for the user), its hash and a possible error 65 + func CreateTokenHash() (string, []byte, error) { 66 + token := make([]byte, 32) 67 + _, err := rand.Read(token) 68 + if err != nil { 69 + return "", nil, err 70 + } 71 + hash := sha256.Sum256(token) 72 + return base64.StdEncoding.EncodeToString(token), hash[:], nil 73 + } 74 + 75 + func GenerateHashFromToken(token string) ([]byte, error) { 76 + // Always perform the hash operation, even if decode fails 77 + raw, err := base64.StdEncoding.DecodeString(token) 78 + if err != nil { 79 + // Continue with dummy data to maintain constant time 80 + raw = make([]byte, 32) 81 + } 82 + hash := sha256.Sum256(raw) 83 + return hash[:], nil 84 + }
+7
pkg/scrobbler/scrobbler.go
··· 1 + package scrobbler 2 + 3 + type Scrobble struct{} 4 + 5 + type Scrobbler interface { 6 + Scrobble() 7 + }
-1
web/components/catalog/Scrobble.svelte
··· 22 22 font-size: var(--text-xs); 23 23 line-height: var(--text-xs--line-height); 24 24 font-weight: var(--font-weight-medium); 25 - border-radius: 9999px; 26 25 } 27 26 </style>
web/components/footer/web/Footer.svelte web/components/layouts/web/Footer.svelte
+32
web/components/interaction/Setting.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from "svelte"; 3 + import Button from "$components/interaction/Button.svelte"; 4 + 5 + type Props = { 6 + value: Snippet; 7 + save: () => boolean; 8 + field: Snippet; 9 + direction: "horizontal" | "vertical"; 10 + }; 11 + 12 + let editing = $state(false); 13 + 14 + let { value, save, field, direction = "horizontal" }: Props = $props(); 15 + </script> 16 + 17 + <div class={direction}> 18 + {#if editing} 19 + {@render field()} 20 + <Button 21 + variant="text" 22 + onclick={() => { 23 + if (save()) { 24 + editing = false; 25 + } 26 + }}>Save</Button 27 + > 28 + {:else} 29 + {@render value()} 30 + <Button variant="text" onclick={() => (editing = true)}>Edit</Button> 31 + {/if} 32 + </div>
+1 -1
web/components/layouts/library/Layout.svelte
··· 1 1 <script lang="ts"> 2 - import Navigation from "$components/navigation/library/Navigation.svelte"; 2 + import Navigation from "$components/layouts/library/Navigation.svelte"; 3 3 import type { Snippet } from "svelte"; 4 4 5 5 type Props = {
+26
web/components/layouts/settings/Layout.svelte
··· 1 + <script lang="ts"> 2 + import Navigation from "$components/layouts/settings/Navigation.svelte"; 3 + import type { Snippet } from "svelte"; 4 + 5 + type Props = { 6 + children: Snippet; 7 + }; 8 + 9 + let { children }: Props = $props(); 10 + </script> 11 + 12 + <div class="view"> 13 + <Navigation /> 14 + 15 + <main> 16 + {@render children()} 17 + </main> 18 + </div> 19 + 20 + <style> 21 + .view { 22 + display: grid; 23 + grid-template-columns: 1fr 3fr; 24 + column-gap: var(--spacing-8); 25 + } 26 + </style>
+88
web/components/layouts/settings/Navigation.svelte
··· 1 + <script lang="ts"> 2 + import type { NavigationItemProps } from "$lib/types"; 3 + import { GET_Settings, GET_SettingsServices } from "$routes"; 4 + import { Link, page } from "@inertiajs/svelte"; 5 + 6 + let items: NavigationItemProps[] = $state([ 7 + { label: "Overview", href: GET_Settings(), view: "library/Index" }, 8 + { 9 + label: "Services", 10 + href: GET_SettingsServices(), 11 + view: "settings/Services", 12 + }, 13 + { 14 + label: "Artists", 15 + href: "/library/artists", 16 + view: "library/artists/Index", 17 + }, 18 + { 19 + label: "Releases ", 20 + href: "/library/releases", 21 + view: "library/releases/Index", 22 + }, 23 + { label: "Songs", href: "/library/songs", view: "library/songs/Index" }, 24 + ]); 25 + </script> 26 + 27 + <aside> 28 + <nav> 29 + <ul> 30 + {#each items as item} 31 + <li> 32 + <Link 33 + aria-current={$page.component === item.view 34 + ? "page" 35 + : undefined} 36 + href={item.href} 37 + > 38 + {item.label} 39 + </Link> 40 + </li> 41 + {/each} 42 + </ul> 43 + </nav> 44 + </aside> 45 + 46 + <style> 47 + nav { 48 + display: flex; 49 + flex-direction: column; 50 + gap: var(--spacing-2); 51 + position: sticky; 52 + top: calc(var(--spacing-16) + var(--spacing-6)); 53 + } 54 + 55 + ul { 56 + width: 100%; 57 + display: flex; 58 + flex-direction: column; 59 + gap: var(--spacing-1); 60 + } 61 + 62 + li > :global(a) { 63 + color: var(--color-primary); 64 + padding-block: var(--spacing-0_5); 65 + font-size: var(--text-sm); 66 + line-height: var(--text-sm--line-height); 67 + font-weight: var(--font-weight-medium); 68 + width: 100%; 69 + display: inline-flex; 70 + align-items: center; 71 + gap: var(--spacing-1); 72 + transition: 73 + background-color 100ms ease, 74 + padding-inline 100ms ease; 75 + } 76 + 77 + li > :global(a:where([aria-current="page"], :hover)) { 78 + padding-inline: var(--spacing-2); 79 + } 80 + 81 + li > :global(a[aria-current="page"]) { 82 + background-color: var(--color-muted-100); 83 + } 84 + 85 + li > :global(a:hover) { 86 + background-color: var(--color-muted-200); 87 + } 88 + </style>
+2 -2
web/components/layouts/web/Layout.svelte
··· 1 1 <script lang="ts"> 2 - import Footer from "$components/footer/web/Footer.svelte"; 3 - import Navigation from "$components/navigation/web/Navigation.svelte"; 2 + import Footer from "$components/layouts/web/Footer.svelte"; 3 + import Navigation from "$components/layouts/web/Navigation.svelte"; 4 4 import type { Snippet } from "svelte"; 5 5 6 6 type Props = {
web/components/navigation/library/Navigation.svelte web/components/layouts/library/Navigation.svelte
+29 -4
web/components/navigation/web/Navigation.svelte web/components/layouts/web/Navigation.svelte
··· 1 1 <script lang="ts"> 2 2 import Logo from "$components/brand/Logo.svelte"; 3 - import { GET_Friends, GET_Index, GET_Library, GET_Mixtapes } from "$routes"; 3 + import { 4 + GET_Friends, 5 + GET_Index, 6 + GET_Library, 7 + GET_Mixtapes, 8 + GET_Settings, 9 + } from "$routes"; 4 10 import type { NavigationItemProps } from "$lib/types"; 5 11 import { Link, page } from "@inertiajs/svelte"; 6 12 ··· 17 23 <Link href={GET_Index()}> 18 24 <Logo /> 19 25 </Link> 20 - <ul> 26 + <ul class="primary"> 21 27 {#each items as item} 22 28 <li> 23 29 <Link ··· 31 37 </li> 32 38 {/each} 33 39 </ul> 40 + <ul class="secondary"> 41 + <li> 42 + <Link 43 + href={GET_Settings()} 44 + aria-current={$page.component.startsWith("settings") 45 + ? "page" 46 + : undefined} 47 + > 48 + Settings 49 + </Link> 50 + </li> 51 + </ul> 34 52 </nav> 35 53 </header> 36 54 ··· 51 69 } 52 70 53 71 ul { 54 - width: 100%; 55 72 display: flex; 56 - justify-content: center; 57 73 align-items: center; 58 74 gap: var(--spacing-2); 75 + width: 100%; 76 + } 77 + 78 + ul.primary { 79 + justify-content: center; 80 + } 81 + 82 + ul.secondary { 83 + justify-content: flex-end; 59 84 } 60 85 61 86 li > :global(a) {
+8 -8
web/styles/colors.css
··· 11 11 --relative-l-900: 82%; 12 12 --relative-l-950: 80%; 13 13 14 - --theme-color-base-100: var(--color-gray-50); 15 - --theme-color-base-200: var(--color-gray-100); 14 + --theme-color-base-100: var(--color-zinc-50); 15 + --theme-color-base-200: var(--color-zinc-100); 16 16 --theme-color-base-300: #fff; 17 17 18 - --theme-color-content-100: var(--color-gray-950); 19 - --theme-color-content-200: var(--color-gray-800); 20 - --theme-color-content-300: var(--color-gray-600); 18 + --theme-color-content-100: var(--color-zinc-950); 19 + --theme-color-content-200: var(--color-zinc-800); 20 + --theme-color-content-300: var(--color-zinc-600); 21 21 22 - --theme-color-muted-100: var(--color-gray-200); 23 - --theme-color-muted-200: var(--color-gray-300); 24 - --theme-color-muted-300: var(--color-gray-400); 22 + --theme-color-muted-100: var(--color-zinc-200); 23 + --theme-color-muted-200: var(--color-zinc-300); 24 + --theme-color-muted-300: var(--color-zinc-400); 25 25 26 26 --theme-color-primary: var(--color-blue-600); 27 27 --theme-color-primary-contrast: var(--color-blue-50);
+16
web/views/settings/Index.svelte
··· 1 + <script lang="ts" module> 2 + import { default as Base } from "$components/layouts/Layout.svelte"; 3 + import { default as Web } from "$components/layouts/web/Layout.svelte"; 4 + import Layout from "$components/layouts/settings/Layout.svelte"; 5 + 6 + export const layout = [Base, Web, Layout]; 7 + </script> 8 + 9 + <script lang="ts"> 10 + </script> 11 + 12 + <header class="header"> 13 + <hgroup> 14 + <h1 class="h1">Settings</h1> 15 + </hgroup> 16 + </header>
+16
web/views/settings/Services.svelte
··· 1 + <script lang="ts" module> 2 + import { default as Base } from "$components/layouts/Layout.svelte"; 3 + import { default as Web } from "$components/layouts/web/Layout.svelte"; 4 + import Layout from "$components/layouts/settings/Layout.svelte"; 5 + 6 + export const layout = [Base, Web, Layout]; 7 + </script> 8 + 9 + <script lang="ts"> 10 + </script> 11 + 12 + <header class="header"> 13 + <hgroup> 14 + <h1 class="h1">Services</h1> 15 + </hgroup> 16 + </header>