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,186 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/auth"
|
||||
)
|
||||
|
||||
type loginPage struct {
|
||||
Error string
|
||||
Username string
|
||||
Next string
|
||||
}
|
||||
|
||||
// safeNext only allows local redirect targets.
|
||||
func safeNext(next string) string {
|
||||
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
|
||||
return "/admin"
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Server) needsSetup(r *http.Request) bool {
|
||||
n, err := s.store.UserCount(r.Context())
|
||||
return err == nil && n == 0
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, ok := s.currentUser(r); ok {
|
||||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "login", "Sign in", loginPage{Next: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
next := r.PostFormValue("next")
|
||||
page := loginPage{Username: username, Next: next}
|
||||
ip := s.clientIP(r)
|
||||
|
||||
if s.loginLimit.Blocked(ip) {
|
||||
page.Error = "Too many failed attempts. Try again in a few minutes."
|
||||
s.render(w, r, http.StatusTooManyRequests, "login", "Sign in", page)
|
||||
return
|
||||
}
|
||||
u, err := s.store.UserByName(r.Context(), username)
|
||||
if err != nil {
|
||||
auth.BurnTime(password)
|
||||
}
|
||||
if err != nil || !auth.CheckPassword(u.PasswordHash, password) {
|
||||
s.loginLimit.Fail(ip)
|
||||
page.Error = "That username and password don't match."
|
||||
s.render(w, r, http.StatusUnauthorized, "login", "Sign in", page)
|
||||
return
|
||||
}
|
||||
s.loginLimit.Reset(ip)
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, safeNext(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
token, hash := auth.NewToken()
|
||||
expires := time.Now().Add(sessionTTL)
|
||||
if err := s.store.CreateSession(r.Context(), hash, userID, expires); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
s.store.DeleteSession(r.Context(), auth.HashToken(c.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type setupPage struct {
|
||||
Error string
|
||||
Username string
|
||||
}
|
||||
|
||||
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "setup", "Welcome", setupPage{})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.sameOrigin(r) {
|
||||
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !s.needsSetup(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
ip := s.clientIP(r)
|
||||
page := setupPage{Username: strings.TrimSpace(r.PostFormValue("username"))}
|
||||
password := r.PostFormValue("password")
|
||||
code := normalizeCode(r.PostFormValue("code"))
|
||||
|
||||
fail := func(status int, msg string) {
|
||||
page.Error = msg
|
||||
s.render(w, r, status, "setup", "Welcome", page)
|
||||
}
|
||||
if s.loginLimit.Blocked(ip) {
|
||||
fail(http.StatusTooManyRequests, "Too many failed attempts. Try again in a few minutes.")
|
||||
return
|
||||
}
|
||||
if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 {
|
||||
s.loginLimit.Fail(ip)
|
||||
fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs topdeck).")
|
||||
return
|
||||
}
|
||||
if page.Username == "" || len(page.Username) > 64 {
|
||||
fail(http.StatusBadRequest, "Pick a username (up to 64 characters).")
|
||||
return
|
||||
}
|
||||
if len(password) < auth.MinPasswordLength {
|
||||
fail(http.StatusBadRequest, "Use a password of at least 8 characters.")
|
||||
return
|
||||
}
|
||||
if password != r.PostFormValue("confirm") {
|
||||
fail(http.StatusBadRequest, "The passwords don't match.")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), page.Username, hash); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.log.Info("admin account created", "username", page.Username)
|
||||
u, err := s.store.UserByName(r.Context(), page.Username)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, u.ID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// normalizeCode makes setup codes forgiving of case, spaces and dashes.
|
||||
func normalizeCode(c string) string {
|
||||
return strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(c)))
|
||||
}
|
||||
Reference in new issue
Block a user