package main import ( "bufio" "context" "crypto/rand" "encoding/json" "errors" "flag" "fmt" "io" "net/url" "os" "slices" "strings" "text/tabwriter" "time" "gardomatic.kleiax.de/internal/auth" "gardomatic.kleiax.de/internal/mailer" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" "gardomatic.kleiax.de/internal/vcs" "golang.org/x/term" ) const activationTokenTTL = 3 * 24 * time.Hour type application struct { stdin io.Reader stdout io.Writer stderr io.Writer openStore func(string) (adminStore, error) newMailer func(mailer.Config) (*mailer.Mailer, error) } func newApplication(stdin io.Reader, stdout, stderr io.Writer) *application { return &application{ stdin: stdin, stdout: stdout, stderr: stderr, openStore: openPostgresStore, newMailer: mailer.New, } } func (app *application) run(ctx context.Context, args []string) error { cfg, err := configFromEnvironment() if err != nil { return err } global := flag.NewFlagSet("gardomatic", flag.ContinueOnError) global.SetOutput(app.stderr) global.StringVar(&cfg.dsn, "dsn", cfg.dsn, "PostgreSQL DSN (default: GARDOMATIC_DB_DSN)") global.StringVar(&cfg.environment, "env", cfg.environment, "environment name") global.StringVar(&cfg.webBaseURL, "web-base-url", cfg.webBaseURL, "public web base URL") global.BoolVar(&cfg.json, "json", false, "write JSON output") global.BoolVar(&cfg.yes, "yes", false, "skip production confirmation") global.Usage = func() { app.printUsage(global) } if err = global.Parse(args); err != nil { return err } remaining := global.Args() if len(remaining) == 0 { global.Usage() return nil } if remaining[0] == "help" || remaining[0] == "--help" || remaining[0] == "-h" { global.Usage() return nil } if slices.Contains(remaining[1:], "--help") || slices.Contains(remaining[1:], "-h") { global.Usage() return nil } if remaining[0] == "version" { return app.writeValue(cfg, map[string]string{"version": vcs.Version()}, "Version: %s\n", vcs.Version()) } store, err := app.openStore(cfg.dsn) if err != nil { return err } defer store.Close() switch remaining[0] { case "users": return app.runUsers(ctx, cfg, store, remaining[1:]) case "gardens": return app.runGardens(ctx, cfg, store, remaining[1:]) case "db": return app.runDB(ctx, cfg, store, remaining[1:]) case "demo": return app.runDemo(ctx, cfg, store, remaining[1:]) default: return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0]) } } func (app *application) runDemo(ctx context.Context, cfg config, store adminStore, args []string) error { if len(args) == 0 || args[0] != "reset" { return errors.New("usage: gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL]") } if !cfg.demoResetEnabled { return errors.New("demo reset is disabled; set GARDOMATIC_DEMO_RESET_ENABLED=true only for a disposable demo database") } if !cfg.yes { return errors.New("demo reset deletes all user and garden data; pass the global --yes flag") } fs := newFlagSet("demo reset", app.stderr) var name, email string var passwordStdin bool fs.StringVar(&name, "name", "Demo-Besucher", "demo account display name") fs.StringVar(&email, "email", "demo@example.com", "demo account email address") fs.BoolVar(&passwordStdin, "password-stdin", false, "read the demo account password from standard input (required)") if err := parseFlags(fs, args[1:]); err != nil { return err } name, email = strings.TrimSpace(name), strings.TrimSpace(email) if !passwordStdin { return errors.New("--password-stdin is required so the demo password is not exposed in the process list") } if err := validateIdentity(name, email); err != nil { return err } password, _, err := app.obtainPassword(false, true) if err != nil { return err } passwordHash, err := hashPassword(password) if err != nil { return err } result, err := store.ResetDemo(ctx, demoResetInput{Name: name, Email: email, PasswordHash: passwordHash}) if err != nil { return fmt.Errorf("reset demo data: %w", err) } if cfg.json { return writeJSON(app.stdout, result) } _, err = fmt.Fprintf(app.stdout, "Demo reset complete.\nLogin: %s\nGarden: %s\nImages: %d\nLocations: %d\nSpecies: %d\nPlants: %d\nTasks: %d\nJournal entries: %d\nAttachments: %d\n", result.Email, result.Garden, result.Images, result.Locations, result.Species, result.Plants, result.Tasks, result.Journal, result.Attachments) return err } func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error { if len(args) == 0 { return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role") } switch args[0] { case "create": return app.createUser(ctx, cfg, store, args[1:]) case "list": return app.listUsers(ctx, cfg, store, args[1:]) case "show": return app.showUser(ctx, cfg, store, args[1:]) case "activate": return app.setUserActivated(ctx, cfg, store, args[1:], true) case "deactivate": return app.setUserActivated(ctx, cfg, store, args[1:], false) case "invite": return app.inviteUser(ctx, cfg, store, args[1:]) case "reset-password": return app.resetPassword(ctx, cfg, store, args[1:]) case "set-role": return app.setUserRole(ctx, cfg, store, args[1:]) default: return fmt.Errorf("unknown users command %q", args[0]) } } func (app *application) runGardens(ctx context.Context, cfg config, store adminStore, args []string) error { if len(args) == 0 { return errors.New("missing gardens command: add-user") } if args[0] != "add-user" { return fmt.Errorf("unknown gardens command %q", args[0]) } return app.addGardenMember(ctx, cfg, store, args[1:]) } func (app *application) setUserRole(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users set-role", app.stderr) var email, role string fs.StringVar(&email, "email", "", "email address (required)") fs.StringVar(&role, "role", "", "application role (required)") if err := parseFlags(fs, args); err != nil { return err } email, role = strings.TrimSpace(email), strings.TrimSpace(role) if err := validateEmail(email); err != nil { return err } if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { return errors.New("role must be application:user or application:admin") } user, err := store.SetUserRole(ctx, email, role) if err != nil { return userError(email, err) } return app.writeUser(cfg, user) } func (app *application) addGardenMember(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("gardens add-user", app.stderr) var gardenID int var email, role string fs.IntVar(&gardenID, "garden-id", 0, "garden ID (required)") fs.StringVar(&email, "email", "", "email address (required)") fs.StringVar(&role, "role", string(storage.GardenRoleMember), "garden role") if err := parseFlags(fs, args); err != nil { return err } email, role = strings.TrimSpace(email), strings.TrimSpace(role) if gardenID < 1 { return errors.New("garden-id must be a positive integer") } if err := validateEmail(email); err != nil { return err } validRole := role == string(storage.GardenRoleOwner) || role == string(storage.GardenRoleAdmin) || role == string(storage.GardenRoleMember) || role == string(storage.GardenRoleViewer) || role == string(storage.GardenRoleWorker) if !validRole { return errors.New("role must be owner, admin, member, viewer, or worker") } member, err := store.AddGardenMember(ctx, gardenID, email, role) if err != nil { if errors.Is(err, storage.ErrRecordNotFound) { return fmt.Errorf("garden %d, user %s, or role %s was not found", gardenID, email, role) } return err } if cfg.json { return writeJSON(app.stdout, member) } _, err = fmt.Fprintf(app.stdout, "User: %s\nGarden: %d\nRole: %s\n", member.Email, member.GardenID, member.Role) return err } func (app *application) createUser(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users create", app.stderr) var name, email, role string var active, invite, generatePassword, passwordStdin, sendEmail bool fs.StringVar(&name, "name", "", "display name (required)") fs.StringVar(&email, "email", "", "email address (required)") fs.StringVar(&role, "role", string(storage.ApplicationRoleUser), "application role") fs.BoolVar(&active, "active", false, "create an activated account") fs.BoolVar(&invite, "invite", false, "create an activation token") fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") if err := parseFlags(fs, args); err != nil { return err } name = strings.TrimSpace(name) email = strings.TrimSpace(email) role = strings.TrimSpace(role) if active == invite { return errors.New("exactly one of --active or --invite is required") } if sendEmail && !invite { return errors.New("--send-email requires --invite") } if generatePassword && passwordStdin { return errors.New("--generate-password and --password-stdin are mutually exclusive") } if err := validateIdentity(name, email); err != nil { return err } if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { return errors.New("role must be application:user or application:admin") } password, generated, err := app.obtainPassword(generatePassword, passwordStdin) if err != nil { return err } passwordHash, err := hashPassword(password) if err != nil { return err } result, err := store.CreateUser(ctx, createUserInput{ Name: name, Email: email, PasswordHash: passwordHash, Activated: active, Role: role, Invite: invite, TokenTTL: activationTokenTTL, }) if err != nil { if errors.Is(err, storage.ErrDuplicateEmail) { return fmt.Errorf("a user with email %s already exists", email) } return err } output := userMutationOutput{User: result.User} if generated { output.GeneratedPassword = password } if result.Token != nil { output.ActivationToken = result.Token.Plaintext output.ActivationURL, err = activationURL(cfg.webBaseURL, result.Token.Plaintext) if err != nil { return err } output.TokenExpiry = &result.Token.Expiry if sendEmail { if err = app.sendInvitation(cfg, result.User, *result.Token, output.ActivationURL, true); err != nil { return fmt.Errorf("user was created, but sending invitation failed: %w", err) } output.EmailSent = true } } return app.writeUserMutation(cfg, output) } func (app *application) listUsers(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users list", app.stderr) if err := parseFlags(fs, args); err != nil { return err } users, err := store.ListUsers(ctx) if err != nil { return err } if cfg.json { return writeJSON(app.stdout, users) } tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(tw, "ID\tNAME\tEMAIL\tACTIVE\tROLE\tCREATED") for _, user := range users { fmt.Fprintf(tw, "%d\t%s\t%s\t%t\t%s\t%s\n", user.ID, user.Name, user.Email, user.Activated, user.Role, user.CreatedAt.Format(time.RFC3339)) } return tw.Flush() } func (app *application) showUser(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users show", app.stderr) var email string fs.StringVar(&email, "email", "", "email address (required)") if err := parseFlags(fs, args); err != nil { return err } if err := validateEmail(email); err != nil { return err } user, err := store.GetUser(ctx, strings.TrimSpace(email)) if err != nil { return userError(email, err) } return app.writeUser(cfg, user) } func (app *application) setUserActivated(ctx context.Context, cfg config, store adminStore, args []string, active bool) error { command := "activate" if !active { command = "deactivate" } fs := newFlagSet("users "+command, app.stderr) var email string fs.StringVar(&email, "email", "", "email address (required)") if err := parseFlags(fs, args); err != nil { return err } email = strings.TrimSpace(email) if err := validateEmail(email); err != nil { return err } if !active { if err := app.confirmProduction(cfg, "deactivate user "+email); err != nil { return err } } user, err := store.SetActivated(ctx, email, active) if err != nil { return userError(email, err) } return app.writeUser(cfg, user) } func (app *application) inviteUser(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users invite", app.stderr) var email string var sendEmail bool fs.StringVar(&email, "email", "", "email address (required)") fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") if err := parseFlags(fs, args); err != nil { return err } email = strings.TrimSpace(email) if err := validateEmail(email); err != nil { return err } user, token, err := store.IssueInvitation(ctx, email, activationTokenTTL) if err != nil { return userError(email, err) } link, err := activationURL(cfg.webBaseURL, token.Plaintext) if err != nil { return err } output := userMutationOutput{ User: user, ActivationToken: token.Plaintext, ActivationURL: link, TokenExpiry: &token.Expiry, } if sendEmail { if err = app.sendInvitation(cfg, user, token, link, false); err != nil { return fmt.Errorf("invitation token was created, but sending email failed: %w", err) } output.EmailSent = true } return app.writeUserMutation(cfg, output) } func (app *application) resetPassword(ctx context.Context, cfg config, store adminStore, args []string) error { fs := newFlagSet("users reset-password", app.stderr) var email string var generatePassword, passwordStdin bool fs.StringVar(&email, "email", "", "email address (required)") fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") if err := parseFlags(fs, args); err != nil { return err } email = strings.TrimSpace(email) if err := validateEmail(email); err != nil { return err } if generatePassword && passwordStdin { return errors.New("--generate-password and --password-stdin are mutually exclusive") } if err := app.confirmProduction(cfg, "reset password for "+email); err != nil { return err } password, generated, err := app.obtainPassword(generatePassword, passwordStdin) if err != nil { return err } hash, err := hashPassword(password) if err != nil { return err } user, err := store.ResetPassword(ctx, email, hash) if err != nil { return userError(email, err) } output := userMutationOutput{User: user} if generated { output.GeneratedPassword = password } return app.writeUserMutation(cfg, output) } func (app *application) runDB(ctx context.Context, cfg config, store adminStore, args []string) error { if len(args) != 1 || args[0] != "ping" { return errors.New("usage: gardomatic db ping") } if err := store.Ping(ctx); err != nil { return err } return app.writeValue(cfg, map[string]string{"status": "ok"}, "database: ok\n") } type userMutationOutput struct { User userView `json:"user"` ActivationToken string `json:"activation_token,omitempty"` ActivationURL string `json:"activation_url,omitempty"` TokenExpiry *time.Time `json:"token_expiry,omitempty"` GeneratedPassword string `json:"generated_password,omitempty"` EmailSent bool `json:"email_sent,omitempty"` } func (app *application) writeUserMutation(cfg config, output userMutationOutput) error { if cfg.json { return writeJSON(app.stdout, output) } if err := app.writeUser(cfg, output.User); err != nil { return err } if output.GeneratedPassword != "" { fmt.Fprintf(app.stdout, "Generated password: %s\n", output.GeneratedPassword) } if output.ActivationToken != "" { fmt.Fprintf(app.stdout, "Activation token: %s\n", output.ActivationToken) fmt.Fprintf(app.stdout, "Activation URL: %s\n", output.ActivationURL) fmt.Fprintf(app.stdout, "Token expires: %s\n", output.TokenExpiry.Format(time.RFC3339)) } if output.EmailSent { fmt.Fprintln(app.stdout, "Invitation email sent.") } return nil } func (app *application) writeUser(cfg config, user userView) error { if cfg.json { return writeJSON(app.stdout, user) } tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) fmt.Fprintf(tw, "ID:\t%d\n", user.ID) fmt.Fprintf(tw, "Name:\t%s\n", user.Name) fmt.Fprintf(tw, "Email:\t%s\n", user.Email) fmt.Fprintf(tw, "Active:\t%t\n", user.Activated) fmt.Fprintf(tw, "Role:\t%s\n", user.Role) return tw.Flush() } func (app *application) writeValue(cfg config, value any, format string, args ...any) error { if cfg.json { return writeJSON(app.stdout, value) } _, err := fmt.Fprintf(app.stdout, format, args...) return err } func (app *application) obtainPassword(generate, fromStdin bool) (string, bool, error) { if generate { return rand.Text(), true, nil } if fromStdin { password, err := bufio.NewReader(app.stdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return "", false, err } password = strings.TrimRight(password, "\r\n") if err = validatePassword(password); err != nil { return "", false, err } return password, false, nil } input, ok := app.stdin.(*os.File) if !ok || !term.IsTerminal(int(input.Fd())) { return "", false, errors.New("standard input is not a terminal; use --password-stdin or --generate-password") } fmt.Fprint(app.stderr, "Password: ") first, err := term.ReadPassword(int(input.Fd())) fmt.Fprintln(app.stderr) if err != nil { return "", false, err } fmt.Fprint(app.stderr, "Repeat password: ") second, err := term.ReadPassword(int(input.Fd())) fmt.Fprintln(app.stderr) if err != nil { return "", false, err } if string(first) != string(second) { return "", false, errors.New("passwords do not match") } if err = validatePassword(string(first)); err != nil { return "", false, err } return string(first), false, nil } func (app *application) confirmProduction(cfg config, action string) error { if !strings.EqualFold(cfg.environment, "production") || cfg.yes { return nil } fmt.Fprintf(app.stderr, "Production: %s. Continue? [y/N] ", action) answer, err := bufio.NewReader(app.stdin).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return err } if strings.ToLower(strings.TrimSpace(answer)) != "y" { return errors.New("operation cancelled") } return nil } func (app *application) sendInvitation(cfg config, user userView, token auth.Token, link string, welcome bool) error { m, err := app.newMailer(cfg.mail) if err != nil { return err } template := "token_activation.tmpl" if welcome { template = "user_welcome.tmpl" } return m.Send(user.Email, template, map[string]any{ "activationToken": token.Plaintext, "activationURL": link, "userID": user.ID, }) } func (app *application) printUsage(fs *flag.FlagSet) { fmt.Fprintln(app.stderr, `Gardomatic administration CLI Usage: gardomatic [global options] users create --name NAME --email EMAIL (--active|--invite) [--role ROLE] [options] gardomatic [global options] users list gardomatic [global options] users show --email EMAIL gardomatic [global options] users activate|deactivate --email EMAIL gardomatic [global options] users invite --email EMAIL [--send-email] gardomatic [global options] users reset-password --email EMAIL [options] gardomatic [global options] users set-role --email EMAIL --role ROLE gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE] gardomatic [global options] db ping gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL] gardomatic [global options] version Global options:`) fs.PrintDefaults() } func activationURL(baseURL, token string) (string, error) { u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/activate") if err != nil { return "", fmt.Errorf("invalid web base URL: %w", err) } if u.Scheme == "" || u.Host == "" { return "", errors.New("web base URL must include scheme and host") } query := u.Query() query.Set("token", token) u.RawQuery = query.Encode() return u.String(), nil } func validateIdentity(name, email string) error { password := auth.NewPassword([]byte("placeholder")) user := storage.User{Name: name, Email: email, Password: *password} v := validate.New() storage.ValidateUser(v, user) delete(v.Errors, "password") if !v.Valid() { return validationError(v.Errors) } return nil } func validateEmail(email string) error { v := validate.New() storage.ValidateEmail(v, strings.TrimSpace(email)) if !v.Valid() { return validationError(v.Errors) } return nil } func validatePassword(password string) error { v := validate.New() auth.ValidatePasswordPlaintext(v, password) if !v.Valid() { return validationError(v.Errors) } return nil } func hashPassword(plaintext string) ([]byte, error) { if err := validatePassword(plaintext); err != nil { return nil, err } var password auth.Password if err := password.Set(plaintext); err != nil { return nil, err } return password.Get(), nil } func validationError(fields map[string]string) error { keys := make([]string, 0, len(fields)) for key := range fields { keys = append(keys, key) } slices.Sort(keys) parts := make([]string, 0, len(keys)) for _, key := range keys { parts = append(parts, key+" "+fields[key]) } return errors.New(strings.Join(parts, "; ")) } func userError(email string, err error) error { switch { case errors.Is(err, errUserNotFound): return fmt.Errorf("no user found with email %s", email) case errors.Is(err, errUserAlreadyActive): return fmt.Errorf("user %s is already active", email) default: return err } } func newFlagSet(name string, output io.Writer) *flag.FlagSet { fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(output) return fs } func parseFlags(fs *flag.FlagSet, args []string) error { if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 0 { return fmt.Errorf("unexpected argument %q", fs.Arg(0)) } return nil } func writeJSON(w io.Writer, value any) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") encoder.SetEscapeHTML(false) return encoder.Encode(value) }