Skip to content
quarry Artifact [01f25278a7]
Login

Artifact [01f25278a7]

Artifact 01f25278a7f12ffe329ad717f274b80c52479aef56b2e966703d39c29408b39a:


// meagents.go is the web side of RFC 0021's agents: the "Your agents"
// section on /me and an agent's own page. Every act is an agent.* op
// called through the same invoke path the other /me sections use; the
// rules (the operator's letters, the caps, the one-time value) live in
// the ops, and this file only draws them and posts to them.
package web

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"sort"
	"strings"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// meAgentsView is the "Your agents" section. Show is false on a forge
// whose account store has no agents, and for a person who has none and
// may make none: a section with nothing to list and nothing to do is
// not drawn.
type meAgentsView struct {
	Show      bool
	CanCreate bool
	Rows      []agentView
	// Live says some agent holds a token that still works, which is
	// when "revoke every agent token" has something to do.
	Live bool
}

// agentView is one agent as agent.list reports it, plus what the pages
// derive from it.
type agentView struct {
	Name, Operator, Created string
	Disabled, Suspended     string
	Tokens                  []meTokenView
	Repos                   []agentRepoView
}

// Newest is the agent's most recently made token that still works, or
// nil when it holds none.
func (a agentView) Newest() *meTokenView {
	var best *meTokenView
	for i := range a.Tokens {
		t := &a.Tokens[i]
		if t.Ended() {
			continue
		}
		if best == nil || t.Created > best.Created {
			best = t
		}
	}
	return best
}

// agentRepoView is one repository an agent holds a string on. Caps is
// what is stored, Effective what it resolves to under the operator's
// letters and Role the nearest preset to that.
type agentRepoView struct {
	Repo      string `json:"repo"`
	Caps      string `json:"caps"`
	Effective string `json:"effective"`
	// Role is the nearest preset to Caps, which is what the role
	// dropdown shows; EffRole the same for Effective.
	Role     string `json:"role"`
	EffRole  string `json:"-"`
	Sentence string `json:"-"`
}

// within is the stored string's letters that survive the operator's:
// the string read as the role it still amounts to. Effective itself
// carries category letters nobody stored, which no preset would name.
func (r agentRepoView) within() string {
	var kept strings.Builder
	for _, l := range oltp.Closure(r.Caps) {
		if strings.ContainsRune(r.Effective, l) {
			kept.WriteRune(l)
		}
	}
	return kept.String()
}

// Narrowed says the operator's letters took something from the stored
// string.
func (r agentRepoView) Narrowed() bool {
	for _, l := range oltp.Closure(r.Caps) {
		if !strings.ContainsRune(r.Effective, l) {
			return true
		}
	}
	return false
}

// agentPageView is an agent's own page: the agent, the dropdown
// choices its forms offer, and whatever the response that drew it has to
// say once (a new token's value, a clipped grant).
type agentPageView struct {
	agentView
	// Grantable are the repositories the operator reaches that this
	// agent holds nothing on yet.
	Grantable []string
	// Ceilings are the roles a token may be limited to: those whose
	// letters the agent resolves to somewhere.
	Ceilings []oltp.Preset
	Roles    []oltp.Preset
	// NewValue is a token's value, present for the one response that
	// minted it. NewName is that token's name.
	NewValue string
	NewName  string
	Warning  string
	Error    string
}

// agentsOf reads agent.list for the caller. A forge whose account store
// has no agents answers an error, which is no section.
func agentsOf(r *ops.Registry, req *http.Request, user string) (meAgentsView, bool) {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.list", url.Values{})
	if err != nil {
		return meAgentsView{}, false
	}
	var l struct {
		Agents []struct {
			Name      string          `json:"name"`
			Operator  string          `json:"operator"`
			Created   string          `json:"created"`
			Disabled  string          `json:"disabled"`
			Suspended string          `json:"suspended"`
			Tokens    []meTokenView   `json:"tokens"`
			Repos     []agentRepoView `json:"repos"`
		} `json:"agents"`
		CanCreate bool `json:"can_create"`
	}
	remarshal(out, &l)
	v := meAgentsView{CanCreate: l.CanCreate}
	for _, a := range l.Agents {
		row := agentView{Name: a.Name, Operator: a.Operator, Created: a.Created,
			Disabled: a.Disabled, Suspended: a.Suspended, Tokens: a.Tokens, Repos: a.Repos}
		for i := range row.Repos {
			g := &row.Repos[i]
			g.EffRole = oltp.Infer(g.within()).Role
			g.Sentence = oltp.Explain(row.Name, oltp.CapSet(g.Effective))
		}
		for _, t := range row.Tokens {
			if !t.Ended() {
				v.Live = true
			}
		}
		v.Rows = append(v.Rows, row)
	}
	v.Show = len(v.Rows) > 0 || v.CanCreate
	return v, true
}

// agentPageOf is one agent's page, or false when the caller has no
// such agent.
func agentPageOf(r *ops.Registry, req *http.Request, user, name string) (*agentPageView, bool) {
	all, ok := agentsOf(r, req, user)
	if !ok {
		return nil, false
	}
	for _, a := range all.Rows {
		if a.Name != name {
			continue
		}
		p := &agentPageView{agentView: a, Roles: oltp.Presets}
		held := map[string]bool{}
		have := ""
		for _, g := range a.Repos {
			held[g.Repo] = true
			have += g.Effective
		}
		have = oltp.Closure(have)
		for _, pr := range oltp.Presets {
			if strings.Trim(oltp.Closure(pr.Caps), have) == "" {
				p.Ceilings = append(p.Ceilings, pr)
			}
		}
		for _, n := range reachableRepos(r, req, user) {
			if !held[n] {
				p.Grantable = append(p.Grantable, n)
			}
		}
		return p, true
	}
	return nil, false
}

// reachableRepos are the repositories the caller can see, which is the
// set agent.grant accepts. A failure offers none rather than failing
// the page.
func reachableRepos(r *ops.Registry, req *http.Request, user string) []string {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "repo.list", nil)
	if err != nil {
		return nil
	}
	var rl struct {
		Repos []struct {
			Name string `json:"name"`
		} `json:"repos"`
	}
	remarshal(out, &rl)
	var names []string
	for _, repo := range rl.Repos {
		names = append(names, repo.Name)
	}
	sort.Strings(names)
	return names
}

// registerAgentPages wires the agent routes. They live beside the /me
// routes, on the same session cookie.
func registerAgentPages(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
	signedIn := func(w http.ResponseWriter, req *http.Request) (string, bool) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
		}
		return user, ok
	}
	// agentPage draws one agent's page. The ops decide what is allowed;
	// a refusal comes back as the page's own sentence.
	agentPage := func(w http.ResponseWriter, req *http.Request, user string, p *agentPageView, code int) {
		renderStatus(r, w, req, agentPageTmpl, pageData{Title: "Agent", Agent: p,
			SettingsJS: true, Appear: appearanceOf(req)}, code)
	}
	// refuse draws the agent's page again with the op's sentence beside
	// the form that caused it.
	refuse := func(w http.ResponseWriter, req *http.Request, user string, err error) {
		name := req.PathValue("name")
		if p, ok := agentPageOf(r, req, user, name); ok {
			p.Error = err.Error()
			agentPage(w, req, user, p, http.StatusBadRequest)
			return
		}
		fail(r, w, req, err)
	}
	// act runs one op for the agent in the path.
	act := func(w http.ResponseWriter, req *http.Request, user, op string, form url.Values) bool {
		form.Set("agent", req.PathValue("name"))
		if _, err := invoke(ops.WithUser(req.Context(), user), r, op, form); err != nil {
			refuse(w, req, user, err)
			return false
		}
		return true
	}
	here := func(req *http.Request) string { return "/me/agents/" + url.PathEscape(req.PathValue("name")) }

	mux.HandleFunc("POST /me/agents", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		name := strings.TrimSpace(req.FormValue("name"))
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.new", url.Values{"name": {name}}); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me/agents/"+url.PathEscape(name), http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/agents/revoke-all", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.revoke_all", url.Values{}); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /me/agents/{name}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		p, ok := agentPageOf(r, req, user, req.PathValue("name"))
		if !ok {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/delete", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.delete", url.Values{"name": {req.PathValue("name")}}) {
			http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
		}
	})
	// Minting renders rather than redirects, for the reason the push
	// credential does: the value is readable once, and a redirect would
	// either lose it or carry it in a URL.
	mux.HandleFunc("POST /me/agents/{name}/token", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		form := url.Values{
			"name":    {strings.TrimSpace(req.FormValue("name"))},
			"scopes":  {strings.Join(req.PostForm["scope"], ",")},
			"repos":   {strings.Join(req.PostForm["repo"], ",")},
			"ceiling": {req.FormValue("ceiling")},
		}
		if d := req.FormValue("days"); d != "" {
			form.Set("days", d)
		}
		form.Set("agent", req.PathValue("name"))
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.new", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		var t struct {
			Value string `json:"value"`
			Token struct {
				Name string `json:"name"`
			} `json:"token"`
		}
		remarshal(out, &t)
		p.NewValue, p.NewName = t.Value, t.Token.Name
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/token/revoke", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.token.revoke", url.Values{"id": {req.FormValue("id")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	// One door for a role change, a new grant and a removal: an empty
	// role is the op's own revoke.
	mux.HandleFunc("POST /me/agents/{name}/grant", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		role := req.FormValue("role")
		// An empty role is the op's revoke, so only the Remove button
		// may send one: a dropdown left on its blank "Custom" must not
		// take an agent's access away.
		if req.FormValue("revoke") != "" {
			role = ""
		} else if role == "" {
			refuse(w, req, user, fmt.Errorf("choose a role: %w", ops.ErrInvalid))
			return
		}
		form := url.Values{"repo": {req.FormValue("repo")}, "role": {role}, "agent": {req.PathValue("name")}}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.grant", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		var g struct {
			Warning string `json:"warning"`
		}
		remarshal(out, &g)
		if g.Warning == "" {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		p.Warning = g.Warning
		agentPage(w, req, user, p, http.StatusOK)
	})
}

func notYourAgent(name string) error {
	return fmt.Errorf("no agent of yours is called %s: %w", name, ops.ErrNotFound)
}

// Limit names what a token is limited to: the role its ceiling is, or
// the letters when it is none.
func (t meTokenView) Limit() string {
	if n := oltp.PresetOf(t.Ceiling); n != "" {
		return n
	}
	return t.Ceiling
}

// meAgentsTmpl is the "Your agents" section of /me.
const meAgentsTmpl = `{{define "me-agents"}}{{with .V}}{{if .Show}}<section id="agents" data-hook="account-agents">
<h2>Your agents</h2>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
{{if .Rows}}<div class="dt-wrap" data-hook="agent-list"><table class="dt dt-stack"><thead><tr>
<th>Agent</th><th>Repositories</th><th>Newest token</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Rows}}<tr data-hook="agent-row" data-agent="{{.Name}}">
<td><span class="who">{{userIcon .Name}} <a href="/me/agents/{{pathesc .Name}}" data-hook="agent-link">{{.Name}}</a></span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</td>
<td data-label="Repositories" data-hook="agent-repos">{{range $i, $g := .Repos}}{{if $i}}, {{end}}<a href="{{repoURL $g.Repo}}">{{$g.Repo}}</a>{{else}}&mdash;{{end}}</td>
<td data-label="Newest token" data-hook="agent-newest">{{with .Newest}}{{.Name}} <span class="sub">{{ago .Created}}</span>{{else}}&mdash;{{end}}</td>
<td class="dt-act"><form method="post" action="/me/agents/{{pathesc .Name}}/delete" class="inline"><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete</button></form></td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .CanCreate}}<form method="post" action="/me/agents" class="acct-email-form" data-hook="agent-new">
<label class="sr-only" for="agent-new-name">Agent name</label>
<input id="agent-new-name" name="name" maxlength="64" required placeholder="name">
<button type="submit" class="btn-primary">New agent</button>
</form>{{end}}
{{if .Live}}<form method="post" action="/me/agents/revoke-all"><p><button type="submit" class="btn-warn" data-hook="agents-revoke-all" data-confirm="Every token of every agent of yours stops working now.">Revoke every agent token</button></p></form>{{end}}
</section>{{end}}{{end}}{{end}}`

// agentPageTmpl is an agent's own page.
var agentPageTmpl = page(template.Must(notifyBase.clone().Parse(`{{define "content"}}
{{template "account-styles"}}
{{with .Agent}}{{$name := .Name}}{{$agent := .}}
<p><a href="/me#agents" data-hook="agent-back">Your agents</a></p>
<p data-hook="agent-head">{{userIcon .Name}} <strong>{{.Name}}</strong> <span class="agent-tag" data-hook="agent-mark">agent</span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</p>
<p data-hook="agent-operator">operated by <a href="/people/{{pathesc .Operator}}">{{.Operator}}</a></p>
{{if .Error}}<p class="status err" role="alert" data-hook="agent-error"><strong>{{.Error}}</strong></p>{{end}}
{{if .Warning}}<p class="status" role="status" data-hook="agent-warning">{{.Warning}}</p>{{end}}
{{if .NewValue}}<div class="agent-once" data-hook="agent-token-once">
<p><strong>{{.NewName}}</strong> <span class="chip">shown once</span></p>
<pre class="push-token" data-hook="agent-token-value">{{.NewValue}}</pre>
<p><button type="button" class="btn" data-hook="agent-token-copy" data-on:click="navigator.clipboard.writeText($el.parentElement.previousElementSibling.textContent)">Copy</button></p>
</div>{{end}}
<section id="access" data-hook="agent-access">
<h2>Access</h2>
{{if .Repos}}<div class="dt-wrap"><table class="dt dt-stack"><thead><tr>
<th>Repository</th><th>Role</th><th>Resolves to</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Repos}}{{$g := .}}<tr data-hook="agent-grant" data-repo="{{.Repo}}" data-caps="{{.Caps}}" data-effective="{{.Effective}}">
<td><span class="who">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{.Repo}}</a></span></td>
<td data-label="Role"><form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline">
<input type="hidden" name="repo" value="{{.Repo}}">
<select name="role" data-hook="agent-role" aria-label="role on {{.Repo}}">{{if not (eq .Role "Reader" "Publisher" "Developer" "Maintainer" "Admin")}}<option value="" selected>{{.Role}}</option>{{end}}{{range $agent.Roles}}<option value="{{.Name}}"{{if eq .Name $g.Role}} selected{{end}}>{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn" data-hook="agent-role-change">Change</button></form></td>
<td class="agent-resolves" data-label="Resolves to" data-hook="agent-resolves">{{if .Effective}}<span class="chip role" data-hook="agent-effective-role">{{.EffRole}}</span> <code data-hook="agent-effective-caps">{{.Effective}}</code>{{if .Narrowed}} <span class="chip minus" data-hook="agent-narrowed">narrowed</span>{{end}}<span class="sub" data-hook="agent-sentence">{{.Sentence}}</span>{{else}}<span class="d-sub" data-hook="agent-no-access">nothing</span>{{end}}</td>
<td class="dt-act"><form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="revoke" value="1"><button type="submit" class="btn-warn" data-hook="agent-grant-remove" data-confirm="{{$name}} loses its access to {{.Repo}}.">Remove</button></form></td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .Grantable}}<form method="post" action="/me/agents/{{pathesc .Name}}/grant" class="acct-email-form" data-hook="agent-grant-new">
<label class="sr-only" for="agent-grant-repo">Repository</label>
<select id="agent-grant-repo" name="repo">{{range .Grantable}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<label class="sr-only" for="agent-grant-role">Role</label>
<select id="agent-grant-role" name="role">{{range .Roles}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn-primary">Give access</button>
</form>{{end}}
</section>
<section id="tokens" data-hook="agent-tokens">
<h2>Tokens</h2>
{{if .Tokens}}<div class="dt-wrap" data-hook="agent-token-list"><table class="dt dt-stack"><thead><tr>
<th>Name</th><th>Can use</th><th>Repositories</th><th>Limited to</th><th>Expires</th><th>Last used</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Tokens}}<tr data-hook="agent-token" data-token="{{.ID}}" data-state="{{.State}}"{{if .Ended}} class="tok-ended"{{end}}>
<td data-hook="agent-token-name" title="{{.ID}}">{{.Name}}{{if eq .State "revoked"}} <span class="b-pill">revoked</span>{{else if eq .State "expired"}} <span class="b-pill">expired</span>{{else if eq .State "expiring"}} <span class="b-pill b-warn">expires soon</span>{{end}}</td>
<td data-label="Can use" data-hook="agent-token-scope">{{range $i, $s := .Scopes}}{{if $i}}, {{end}}{{$s}}{{end}}</td>
<td data-label="Repositories" data-hook="agent-token-repos">{{range $i, $r := .Repos}}{{if $i}}, {{end}}{{$r}}{{else}}all{{end}}</td>
<td data-label="Limited to" data-hook="agent-token-limit">{{if .Ceiling}}{{.Limit}}{{else}}its role{{end}}</td>
<td data-label="Expires" data-hook="agent-token-expires"><time datetime="{{.Expires}}">{{if ge (len .Expires) 10}}{{slice .Expires 0 10}}{{else}}{{.Expires}}{{end}}</time></td>
<td data-label="Last used" data-hook="agent-token-used">{{if .LastUsed}}{{ago .LastUsed}}{{else}}&mdash;{{end}}</td>
<td class="dt-act">{{if not .Ended}}<form method="post" action="/me/agents/{{pathesc $name}}/token/revoke" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn-warn" data-hook="agent-token-revoke">Revoke</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>{{end}}
<form method="post" action="/me/agents/{{pathesc .Name}}/token" data-hook="agent-token-new">
<p><label>Name<br><input name="name" maxlength="80" required></label></p>
<p data-hook="agent-token-scopes">Can use<br><label><input type="checkbox" name="scope" value="sync" checked> sync</label> <label><input type="checkbox" name="scope" value="api" checked> api</label> <label><input type="checkbox" name="scope" value="web"> web</label></p>
{{if .Repos}}<p data-hook="agent-token-repos-pick">Repositories<br>{{range .Repos}}<label><input type="checkbox" name="repo" value="{{.Repo}}"> {{.Repo}}</label> {{end}}</p>{{end}}
{{if .Ceilings}}<p><label>Limited to<br><select name="ceiling" data-hook="agent-token-ceiling"><option value="">its role</option>{{range .Ceilings}}<option value="{{.Caps}}">{{.Name}}</option>{{end}}</select></label></p>{{end}}
<p><label>Expires<br><select name="days" data-hook="agent-token-days"><option value="30">in 30 days</option><option value="90" selected>in 90 days</option><option value="180">in 180 days</option><option value="365">in a year</option></select></label></p>
<p><button type="submit" class="btn-primary">New token</button></p>
</form>
</section>
<form method="post" action="/me/agents/{{pathesc .Name}}/delete"><p><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete {{.Name}}</button></p></form>
{{end}}
{{end}}`)))