Files
minstrel/internal/netsettings/service.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

167 lines
5.5 KiB
Go

// Package netsettings holds the DB-backed network settings the request path
// needs: the trusted reverse-proxy depth used to pull a real client address
// out of X-Forwarded-For, and the public address links are built from.
//
// Values are cached under an RWMutex and refreshed on write. That isn't an
// optimisation: auth.ClientIP runs in the RequireUser middleware for every
// authenticated request, so a per-request query here would put the database
// on the critical path of the entire API.
package netsettings
import (
"context"
"errors"
"log/slog"
"net/url"
"strings"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
// DefaultTrustedProxyHops mirrors migration 0053's column default. One
// proxy, because anything publicly reachable needs a TLS terminator in
// front of it.
DefaultTrustedProxyHops = 1
// MaxTrustedProxyHops mirrors the CHECK in migration 0053.
MaxTrustedProxyHops = 10
)
// ErrHopsOutOfRange is returned by SetHops for values the CHECK would reject,
// so the API layer can answer 400 instead of surfacing a constraint violation.
var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10")
// ErrInvalidPublicURL is returned by SetPublicURL for anything that isn't a
// bare http(s) origin, so the API layer can answer 400.
var ErrInvalidPublicURL = errors.New("public URL must be an http:// or https:// address with a host and no path, query or fragment")
// Service caches the network settings and owns their persistence.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
hops int
publicURL string
}
// New loads the settings once and caches them.
//
// It ALWAYS returns a usable Service, even alongside a non-nil error. The
// value it holds sits on the authenticated request path, so a boot-time
// database hiccup must degrade to the default rather than take every request
// down with it. The error is returned so the caller can log that
// the cache holds a default rather than stored state.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops}
if pool == nil {
return s, nil
}
row, err := dbq.New(pool).GetNetworkSettings(ctx)
if err != nil {
return s, err
}
s.hops = int(row.TrustedProxyHops)
s.publicURL = row.PublicUrl
return s, nil
}
// Hops returns the cached trusted-proxy depth.
//
// Nil-safe: test contexts construct routers without this service, and a
// missing setting should mean "trust nothing" rather than a panic in
// middleware.
func (s *Service) Hops() int {
if s == nil {
return 0
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.hops
}
// SetHops persists a new depth and refreshes the cache, so an admin change
// takes effect on the next request with no restart.
func (s *Service) SetHops(ctx context.Context, hops int) error {
// Range first, availability second. The argument is wrong regardless of
// whether the database is reachable, and the distinction is user-visible:
// this ordering answers 400 for a bad value, where the reverse would
// report 500 and blame the server for the caller's input.
if hops < 0 || hops > MaxTrustedProxyHops {
return ErrHopsOutOfRange
}
if s == nil || s.pool == nil {
// Mirrors Hops()'s nil-tolerance: handlers can be constructed without
// this service in tests, and a write attempt there should be an error
// rather than a panic in an HTTP handler.
return errors.New("network settings unavailable")
}
row, err := dbq.New(s.pool).UpdateTrustedProxyHops(ctx, int32(hops))
if err != nil {
return err
}
s.mu.Lock()
s.hops = int(row.TrustedProxyHops)
s.mu.Unlock()
// Worth a line in the log: this changes how much of a client-supplied
// header the server believes, so an operator debugging odd addresses in
// the sessions list wants to see when it last moved.
if s.logger != nil {
s.logger.Info("netsettings: trusted proxy hops updated", "hops", hops)
}
return nil
}
// PublicURL returns the admin-set address users reach Minstrel at, with no
// trailing slash, or "" when it hasn't been set. Links that leave the app (a
// password-reset email) are built from this and never from the request's
// Host header, which the requester controls. Nil-safe like Hops.
func (s *Service) PublicURL() string {
if s == nil {
return ""
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.publicURL
}
// NormalizePublicURL validates raw as a bare http(s) origin and returns it
// without a trailing slash. "" is valid and means unset.
func NormalizePublicURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" ||
u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
return "", ErrInvalidPublicURL
}
return u.Scheme + "://" + u.Host, nil
}
// SetPublicURL validates, persists and caches the public URL. "" clears it.
func (s *Service) SetPublicURL(ctx context.Context, raw string) error {
normalized, err := NormalizePublicURL(raw)
if err != nil {
return err
}
if s == nil || s.pool == nil {
return errors.New("network settings unavailable")
}
row, err := dbq.New(s.pool).UpdatePublicURL(ctx, normalized)
if err != nil {
return err
}
s.mu.Lock()
s.publicURL = row.PublicUrl
s.mu.Unlock()
if s.logger != nil {
s.logger.Info("netsettings: public URL updated", "public_url", normalized)
}
return nil
}