package clicmd import ( "context" "errors" "flag" "fmt" "time" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/cliutil" ) func keyCmd(g *Globals) *cliutil.Command { return &cliutil.Command{ Name: "key", Short: "Manage API keys", Sub: []*cliutil.Command{ keyCreateCmd(g), keyListCmd(g), keyRevokeCmd(g), }, } } func keyCreateCmd(g *Globals) *cliutil.Command { var ( name string admin bool expires string ) return &cliutil.Command{ Name: "create", Short: "Mint a key", Long: "The token is printed once and cannot be retrieved again — the server\n" + "stores only its hash. Redirect it straight into a file or a secret\n" + "store; do not let it reach a CI log.\n\n" + "Without --admin the key is scoped to one project and can do nothing\n" + "outside it. Requires an admin key either way.", Flags: func(fs *flag.FlagSet) { fs.StringVar(&name, "name", "", "`label` recorded with the key, e.g. github-actions") fs.BoolVar(&admin, "admin", false, "mint an admin key instead of a project key") fs.StringVar(&expires, "expires", "", "expire after this `duration`, e.g. 90d; default never") }, Exec: func(ctx context.Context, args []string) error { if err := exactArgs(args, 0, "no arguments"); err != nil { return err } req := api.CreateKeyRequest{Name: name} if expires != "" { d, err := cliutil.ParseDuration(expires) if err != nil { return err } if d <= 0 { return errors.New("--expires must be positive") } // Computed here, sent absolute: a clock difference between this // machine and the server then shifts nothing. t := time.Now().Add(d).UTC().Truncate(time.Second) req.ExpiresAt = &t } c, err := g.Client() if err != nil { return err } var out api.CreateKeyResponse if admin { out, err = c.CreateAdminKey(ctx, req) } else { var project string if project, err = g.ProjectName(); err != nil { return fmt.Errorf("%w, or pass --admin for a server-wide key", err) } out, err = c.CreateProjectKey(ctx, project, req) } if err != nil { return err } p, err := g.Printer() if err != nil { return err } if p.Format == cliutil.FormatJSON { return p.JSON(out) } // Table format prints the token on a line of its own so it survives // a copy-paste and so `pages key create | tail -1` is not tempting. if err := keyTable(out.Key).Write(g.Out); err != nil { return err } fmt.Fprintf(g.Out, "\n%s\n", out.Token) fmt.Fprintln(g.Err, "this token is shown once and cannot be recovered; store it now") return nil }, } } func keyListCmd(g *Globals) *cliutil.Command { return &cliutil.Command{ Name: "list", Short: "List keys", Long: "With --project, lists that project's keys; a project key may list its\n" + "own. Without it, lists every key on the server and requires an admin\n" + "key. Secrets are never listed — only key ids.", Exec: func(ctx context.Context, args []string) error { if err := exactArgs(args, 0, "no arguments"); err != nil { return err } if err := g.Resolve(); err != nil { return err } c, err := g.Client() if err != nil { return err } var list api.KeyList if g.Project != "" { list, err = c.ListProjectKeys(ctx, g.Project) } else { list, err = c.ListKeys(ctx) } if err != nil { return err } if list.Keys == nil { list.Keys = []api.Key{} } p, err := g.Printer() if err != nil { return err } return p.Print(list, func() *cliutil.Table { t := cliutil.NewTable("ID", "SCOPE", "PROJECT", "NAME", "CREATED", "EXPIRES", "LAST USED", "STATE") for _, k := range list.Keys { t.Row(k.ID, k.Scope, cliutil.Str(k.Project), cliutil.Str(k.Name), cliutil.Time(k.CreatedAt), cliutil.TimePtr(k.ExpiresAt), cliutil.TimePtr(k.LastUsed), keyState(k)) } return t }) }, } } func keyRevokeCmd(g *Globals) *cliutil.Command { var yes bool return &cliutil.Command{ Name: "revoke", Args: "", Short: "Revoke a key", Long: "Takes effect immediately across the server. The key id is the middle\n" + "segment of a token (pgs__) and is what `key list`\n" + "shows. Revoking an already-revoked key succeeds.", Flags: func(fs *flag.FlagSet) { fs.BoolVar(&yes, "yes", false, "do not ask for confirmation") }, Exec: func(ctx context.Context, args []string) error { if err := exactArgs(args, 1, "one key id"); err != nil { return err } id := args[0] if !yes { if err := cliutil.Confirm(g.In, g.Err, fmt.Sprintf("Revoke key %s?", id)); err != nil { return err } } c, err := g.Client() if err != nil { return err } if err := c.RevokeKey(ctx, id); err != nil { return err } p, err := g.Printer() if err != nil { return err } p.Printf("revoked key %s\n", id) return nil }, } } // keyState collapses the two timestamps that decide whether a key still works. func keyState(k api.Key) string { switch { case k.Revoked(): return "revoked" case k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()): return "expired" default: return "active" } } func keyTable(k api.Key) *cliutil.Table { t := cliutil.NewTable() t.Row("id", k.ID) t.Row("scope", k.Scope) t.Row("project", cliutil.Str(k.Project)) t.Row("name", cliutil.Str(k.Name)) t.Row("created_at", cliutil.Time(k.CreatedAt)) t.Row("expires_at", cliutil.TimePtr(k.ExpiresAt)) return t }