Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
9.9 KiB
Go
283 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"regexp"
|
|
"time"
|
|
|
|
"github.com/jackc/pgerrcode"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// usernameRe enforces a conservative username shape: 3-32 chars,
|
|
// alphanumeric + underscore + hyphen. The same regex is reused on
|
|
// the frontend for live validation.
|
|
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`)
|
|
|
|
const minPasswordLength = 8
|
|
|
|
type registerReq struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
InviteToken string `json:"invite_token"`
|
|
// SetupToken is required only for the very first account; see
|
|
// auth.SetupToken.
|
|
SetupToken string `json:"setup_token"`
|
|
DisplayName *string `json:"display_name"`
|
|
}
|
|
|
|
// handleRegister implements POST /api/auth/register.
|
|
//
|
|
// Flow:
|
|
// 1. Validate username/password format + minimum lengths.
|
|
// 2. Look up registration_settings.mode and current user count.
|
|
// 3. If users table is empty: insert via CreateUserFirstAdminRace
|
|
// (sets is_admin from a SELECT NOT EXISTS subquery). Skip invite
|
|
// check.
|
|
// 4. Else if mode == 'invite_only': atomically redeem invite via
|
|
// RedeemInvite (returns rows-affected; 0 means no usable
|
|
// invite — reject 400).
|
|
// 5. Else (mode == 'open'): proceed without invite check.
|
|
// 6. Hash password, mint session token, insert session row, set
|
|
// cookie, return user + token.
|
|
// 7. Audit ActionRegister and (if invite was used) ActionInviteRedeem.
|
|
//
|
|
// Errors:
|
|
// - 400 invalid_body / username_invalid / password_too_short
|
|
// - 400 invite_required (invite_only mode, no token supplied)
|
|
// - 400 invite_invalid (token doesn't exist, expired, or already redeemed)
|
|
// - 409 username_taken (PG unique violation on users.username)
|
|
// - 500 server_error otherwise
|
|
func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
// Every attempt counts, not only failures: a successful registration is
|
|
// the thing being sprayed when the mode is open.
|
|
addr := auth.ClientIP(r, h.netSettings.Hops())
|
|
if blocked, wait := h.registerLimit.Blocked(addr); blocked {
|
|
writeRateLimited(w, wait)
|
|
return
|
|
}
|
|
h.registerLimit.Record(addr)
|
|
|
|
var req registerReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, apierror.BadRequest("invalid_body", "invalid JSON body"))
|
|
return
|
|
}
|
|
if !usernameRe.MatchString(req.Username) {
|
|
writeErr(w, apierror.BadRequest("username_invalid", "username must be 3-32 chars, alphanumeric + underscore + hyphen"))
|
|
return
|
|
}
|
|
if len(req.Password) < minPasswordLength {
|
|
writeErr(w, apierror.BadRequest("password_too_short", "password must be at least 8 chars"))
|
|
return
|
|
}
|
|
|
|
q := dbq.New(h.pool)
|
|
|
|
// Determine whether this is the empty-users (bootstrap-admin) case.
|
|
userCount, err := q.CountUsers(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("register: count users failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
|
|
// The first account becomes admin, so it must prove it can read the
|
|
// server log. Checked before the invite logic, which the empty-users
|
|
// state skips.
|
|
if userCount == 0 && h.requireSetupToken && !h.setupToken.Matches(req.SetupToken) {
|
|
// Repeat the token in the log at the moment someone needs it: the
|
|
// boot line may have scrolled away, or users may have been deleted
|
|
// since boot.
|
|
h.logger.Warn("register: first-admin registration needs the setup token",
|
|
"setup_token", h.setupToken.Value())
|
|
writeErr(w, apierror.Forbidden("setup_token_invalid", "the setup token from the server log is required to create the first account"))
|
|
return
|
|
}
|
|
|
|
// Validate invite (skipped on empty-users state; skipped in 'open' mode).
|
|
usedInviteToken := ""
|
|
if userCount > 0 {
|
|
mode, err := q.GetRegistrationMode(r.Context())
|
|
if err != nil {
|
|
h.logger.Error("register: get mode failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
if mode == "invite_only" {
|
|
if req.InviteToken == "" {
|
|
writeErr(w, apierror.BadRequest("invite_required", "an invite token is required to register"))
|
|
return
|
|
}
|
|
// Pre-validate existence + non-redeemed + non-expired so we fail
|
|
// fast on bad tokens before creating the user.
|
|
invite, ierr := q.GetInviteByToken(r.Context(), req.InviteToken)
|
|
if ierr != nil {
|
|
if errors.Is(ierr, pgx.ErrNoRows) {
|
|
writeErr(w, apierror.BadRequest("invite_invalid", "invite token not found"))
|
|
return
|
|
}
|
|
h.logger.Error("register: get invite failed", "err", ierr)
|
|
writeErr(w, apierror.Internal(ierr))
|
|
return
|
|
}
|
|
if invite.RedeemedAt.Valid || invite.ExpiresAt.Time.Before(time.Now()) {
|
|
writeErr(w, apierror.BadRequest("invite_invalid", "invite token is expired or already redeemed"))
|
|
return
|
|
}
|
|
usedInviteToken = req.InviteToken
|
|
}
|
|
}
|
|
|
|
// Hash password.
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
h.logger.Error("register: hash password failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
|
|
// Mint API token (same primitive used by bootstrap).
|
|
apiToken, err := auth.MintSessionToken()
|
|
if err != nil {
|
|
h.logger.Error("register: mint api_token failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
|
|
// Insert user (race-safe-first-admin variant).
|
|
user, err := q.CreateUserFirstAdminRace(r.Context(), dbq.CreateUserFirstAdminRaceParams{
|
|
Username: req.Username,
|
|
PasswordHash: string(hash),
|
|
ApiTokenHash: auth.HashAPIToken(apiToken),
|
|
DisplayName: req.DisplayName,
|
|
})
|
|
if err != nil {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
|
|
writeErr(w, apierror.Conflict("username_taken", "username is already taken"))
|
|
return
|
|
}
|
|
h.logger.Error("register: create user failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
|
|
// Atomic invite redeem (if applicable). Now we have user.ID for
|
|
// redeemed_by.
|
|
if usedInviteToken != "" {
|
|
rows, rerr := q.RedeemInvite(r.Context(), dbq.RedeemInviteParams{
|
|
Token: usedInviteToken,
|
|
RedeemedBy: user.ID,
|
|
})
|
|
if rerr != nil || rows != 1 {
|
|
// Race: another caller redeemed between our pre-check and now.
|
|
// User was already inserted — the redeem failed but the user
|
|
// row exists. Best to leave the user in place (operator can
|
|
// promote them manually) but surface a clear log entry.
|
|
h.logger.Error("register: invite redeem failed; user inserted but invite not consumed",
|
|
"user_id", uuidToString(user.ID), "rows", rows, "err", rerr)
|
|
// Don't fail the response — the user IS registered.
|
|
}
|
|
}
|
|
|
|
// Mint session token + cookie (same shape as handleLogin).
|
|
sessionToken, err := auth.MintSessionToken()
|
|
if err != nil {
|
|
h.logger.Error("register: mint session failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
|
|
UserID: user.ID,
|
|
TokenHash: auth.HashSessionToken(sessionToken),
|
|
UserAgent: r.UserAgent(),
|
|
Ip: addr,
|
|
}); err != nil {
|
|
h.logger.Error("register: insert session failed", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: auth.SessionCookieName,
|
|
Value: sessionToken,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: auth.IsHTTPS(r, h.netSettings.Hops()), // plain HTTP and LAN stay functional
|
|
SameSite: http.SameSiteStrictMode,
|
|
MaxAge: int(sessionCookieMaxAge.Seconds()),
|
|
})
|
|
|
|
// Audit (best-effort; a failed audit write does NOT fail the user-
|
|
// facing operation).
|
|
auditMeta := map[string]any{}
|
|
if user.IsAdmin {
|
|
auditMeta["first_admin"] = true
|
|
}
|
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionRegister, auditMeta)
|
|
if usedInviteToken != "" {
|
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionInviteRedeem,
|
|
map[string]any{"token": usedInviteToken})
|
|
}
|
|
|
|
// Register the new user with the playlist scheduler so their daily
|
|
// builds are scheduled without waiting for the hourly reconciliation
|
|
// pass. Failure here is logged but doesn't fail registration — the
|
|
// next hourly pass will pick them up.
|
|
if h.playlistScheduler != nil {
|
|
if err := h.playlistScheduler.Refresh(r.Context(), user.ID); err != nil {
|
|
h.logger.Warn("api: scheduler refresh after registration",
|
|
"user_id", uuidToString(user.ID), "err", err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, LoginResponse{
|
|
Token: sessionToken,
|
|
User: UserView{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
IsAdmin: user.IsAdmin,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleSetupStatus implements GET /api/auth/setup-status. It tells the
|
|
// register screen whether to ask for the setup token, i.e. whether no
|
|
// account exists yet. Public, since it is needed before anyone can sign in;
|
|
// "this server has no users" is not worth hiding from someone who could
|
|
// simply try to register.
|
|
func (h *handlers) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
|
|
n, err := dbq.New(h.pool).CountUsers(r.Context())
|
|
if err != nil {
|
|
writeErrWithLog(w, h.logger, "setup status: count users failed", apierror.Internal(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"setup_required": n == 0})
|
|
}
|
|
|
|
// logSetupTokenIfNeeded writes the setup token to the log at boot when the
|
|
// instance has no accounts yet, with the instruction for using it.
|
|
func logSetupTokenIfNeeded(pool *pgxpool.Pool, logger *slog.Logger, token *auth.SetupToken) {
|
|
if pool == nil || logger == nil {
|
|
return
|
|
}
|
|
n, err := dbq.New(pool).CountUsers(context.Background())
|
|
if err != nil || n > 0 {
|
|
return
|
|
}
|
|
logger.Warn("no accounts yet: open the web app, choose Create account, and enter this setup token to become the admin",
|
|
"setup_token", token.Value())
|
|
}
|