Files
silo-server/internal/jellycompat/session_store.go

221 lines
5.5 KiB
Go

package jellycompat
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
)
// ErrSessionNotFound is returned when a compat session does not exist.
var ErrSessionNotFound = errors.New("compat session not found")
type sessionPersistence interface {
Upsert(ctx context.Context, session Session) error
GetByToken(ctx context.Context, token string, now time.Time) (*Session, error)
DeleteByToken(ctx context.Context, token string) error
}
// Session stores a compat login plus upstream Silo credentials.
type Session struct {
Token string
Username string
AccountUsername string
ProfileID string
ProfileName string
PseudoUserID uuid.UUID
StreamAppUserID int
StreamAppAccessToken string
StreamAppRefreshToken string
StreamAppTokenExpiry time.Time
CreatedAt time.Time
ExpiresAt time.Time
}
// SessionStore keeps compat sessions in memory.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
ttl time.Duration
now func() time.Time
repo sessionPersistence
}
// NewSessionStore creates a new in-memory session store.
func NewSessionStore(ttl time.Duration, now func() time.Time) *SessionStore {
return newSessionStore(ttl, now, nil)
}
// NewPersistentSessionStore creates a session store backed by persistent storage.
func NewPersistentSessionStore(ttl time.Duration, now func() time.Time, repo sessionPersistence) *SessionStore {
return newSessionStore(ttl, now, repo)
}
func newSessionStore(ttl time.Duration, now func() time.Time, repo sessionPersistence) *SessionStore {
if now == nil {
now = time.Now
}
return &SessionStore{
sessions: make(map[string]Session),
ttl: ttl,
now: now,
repo: repo,
}
}
// Put stores or replaces a compat session.
func (s *SessionStore) Put(session Session) error {
if session.CreatedAt.IsZero() {
session.CreatedAt = s.now()
}
if session.ExpiresAt.IsZero() {
session.ExpiresAt = session.CreatedAt.Add(s.ttl)
}
if s.repo != nil {
if err := s.repo.Upsert(context.Background(), session); err != nil {
return err
}
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.Token] = session
return nil
}
// Get returns a compat session when it exists and is not expired.
// If the session's remaining lifetime is less than half the configured TTL,
// ExpiresAt is extended by the full TTL (sliding window).
func (s *SessionStore) Get(token string) (*Session, bool) {
s.mu.RLock()
session, ok := s.sessions[token]
s.mu.RUnlock()
if ok {
if !session.ExpiresAt.IsZero() && !session.ExpiresAt.After(s.now()) {
s.Delete(token)
return nil, false
}
s.maybeExtendSession(&session, token)
sessionCopy := session
return &sessionCopy, true
}
if s.repo == nil {
return nil, false
}
persisted, err := s.repo.GetByToken(context.Background(), token, s.now())
if err != nil {
if !errors.Is(err, ErrSessionNotFound) {
slog.Warn("jellycompat session store load failed", "token", token, "error", err)
}
return nil, false
}
s.maybeExtendSession(persisted, token)
s.mu.Lock()
s.sessions[token] = *persisted
s.mu.Unlock()
sessionCopy := *persisted
return &sessionCopy, true
}
// maybeExtendSession extends the session's ExpiresAt if more than half the TTL has elapsed.
func (s *SessionStore) maybeExtendSession(session *Session, token string) {
if s.ttl <= 0 || session.ExpiresAt.IsZero() {
return
}
remaining := session.ExpiresAt.Sub(s.now())
if remaining >= s.ttl/2 {
return
}
session.ExpiresAt = s.now().Add(s.ttl)
s.mu.Lock()
s.sessions[token] = *session
s.mu.Unlock()
if s.repo != nil {
if err := s.repo.Upsert(context.Background(), *session); err != nil {
slog.Warn("jellycompat session store extend failed", "token", token, "error", err)
}
}
}
// Delete removes a compat session.
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
if s.repo != nil {
if err := s.repo.DeleteByToken(context.Background(), token); err != nil && !errors.Is(err, ErrSessionNotFound) {
slog.Warn("jellycompat session store delete failed", "token", token, "error", err)
}
}
}
// DeleteByUserID removes all compat sessions for a given Silo user ID.
func (s *SessionStore) DeleteByUserID(userID int) {
s.mu.Lock()
for token, session := range s.sessions {
if session.StreamAppUserID == userID {
delete(s.sessions, token)
}
}
s.mu.Unlock()
if s.repo != nil {
if repo, ok := s.repo.(*SessionRepository); ok {
if _, err := repo.DeleteByUserID(context.Background(), userID); err != nil {
slog.Warn("jellycompat session store delete by user failed", "user_id", userID, "error", err)
}
}
}
}
// Update modifies a compat session in place.
func (s *SessionStore) Update(token string, fn func(*Session) error) error {
s.mu.Lock()
session, ok := s.sessions[token]
s.mu.Unlock()
if !ok && s.repo != nil {
persisted, err := s.repo.GetByToken(context.Background(), token, s.now())
if err != nil {
if errors.Is(err, ErrSessionNotFound) {
return ErrSessionNotFound
}
return err
}
session = *persisted
ok = true
}
if !ok {
return ErrSessionNotFound
}
if !session.ExpiresAt.IsZero() && !session.ExpiresAt.After(s.now()) {
s.Delete(token)
return ErrSessionNotFound
}
if err := fn(&session); err != nil {
return err
}
if s.repo != nil {
if err := s.repo.Upsert(context.Background(), session); err != nil {
return err
}
}
s.mu.Lock()
s.sessions[token] = session
s.mu.Unlock()
return nil
}