From 45ce8c1aa45e810f9b4e64a44852919b461a555c Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 20 Apr 2026 17:18:54 +0200 Subject: [PATCH] feat: add create-user cli --- Dockerfile | 4 +- cmd/README.md | 3 +- cmd/create-user/main.go | 146 ++++++++++++++++++++++++++++++++++++++++ scripts/create-user.sh | 133 +----------------------------------- 4 files changed, 152 insertions(+), 134 deletions(-) create mode 100644 cmd/create-user/main.go diff --git a/Dockerfile b/Dockerfile index f2cf389..d843123 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,9 @@ RUN bun run build:assets # Generate templ files RUN templ generate -# Build the server and the CLI tools +# Build the server and CLI tools RUN go build -o main_server ./cmd/server +RUN go build -o create_user ./cmd/create-user FROM debian:bullseye-slim @@ -41,6 +42,7 @@ RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/ RUN mkdir -p /app/data COPY --from=builder /app/main_server . +COPY --from=builder /app/create_user . COPY --from=builder /app/static ./static COPY --from=builder /app/dist ./dist COPY --from=builder /app/migrations ./migrations diff --git a/cmd/README.md b/cmd/README.md index 01ec5ca..97eb036 100644 --- a/cmd/README.md +++ b/cmd/README.md @@ -3,7 +3,8 @@ Executable entrypoints live here. - `cmd/server`: main web process (`go run ./cmd/server`) +- `cmd/create-user`: admin CLI for adding login users (`go run ./cmd/create-user --email user@example.com --password-stdin`) ## Why this structure -I wanted to keep the repository root clean and focused on project metadata like `README.md`, `go.mod`, and `Dockerfile`. Keeping entrypoints under `cmd/` also makes it easy to add more binaries later without cluttering the root, and it matches standard Go conventions for projects that grow beyond a single binary. \ No newline at end of file +I wanted to keep the repository root clean and focused on project metadata like `README.md`, `go.mod`, and `Dockerfile`. Keeping entrypoints under `cmd/` also makes it easy to add more binaries later without cluttering the root, and it matches standard Go conventions for projects that grow beyond a single binary. diff --git a/cmd/create-user/main.go b/cmd/create-user/main.go new file mode 100644 index 0000000..80486f5 --- /dev/null +++ b/cmd/create-user/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "database/sql" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/google/uuid" + sqlite3 "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +const bcryptCost = 12 + +type config struct { + email string + password string + passwordStdin bool + dbFile string +} + +func trimTrailingNewline(v string) string { + v = strings.TrimSuffix(v, "\n") + v = strings.TrimSuffix(v, "\r") + return v +} + +func parseConfig() (config, error) { + var cfg config + + flag.StringVar(&cfg.email, "email", "", "User email/username") + flag.StringVar(&cfg.password, "password", "", "User password") + flag.BoolVar(&cfg.passwordStdin, "password-stdin", false, "Read password from stdin") + flag.StringVar(&cfg.dbFile, "db", "", "SQLite database file path") + flag.Parse() + + args := flag.Args() + if len(args) > 2 { + return cfg, fmt.Errorf("too many arguments") + } + + if cfg.email == "" && len(args) >= 1 { + cfg.email = args[0] + } + + if cfg.password == "" && len(args) == 2 { + cfg.password = args[1] + } + + if cfg.password != "" && cfg.passwordStdin { + return cfg, fmt.Errorf("use either --password or --password-stdin") + } + + cfg.email = strings.TrimSpace(cfg.email) + if cfg.email == "" { + return cfg, fmt.Errorf("email is required") + } + + if cfg.passwordStdin { + passwordBytes, err := io.ReadAll(os.Stdin) + if err != nil { + return cfg, fmt.Errorf("read password from stdin: %w", err) + } + cfg.password = trimTrailingNewline(string(passwordBytes)) + } + + if cfg.password == "" { + return cfg, fmt.Errorf("password is required") + } + + if cfg.dbFile == "" { + cfg.dbFile = os.Getenv("DATABASE_FILE") + } + + if cfg.dbFile == "" { + cfg.dbFile = "mal.db" + } + + if _, err := os.Stat(cfg.dbFile); err != nil { + if errors.Is(err, os.ErrNotExist) { + return cfg, fmt.Errorf("database file not found: %s", cfg.dbFile) + } + return cfg, fmt.Errorf("inspect database file: %w", err) + } + + return cfg, nil +} + +func run() error { + cfg, err := parseConfig() + if err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(cfg.password), bcryptCost) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", cfg.dbFile)) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer db.Close() + + _, err = db.Exec( + "INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", + uuid.NewString(), + cfg.email, + string(hash), + ) + if err != nil { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return fmt.Errorf("user already exists: %s", cfg.email) + } + return fmt.Errorf("insert user: %w", err) + } + + fmt.Printf("created user: %s\n", cfg.email) + return nil +} + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [--db path] [--email email] [--password value | --password-stdin] [email] [password]\n", os.Args[0]) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Examples:") + fmt.Fprintf(os.Stderr, " %s --db /app/data/mal.db --email admin@example.com --password 'strong-pass'\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " printf 'strong-pass' | %s --email admin@example.com --password-stdin\n", os.Args[0]) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Defaults:") + fmt.Fprintln(os.Stderr, " --db uses DATABASE_FILE, then mal.db") + flag.PrintDefaults() + } + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + flag.Usage() + os.Exit(1) + } +} diff --git a/scripts/create-user.sh b/scripts/create-user.sh index 0f489c0..1576027 100755 --- a/scripts/create-user.sh +++ b/scripts/create-user.sh @@ -2,35 +2,8 @@ set -euo pipefail -usage() { - cat <<'EOF' -Usage: - ./scripts/create-user.sh - -Creates a user directly in the SQLite database so they can log in. - -Environment: - DATABASE_FILE SQLite database path (default: /mal.db) -EOF -} - -if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then - usage - exit 0 -fi - -if [[ $# -gt 2 ]]; then - usage >&2 - exit 1 -fi - script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" repo_root="$(cd -- "${script_dir}/.." >/dev/null 2>&1 && pwd)" -db_file="${DATABASE_FILE:-${repo_root}/mal.db}" - -if [[ "${db_file}" != /* ]]; then - db_file="$(pwd)/${db_file}" -fi email="${1:-}" password="${2:-}" @@ -49,111 +22,7 @@ if [[ -z "${email}" || -z "${password}" ]]; then exit 1 fi -if [[ ! -f "${db_file}" ]]; then - printf 'Database file not found: %s\n' "${db_file}" >&2 - printf 'Run the server once to apply migrations, or set DATABASE_FILE.\n' >&2 - exit 1 -fi - -tmp_dir="${repo_root}/tmp" -mkdir -p "${tmp_dir}" -tmp_go="$(mktemp "${tmp_dir}/create-user.XXXXXX.go")" -trap 'rm -f "$tmp_go"' EXIT - -cat >"${tmp_go}" <<'EOF' -package main - -import ( - "crypto/rand" - "database/sql" - "encoding/hex" - "fmt" - "io" - "os" - "strings" - - _ "github.com/mattn/go-sqlite3" - "golang.org/x/crypto/bcrypt" -) - -const bcryptCost = 12 - -func randomID() (string, error) { - id := make([]byte, 16) - if _, err := rand.Read(id); err != nil { - return "", fmt.Errorf("generate id: %w", err) - } - - return hex.EncodeToString(id), nil -} - -func trimTrailingNewline(v string) string { - v = strings.TrimSuffix(v, "\n") - v = strings.TrimSuffix(v, "\r") - return v -} - -func main() { - if len(os.Args) != 3 { - fmt.Fprintln(os.Stderr, "usage: go run create-user.go ") - os.Exit(2) - } - - dbFile := os.Args[1] - email := strings.TrimSpace(os.Args[2]) - - passwordBytes, err := io.ReadAll(os.Stdin) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to read password: %v\n", err) - os.Exit(1) - } - - password := trimTrailingNewline(string(passwordBytes)) - if email == "" || password == "" { - fmt.Fprintln(os.Stderr, "email and password are required") - os.Exit(1) - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to hash password: %v\n", err) - os.Exit(1) - } - - userID, err := randomID() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create user id: %v\n", err) - os.Exit(1) - } - - db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile)) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open database: %v\n", err) - os.Exit(1) - } - defer db.Close() - - _, err = db.Exec( - "INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)", - userID, - email, - string(hash), - ) - if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed: user.username") { - fmt.Fprintf(os.Stderr, "user already exists: %s\n", email) - os.Exit(1) - } - - fmt.Fprintf(os.Stderr, "failed to insert user: %v\n", err) - os.Exit(1) - } - - fmt.Printf("created user: %s\n", email) -} -EOF - ( cd "${repo_root}" - printf '%s' "${password}" | go run "${tmp_go}" "${db_file}" "${email}" + printf '%s' "${password}" | go run ./cmd/create-user --email "${email}" --password-stdin )