mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-11 09:08:04 -04:00
* fix(artwork): include top-level album folders in parent cover art lookup The Path != "." guard added in #5451 was too aggressive — it excluded any folder with Path=".", which includes top-level album folders (not just the library root). Changed to ParentID != "" which correctly excludes only the actual library root folder. Fixes #5456 * fix: correct comment in test — album is under library root, not artist root * test: add ascii tree diagram to top-level album e2e test * test: replace internal bug references with issue link in e2e comments Signed-off-by: Deluan <deluan@navidrome.org> * test: add e2e test matching reporter's exact library layout (#5456) Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs using the reporter's actual folder names to verify artwork resolution works for non-top-level album folders too. * fix(scanner): use a syntectic admin user when no admin user is found Signed-off-by: Deluan <deluan@navidrome.org> * fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache When Phase 3 corrects an album's FolderIDs (or any other field), bump UpdatedAt to the current time. This ensures the artwork cache key changes, invalidating any stale artwork that was resolved and cached during Phase 1 when the album had incomplete folder data. * fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt semantics) and instead includes album.ImportedAt in the artwork cache key computation. Since ImportedAt is bumped to time.Now() on every album Put, any Phase 3 correction naturally invalidates cached artwork that was resolved mid-scan with incomplete folder data. * fix(artwork): simplify lastUpdate logic using TimeNewest utility Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
144 lines
3.8 KiB
Go
144 lines
3.8 KiB
Go
package auth
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"crypto/sha256"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/id"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils"
|
|
)
|
|
|
|
var (
|
|
once sync.Once
|
|
TokenAuth *jwtauth.JWTAuth
|
|
)
|
|
|
|
// Init creates a JWTAuth object from the secret stored in the DB.
|
|
// If the secret is not found, it will create a new one and store it in the DB.
|
|
func Init(ds model.DataStore) {
|
|
once.Do(func() {
|
|
ctx := context.TODO()
|
|
log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
|
|
|
|
secret, err := ds.Property(ctx).Get(consts.JWTSecretKey)
|
|
if err != nil || secret == "" {
|
|
log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions")
|
|
secret = createNewSecret(ctx, ds)
|
|
} else {
|
|
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
|
|
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err)
|
|
secret = createNewSecret(ctx, ds)
|
|
}
|
|
}
|
|
|
|
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
|
})
|
|
}
|
|
|
|
func CreatePublicToken(claims Claims) (string, error) {
|
|
claims.Issuer = consts.JWTIssuer
|
|
_, token, err := TokenAuth.Encode(claims.ToMap())
|
|
return token, err
|
|
}
|
|
|
|
func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
|
|
claims.Issuer = consts.JWTIssuer
|
|
if !exp.IsZero() {
|
|
claims.ExpiresAt = exp
|
|
}
|
|
_, token, err := TokenAuth.Encode(claims.ToMap())
|
|
return token, err
|
|
}
|
|
|
|
func CreateToken(u *model.User) (string, error) {
|
|
claims := Claims{
|
|
Issuer: consts.JWTIssuer,
|
|
Subject: u.UserName,
|
|
IssuedAt: time.Now(),
|
|
UserID: u.ID,
|
|
IsAdmin: u.IsAdmin,
|
|
}
|
|
token, _, err := TokenAuth.Encode(claims.ToMap())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return TouchToken(token)
|
|
}
|
|
|
|
func TouchToken(token jwt.Token) (string, error) {
|
|
claims := ClaimsFromToken(token).
|
|
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
|
|
_, newToken, err := TokenAuth.Encode(claims.ToMap())
|
|
return newToken, err
|
|
}
|
|
|
|
func Validate(tokenStr string) (Claims, error) {
|
|
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
|
if err != nil {
|
|
return Claims{}, err
|
|
}
|
|
return ClaimsFromToken(token), nil
|
|
}
|
|
|
|
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
|
u, err := ds.User(ctx).FindFirstAdmin()
|
|
if err != nil {
|
|
c, err := ds.User(ctx).CountAll()
|
|
if c == 0 && err == nil {
|
|
log.Debug(ctx, "No admin user yet!", err)
|
|
} else {
|
|
log.Error(ctx, "No admin user found!", err)
|
|
}
|
|
u = &model.User{IsAdmin: true, UserName: "admin"}
|
|
}
|
|
|
|
ctx = request.WithUsername(ctx, u.UserName)
|
|
return request.WithUser(ctx, *u)
|
|
}
|
|
|
|
func createNewSecret(ctx context.Context, ds model.DataStore) string {
|
|
secret := id.NewRandom()
|
|
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
|
|
if err != nil {
|
|
log.Error(ctx, "Could not encrypt JWT secret", err)
|
|
return secret
|
|
}
|
|
if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil {
|
|
log.Error(ctx, "Could not save JWT secret in DB", err)
|
|
}
|
|
return secret
|
|
}
|
|
|
|
// EncodeToken creates a signed JWT from an arbitrary claims map.
|
|
// It sets the issuer claim automatically.
|
|
func EncodeToken(claims map[string]any) (string, error) {
|
|
claims[jwt.IssuerKey] = consts.JWTIssuer
|
|
_, token, err := TokenAuth.Encode(claims)
|
|
return token, err
|
|
}
|
|
|
|
// DecodeAndVerifyToken verifies a JWT string and returns the parsed token.
|
|
func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
|
|
return jwtauth.VerifyToken(TokenAuth, tokenStr)
|
|
}
|
|
|
|
func getEncKey() []byte {
|
|
key := cmp.Or(
|
|
conf.Server.PasswordEncryptionKey,
|
|
consts.DefaultEncryptionKey,
|
|
)
|
|
sum := sha256.Sum256([]byte(key))
|
|
return sum[:]
|
|
}
|