krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/control/token.go · raw

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15func init() {
 16	register(Command{Path: []string{"token", "create"},
 17		Summary: "mint an API token (shown once): token create --name <n> [--scope full|read] [--ttl 30d|720h]",
 18		SSHOnly: true, Run: runTokenCreate})
 19	register(Command{Path: []string{"token", "list"},
 20		Summary: "list API tokens", ReadOnly: true, SSHOnly: true, Run: runTokenList})
 21	register(Command{Path: []string{"token", "revoke"},
 22		Summary: "revoke an API token by name: token revoke <name>",
 23		SSHOnly: true, Run: runTokenRevoke})
 24}
 25
 26// parseTTL accepts Go durations plus a day suffix ("30d").
 27func parseTTL(s string) (time.Duration, error) {
 28	if days, ok := strings.CutSuffix(s, "d"); ok {
 29		n, err := strconv.Atoi(days)
 30		if err != nil || n < 1 {
 31			return 0, fmt.Errorf("bad ttl %q", s)
 32		}
 33		return time.Duration(n) * 24 * time.Hour, nil
 34	}
 35	return time.ParseDuration(s)
 36}
 37
 38func runTokenCreate(c *Ctx, args []string) int {
 39	name, scope, ttl := "", "full", ""
 40	for i := 0; i < len(args); i++ {
 41		switch args[i] {
 42		case "--name", "--scope", "--ttl":
 43			if i+1 >= len(args) {
 44				return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
 45			}
 46			switch args[i] {
 47			case "--name":
 48				name = args[i+1]
 49			case "--scope":
 50				scope = args[i+1]
 51			case "--ttl":
 52				ttl = args[i+1]
 53			}
 54			i++
 55		default:
 56			return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
 57		}
 58	}
 59	if name == "" || (scope != "full" && scope != "read") {
 60		return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]")
 61	}
 62	var expires *time.Time
 63	if ttl != "" {
 64		d, err := parseTTL(ttl)
 65		if err != nil {
 66			return c.fail(protocol.ExitUsage, "%v", err)
 67		}
 68		t := time.Now().Add(d)
 69		expires = &t
 70	}
 71	raw, _, err := store.NewToken()
 72	if err != nil {
 73		return c.fail(protocol.ExitFailure, "%v", err)
 74	}
 75	// The gb_ prefix makes leaked tokens findable by secret scanners.
 76	token := "gb_" + raw
 77	if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil {
 78		return c.fail(protocol.ExitUsage, "%v", err)
 79	}
 80	type out struct {
 81		Name  string `json:"name"`
 82		Scope string `json:"scope"`
 83		Token string `json:"token"`
 84	}
 85	d := out{name, scope, token}
 86	return c.emit(d, func(w io.Writer) {
 87		fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token)
 88	})
 89}
 90
 91func runTokenList(c *Ctx, args []string) int {
 92	tokens, err := c.Store.ListAPITokens(c.User.ID)
 93	if err != nil {
 94		return c.fail(protocol.ExitFailure, "%v", err)
 95	}
 96	type out struct {
 97		Name       string     `json:"name"`
 98		Scope      string     `json:"scope"`
 99		CreatedAt  string     `json:"created_at"`
100		ExpiresAt  *time.Time `json:"expires_at,omitempty"`
101		LastUsedAt *time.Time `json:"last_used_at,omitempty"`
102	}
103	var ds []out
104	for _, t := range tokens {
105		ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt})
106	}
107	return c.emit(ds, func(w io.Writer) {
108		for _, d := range ds {
109			exp := "never expires"
110			if d.ExpiresAt != nil {
111				exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339)
112			}
113			fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp)
114		}
115	})
116}
117
118func runTokenRevoke(c *Ctx, args []string) int {
119	if len(args) != 1 {
120		return c.fail(protocol.ExitUsage, "usage: token revoke <name>")
121	}
122	if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil {
123		if errors.Is(err, store.ErrNotFound) {
124			return c.fail(protocol.ExitNotFound, "no token named %q", args[0])
125		}
126		return c.fail(protocol.ExitFailure, "%v", err)
127	}
128	return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) {
129		fmt.Fprintf(w, "revoked %s\n", args[0])
130	})
131}