Go backend (chi, SQLite) with an embedded React/TypeScript UI that finds songs present in both a host and a slave music library and replaces the slave copy with a hardlink to the host copy. - Path-first matching with format/title/album-folder fallbacks - Auto-linking of byte-identical files, re-verified with cmp before linking - Manual review with ffprobe quality comparison, downgrade guard and promote-to-host override - Build mode to populate an empty slave library from the host - Dry run by default, atomic stage+rename replacement, quarantine with retention, undo, path confinement and authenticated UI - Dockerfile and compose file for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
7.1 KiB
Go
282 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
)
|
|
|
|
const (
|
|
cookieName = "playlink_session"
|
|
sessionTTL = 30 * 24 * time.Hour
|
|
csrfHeader = "X-Playlink-Request"
|
|
)
|
|
|
|
type ctxKey int
|
|
|
|
const userKey ctxKey = 1
|
|
|
|
func hashToken(t string) string {
|
|
h := sha256.Sum256([]byte(t))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func newToken() (string, error) {
|
|
var b [32]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
|
}
|
|
|
|
// limiter is a small per-IP fixed-window limiter for login attempts.
|
|
type limiter struct {
|
|
mu sync.Mutex
|
|
hits map[string][]time.Time
|
|
max int
|
|
window time.Duration
|
|
}
|
|
|
|
func newLimiter(max int, window time.Duration) *limiter {
|
|
return &limiter{hits: map[string][]time.Time{}, max: max, window: window}
|
|
}
|
|
|
|
func (l *limiter) allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := time.Now()
|
|
kept := l.hits[key][:0]
|
|
for _, t := range l.hits[key] {
|
|
if now.Sub(t) < l.window {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
if len(kept) >= l.max {
|
|
l.hits[key] = kept
|
|
return false
|
|
}
|
|
l.hits[key] = append(kept, now)
|
|
return true
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
|
tok, err := newToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
exp := time.Now().Add(sessionTTL)
|
|
if err := s.st.CreateSession(r.Context(), hashToken(tok), uid, exp.Unix()); err != nil {
|
|
return err
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
|
|
Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) currentUser(r *http.Request) (store.User, string, error) {
|
|
c, err := r.Cookie(cookieName)
|
|
if err != nil || c.Value == "" {
|
|
return store.User{}, "", store.ErrNotFound
|
|
}
|
|
th := hashToken(c.Value)
|
|
uid, err := s.st.SessionUser(r.Context(), th)
|
|
if err != nil {
|
|
return store.User{}, "", err
|
|
}
|
|
u, err := s.st.UserByID(r.Context(), uid)
|
|
return u, th, err
|
|
}
|
|
|
|
// requireAuth rejects unauthenticated requests and, for state-changing
|
|
// methods, requests that are not same-origin API calls (CSRF defence in
|
|
// addition to the SameSite=Strict cookie).
|
|
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, _, err := s.currentUser(r)
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, errors.New("not signed in"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
|
})
|
|
}
|
|
|
|
func (s *Server) csrf(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
default:
|
|
if r.Header.Get(csrfHeader) != "1" {
|
|
writeErr(w, http.StatusForbidden, errors.New("missing request header"))
|
|
return
|
|
}
|
|
if o := r.Header.Get("Origin"); o != "" {
|
|
u, err := url.Parse(o)
|
|
if err != nil || !strings.EqualFold(u.Host, r.Host) {
|
|
writeErr(w, http.StatusForbidden, errors.New("cross-origin request refused"))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type credentials struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func validPassword(p string) error {
|
|
if len(p) < 10 {
|
|
return errors.New("password must be at least 10 characters")
|
|
}
|
|
if len(p) > 72 {
|
|
return errors.New("password must be at most 72 bytes")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) handleAuthState(w http.ResponseWriter, r *http.Request) {
|
|
n, err := s.st.UserCount(r.Context())
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
resp := map[string]any{"setup_required": n == 0, "authenticated": false}
|
|
if u, _, err := s.currentUser(r); err == nil {
|
|
resp["authenticated"] = true
|
|
resp["username"] = u.Username
|
|
}
|
|
writeJSON(w, 200, resp)
|
|
}
|
|
|
|
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|
var c credentials
|
|
if !readJSON(w, r, &c) {
|
|
return
|
|
}
|
|
c.Username = strings.TrimSpace(c.Username)
|
|
if c.Username == "" {
|
|
writeErr(w, 400, errors.New("username is required"))
|
|
return
|
|
}
|
|
if err := validPassword(c.Password); err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), 12)
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
if err := s.st.CreateFirstUser(r.Context(), c.Username, string(hash)); err != nil {
|
|
writeErr(w, 409, err)
|
|
return
|
|
}
|
|
u, err := s.st.UserByName(r.Context(), c.Username)
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
if err := s.startSession(w, r, u.ID); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
s.log.Info("initial user created", "user", u.Username)
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// dummyHash keeps login timing similar for unknown usernames.
|
|
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("playlink-timing-equalizer"), 12)
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !s.loginLimit.allow(clientIP(r)) {
|
|
writeErr(w, http.StatusTooManyRequests, errors.New("too many attempts; try again in a few minutes"))
|
|
return
|
|
}
|
|
var c credentials
|
|
if !readJSON(w, r, &c) {
|
|
return
|
|
}
|
|
u, err := s.st.UserByName(r.Context(), strings.TrimSpace(c.Username))
|
|
hash := dummyHash
|
|
if err == nil {
|
|
hash = []byte(u.PasswordHash)
|
|
}
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil {
|
|
s.log.Warn("failed login", "ip", clientIP(r))
|
|
writeErr(w, http.StatusUnauthorized, errors.New("invalid username or password"))
|
|
return
|
|
}
|
|
if err := s.startSession(w, r, u.ID); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(cookieName); err == nil {
|
|
_ = s.st.DeleteSession(r.Context(), hashToken(c.Value))
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
|
|
Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode})
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Current string `json:"current"`
|
|
New string `json:"new"`
|
|
}
|
|
if !readJSON(w, r, &body) {
|
|
return
|
|
}
|
|
u, th, err := s.currentUser(r)
|
|
if err != nil {
|
|
writeErr(w, 401, err)
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Current)) != nil {
|
|
writeErr(w, 400, errors.New("current password is incorrect"))
|
|
return
|
|
}
|
|
if err := validPassword(body.New); err != nil {
|
|
writeErr(w, 400, err)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(body.New), 12)
|
|
if err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
if err := s.st.SetPassword(r.Context(), u.ID, string(hash)); err != nil {
|
|
writeErr(w, 500, err)
|
|
return
|
|
}
|
|
_ = s.st.DeleteUserSessions(r.Context(), u.ID, th) // sign out other devices
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|