Files
pages/internal/clicmd/key.go
T
2026-08-15 07:13:00 +00:00

209 lines
5.5 KiB
Go

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: "<key-id>",
Short: "Revoke a key",
Long: "Takes effect immediately across the server. The key id is the middle\n" +
"segment of a token (pgs_<key-id>_<secret>) 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
}