Gitfed
bastien-mrq/gitfed / internal / store / sessions.go
package store

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"time"

	bolt "go.etcd.io/bbolt"
)

// Session backs web login: an opaque bearer token (held in a cookie) maps
// to this record. Nothing about it is derivable from the token itself
// (unlike a signed JWT), so deleting the record here is a real, immediate
// logout/revocation — there's no separate secret to rotate.
type Session struct {
	Token     string    `json:"token"`
	Principal string    `json:"principal"` // "<username>@<domain>"
	Username  string    `json:"username"`
	IsAdmin   bool      `json:"is_admin"`
	CreatedAt time.Time `json:"created_at"`
	ExpiresAt time.Time `json:"expires_at"`
}

// CreateSession mints a new random token and stores the session, valid for
// ttl.
func (s *Store) CreateSession(principal, username string, isAdmin bool, ttl time.Duration) (string, error) {
	buf := make([]byte, 32)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	token := base64.RawURLEncoding.EncodeToString(buf)

	now := time.Now().UTC()
	sess := Session{
		Token:     token,
		Principal: principal,
		Username:  username,
		IsAdmin:   isAdmin,
		CreatedAt: now,
		ExpiresAt: now.Add(ttl),
	}
	err := s.db.Update(func(tx *bolt.Tx) error {
		return putJSON(tx, bucketSessions, token, sess)
	})
	if err != nil {
		return "", err
	}
	return token, nil
}

// GetSession looks up a session by token. An expired session is treated
// (and lazily deleted) as not found.
func (s *Store) GetSession(token string) (Session, error) {
	var sess Session
	err := s.db.Update(func(tx *bolt.Tx) error {
		if err := getJSON(tx, bucketSessions, token, &sess); err != nil {
			return err
		}
		if time.Now().After(sess.ExpiresAt) {
			_ = deleteKey(tx, bucketSessions, token)
			return ErrNotFound
		}
		return nil
	})
	return sess, err
}

func (s *Store) DeleteSession(token string) error {
	return s.db.Update(func(tx *bolt.Tx) error {
		return deleteKey(tx, bucketSessions, token)
	})
}

// PurgeExpiredSessions removes all sessions past their expiry and returns how
// many were deleted. GetSession only evicts a session lazily when it happens
// to be looked up; this sweeps the ones that are never touched again so the
// bucket doesn't grow without bound.
func (s *Store) PurgeExpiredSessions() (int, error) {
	var deleted int
	err := s.db.Update(func(tx *bolt.Tx) error {
		b := tx.Bucket(bucketSessions)
		now := time.Now()
		var stale [][]byte
		c := b.Cursor()
		for k, v := c.First(); k != nil; k, v = c.Next() {
			var sess Session
			if json.Unmarshal(v, &sess) != nil || now.After(sess.ExpiresAt) {
				stale = append(stale, append([]byte(nil), k...))
			}
		}
		for _, k := range stale {
			if err := b.Delete(k); err != nil {
				return err
			}
			deleted++
		}
		return nil
	})
	return deleted, err
}