// 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 (
"errors"
"fmt"
"html/template"
"log"
"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 {
// A forge with no agents, and a caller who may not manage them
// (a token), are no section; anything else is worth a trace.
if !errors.Is(err, ops.ErrUnavailable) && !errors.Is(err, ops.ErrForbidden) {
log.Printf("web: agent.list for %s: %v", user, err)
}
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 {
log.Printf("web: repo.list for %s: %v", user, err)
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
// The value is in this body and must not outlive it: no cache,
// bfcache or intermediary keeps the page, and nothing is leaked
// through a Referer from it.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
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}}—{{end}}</td>
<td data-label="Newest token" data-hook="agent-newest">{{with .Newest}}{{.Name}} <span class="sub">{{ago .Created}}</span>{{else}}—{{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}}—{{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}}`)))