Files
cdnutterandClaude Opus 5.5 e9a75a968a Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's
ranked lists (movies, TV, albums, songs, video games, theater) and a
login-gated editor.

- Lists of 1–65,535 entries (100 recommended), several per category
- Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia;
  manual entry as a fallback
- Theater entries carry a production (Broadway, tour, …), venue and cast
- Artwork cached locally; URL override and uploads
- Drag, arrow and type-a-rank reordering; paginated for long lists
- 12 theme presets plus custom CSS
- First-run setup code or env-provided admin; bcrypt, rate-limited login,
  CSRF-guarded JSON API
- Dockerfile and docker-compose.yml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:54:19 -07:00

92 lines
2.1 KiB
Go

// Package auth has password hashing, session tokens and a login rate limiter.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const MinPasswordLength = 8
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
return string(b), err
}
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
// dummyHash lets failed lookups of unknown users take as long as real ones.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 12)
func BurnTime(pw string) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(pw)) }
// NewToken returns a random session token and the hash stored server side.
func NewToken() (token, hash string) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
token = base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token)
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// Limiter allows a fixed number of failed attempts per key per window.
type Limiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string][]time.Time
}
func NewLimiter(max int, window time.Duration) *Limiter {
return &Limiter{max: max, window: window, hits: map[string][]time.Time{}}
}
func (l *Limiter) prune(key string, now time.Time) []time.Time {
kept := l.hits[key][:0]
for _, t := range l.hits[key] {
if now.Sub(t) < l.window {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.hits, key)
return nil
}
l.hits[key] = kept
return kept
}
// Blocked reports whether key has used up its attempts.
func (l *Limiter) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.prune(key, time.Now())) >= l.max
}
func (l *Limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.prune(key, now), now)
}
func (l *Limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}