@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/api"
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
"gardomatic.kleiax.de/internal/platform/environment"
|
||||
)
|
||||
|
||||
func configFromEnvironment() (api.Config, error) {
|
||||
var errs []error
|
||||
required := func(name string) string {
|
||||
value, err := environment.Required(name)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
integer := func(name string, fallback int) int {
|
||||
value, err := environment.Int(name, fallback)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
boolean := func(name string, fallback bool) bool {
|
||||
value, err := environment.Bool(name, fallback)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
duration := func(name string, fallback time.Duration) time.Duration {
|
||||
value, err := environment.Duration(name, fallback)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
floating := func(name string, fallback float64) float64 {
|
||||
value, err := environment.Float(name, fallback)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
cfg := api.Config{
|
||||
Host: environment.String("GARDOMATIC_API_HOST", ""),
|
||||
Port: integer("GARDOMATIC_API_PORT", 4000),
|
||||
Env: environment.String("GARDOMATIC_ENV", "development"),
|
||||
WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||
DB: api.DatabaseConfig{
|
||||
Dsn: required("GARDOMATIC_DB_DSN"), MaxOpenConns: integer("GARDOMATIC_DB_MAX_OPEN_CONNS", 25),
|
||||
MaxIdleConns: integer("GARDOMATIC_DB_MAX_IDLE_CONNS", 25), MaxIdleTime: duration("GARDOMATIC_DB_MAX_IDLE_TIME", 15*time.Minute),
|
||||
},
|
||||
Limiter: api.LimiterConfig{Enabled: boolean("GARDOMATIC_RATE_LIMIT_ENABLED", true), Rps: floating("GARDOMATIC_RATE_LIMIT_RPS", 10), Burst: integer("GARDOMATIC_RATE_LIMIT_BURST", 40)},
|
||||
Session: api.SessionConfig{Lifetime: duration("GARDOMATIC_SESSION_LIFETIME", 12*time.Hour), IdleTimeout: duration("GARDOMATIC_SESSION_IDLE_TIMEOUT", 30*time.Minute), CookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: boolean("GARDOMATIC_COOKIE_SECURE", false)},
|
||||
Mail: mailer.Config{Mode: mailer.Mode(environment.String("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), Host: environment.String("GARDOMATIC_SMTP_HOST", ""), Port: integer("GARDOMATIC_SMTP_PORT", 25), Username: environment.String("GARDOMATIC_SMTP_USERNAME", ""), Password: environment.String("GARDOMATIC_SMTP_PASSWORD", ""), Sender: environment.String("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), FilePath: environment.String("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log")},
|
||||
Cors: api.CORSConfig{TrustedOrigins: environment.CSV("GARDOMATIC_CORS_TRUSTED_ORIGINS")},
|
||||
}
|
||||
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
errs = append(errs, errors.New("GARDOMATIC_API_PORT must be between 1 and 65535"))
|
||||
}
|
||||
if cfg.Env != "development" && cfg.Env != "test" && cfg.Env != "production" {
|
||||
errs = append(errs, fmt.Errorf("GARDOMATIC_ENV has unsupported value %q", cfg.Env))
|
||||
}
|
||||
if cfg.DB.MaxOpenConns < 1 || cfg.DB.MaxIdleConns < 0 || cfg.DB.MaxIdleConns > cfg.DB.MaxOpenConns {
|
||||
errs = append(errs, errors.New("database pool limits are invalid"))
|
||||
}
|
||||
if cfg.Limiter.Rps <= 0 || cfg.Limiter.Burst < 1 {
|
||||
errs = append(errs, errors.New("rate limit values must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(cfg.Session.CookieName) == "" {
|
||||
errs = append(errs, errors.New("GARDOMATIC_SESSION_COOKIE_NAME must not be empty"))
|
||||
}
|
||||
for _, origin := range cfg.Cors.TrustedOrigins {
|
||||
parsed, err := url.ParseRequestURI(origin)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
errs = append(errs, fmt.Errorf("invalid trusted origin %q", origin))
|
||||
}
|
||||
}
|
||||
if parsed, err := url.ParseRequestURI(cfg.WebBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
errs = append(errs, errors.New("GARDOMATIC_WEB_BASE_URL must be an absolute URL"))
|
||||
}
|
||||
if cfg.Env == "production" && !cfg.Session.CookieSecure {
|
||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
||||
}
|
||||
if _, err := mailer.New(cfg.Mail); err != nil {
|
||||
errs = append(errs, fmt.Errorf("mail configuration: %w", err))
|
||||
}
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfigFromEnvironment(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_DB_DSN", "postgres://example")
|
||||
t.Setenv("GARDOMATIC_API_HOST", "127.0.0.1")
|
||||
t.Setenv("GARDOMATIC_API_PORT", "4100")
|
||||
t.Setenv("GARDOMATIC_CORS_TRUSTED_ORIGINS", "https://example.com, https://app.example.com")
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 {
|
||||
t.Fatalf("unexpected config: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRequiresDSN(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_DB_DSN", "")
|
||||
if _, err := configFromEnvironment(); err == nil {
|
||||
t.Fatal("expected missing DSN error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main starts the Gardomatic JSON API service.
|
||||
package main
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gardomatic.kleiax.de/internal/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server := api.New(cfg)
|
||||
server.Run()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type fakeAdminStore struct {
|
||||
createInput createUserInput
|
||||
createCalls int
|
||||
setActivatedCalls int
|
||||
inviteCalls int
|
||||
setRoleCalls int
|
||||
addMemberCalls int
|
||||
lastRole string
|
||||
}
|
||||
|
||||
func (s *fakeAdminStore) Ping(context.Context) error { return nil }
|
||||
func (s *fakeAdminStore) Close() error { return nil }
|
||||
|
||||
func (s *fakeAdminStore) CreateUser(_ context.Context, input createUserInput) (createUserResult, error) {
|
||||
s.createCalls++
|
||||
s.createInput = input
|
||||
result := createUserResult{User: userView{
|
||||
ID: 42,
|
||||
Name: input.Name,
|
||||
Email: input.Email,
|
||||
Activated: input.Activated,
|
||||
Role: input.Role,
|
||||
CreatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC),
|
||||
}}
|
||||
if input.Invite {
|
||||
token := auth.NewToken(42, input.TokenTTL, auth.ScopeActivation)
|
||||
result.Token = &token
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fakeAdminStore) ListUsers(context.Context) ([]userView, error) { return nil, nil }
|
||||
func (s *fakeAdminStore) GetUser(context.Context, string) (userView, error) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
func (s *fakeAdminStore) SetActivated(_ context.Context, email string, active bool) (userView, error) {
|
||||
s.setActivatedCalls++
|
||||
return userView{ID: 42, Email: email, Activated: active}, nil
|
||||
}
|
||||
func (s *fakeAdminStore) IssueInvitation(_ context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
|
||||
s.inviteCalls++
|
||||
return userView{ID: 42, Name: "Alice", Email: email}, auth.NewToken(42, ttl, auth.ScopeActivation), nil
|
||||
}
|
||||
func (s *fakeAdminStore) ResetPassword(context.Context, string, []byte) (userView, error) {
|
||||
return userView{}, nil
|
||||
}
|
||||
func (s *fakeAdminStore) SetUserRole(_ context.Context, email, role string) (userView, error) {
|
||||
s.setRoleCalls++
|
||||
s.lastRole = role
|
||||
return userView{ID: 42, Email: email, Role: role}, nil
|
||||
}
|
||||
func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email, role string) (gardenMemberView, error) {
|
||||
s.addMemberCalls++
|
||||
s.lastRole = role
|
||||
return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
|
||||
}
|
||||
|
||||
func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("GARDOMATIC_DB_DSN", "postgres://unused")
|
||||
t.Setenv("GARDOMATIC_ENV", "development")
|
||||
stdout := new(bytes.Buffer)
|
||||
stderr := new(bytes.Buffer)
|
||||
app := newApplication(strings.NewReader(stdin), stdout, stderr)
|
||||
app.openStore = func(string) (adminStore, error) { return store, nil }
|
||||
return app, stdout, stderr
|
||||
}
|
||||
|
||||
func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, stdout, _ := newTestApplication(t, "", store)
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"--json",
|
||||
"--web-base-url", "https://gardomatic.example/app",
|
||||
"users", "create",
|
||||
"--name", " Alice ",
|
||||
"--email", "alice@example.com",
|
||||
"--invite",
|
||||
"--generate-password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run() returned an error: %v", err)
|
||||
}
|
||||
if store.createCalls != 1 {
|
||||
t.Fatalf("CreateUser calls = %d, want 1", store.createCalls)
|
||||
}
|
||||
if store.createInput.Name != "Alice" || !store.createInput.Invite || store.createInput.Activated {
|
||||
t.Errorf("CreateUser input = %+v", store.createInput)
|
||||
}
|
||||
|
||||
var output userMutationOutput
|
||||
if err = json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||
t.Fatalf("decoding output: %v; output: %s", err, stdout.String())
|
||||
}
|
||||
if output.GeneratedPassword == "" {
|
||||
t.Fatal("generated password is missing")
|
||||
}
|
||||
if err = bcrypt.CompareHashAndPassword(store.createInput.PasswordHash, []byte(output.GeneratedPassword)); err != nil {
|
||||
t.Errorf("stored password hash does not match generated password: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(output.ActivationURL, "https://gardomatic.example/app/activate?token=") {
|
||||
t.Errorf("activation URL = %q", output.ActivationURL)
|
||||
}
|
||||
if output.ActivationToken == "" || !strings.Contains(output.ActivationURL, output.ActivationToken) {
|
||||
t.Errorf("activation token and URL do not match: %+v", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "", store)
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"users", "create",
|
||||
"--name", "Alice",
|
||||
"--email", "alice@example.com",
|
||||
"--generate-password",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one") {
|
||||
t.Fatalf("run() error = %v, want account mode error", err)
|
||||
}
|
||||
if store.createCalls != 0 {
|
||||
t.Errorf("CreateUser calls = %d, want 0", store.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserCanAtomicallyCreateApplicationAdmin(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, stdout, _ := newTestApplication(t, "", store)
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"users", "create",
|
||||
"--name", "Initial Admin",
|
||||
"--email", "admin@example.com",
|
||||
"--role", "application:admin",
|
||||
"--active",
|
||||
"--generate-password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run() returned an error: %v", err)
|
||||
}
|
||||
if store.createInput.Role != "application:admin" {
|
||||
t.Fatalf("created role = %q, want application:admin", store.createInput.Role)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "application:admin") {
|
||||
t.Fatalf("output does not contain admin role: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsUnknownApplicationRole(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "", store)
|
||||
err := app.run(context.Background(), []string{
|
||||
"users", "create",
|
||||
"--name", "Alice",
|
||||
"--email", "alice@example.com",
|
||||
"--role", "superadmin",
|
||||
"--active",
|
||||
"--generate-password",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "role must be") {
|
||||
t.Fatalf("run() error = %v, want role validation error", err)
|
||||
}
|
||||
if store.createCalls != 0 {
|
||||
t.Fatalf("CreateUser calls = %d, want 0", store.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionDeactivationRequiresConfirmation(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "n\n", store)
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"--env", "production",
|
||||
"users", "deactivate", "--email", "alice@example.com",
|
||||
})
|
||||
if err == nil || err.Error() != "operation cancelled" {
|
||||
t.Fatalf("run() error = %v, want operation cancelled", err)
|
||||
}
|
||||
if store.setActivatedCalls != 0 {
|
||||
t.Errorf("SetActivated calls = %d, want 0", store.setActivatedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteCanWriteEmailWithActivationLink(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "", store)
|
||||
mailPath := filepath.Join(t.TempDir(), "mail.log")
|
||||
t.Setenv("GARDOMATIC_SMTP_MODE", "file")
|
||||
t.Setenv("GARDOMATIC_SMTP_FILE_PATH", mailPath)
|
||||
|
||||
err := app.run(context.Background(), []string{
|
||||
"--web-base-url", "https://gardomatic.example",
|
||||
"users", "invite", "--email", "alice@example.com", "--send-email",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run() returned an error: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(mailPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "https://gardomatic.example/activate?token=") {
|
||||
t.Errorf("mail does not contain activation URL: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationURLRejectsRelativeBase(t *testing.T) {
|
||||
_, err := activationURL("localhost:4040", "token")
|
||||
if err == nil {
|
||||
t.Fatal("activationURL() returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserRoleCanMakeApplicationAdmin(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, stdout, _ := newTestApplication(t, "", store)
|
||||
err := app.run(context.Background(), []string{"users", "set-role", "--email", "alice@example.com", "--role", "application:admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.setRoleCalls != 1 || store.lastRole != "application:admin" || !strings.Contains(stdout.String(), "application:admin") {
|
||||
t.Fatalf("role update missing: calls=%d role=%q output=%q", store.setRoleCalls, store.lastRole, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUserToGardenWithAdminRole(t *testing.T) {
|
||||
store := new(fakeAdminStore)
|
||||
app, stdout, _ := newTestApplication(t, "", store)
|
||||
err := app.run(context.Background(), []string{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.addMemberCalls != 1 || store.lastRole != "admin" || !strings.Contains(stdout.String(), "Garden: 7") {
|
||||
t.Fatalf("garden membership missing: calls=%d role=%q output=%q", store.addMemberCalls, store.lastRole, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleCommandsRejectUnknownRoles(t *testing.T) {
|
||||
tests := [][]string{
|
||||
{"users", "set-role", "--email", "alice@example.com", "--role", "superadmin"},
|
||||
{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "superadmin"},
|
||||
}
|
||||
for _, args := range tests {
|
||||
store := new(fakeAdminStore)
|
||||
app, _, _ := newTestApplication(t, "", store)
|
||||
if err := app.run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "role must be") {
|
||||
t.Errorf("run(%v) error = %v, want role validation error", args, err)
|
||||
}
|
||||
if store.setRoleCalls != 0 || store.addMemberCalls != 0 {
|
||||
t.Errorf("run(%v) reached store", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gardomatic.kleiax.de/internal/mailer"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
dsn string
|
||||
environment string
|
||||
webBaseURL string
|
||||
json bool
|
||||
yes bool
|
||||
mail mailer.Config
|
||||
}
|
||||
|
||||
func configFromEnvironment() (config, error) {
|
||||
port, err := envInt("GARDOMATIC_SMTP_PORT", 25)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
|
||||
return config{
|
||||
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
|
||||
environment: envString("GARDOMATIC_ENV", "development"),
|
||||
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||
mail: mailer.Config{
|
||||
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
|
||||
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
|
||||
Port: port,
|
||||
Username: os.Getenv("GARDOMATIC_SMTP_USERNAME"),
|
||||
Password: os.Getenv("GARDOMATIC_SMTP_PASSWORD"),
|
||||
Sender: envString("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"),
|
||||
FilePath: envString("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log"),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envString(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) (int, error) {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer: %w", name, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main provides the Gardomatic administration command-line tool.
|
||||
package main
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := newApplication(os.Stdin, os.Stdout, os.Stderr)
|
||||
if err := app.run(context.Background(), os.Args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gardomatic.kleiax.de/internal/auth"
|
||||
"gardomatic.kleiax.de/internal/storage"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
errUserNotFound = errors.New("user not found")
|
||||
errUserAlreadyActive = errors.New("user is already active")
|
||||
)
|
||||
|
||||
type userView struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Activated bool `json:"activated"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type gardenMemberView struct {
|
||||
GardenID int `json:"garden_id"`
|
||||
UserID int `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
type createUserInput struct {
|
||||
Name string
|
||||
Email string
|
||||
PasswordHash []byte
|
||||
Activated bool
|
||||
Role string
|
||||
Invite bool
|
||||
TokenTTL time.Duration
|
||||
}
|
||||
|
||||
type createUserResult struct {
|
||||
User userView
|
||||
Token *auth.Token
|
||||
}
|
||||
|
||||
type adminStore interface {
|
||||
Ping(context.Context) error
|
||||
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
||||
ListUsers(context.Context) ([]userView, error)
|
||||
GetUser(context.Context, string) (userView, error)
|
||||
SetActivated(context.Context, string, bool) (userView, error)
|
||||
IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error)
|
||||
ResetPassword(context.Context, string, []byte) (userView, error)
|
||||
SetUserRole(context.Context, string, string) (userView, error)
|
||||
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type postgresStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func openPostgresStore(dsn string) (adminStore, error) {
|
||||
if dsn == "" {
|
||||
return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn")
|
||||
}
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(2)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
return &postgresStore{db: db}, nil
|
||||
}
|
||||
|
||||
// Close releases the CLI database pool.
|
||||
func (s *postgresStore) Close() error { return s.db.Close() }
|
||||
|
||||
// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout.
|
||||
func (s *postgresStore) Ping(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return s.db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// CreateUser creates an account and optionally an invitation token atomically.
|
||||
func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var result createUserResult
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users (name, email, password_hash, activated, application_role)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`,
|
||||
input.Name, input.Email, input.PasswordHash, input.Activated, input.Role,
|
||||
).Scan(
|
||||
&result.User.ID,
|
||||
&result.User.Name,
|
||||
&result.User.Email,
|
||||
&result.User.Activated,
|
||||
&result.User.Role,
|
||||
&result.User.CreatedAt,
|
||||
&result.User.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return createUserResult{}, mapPostgresError(err)
|
||||
}
|
||||
|
||||
if input.Invite {
|
||||
token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation)
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
result.Token = &token
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return createUserResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListUsers returns all non-deleted accounts for CLI administration.
|
||||
func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, email, activated, application_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := make([]userView, 0)
|
||||
for rows.Next() {
|
||||
var user userView
|
||||
if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// GetUser returns a non-deleted account by email.
|
||||
func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
return getUser(ctx, s.db, email)
|
||||
}
|
||||
|
||||
// SetActivated changes whether an account may authenticate.
|
||||
func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var user userView
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if activated {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// IssueInvitation replaces an account's activation token.
|
||||
func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
user, err := getUser(ctx, tx, email)
|
||||
if err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
if user.Activated {
|
||||
return userView{}, auth.Token{}, errUserAlreadyActive
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
token := auth.NewToken(user.ID, ttl, auth.ScopeActivation)
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tokens (hash, user_id, expiry, scope)
|
||||
VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, auth.Token{}, err
|
||||
}
|
||||
return user, token, nil
|
||||
}
|
||||
|
||||
// ResetPassword replaces an account password hash and revokes authentication tokens.
|
||||
func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var user userView
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
DELETE FROM tokens
|
||||
WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// SetUserRole changes an account's application role.
|
||||
func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var user userView
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1
|
||||
WHERE email=$1 AND deleted_at IS NULL
|
||||
RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
// AddGardenMember adds or updates a user's membership in a garden.
|
||||
func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var member gardenMemberView
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO garden_members(garden_id,user_id,role)
|
||||
SELECT $1,u.id,$3 FROM users u
|
||||
WHERE u.email=$2 AND u.deleted_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM gardens WHERE id=$1)
|
||||
AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1))
|
||||
RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role,
|
||||
).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return gardenMemberView{}, storage.ErrRecordNotFound
|
||||
}
|
||||
return member, mapPostgresError(err)
|
||||
}
|
||||
|
||||
type dbQuerier interface {
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) {
|
||||
var user userView
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, name, email, activated, application_role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1 AND deleted_at IS NULL`, email,
|
||||
).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return userView{}, errUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return userView{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func mapPostgresError(err error) error {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" {
|
||||
return storage.ErrDuplicateEmail
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"gardomatic.kleiax.de/internal/platform/environment"
|
||||
"gardomatic.kleiax.de/internal/web"
|
||||
)
|
||||
|
||||
func configFromEnvironment() (web.Config, error) {
|
||||
port, err := environment.Int("GARDOMATIC_WEB_PORT", 4040)
|
||||
if err != nil {
|
||||
return web.Config{}, err
|
||||
}
|
||||
cookieSecure, err := environment.Bool("GARDOMATIC_COOKIE_SECURE", false)
|
||||
if err != nil {
|
||||
return web.Config{}, err
|
||||
}
|
||||
cfg := web.Config{Host: environment.String("GARDOMATIC_WEB_HOST", ""), Port: port, Env: environment.String("GARDOMATIC_ENV", "development"), APIBaseURL: environment.String("GARDOMATIC_API_BASE_URL", "http://localhost:4000"), SessionCookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: cookieSecure}
|
||||
var errs []error
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 65535"))
|
||||
}
|
||||
parsed, parseErr := url.ParseRequestURI(cfg.APIBaseURL)
|
||||
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
errs = append(errs, fmt.Errorf("GARDOMATIC_API_BASE_URL must be an absolute HTTP URL"))
|
||||
}
|
||||
if cfg.Env == "production" && !cfg.CookieSecure {
|
||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
||||
}
|
||||
return cfg, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfigFromEnvironment(t *testing.T) {
|
||||
t.Setenv("GARDOMATIC_WEB_HOST", "0.0.0.0")
|
||||
t.Setenv("GARDOMATIC_WEB_PORT", "4444")
|
||||
t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com")
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" {
|
||||
t.Fatalf("unexpected config: %#v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main starts the Gardomatic server-rendered web application.
|
||||
package main
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gardomatic.kleiax.de/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := configFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := web.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server.Run()
|
||||
}
|
||||
Reference in New Issue
Block a user