package main import ( "context" "errors" "flag" "fmt" "os" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "git.dcrubro.com/dcrubro/hammy-backend/internal/apikey" "git.dcrubro.com/dcrubro/hammy-backend/internal/db" ) // runKeygen issues an API key from the command line. This is how you get the // first key, before there is any self-service registration, and how you issue // yourself something to test against. // // hammyd keygen -email you@example.com -scopes reference,callsign func runKeygen(ctx context.Context, args []string) error { fs := flag.NewFlagSet("keygen", flag.ExitOnError) email := fs.String("email", "", "owner email (required); created if new") callsign := fs.String("callsign", "", "owner callsign (optional, uppercase)") label := fs.String("label", "", "what this key is for, shown in listings") scopes := fs.String("scopes", "reference", "comma-separated scopes") tier := fs.String("tier", "default", "quota tier: default, verified, trusted, internal") env := fs.String("env", "live", "live or test") expires := fs.Duration("expires", 0, "optional lifetime, e.g. 720h. Zero means no expiry") if err := fs.Parse(args); err != nil { return err } if *email == "" { fs.Usage() return errors.New("keygen: -email is required") } // The core.email domain requires lowercase and core.callsign requires // uppercase. Normalise here so the CLI is forgiving and the database stays // canonical. normEmail := strings.ToLower(strings.TrimSpace(*email)) normCall := strings.ToUpper(strings.TrimSpace(*callsign)) dsn := os.Getenv("HAMMY_DSN") if dsn == "" { return errors.New("keygen: HAMMY_DSN is not set") } pool, err := pgxpool.New(ctx, dsn) if err != nil { return fmt.Errorf("keygen: connect: %w", err) } defer pool.Close() q := db.New(pool) owner, err := q.OwnerByEmail(ctx, normEmail) if errors.Is(err, pgx.ErrNoRows) { var callsignArg any if normCall != "" { callsignArg = normCall } created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{ Email: normEmail, Callsign: callsignArg, }) if cerr != nil { return fmt.Errorf("keygen: creating owner: %w", cerr) } owner.ID = created.ID fmt.Printf("created owner %d for %s\n", owner.ID, normEmail) } else if err != nil { return fmt.Errorf("keygen: looking up owner: %w", err) } gen, err := apikey.Generate(apikey.Environment(*env)) if err != nil { return fmt.Errorf("keygen: %w", err) } var expiresAt *time.Time if *expires > 0 { t := time.Now().Add(*expires) expiresAt = &t } scopeList := splitScopes(*scopes) key, err := q.CreateKey(ctx, db.CreateKeyParams{ OwnerID: owner.ID, KeyHash: gen.Hash, KeyPrefix: gen.Display, Label: nullableString(*label), Scopes: scopeList, QuotaTier: *tier, ExpiresAt: db.Timestamptz(expiresAt), }) if err != nil { // The keys_scopes_known CHECK rejects unknown scopes, which is the // most likely failure here and worth naming. return fmt.Errorf("keygen: creating key (check scopes against migrations/003_api.sql): %w", err) } fmt.Println() fmt.Println("Key issued. This is the only time it will be shown.") fmt.Println() fmt.Printf(" %s\n", gen.Plaintext) fmt.Println() fmt.Printf(" id %d\n", key.ID) fmt.Printf(" owner %d (%s)\n", owner.ID, normEmail) fmt.Printf(" scopes %s\n", strings.Join(scopeList, ", ")) fmt.Printf(" tier %s\n", key.QuotaTier) if expiresAt != nil { fmt.Printf(" expires %s\n", expiresAt.UTC().Format(time.RFC3339)) } else { fmt.Printf(" expires never\n") } fmt.Println() fmt.Println("Test it with:") fmt.Printf(" curl -H 'Authorization: Bearer %s' http://localhost:8080/v1/whoami\n", gen.Plaintext) fmt.Println() return nil } func splitScopes(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } func nullableString(s string) *string { if s == "" { return nil } return &s }