Skip to content
quarry Artifact [49fa66ed64]
Login

Artifact [49fa66ed64]

Artifact 49fa66ed6494e1fc4a44e113fe061471fec91ed10e0ee8d3aa0cbc3c515f9043:


// 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
		},
	})
}