package main import ( "context" "crypto/rand" "encoding/base64" "errors" "flag" "fmt" "os" "golang.org/x/crypto/bcrypt" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" ) // runMigrate applies the embedded migrations and exits. The server also // auto-migrates on startup (main.go); this standalone path exists for CI // (provision a fresh DB before the integration suite) and operators who // want to migrate without starting the server. func runMigrate(args []string) error { fs := flag.NewFlagSet("migrate", flag.ContinueOnError) configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") if err := fs.Parse(args); err != nil { return err } cfg, err := config.Load(*configPath) if err != nil { return fmt.Errorf("load config: %w", err) } logger, err := logging.New(os.Stdout, cfg.Log.Level, cfg.Log.Format) if err != nil { return fmt.Errorf("init logger: %w", err) } if err := db.Migrate(cfg.Database.URL, logger); err != nil { return fmt.Errorf("migrate: %w", err) } fmt.Println("minstrel: migrations applied") return nil } // runAdmin dispatches `minstrel admin `. func runAdmin(args []string) error { if len(args) == 0 { return errors.New("usage: minstrel admin reset-password [-user NAME] [-password PW] [-config PATH]") } switch args[0] { case "reset-password": return adminResetPassword(args[1:]) default: return fmt.Errorf("unknown admin subcommand %q", args[0]) } } // adminResetPassword resets a user's credentials. It updates BOTH // password_hash (bcrypt, for /api/auth/login) and subsonic_password // (plaintext, required for Subsonic t+s token verification) so neither // auth path is left stale. Recovers a locked-out operator when the // bootstrap password was missed or the DB volume was recreated (Fable // #321) without DB surgery. func adminResetPassword(args []string) error { fs := flag.NewFlagSet("admin reset-password", flag.ContinueOnError) configPath := fs.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") username := fs.String("user", "admin", "username to reset") password := fs.String("password", "", "new password; if empty a strong one is generated and printed") if err := fs.Parse(args); err != nil { return err } cfg, err := config.Load(*configPath) if err != nil { return fmt.Errorf("load config: %w", err) } if cfg.Database.URL == "" { return errors.New("no database URL configured (set it in the config file or MINSTREL_DATABASE_URL)") } ctx := context.Background() pool, err := db.Open(ctx, cfg.Database.URL) if err != nil { return fmt.Errorf("open db: %w", err) } defer pool.Close() q := dbq.New(pool) user, err := q.GetUserByUsername(ctx, *username) if err != nil { return fmt.Errorf("look up user %q: %w", *username, err) } pw := *password generated := false if pw == "" { b := make([]byte, 18) if _, err := rand.Read(b); err != nil { return fmt.Errorf("generate password: %w", err) } pw = base64.RawURLEncoding.EncodeToString(b) generated = true } hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash password: %w", err) } if err := q.ChangeUserPassword(ctx, dbq.ChangeUserPasswordParams{ ID: user.ID, PasswordHash: string(hash), }); err != nil { return fmt.Errorf("update password_hash: %w", err) } sp := pw if err := q.SetSubsonicPassword(ctx, dbq.SetSubsonicPasswordParams{ ID: user.ID, SubsonicPassword: &sp, }); err != nil { return fmt.Errorf("update subsonic_password: %w", err) } if generated { fmt.Printf("minstrel: password for %q reset.\nNew password: %s\n", *username, pw) } else { fmt.Printf("minstrel: password for %q reset.\n", *username) } return nil }