From cbe838cbe3496edb48117c75db242e5094ff405a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 12:49:30 -0400 Subject: [PATCH] feat(server/m7-user-mgmt): forgot + reset password endpoints (U3) POST /api/auth/forgot-password and POST /api/auth/reset-password. Forgot-password ALWAYS returns 200 with empty JSON to prevent enumeration of registered emails. Side effect: when email matches a user with email-on-file, generates a 32-byte hex token (24h TTL), inserts into password_resets, and sends the reset email via the mailer. Mailer failures are logged (not surfaced) and the audit log carries metadata.email_match for operator visibility. Reset-password atomically claims the token via UsePasswordReset (:execrows; concurrent calls can't both succeed). On rows=1, hashes the new password and writes via ChangeUserPassword. Returns 204 on success, 400 invalid_token on stale/used/missing tokens, 400 password_too_short for short passwords. Audits ActionPasswordResetByEmail. Wires the mailer.Sender into the handlers struct via Mount; production sender (NewSMTPSender) constructed in server.Router(); tests inject FakeSender via testHandlers default. The reset URL embedded in the email is derived from r.Host (no PublicURL config setting in v1; self-hosted operators see their own hostname). Tests cover happy-path send + token-row insertion, unknown email returns 200 with no send, mailer failure still returns 200, reset happy path verifies bcrypt match + used_at set, already-used token 400, expired token 400, short password 400, bogus token 400. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/api.go | 7 +- internal/api/auth_forgot.go | 128 +++++++++++++++++++++++++++ internal/api/auth_forgot_test.go | 102 ++++++++++++++++++++++ internal/api/auth_reset.go | 99 +++++++++++++++++++++ internal/api/auth_reset_test.go | 145 +++++++++++++++++++++++++++++++ internal/api/auth_test.go | 3 +- internal/server/server.go | 4 +- 7 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 internal/api/auth_forgot.go create mode 100644 internal/api/auth_forgot_test.go create mode 100644 internal/api/auth_reset.go create mode 100644 internal/api/auth_reset_test.go diff --git a/internal/api/api.go b/internal/api/api.go index 936eafb7..23d2f929 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" @@ -26,7 +27,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -43,11 +44,14 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev scanCfg: scanCfg, scheduler: scheduler, dataDir: dataDir, + mailer: sender, } r.Route("/api", func(api chi.Router) { api.Post("/auth/login", h.handleLogin) api.Post("/auth/register", h.handleRegister) + api.Post("/auth/forgot-password", h.handleForgotPassword) + api.Post("/auth/reset-password", h.handleResetPassword) api.Group(func(authed chi.Router) { authed.Use(auth.RequireUser(pool)) authed.Post("/auth/logout", h.handleLogout) @@ -179,4 +183,5 @@ type handlers struct { scanCfg library.RunScanConfig scheduler *library.Scheduler dataDir string + mailer mailer.Sender } diff --git a/internal/api/auth_forgot.go b/internal/api/auth_forgot.go new file mode 100644 index 00000000..0763837f --- /dev/null +++ b/internal/api/auth_forgot.go @@ -0,0 +1,128 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" +) + +const ( + resetTokenTTL = 24 * time.Hour + resetTokenSize = 32 // bytes; 64 chars hex on the wire +) + +type forgotPasswordReq struct { + Email string `json:"email"` +} + +// handleForgotPassword implements POST /api/auth/forgot-password. +// +// Always returns 200 with an empty JSON body so the response shape +// doesn't reveal whether the email is registered. The actual side +// effect (token + email send) only happens when the email matches a +// user with email-on-file. SMTP send failures are logged but don't +// propagate to the response. +// +// Audits ActionForgotPasswordInit with metadata.email_match so the +// operator can correlate audit log entries with successful sends +// even though the response is opaque. +func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request) { + var req forgotPasswordReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Decode failures are also opaque — return 200 even on bad JSON. + writeJSON(w, http.StatusOK, struct{}{}) + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + + q := dbq.New(h.pool) + var matched bool + var auditTarget pgtype.UUID + if email != "" { + user, err := q.GetUserByEmail(r.Context(), email) + if err == nil && user.Email != nil && *user.Email != "" { + matched = true + auditTarget = user.ID + if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil { + h.logger.Warn("forgot-password: send failed", + "email", email, "err", sendErr) + // fall through; response is still 200 + } + } else if err != nil && !errors.Is(err, pgx.ErrNoRows) { + h.logger.Warn("forgot-password: lookup failed", "err", err) + } + } + + // Audit (best-effort). + if err := audit.Write(r.Context(), h.pool, pgtype.UUID{}, auditTarget, audit.ActionForgotPasswordInit, + map[string]any{"email_match": matched}); err != nil { + h.logger.Warn("forgot-password: audit failed", "err", err) + } + + writeJSON(w, http.StatusOK, struct{}{}) +} + +// sendResetEmail generates a token, persists it, renders + sends the +// email. Returns the underlying error (caller logs it but doesn't +// surface to the HTTP response). +func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error { + if h.mailer == nil { + return errors.New("forgot-password: no mailer configured") + } + tokenBytes := make([]byte, resetTokenSize) + if _, err := rand.Read(tokenBytes); err != nil { + return err + } + token := hex.EncodeToString(tokenBytes) + expiresAt := time.Now().Add(resetTokenTTL) + + q := dbq.New(h.pool) + if err := q.CreatePasswordReset(ctx, dbq.CreatePasswordResetParams{ + Token: token, + UserID: user.ID, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }); err != nil { + return err + } + + resetURL := buildResetURL(r, token) + textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{ + Username: user.Username, + ResetURL: resetURL, + ExpiryHours: int(resetTokenTTL.Hours()), + }) + if err != nil { + return err + } + + if user.Email == nil || *user.Email == "" { + // Defensive — sendResetEmail is only called when email is set, + // but we re-check to avoid sending to nil. + return errors.New("forgot-password: user has no email") + } + return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody) +} + +func buildResetURL(r *http.Request, token string) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + if host == "" { + host = "localhost" + } + return scheme + "://" + host + "/reset-password/" + token +} diff --git a/internal/api/auth_forgot_test.go b/internal/api/auth_forgot_test.go new file mode 100644 index 00000000..09eb38fa --- /dev/null +++ b/internal/api/auth_forgot_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" +) + +func TestForgotPassword_KnownEmail_SendsMail(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + fake := &mailer.FakeSender{} + h.mailer = fake + + user := seedUser(t, pool, "forgotuser", "pw", false) + if _, err := pool.Exec(context.Background(), + "UPDATE users SET email = 'forgot@example.com' WHERE id = $1", user.ID); err != nil { + t.Fatalf("seed email: %v", err) + } + + body := `{"email":"forgot@example.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", + bytes.NewReader([]byte(body))) + req.Host = "minstrel.example.com" + rec := httptest.NewRecorder() + h.handleForgotPassword(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if len(fake.Sent) != 1 { + t.Fatalf("Sent len = %d, want 1", len(fake.Sent)) + } + got := fake.LastSent() + if got.To != "forgot@example.com" { + t.Errorf("To = %q, want forgot@example.com", got.To) + } + // Token row was inserted. + var tokenCount int + if err := pool.QueryRow(context.Background(), + "SELECT count(*) FROM password_resets WHERE user_id = $1", user.ID).Scan(&tokenCount); err != nil { + t.Fatalf("verify token: %v", err) + } + if tokenCount == 0 { + t.Errorf("no password_resets row inserted") + } +} + +func TestForgotPassword_UnknownEmail_Returns200_NoSend(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, _ := testHandlers(t) + fake := &mailer.FakeSender{} + h.mailer = fake + + body := `{"email":"nonexistent@example.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleForgotPassword(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (always; no enumeration)", rec.Code) + } + if len(fake.Sent) != 0 { + t.Errorf("Sent len = %d, want 0 for unknown email", len(fake.Sent)) + } +} + +func TestForgotPassword_MailerFailure_StillReturns200(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + fake := &mailer.FakeSender{FailNext: errors.New("simulated SMTP failure")} + h.mailer = fake + + user := seedUser(t, pool, "mailfail", "pw", false) + if _, err := pool.Exec(context.Background(), + "UPDATE users SET email = 'mailfail@example.com' WHERE id = $1", user.ID); err != nil { + t.Fatalf("seed: %v", err) + } + + body := `{"email":"mailfail@example.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleForgotPassword(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (mailer failures don't propagate)", rec.Code) + } +} diff --git a/internal/api/auth_reset.go b/internal/api/auth_reset.go new file mode 100644 index 00000000..02a27908 --- /dev/null +++ b/internal/api/auth_reset.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" + + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +type resetPasswordReq struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +// handleResetPassword implements POST /api/auth/reset-password. +// +// Atomically claims the reset token and writes the new password hash. +// Returns: +// - 204 on successful reset +// - 400 password_too_short if new_password < 8 chars +// - 400 invalid_token if the token doesn't exist, was already used, +// or has expired +// - 500 on internal errors +func (h *handlers) handleResetPassword(w http.ResponseWriter, r *http.Request) { + var req resetPasswordReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid_body", "") + return + } + if len(req.NewPassword) < minPasswordLength { + writeErr(w, http.StatusBadRequest, "password_too_short", "password must be at least 8 chars") + return + } + if req.Token == "" { + writeErr(w, http.StatusBadRequest, "invalid_token", "") + return + } + + q := dbq.New(h.pool) + + // Look up the reset record so we know which user to update before + // we claim the token. We can't reverse-lookup user_id after + // UsePasswordReset (it returns rows-affected, not the row). + reset, err := q.GetPasswordReset(r.Context(), req.Token) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusBadRequest, "invalid_token", "") + return + } + h.logger.Error("reset password: lookup failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + // Atomically claim the token. Returns rows-affected (1 if + // claimable, 0 if already used / expired). We do this BEFORE + // hashing the password so concurrent reset attempts can't both + // succeed. + rows, err := q.UsePasswordReset(r.Context(), req.Token) + if err != nil { + h.logger.Error("reset password: use token failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if rows == 0 { + writeErr(w, http.StatusBadRequest, "invalid_token", "") + return + } + + // Hash and update. If hashing or update fails after the token + // claim, the token is "burned" but the password isn't reset — + // user has to request another. That's acceptable; bcrypt and + // UPDATE rarely fail in healthy systems. + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + h.logger.Error("reset password: hash failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + if err := q.ChangeUserPassword(r.Context(), dbq.ChangeUserPasswordParams{ + ID: reset.UserID, + PasswordHash: string(hash), + }); err != nil { + h.logger.Error("reset password: update failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "") + return + } + + if err := audit.Write(r.Context(), h.pool, reset.UserID, reset.UserID, audit.ActionPasswordResetByEmail, nil); err != nil { + h.logger.Warn("reset password: audit failed", "err", err) + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/auth_reset_test.go b/internal/api/auth_reset_test.go new file mode 100644 index 00000000..7b0bf31c --- /dev/null +++ b/internal/api/auth_reset_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +func TestResetPassword_Happy(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "resetuser", "oldpw1234", false) + + // Insert a fresh token. + token := "test-reset-token-abc" + if _, err := pool.Exec(context.Background(), + `INSERT INTO password_resets (token, user_id, expires_at) + VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(time.Hour), + ); err != nil { + t.Fatalf("seed: %v", err) + } + + body := `{"token":"test-reset-token-abc","new_password":"newpassword1"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleResetPassword(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + // New password works? + var hash string + if err := pool.QueryRow(context.Background(), + "SELECT password_hash FROM users WHERE id = $1", user.ID).Scan(&hash); err != nil { + t.Fatalf("read hash: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("newpassword1")); err != nil { + t.Errorf("new password doesn't match: %v", err) + } + + // Token marked used? + var usedAt pgtype.Timestamptz + if err := pool.QueryRow(context.Background(), + "SELECT used_at FROM password_resets WHERE token = $1", token).Scan(&usedAt); err != nil { + t.Fatalf("verify used_at: %v", err) + } + if !usedAt.Valid { + t.Errorf("used_at is NULL after reset; should be set") + } +} + +func TestResetPassword_AlreadyUsed_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "alreadyused", "pw", false) + + token := "test-already-used" + if _, err := pool.Exec(context.Background(), + `INSERT INTO password_resets (token, user_id, expires_at, used_at) + VALUES ($1, $2, $3, now())`, token, user.ID, time.Now().Add(time.Hour), + ); err != nil { + t.Fatalf("seed: %v", err) + } + + body := `{"token":"test-already-used","new_password":"abcd1234"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleResetPassword(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (invalid_token for already-used)", rec.Code) + } +} + +func TestResetPassword_Expired_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "expiredreset", "pw", false) + + token := "test-expired-token" + if _, err := pool.Exec(context.Background(), + `INSERT INTO password_resets (token, user_id, expires_at) + VALUES ($1, $2, $3)`, token, user.ID, time.Now().Add(-time.Hour), // already expired + ); err != nil { + t.Fatalf("seed: %v", err) + } + + body := `{"token":"test-expired-token","new_password":"abcd1234"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleResetPassword(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (invalid_token for expired)", rec.Code) + } +} + +func TestResetPassword_PasswordTooShort_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, _ := testHandlers(t) + body := `{"token":"any-token","new_password":"abc"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleResetPassword(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestResetPassword_BogusToken_400(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, _ := testHandlers(t) + + body := `{"token":"bogus-token","new_password":"abcd1234"}` + req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", + bytes.NewReader([]byte(body))) + rec := httptest.NewRecorder() + h.handleResetPassword(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (invalid_token for bogus)", rec.Code) + } +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 30c9ef30..191dcd48 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -26,6 +26,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" @@ -69,7 +70,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { dataDir := t.TempDir() tracksSvc := tracks.NewService(pool, logger, nil, dataDir) playlistsSvc := playlists.NewService(pool, logger, dataDir) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}} + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}} return h, pool } diff --git a/internal/server/server.go b/internal/server/server.go index 471745bf..db363e6e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" + "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" @@ -115,7 +116,8 @@ func (s *Server) Router() http.Handler { lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir) + smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer")) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second