package web
import (
"context"
"errors"
"html/template"
"net/http"
"net/url"
"strings"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// RFC 0030, Starting without a credential. A client with no credential
// starts a setup and shows its person a short code; the person types it
// here, sees what the client asked for, picks the repositories and
// continues, which files an ordinary request for them to approve. The
// code travels in a form body only, never in a URL.
// setupPageView is /setup in whichever state it is drawn.
type setupPageView struct {
// State is "" for the code's own form, "nocode" for a code that is
// not held (every way of not being held reads the same), "held" for
// an account or address that has missed too often, and "claim" for a
// found code.
State string
// The found code's start. Match says the start came from the network
// the person is on now.
Code, Name, Kind, Address, Created string
Match bool
Repos []setupRepo
Roles []string
Problem, Error string
// What the person had picked before a refusal.
Picked map[string]bool
PickRole string
}
func (v setupPageView) Push() bool { return v.Kind == "push" }
// Filterable says the list is long enough to want a filter.
func (v setupPageView) Filterable() bool { return len(v.Repos) > 12 }
// Count is how many repositories were picked before a refusal.
func (v setupPageView) Count() int { return len(v.Picked) }
// setupRepo is one row of the picker.
type setupRepo struct {
Name string `json:"name"`
Handle string `json:"handle"`
Visibility string `json:"visibility"`
Namespace string `json:"namespace"`
}
type setupLook struct {
Code string `json:"code"`
Name string `json:"name"`
Kind string `json:"kind"`
Address string `json:"address"`
Created string `json:"created"`
Repos []setupRepo `json:"repos"`
Roles []string `json:"roles"`
Problem string `json:"problem"`
}
func registerSetupPages(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
entry := func(w http.ResponseWriter, req *http.Request, v setupPageView, status int) {
renderStatus(r, w, req, setupEntryTmpl, pageData{Title: "Set up a client", SetupPage: &v, OwnH1: true, Appear: appearanceOf(req)}, status)
}
claim := func(w http.ResponseWriter, req *http.Request, v setupPageView, status int) {
v.State = "claim"
renderStatus(r, w, req, setupClaimTmpl, pageData{Title: "A client asks for access", SetupPage: &v, OwnH1: true, Appear: appearanceOf(req)}, status)
}
signedIn := func(w http.ResponseWriter, req *http.Request) (string, bool) {
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
http.Redirect(w, req, "/login?next="+url.QueryEscape("/setup"), http.StatusSeeOther)
}
return user, ok
}
// notHeld draws what an op's refusal of a code means: a code not held
// is the one state, whatever became of it.
notHeld := func(w http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, ops.ErrNotFound):
entry(w, req, setupPageView{State: "nocode"}, http.StatusNotFound)
case errors.Is(err, ops.ErrRateLimited):
w.Header().Set("Retry-After", "60")
entry(w, req, setupPageView{State: "held"}, http.StatusTooManyRequests)
case errors.Is(err, ops.ErrUnavailable):
entry(w, req, setupPageView{State: "lost"}, http.StatusServiceUnavailable)
default:
fail(r, w, req, err)
}
}
// as is the call's context: the person, and the address the page was
// asked from, which the ops count misses against.
as := func(req *http.Request, user string) context.Context {
return ops.WithAddr(ops.WithUser(req.Context(), user), clientIP(req))
}
look := func(req *http.Request, user, code string) (setupLook, error) {
var l setupLook
if len(code) > 64 {
code = code[:64]
}
out, err := invoke(as(req, user), r, "setup.start.look", url.Values{"code": {code}})
if err != nil {
return l, err
}
remarshal(out, &l)
return l, nil
}
view := func(req *http.Request, l setupLook) setupPageView {
return setupPageView{Code: l.Code, Name: l.Name, Kind: l.Kind, Address: l.Address, Created: l.Created,
Match: l.Address == oltp.AddrGroup(clientIP(req)), Repos: l.Repos, Roles: l.Roles, Problem: l.Problem}
}
mux.HandleFunc("GET /setup", func(w http.ResponseWriter, req *http.Request) {
if _, ok := signedIn(w, req); !ok {
return
}
entry(w, req, setupPageView{}, http.StatusOK)
})
mux.HandleFunc("POST /setup", func(w http.ResponseWriter, req *http.Request) {
user, ok := signedIn(w, req)
if !ok {
return
}
l, err := look(req, user, req.FormValue("code"))
if err != nil {
notHeld(w, req, err)
return
}
claim(w, req, view(req, l), http.StatusOK)
})
mux.HandleFunc("POST /setup/claim", func(w http.ResponseWriter, req *http.Request) {
user, ok := signedIn(w, req)
if !ok {
return
}
code := req.FormValue("code")
if req.FormValue("do") == "discard" {
if _, err := invoke(as(req, user), r, "setup.start.discard", url.Values{"code": {code}}); err != nil {
notHeld(w, req, err)
return
}
http.Redirect(w, req, "/setup", http.StatusSeeOther)
return
}
repos := make([]string, 0, len(req.Form["repos"]))
for _, repo := range req.Form["repos"] {
if repo = strings.TrimSpace(repo); repo != "" {
repos = append(repos, repo)
}
}
out, err := invoke(as(req, user), r, "setup.start.claim",
url.Values{"code": {code}, "repos": {strings.Join(repos, ",")}, "role": {req.FormValue("role")}})
if err != nil {
if errors.Is(err, ops.ErrInvalid) || errors.Is(err, ops.ErrForbidden) {
// Refused for what was picked: the same page again, with
// the sentence and the picks kept.
l, lerr := look(req, user, code)
if lerr != nil {
notHeld(w, req, lerr)
return
}
v := view(req, l)
v.Error, v.PickRole = opsSentence(err), req.FormValue("role")
v.Picked = map[string]bool{}
for _, repo := range repos {
v.Picked[repo] = true
}
claim(w, req, v, http.StatusBadRequest)
return
}
notHeld(w, req, err)
return
}
var got struct {
Request string `json:"request"`
}
remarshal(out, &got)
http.Redirect(w, req, "/me/requests/"+url.PathEscape(got.Request), http.StatusSeeOther)
})
}
// setupEntryTmpl is the form that takes a client's code.
var setupEntryTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .SetupPage}}
<section class="approve" data-hook="setup-entry" data-state="{{.State}}">
<h1>Set up a client</h1>
<p>A client that has no access yet showed you a code. Type it here to see what it asks for. Nothing is made until you approve a request.</p>
{{if eq .State "nocode"}}<p class="status err" role="alert" data-hook="setup-nocode">There is no such code. It may be mistyped, already used, discarded, or past its ten minutes; the client can start again.</p>{{end}}
{{if eq .State "held"}}<p class="status err" role="alert" data-hook="setup-held">Too many codes were tried that are not held. Wait a few minutes and try again.</p>{{end}}
{{if eq .State "lost"}}<p class="status err" role="alert" data-hook="setup-lost">The request could not be filed, and the code was lost with it. Start again from the client.</p>{{end}}
<form method="post" action="/setup" class="auth-form" data-hook="setup-code-form">
<p><label>Code from the client<br><input name="code" data-hook="setup-code" placeholder="XXXX-XXXX" maxlength="40" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus></label></p>
<p><button type="submit" class="btn-primary" data-hook="setup-look">Look up</button></p>
</form>
</section>{{end}}{{end}}`)))
// setupClaimTmpl shows what a client asked for and takes the person's
// picks.
var setupClaimTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .SetupPage}}
<section class="approve" data-hook="setup-claim" data-kind="{{.Kind}}">
<h1>A client asks for access</h1>
<p class="approve-title">It asks to be set up as <strong data-hook="setup-claim-name">{{.Name}}</strong>, {{if .Push}}pushing to one repository{{else}}reading the repositories you pick{{end}}.</p>
<dl class="review-definition">
<dt>Agent</dt><dd data-hook="setup-claim-agent" data-agent="{{.Name}}">{{.Name}}, a new agent. {{if .Push}}It belongs to the repository you pick if you administer that repository, and its admins then manage it; otherwise it is yours.{{else}}Yours: you answer for it.{{end}}</dd>
<dt>Kind</dt><dd data-hook="setup-claim-kind" data-kind="{{.Kind}}">{{if .Push}}Pushes: sync only, to the one repository you pick, with the role you choose.{{else}}Reads: the code, tickets, wiki and CI runs of the repositories you pick, and nothing else.{{end}}</dd>
<dt>Code</dt><dd><code data-hook="setup-claim-code">{{.Code}}</code></dd>
<dt>Started</dt><dd data-hook="setup-claim-from" data-address="{{.Address}}" data-match="{{.Match}}">From {{if .Match}}this network{{else}}<code>{{.Address}}</code>{{end}}, <span data-hook="setup-claim-when">{{ago .Created}}</span>.</dd>
</dl>
{{if not .Match}}<p class="status err" role="alert" data-hook="setup-claim-elsewhere" data-match="false">This client is on a different network than you are. That is expected only if the client runs on another machine than the one you are using now.</p>{{end}}
<p data-hook="setup-claim-warn"><strong>Continue only for a client you started yourself, just now, on a device you are using.</strong> Never use a code someone sent you: anyone can start a client and send its code to you.</p>
{{with .Error}}<p class="status err" role="alert" data-hook="setup-claim-error"><strong>{{.}}</strong></p>{{end}}
<form method="post" action="/setup/claim" data-hook="setup-claim-form">
<input type="hidden" name="code" value="{{.Code}}">
{{if .Repos}}<div class="claim-pick" data-hook="setup-claim-repos" data-signals="{q: '', n: {{.Count}}}" data-on:change="$n = evt.target.form.querySelectorAll('input[name=repos]:checked').length">
<h2>{{if .Push}}Pick the repository{{else}}Pick the repositories{{end}}{{if not .Push}} <span class="tl-meta-inline"><span data-hook="setup-claim-count" data-text="$n">{{.Count}}</span> selected</span>{{end}}</h2>
{{if .Filterable}}<p><input type="search" class="claim-filter" placeholder="Filter repositories" aria-label="Filter repositories" autocomplete="off" data-bind="q" data-hook="setup-claim-filter"></p>{{end}}
<ul class="claim-list">
{{range .Repos}}<li data-hook="setup-claim-row" data-show="!$q || '{{.Name}} {{.Namespace}}'.toLowerCase().includes($q.toLowerCase())"><label class="claim-row"><input type="{{if $.SetupPage.Push}}radio{{else}}checkbox{{end}}" name="repos" value="{{.Name}}" data-hook="setup-claim-repo"{{if index $.SetupPage.Picked .Name}} checked{{end}}{{if $.SetupPage.Push}} required{{end}}><span class="claim-name">{{repoName .Name}}</span><span class="claim-meta">{{with .Visibility}}{{.}}{{end}}{{if and .Visibility .Namespace}} ยท {{end}}{{with .Namespace}}{{.}}{{end}}</span></label></li>
{{end}}</ul>
</div>
{{if .Push}}<p><label>Role<br><select name="role" data-hook="setup-claim-role">{{range .Roles}}<option value="{{.}}"{{if or (eq . $.SetupPage.PickRole) (and (not $.SetupPage.PickRole) (eq . "developer"))}} selected{{end}}>{{.}}</option>{{end}}</select></label></p>{{end}}
<p>Continue files a request for you to approve, with exactly these picks. Nothing is made until you do.</p>
{{else}}<p class="status err" role="alert" data-hook="setup-claim-empty">{{with .Problem}}{{.}}{{else}}There is no repository you may set this agent up on.{{end}} There is nothing to continue with: discard the code.</p>{{end}}
<div class="signoff-acts"><button type="submit" name="do" value="discard" class="btn" formnovalidate data-hook="setup-claim-discard">Discard</button>{{if .Repos}}<button type="submit" name="do" value="continue" class="btn btn-primary" data-hook="setup-claim-continue">Continue</button>{{end}}</div>
</form>
</section>{{end}}{{end}}`)))