// categories.go is the API door to a repository's category capability
// strings (#291): nobody, anonymous (anyone signed in), reader and
// developer. Until it existed only the settings page could read or set
// them, behind a browser session, so a token holder could neither see
// nor change who may do what without an account of their own.
package api
import (
"context"
"fmt"
"log"
"slices"
"strings"
"craftdesign.group/quarry/internal/evt"
"craftdesign.group/quarry/internal/ops"
)
// CategoryStore reads and writes a repository's category strings. The
// write is the settings page's own: it refreshes the private-repository
// list when nobody or anonymous changes, so a repository made private
// through the API stops being served publicly at once.
type CategoryStore interface {
CategoryCapsFrom(repo, category string) (caps, from string)
SetCategoryCaps(repo, category, caps string) error
}
// capCategories are the four, in the order fossil's user page lists them.
var capCategories = []string{"nobody", "anonymous", "reader", "developer"}
type categoriesIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type category struct {
Name string `json:"name"`
Caps string `json:"caps"`
// From says where the string comes from: "repository" (its own),
// "fleet" (the forge-wide default) or "stock" (fossil's value).
From string `json:"from"`
}
type categoriesOut struct {
Repo string `json:"repo"`
Categories []category `json:"categories"`
}
type categorySetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Category string `json:"category" validate:"required,oneof=nobody|anonymous|reader|developer"`
// Caps is the whole string, like account.caps. EMPTY is a real
// value, not a reset: blanking nobody and anonymous is how a
// repository goes private, which is what the settings form does too.
Caps string `json:"caps" validate:"max=64"`
}
func (d Deps) categoriesOf(repo string) []category {
out := make([]category, 0, len(capCategories))
for _, name := range capCategories {
caps, from := d.Categories.CategoryCapsFrom(repo, name)
out = append(out, category{Name: name, Caps: caps, From: from})
}
return out
}
// registerCategories mounts repo.categories and repo.category.set.
func (d Deps) registerCategories(r *ops.Registry) error {
unwired := func() error {
if d.Categories == nil {
return fmt.Errorf("this forge keeps no category capabilities: %w", ops.ErrUnavailable)
}
return nil
}
if err := ops.Register(r, ops.Op[categoriesIn, categoriesOut]{
Name: "repo.categories",
Caps: "a",
Doc: "A repository's four category capability strings (RFC 0004): nobody (every visitor), anonymous (anyone signed in), reader and developer, " +
"each with `from`: the repository's own (`repository`), the forge-wide default (`fleet`) or fossil's stock value (`stock`). Gated on `a`, like the settings page.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/categories",
Run: func(ctx context.Context, in categoriesIn) (categoriesOut, error) {
if err := unwired(); err != nil {
return categoriesOut{}, err
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return categoriesOut{}, err
}
return categoriesOut{Repo: in.Repo, Categories: d.categoriesOf(in.Repo)}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[categorySetIn, categoriesOut]{
Name: "repo.category.set",
Caps: "a",
Doc: "Set one category's capability string on a repository: the whole string, like account.caps. An empty `caps` is a real value, not a reset: " +
"blanking nobody and anonymous makes the repository private, as the settings form does. Whitespace is refused. " +
"Raises repo.settings, so the settings trail records who changed what. Answers all four categories as repo.categories does.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/categories/{category}",
Run: func(ctx context.Context, in categorySetIn) (categoriesOut, error) {
if err := unwired(); err != nil {
return categoriesOut{}, err
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return categoriesOut{}, err
}
// Checked here as well as by the input rule: the settings
// form reaches this op too, and a row under any other name
// would be stored and never read.
if !slices.Contains(capCategories, in.Category) {
return categoriesOut{}, fmt.Errorf("%q is not a category; use one of %s: %w", in.Category, strings.Join(capCategories, ", "), ops.ErrInvalid)
}
// Whitespace is not a capability; a stray space would
// silently become a letter nobody can read.
if strings.ContainsFunc(in.Caps, func(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }) {
return categoriesOut{}, fmt.Errorf("caps %q holds whitespace; give the letters alone: %w", in.Caps, ops.ErrInvalid)
}
before, _ := d.Categories.CategoryCapsFrom(in.Repo, in.Category)
if err := d.Categories.SetCategoryCaps(in.Repo, in.Category, in.Caps); err != nil {
return categoriesOut{}, err
}
after, _ := d.Categories.CategoryCapsFrom(in.Repo, in.Category)
if after != before && d.Events != nil {
if err := d.Events.Raise(evt.RepoSettings, in.Repo, map[string]any{
"repo": in.Repo, "actor": ops.User(ctx),
"summary": fmt.Sprintf("%s category: %q → %q", in.Category, before, after),
}); err != nil {
log.Printf("events: raise %s: %v", evt.RepoSettings, err)
}
}
return categoriesOut{Repo: in.Repo, Categories: d.categoriesOf(in.Repo)}, nil
},
})
}