package api
import (
"context"
"fmt"
"net/url"
"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"`
// Paused says why the mirror is on but not pushing: the repository
// is private or taken down. Nothing already pushed is withdrawn.
Paused string `json:"paused,omitempty"`
}
func fossilMirrorView(m oltp.FossilMirror, on bool) fossilMirrorOut {
if !on {
return fossilMirrorOut{Repo: m.Repo}
}
return fossilMirrorOut{Repo: m.Repo, On: true, Remote: m.Remote, User: m.User,
Pushed: m.Pushed, PushedAt: m.PushedAt, Error: m.Err, ErrorAt: m.ErrAt}
}
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 and the last error. 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.Paused = d.gitMirrorPaused(in.Repo)
}
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. 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)
// 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{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
}
}
// 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{}, fmt.Errorf("%s follows %s; mirror to a different Fossil: %w",
in.Repo, up.URL, ops.ErrInvalid)
}
}
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)
}
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
}
return label, "https://" + label
}
// fossilPlace is a remote's host and path, lower-cased, without the
// port a scheme implies, slashes at either end or a trailing /xfer.
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 p := u.Port(); p != "" && !(p == "443" && u.Scheme == "https") && !(p == "80" && u.Scheme == "http") {
host += ":" + p
}
path = strings.Trim(u.Path, "/")
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
}