Skip to content
quarry Artifact [432d178d3a]
Login

Artifact [432d178d3a]

Artifact 432d178d3a5cfe3440aa9316e1e3510bce12028d5279425ff1f7d67d429be4a1:


package api

import (
	"context"
	"fmt"
	"net/url"
	pathpkg "path"
	"strings"

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

// FossilMirrorStore keeps the setting of a repository's mirror to a
// Fossil server (RFC 0022, Fossil remote). The store owns every rule
// that decides whether a mirror may exist (the address, the login,
// private, takedown), so the ops here only map its refusals. The
// password goes in and never comes back out.
type FossilMirrorStore interface {
	FossilMirrorOf(repo string) (oltp.FossilMirror, bool, error)
	SetFossilMirror(repo, remote, user, password, by, via string) (oltp.FossilMirror, error)
	DisableFossilMirror(repo, by, via string) (bool, error)
}

type fossilMirrorIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Remote is the https sync address of a Fossil server's copy of
	// the repository, such as https://fossil.example.org/repo.
	Remote string `json:"remote" validate:"max=300"`
	// User is the login on that server the push authenticates as; it
	// needs the capability to push.
	User string `json:"user" validate:"max=64"`
	// Password is that login's password. Required when the mirror is
	// first set and when the remote or the user changes; empty keeps
	// the stored one.
	Password string `json:"password" validate:"max=200"`
}

type fossilMirrorRepoIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type fossilMirrorOut struct {
	Repo string `json:"repo"`
	On   bool   `json:"on"`
	// Remote and User are where and as whom the repository is pushed.
	Remote string `json:"remote,omitempty"`
	User   string `json:"user,omitempty"`
	// Pushed is trunk's tip at the last push the server accepted, and
	// when.
	Pushed   string `json:"pushed,omitempty"`
	PushedAt string `json:"pushed_at,omitempty"`
	// Error is the last failure in words, and when.
	Error   string `json:"error,omitempty"`
	ErrorAt string `json:"error_at,omitempty"`
	// Fails is how many pushes have failed in a row, and NextTry when
	// the next attempt is due at the earliest (RFC 3339), from the
	// schedule the runner itself obeys. Both are empty while the last
	// push succeeded. Pushed and PushedAt keep the last good push while
	// the mirror is failing.
	Created string `json:"created,omitempty"`
	Fails   int    `json:"fails,omitempty"`
	NextTry string `json:"next_try,omitempty"`
	// ErrorKind classes the failure for a client that offers a remedy:
	// "login" is the server refusing the user or password.
	ErrorKind string `json:"error_kind,omitempty"`
	// Paused says why the mirror is on but not pushing: the repository
	// is private or taken down. Nothing already pushed is withdrawn.
	// PausedBy names which: "private" or "takedown".
	Paused   string `json:"paused,omitempty"`
	PausedBy string `json:"paused_by,omitempty"`
}

func fossilMirrorView(m oltp.FossilMirror, on bool) fossilMirrorOut {
	if !on {
		return fossilMirrorOut{Repo: m.Repo}
	}
	out := fossilMirrorOut{Repo: m.Repo, On: true, Remote: m.Remote, User: m.User,
		Pushed: m.Pushed, PushedAt: m.PushedAt, Error: m.Err, ErrorAt: m.ErrAt,
		Created: m.Created, Fails: m.Fails, NextTry: nextTry(m.Err, m.ErrAt, m.Fails)}
	if m.Err == oltp.FossilLoginRefusedWords {
		out.ErrorKind = "login"
	}
	return out
}

func (d Deps) registerFossilMirror(r *ops.Registry) error {
	store := func(ctx context.Context, repo string) error {
		if _, err := d.repoFor(ctx, repo); err != nil {
			return err
		}
		if d.FossilMirrors == nil {
			return fmt.Errorf("this forge keeps no mirror settings: %w", ops.ErrUnavailable)
		}
		return nil
	}
	if err := ops.Register(r, ops.Op[fossilMirrorRepoIn, fossilMirrorOut]{
		Name: "repo.fossilmirror.get",
		Caps: "a",
		Doc: "A repository's mirror to a Fossil server (RFC 0022, Fossil remote): the remote, the login, " +
			"trunk's tip at the last push, the last error with how many pushes in a row have failed and when the " +
			"next try is due, and why the mirror is paused if it is. The password is never returned.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/fossil-mirror",
		Run: func(ctx context.Context, in fossilMirrorRepoIn) (fossilMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return fossilMirrorOut{}, err
			}
			m, on, err := d.FossilMirrors.FossilMirrorOf(in.Repo)
			if err != nil {
				return fossilMirrorOut{}, err
			}
			m.Repo = in.Repo
			out := fossilMirrorView(m, on)
			if on {
				out.PausedBy, out.Paused = d.mirrorPause(in.Repo)
				if out.PausedBy != "" {
					// Nothing is tried while paused, so no try is due.
					out.NextTry = ""
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fossilMirrorIn, fossilMirrorOut]{
		Name: "repo.fossilmirror.set",
		Caps: "a",
		Doc: "Mirror a public repository to a Fossil server, or change where it is pushed. `remote` is the " +
			"https sync address of a copy of this repository on that server (it must share this repository's " +
			"project code, as a clone does), `user` a login there that may push, `password` its password: " +
			"required when the mirror is first set or its remote or user changes, otherwise empty keeps the " +
			"stored one; a `password` with empty `remote` and `user` on a mirror that is already on replaces the " +
			"password and keeps the stored remote and user. Everything Fossil sync carries is pushed (check-ins, wiki, tickets, forum, every " +
			"branch), and nothing on the server is ever discarded. Refused for a private repository, while a " +
			"takedown is in force, for a private or loopback host unless the forge permits them, and for the " +
			"Fossil this repository is itself mirrored from.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/fossil-mirror",
		Run: func(ctx context.Context, in fossilMirrorIn) (fossilMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return fossilMirrorOut{}, err
			}
			in.Remote = strings.TrimSpace(in.Remote)
			in.User = strings.TrimSpace(in.User)
			// A password alone on a mirror that is on replaces the
			// password and keeps the stored remote and user, which the
			// store reads and vets as they were when it was set.
			if in.Remote == "" && in.User == "" && in.Password != "" {
				if _, on, err := d.FossilMirrors.FossilMirrorOf(in.Repo); err == nil && on {
					m, err := d.FossilMirrors.SetFossilMirror(in.Repo, "", "", in.Password, ops.User(ctx), ops.Via(ctx))
					if err != nil {
						return fossilMirrorOut{}, gitMirrorErr(err)
					}
					if d.FossilMirrorChanged != nil {
						d.FossilMirrorChanged(in.Repo)
					}
					return fossilMirrorView(m, true), nil
				}
			}
			// Syntax first, so the host below is one the store would
			// keep, and so a refusal names the address, not the lookup.
			if err := oltp.CheckFossilRemote(in.Remote); err != nil {
				return fossilMirrorOut{}, gitMirrorErr(err)
			}
			if d.FossilMirrorHost != nil {
				if u, err := url.Parse(in.Remote); err == nil {
					if err := d.FossilMirrorHost(ctx, u.Hostname()); err != nil {
						return fossilMirrorOut{}, mirrorRefusal("remote", err.Error())
					}
				}
			}
			// One direction per repository: pushing it back to the
			// Fossil it follows would make two sources of record.
			if d.Mirrors != nil {
				if up, ok, err := d.Mirrors.MirrorFor(in.Repo); err == nil && ok && up.Enabled && !up.Git &&
					sameFossilRemote(in.Remote, up.URL) {
					return fossilMirrorOut{}, mirrorRefusal("remote", fmt.Sprintf("%s follows %s; mirror to a different Fossil",
						in.Repo, up.URL))
				}
			}
			m, err := d.FossilMirrors.SetFossilMirror(in.Repo, in.Remote, in.User, in.Password, ops.User(ctx), ops.Via(ctx))
			if err != nil {
				return fossilMirrorOut{}, gitMirrorErr(err)
			}
			if d.FossilMirrorChanged != nil {
				d.FossilMirrorChanged(in.Repo)
			}
			return fossilMirrorView(m, true), nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[fossilMirrorRepoIn, fossilMirrorOut]{
		Name: "repo.fossilmirror.off",
		Caps: "a",
		Doc: "Stop mirroring a repository to its Fossil server and discard the stored password. Nothing " +
			"already pushed is withdrawn.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/fossil-mirror/off",
		Run: func(ctx context.Context, in fossilMirrorRepoIn) (fossilMirrorOut, error) {
			if err := store(ctx, in.Repo); err != nil {
				return fossilMirrorOut{}, err
			}
			if _, err := d.FossilMirrors.DisableFossilMirror(in.Repo, ops.User(ctx), ops.Via(ctx)); err != nil {
				return fossilMirrorOut{}, err
			}
			return fossilMirrorOut{Repo: in.Repo}, nil
		},
	})
}

// fossilMirrorAbout is the About line's host path and web address for a
// Fossil mirror that is on and current; empty otherwise. One whose last
// push failed, or that is paused, is not claimed.
func (d Deps) fossilMirrorAbout(repo string) (label, link string) {
	if d.FossilMirrors == nil {
		return "", ""
	}
	m, on, err := d.FossilMirrors.FossilMirrorOf(repo)
	if err != nil || !on || m.PushedAt == "" || m.Err != "" || d.gitMirrorPaused(repo) != "" {
		return "", ""
	}
	host, path := fossilPlace(m.Remote)
	if host == "" {
		return "", ""
	}
	label = host
	if path != "" {
		label += "/" + path
	}
	// The remote's own scheme: an http remote (this machine's, by the
	// forge's outbound rule) is not an https address.
	scheme := "https"
	if u, err := url.Parse(m.Remote); err == nil && u.Scheme == "http" {
		scheme = "http"
	}
	return label, scheme + "://" + label
}

// fossilPlace is a remote's host and path in the one spelling that
// compares: the host lower-cased and without the port its scheme
// implies, the path percent-decoded, cleaned, without slashes at either
// end and without a trailing /xfer (the sync endpoint of the same
// repository).
func fossilPlace(remote string) (host, path string) {
	u, err := url.Parse(strings.TrimSpace(remote))
	if err != nil || u.Hostname() == "" {
		return "", ""
	}
	host = strings.ToLower(u.Hostname())
	if strings.Contains(host, ":") {
		host = "[" + host + "]"
	}
	if p := u.Port(); p != "" && !(p == "443" && u.Scheme == "https") && !(p == "80" && u.Scheme == "http") {
		host += ":" + p
	}
	path = strings.Trim(pathpkg.Clean("/"+u.Path), "/")
	if path == "xfer" {
		path = ""
	}
	path = strings.TrimSuffix(path, "/xfer")
	return host, path
}

// sameFossilRemote reports whether two spellings name one Fossil
// repository: the scheme, a default port, case in the host and slashes
// at either end of the path do not make a different place.
func sameFossilRemote(a, b string) bool {
	ha, pa := fossilPlace(a)
	hb, pb := fossilPlace(b)
	return ha != "" && ha == hb && pa == pb
}