@@ -0,0 +1,663 @@
|
||||
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:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
|
||||
}
|
||||
}
|
||||
|
||||
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 [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)
|
||||
}
|
||||
Reference in New Issue
Block a user