Skip to content
quarry Artifact [fe805b9514]
Login

Artifact [fe805b9514]

Artifact fe805b9514c52fe87c46494055d2c2c0f29efc7d8494e673c226770e7ff9f50f:


// chatbridge.go is RFC 0011 §9's configuration surface: which
// repository's channel is bridged to which Fossil server's chat, and
// under whose login.
//
// The bridge is repository configuration, so it is `a` and Admin, the
// same standing every other mirror op takes. The credential goes in
// and never comes back: there is no field on any Out here that could
// carry it, which is the only way to be sure a page never renders it.
package api

import (
	"context"
	"craftdesign.group/quarry/internal/outbound"
	"errors"
	"fmt"
	"net/url"

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

// ChatBridge is one bridged channel as every caller but the poller
// sees it. No secret, by construction.
type ChatBridge struct {
	Repo string `json:"repo"`
	URL  string `json:"url"`
	User string `json:"user"`
	// LastMsgID is the upstream chat message the poller has read
	// through; At is when one last arrived and LastErr why the last
	// poll failed, kept apart so "quiet" and "broken" read differently.
	LastMsgID int64  `json:"lastMsgId"`
	At        string `json:"at,omitempty"`
	LastErr   string `json:"lastErr,omitempty"`
}

// State is the one word a status line needs: a bridge that has never
// heard anything and has no error is still connecting.
func (b ChatBridge) State() string {
	switch {
	case b.LastErr != "":
		return "failing"
	case b.At != "":
		return "connected"
	default:
		return "connecting"
	}
}

// ChatBridgeStore is the bridge's configuration. Stated in this
// package's own terms for the same reason MirrorStore is: the op layer
// names what it needs. nil disables the op, which is a forge with no
// OLTP — and therefore no bridge to configure.
type ChatBridgeStore interface {
	SetChatBridge(repo, url, user, secret string) error
	DetachChatBridge(repo string) error
	ChatBridgeFor(repo string) (ChatBridge, bool, error)
	ChatBridges() ([]ChatBridge, error)
}

type chatBridgeIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// URL is the upstream fossil's base URL — the one its /chat page
	// lives under, not the /chat-poll endpoint.
	URL      string `json:"url" validate:"max=512"`
	User     string `json:"user" validate:"max=64"`
	Password string `json:"password" validate:"max=256"`
	// Detach removes the bridge and forgets its cursor.
	Detach bool `json:"detach"`
}

type chatBridgeOut struct {
	// Bridge is the configuration as stored — a value rather than a
	// pointer because the ops spec reflector documents only concrete
	// shapes, and Detached already says when there is nothing to read.
	Bridge   ChatBridge `json:"bridge"`
	Detached bool       `json:"detached,omitempty"`
}

func registerChatBridge(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatBridgeIn, chatBridgeOut]{
		Name:  "mirror.chatbridge",
		Caps:  "a",
		Doc:   "Bridge a repository's chat channel to a Fossil server's chat, or detach it. The password is stored and never returned. The repository's own admin's call.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/mirror/chatbridge",
		Run: func(ctx context.Context, in chatBridgeIn) (chatBridgeOut, error) {
			if d.ChatBridges == nil {
				return chatBridgeOut{}, fmt.Errorf("the chat bridge is not configured: %w", ops.ErrUnavailable)
			}
			// The store must be one quarry serves, for the reason
			// mirror.set checks the same thing: a row for a name that
			// resolves to nothing is a bridge the scheduler skips for
			// ever, which reads as "bridging is broken".
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return chatBridgeOut{}, err
			}
			if in.Detach {
				if err := d.ChatBridges.DetachChatBridge(in.Repo); err != nil {
					return chatBridgeOut{}, err
				}
				return chatBridgeOut{Detached: true}, nil
			}
			if in.URL == "" || in.User == "" || in.Password == "" {
				return chatBridgeOut{}, fmt.Errorf(
					"a chat bridge needs a url, a user and a password: %w", ops.ErrInvalid)
			}
			// Checked HERE and not only in the store: the op is the
			// forge's own answer about what it will accept, and a
			// refusal that depends on which store is wired is a rule
			// that holds in production and not in a test.
			if err := checkBridgeURL(in.URL); err != nil {
				return chatBridgeOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			if err := d.ChatBridges.SetChatBridge(in.Repo, in.URL, in.User, in.Password); err != nil {
				return chatBridgeOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			b, ok, err := d.ChatBridges.ChatBridgeFor(in.Repo)
			if err != nil || !ok {
				return chatBridgeOut{}, err
			}
			return chatBridgeOut{Bridge: b}, nil
		},
	})
}

// bridgeOf answers one repository's bridge for a listing, false when
// it has none or the forge keeps no bridges at all.
func (d Deps) bridgeOf(repo string) (ChatBridge, bool) {
	if d.ChatBridges == nil {
		return ChatBridge{}, false
	}
	b, ok, err := d.ChatBridges.ChatBridgeFor(repo)
	if err != nil || !ok {
		return ChatBridge{}, false
	}
	return b, true
}

// checkBridgeURL is the forge's rule for an upstream it will fetch on
// its own initiative: http(s) only, and no credentials in the URL —
// those go in the user and password fields, where exactly one reader
// sees them instead of every log line that names the upstream.
func checkBridgeURL(raw string) error {
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" {
		return errors.New("chat bridge url is not a url")
	}
	if _, err := outbound.Check(raw); err != nil {
		return errors.New("chat bridge url " + err.Error())
	}
	if u.User != nil {
		return errors.New("chat bridge url must not carry a user or password; " +
			"give them as the user and password fields")
	}
	return nil
}