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>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,91 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user