From 13627a369a0281ecee1eefb76f4f347c669f8f34 Mon Sep 17 00:00:00 2001 From: DcruBro Date: Sun, 6 Sep 2026 18:28:52 +0200 Subject: [PATCH] fix namings --- cmd/hammyd/keygen.go | 159 ++++++++++++++++++ .../{004_privacy.sql => 005_privacy.sql} | 0 {migrations => scripts}/bootstrap.sql | 0 3 files changed, 159 insertions(+) create mode 100644 cmd/hammyd/keygen.go rename migrations/{004_privacy.sql => 005_privacy.sql} (100%) rename {migrations => scripts}/bootstrap.sql (100%) diff --git a/cmd/hammyd/keygen.go b/cmd/hammyd/keygen.go new file mode 100644 index 0000000..4449317 --- /dev/null +++ b/cmd/hammyd/keygen.go @@ -0,0 +1,159 @@ +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) { + created, cerr := q.CreateOwner(ctx, db.CreateOwnerParams{ + Email: normEmail, + Callsign: nullableCallsign(normCall), + }) + 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: 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 +} + +func nullableCallsign(s string) *string { + if s == "" { + return nil + } + + return &s +} diff --git a/migrations/004_privacy.sql b/migrations/005_privacy.sql similarity index 100% rename from migrations/004_privacy.sql rename to migrations/005_privacy.sql diff --git a/migrations/bootstrap.sql b/scripts/bootstrap.sql similarity index 100% rename from migrations/bootstrap.sql rename to scripts/bootstrap.sql