Skip to content
quarry Artifact [f66ba66022]
Login

Artifact [f66ba66022]

Artifact f66ba660226eadf32f2c567d8d750f1fc41df45634410491e378c7696adfa19b:


package web

import (
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"strings"
	"time"

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

// mirrorPanel is one of the two mirror sections of repository settings
// as the page draws it, for the Git mirror (Kind "git") and the Fossil
// mirror (Kind "fossil"): one shape, one template. The password is not
// on it, and nothing here ever holds one.
type mirrorPanel struct {
	Kind      string `json:"-"`
	On        bool   `json:"on"`
	Remote    string `json:"remote"`
	Branch    string `json:"branch"`
	User      string `json:"user"`
	PublicKey string `json:"public_key"`
	Created   string `json:"created"`
	// Pushed and PushedAt are the last good push; a failure does not
	// replace them.
	Pushed   string `json:"pushed"`
	PushedAt string `json:"pushed_at"`
	// LastErr and LastErrAt are the last failure; Fails how many in a
	// row, and NextTry the earliest the next attempt is made.
	LastErr   string `json:"error"`
	LastErrAt string `json:"error_at"`
	Fails     int    `json:"fails"`
	NextTry   string `json:"next_try"`
	// ErrKind is "login" when the Fossil server refused the login.
	ErrKind string `json:"error_kind"`
	// Paused says why a mirror that is on does not push, in words, and
	// PausedBy which: "private" or "takedown".
	Paused   string `json:"paused"`
	PausedBy string `json:"paused_by"`

	// Edit is which part is open for editing: "all" (remote and
	// user or branch), "password" (the password row alone) or "".
	// Chosen by the address on a visit and by the form on a refusal.
	Edit string `json:"-"`
	// Refusal is this section's own refused save, back from its POST in
	// the flash, never from the address.
	Refusal *mirrorRefusalView `json:"-"`
}

// mirrorRefusalView is a refused save as the section shows it: the
// words, and the input they are about, "" for none.
type mirrorRefusalView struct {
	Field string
	Words string
}

// mirrorPanelFor reads the mirror of one kind through its get op, the
// op the API serves. nil, so no section, when the forge keeps no
// mirrors. An in-process invoke does not check capabilities: the page
// is safe because the settings page and the template both require a.
func mirrorPanelFor(r *ops.Registry, req *http.Request, repo, kind string) *mirrorPanel {
	out, err := invoke(req.Context(), r, "repo."+kind+"mirror.get", url.Values{"repo": {repo}})
	if err != nil {
		return nil
	}
	p := mirrorPanel{Kind: kind}
	remarshal(out, &p)
	if p.On && p.Paused == "" {
		switch req.URL.Query().Get("edit") {
		case kind + "-mirror":
			p.Edit = "all"
		case kind + "-mirror-password":
			if kind == "fossil" {
				p.Edit = "password"
			}
		}
	}
	return &p
}

func gitMirrorPanelFor(r *ops.Registry, req *http.Request, repo string) *mirrorPanel {
	return mirrorPanelFor(r, req, repo, "git")
}

func fossilMirrorPanelFor(r *ops.Registry, req *http.Request, repo string) *mirrorPanel {
	return mirrorPanelFor(r, req, repo, "fossil")
}

// ID is the section's id and hook stem: git-mirror or fossil-mirror.
func (p *mirrorPanel) ID() string { return p.Kind + "-mirror" }

// IsGit says which section this is.
func (p *mirrorPanel) IsGit() bool { return p.Kind == "git" }

// Failing is a mirror that is trying and being refused. A paused one
// is not trying, so it is not failing.
func (p *mirrorPanel) Failing() bool { return p.On && p.Paused == "" && p.LastErr != "" }

// State is the one word the section's heading carries, as a value the
// page and its tests read: off, paused, failing, pushing (saved and not
// yet pushed) or ok.
func (p *mirrorPanel) State() string {
	switch {
	case !p.On:
		return "off"
	case p.Paused != "":
		return "paused"
	case p.LastErr != "":
		return "failing"
	case p.PushedAt == "":
		return "pushing"
	}
	return "ok"
}

// StateWord is the state as the heading says it.
func (p *mirrorPanel) StateWord() string {
	return map[string]string{"off": "Off", "paused": "Paused", "failing": "Failing",
		"pushing": "Waiting for the first push", "ok": "Up to date"}[p.State()]
}

// StateClass is the dot's colour class: none for Off, which has no dot.
func (p *mirrorPanel) StateClass() string {
	return map[string]string{"off": "none", "paused": "warn", "failing": "bad",
		"pushing": "start", "ok": "ok"}[p.State()]
}

// EditAll says the remote and the user or branch are inputs: always
// while the mirror is off, and when Edit opened them.
func (p *mirrorPanel) EditAll() bool { return !p.On || p.editing() == "all" }

// editing is the open edit: none while the mirror is paused, because
// every save is refused then.
func (p *mirrorPanel) editing() string {
	if p.On && p.Paused == "" {
		return p.Edit
	}
	return ""
}

// PasswordInput says the Fossil password row is an input: when the
// mirror is off, when Edit opened everything, when Replace opened the
// row, and when the server has refused the login, which only a new
// password cures.
func (p *mirrorPanel) PasswordInput() bool {
	return p.EditAll() || p.editing() == "password" || (p.Failing() && p.ErrKind == "login")
}

// Replacing says Replace opened the password row, as opposed to the row
// being open because the login was refused.
func (p *mirrorPanel) Replacing() bool { return p.editing() == "password" }

// PasswordOwnRow says the password input stands alone, with its own
// Save password, rather than being one field of the edit form.
func (p *mirrorPanel) PasswordOwnRow() bool { return p.PasswordInput() && !p.EditAll() }

// EditValue is the hidden `edit` the form carries so a refused save
// reopens what it was saved from.
func (p *mirrorPanel) EditValue() string {
	switch {
	case p.EditAll() && p.On:
		return "all"
	case p.PasswordOwnRow():
		return "password"
	}
	return ""
}

// BranchValue is the branch input's value: what was saved or typed, and
// main when there is none.
func (p *mirrorPanel) BranchValue() string {
	if p.Branch == "" {
		return "main"
	}
	return p.Branch
}

// FieldErr is the words of a refusal about this field, "" when the
// refusal is about another or none.
func (p *mirrorPanel) FieldErr(field string) string {
	if p.Refusal != nil && p.Refusal.Field == field && field != "" {
		return p.Refusal.Words
	}
	return ""
}

// GeneralErr is a refusal about no one input: a private repository, a
// takedown.
func (p *mirrorPanel) GeneralErr() string {
	if p.Refusal != nil && p.Refusal.Field == "" {
		return p.Refusal.Words
	}
	return ""
}

// WhyHead and WhyTail split the stored failure at its first colon: what
// went wrong, and what to do. Each is a sentence, capitalised, without
// its full stop.
func (p *mirrorPanel) WhyHead() string {
	head, _, _ := strings.Cut(p.LastErr, ": ")
	return capfirst(strings.TrimRight(head, ". "))
}

func (p *mirrorPanel) WhyTail() string {
	_, tail, ok := strings.Cut(p.LastErr, ": ")
	if !ok {
		return ""
	}
	return capfirst(strings.TrimRight(tail, ". ")) + "."
}

// NextTryWords is when the next try is due, as a sentence, in the
// forge's own time: "In 8 minutes.", or "Within a minute." once the
// wait is over and the retry tick has yet to run.
func (p *mirrorPanel) NextTryWords() template.HTML {
	t, err := time.Parse(time.RFC3339, p.NextTry)
	if err != nil {
		return ""
	}
	words := untilWords(t, time.Now())
	return template.HTML(fmt.Sprintf(`<time datetime="%s" title="%s">%s</time>.`,
		template.HTMLEscapeString(t.UTC().Format(time.RFC3339)),
		template.HTMLEscapeString(t.UTC().Format("2006-01-02 15:04:05 UTC")),
		template.HTMLEscapeString(words)))
}

// untilWords words how long until t at one significant unit.
func untilWords(t, now time.Time) string {
	d := t.Sub(now)
	switch {
	case d <= time.Minute:
		return "Within a minute"
	case d < time.Hour:
		return "In " + plural(int((d+time.Minute-1)/time.Minute), "minute")
	}
	return "In " + plural(int((d+time.Hour-1)/time.Hour), "hour")
}

// InARow says which failure in a row this is.
func (p *mirrorPanel) InARow() string {
	switch {
	case p.Fails <= 0:
		return ""
	case p.Fails == 1:
		return "This was the first failure."
	}
	return "This was the " + ordinal(p.Fails) + " failure in a row."
}

func ordinal(n int) string {
	words := []string{"", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"}
	if n > 0 && n < len(words) {
		return words[n]
	}
	return fmt.Sprintf("%dth", n)
}

// mirrorRefusalFlash is the flash that carries a refused save back to
// the settings page, marked so it is told from a saved panel's name.
// The words and what was typed travel in it, never in the address; the
// password is not among them.
const mirrorRefusalFlash = "mirror-refused:"

// mirrorRefusal is what a refused save leaves for the next page: the
// words, the input they are about, and the typed values that are safe to
// give back.
type mirrorRefusal struct {
	Kind, Field, Words, Remote, User, Branch, Edit string
}

// mirrorFields are the inputs a refusal may name.
var mirrorFields = map[string]bool{"remote": true, "branch": true, "user": true, "password": true}

// typedRemote is what was typed into the Remote input, as far as it may
// be given back: only an address the op's own check accepts, so one that
// carries a credential, a query or anything else the op refuses is not
// echoed, wherever the person put a secret in it. A value longer than
// the input holds is dropped, not cut.
func typedRemote(kind, remote string) string {
	remote = strings.TrimSpace(remote)
	if len(remote) > 300 {
		return ""
	}
	check := oltp.CheckFossilRemote
	if kind == "git" {
		check = oltp.CheckGitRemote
	}
	if check(remote) != nil {
		return ""
	}
	return remote
}

// typedWithin is a typed value as far as its input holds it: dropped
// when longer, so the flash cannot outgrow what a browser accepts.
func typedWithin(v string, max int) string {
	v = strings.TrimSpace(v)
	if len(v) > max {
		return ""
	}
	return v
}

// scrubWords removes from a refusal's words everything typed that could
// be a secret: the whole address, any credentials in it, and the
// password.
func scrubWords(words string, form url.Values) string {
	remote := strings.TrimSpace(form.Get("remote"))
	secrets := []string{remote, form.Get("password")}
	if u, err := url.Parse(remote); err == nil && u.User != nil {
		secrets = append(secrets, u.User.Username(), passwordOf(u))
	}
	// An address in the scp form has no scheme: whatever stands before
	// the last @ is its credential, and after a colon its password.
	if at := strings.LastIndex(remote, "@"); at > 0 {
		cred := remote[strings.LastIndex(remote[:at], "/")+1 : at]
		secrets = append(secrets, cred)
		if _, pw, ok := strings.Cut(cred, ":"); ok {
			secrets = append(secrets, pw)
		}
	}
	for _, secret := range secrets {
		if secret != "" {
			words = strings.ReplaceAll(words, secret, "")
		}
	}
	return words
}

// flashMirrorRefusal sets the flash for a refused save. The words are
// the op's, with any user name or password the remote carried, and the
// password field's own value, removed, so a secret typed anywhere is
// echoed nowhere. form is the post; its password is read only to be
// scrubbed, never kept.
func flashMirrorRefusal(w http.ResponseWriter, req *http.Request, kind string, err error, form url.Values) {
	words := scrubWords(opRefusal(err), form)
	var field string
	var ref *ops.Refusal
	if errors.As(err, &ref) && mirrorFields[ref.Fields["field"]] {
		field = ref.Fields["field"]
	}
	edit := form.Get("edit")
	if edit != "all" && edit != "password" {
		edit = ""
	}
	v := url.Values{"kind": {kind}, "field": {field}, "words": {words}, "edit": {edit},
		"remote": {typedRemote(kind, form.Get("remote"))}, "user": {typedWithin(form.Get("user"), 64)},
		"branch": {typedWithin(form.Get("branch"), 100)}}
	setFlash(w, req, mirrorRefusalFlash+url.QueryEscape(v.Encode()))
}

func passwordOf(u *url.URL) string {
	p, _ := u.User.Password()
	return p
}

// takeMirrorRefusal splits a refusal out of the flash the settings page
// just took: the refusal, and the flash left for the other panels
// (empty when it was the refusal).
func takeMirrorRefusal(flash string) (ref *mirrorRefusal, rest string) {
	enc, ok := strings.CutPrefix(flash, mirrorRefusalFlash)
	if !ok {
		return nil, flash
	}
	raw, err := url.QueryUnescape(enc)
	if err != nil {
		return nil, ""
	}
	v, err := url.ParseQuery(raw)
	if err != nil || (v.Get("kind") != "git" && v.Get("kind") != "fossil") {
		return nil, ""
	}
	return &mirrorRefusal{Kind: v.Get("kind"), Field: v.Get("field"), Words: v.Get("words"),
		Remote: v.Get("remote"), User: v.Get("user"), Branch: v.Get("branch"), Edit: v.Get("edit")}, ""
}

// refuse puts a refusal on the panel it is about: its words, the values
// that were typed, and the edit it came from reopened.
func (p *mirrorPanel) refuse(ref *mirrorRefusal) {
	if p == nil || ref == nil || ref.Kind != p.Kind {
		return
	}
	p.Refusal = &mirrorRefusalView{Field: ref.Field, Words: capfirst(strings.TrimSpace(ref.Words))}
	if !strings.HasSuffix(p.Refusal.Words, ".") {
		p.Refusal.Words += "."
	}
	if p.On && p.Paused == "" {
		// A refusal names a field, so the field is on the page: the edit
		// it came from, or the one that holds it.
		p.Edit = ref.Edit
		if p.Edit == "" && ref.Field != "" {
			p.Edit = "all"
			if ref.Field == "password" {
				p.Edit = "password"
			}
		}
	}
	// What was typed is shown only where there is an input to hold it.
	// Elsewhere the rows are what is saved, and a refused post that
	// carried none (a stop) must not blank them.
	if p.EditAll() {
		p.Remote, p.User = ref.Remote, ref.User
		if p.IsGit() {
			p.Branch = ref.Branch
		}
	}
}

// opRefusal is an op's error as a sentence for the page: the class the
// API answers with is a status code there, not words for a person.
func opRefusal(err error) string {
	return ops.Sentence(err)
}

// capfirst starts a sentence an op words in lower case, as error text
// is, with a capital.
func capfirst(s string) string {
	for i, r := range s {
		return strings.ToUpper(string(r)) + s[i+len(string(r)):]
	}
	return s
}