// 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"
"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.",
Kind: ops.Command,
Auth: ops.Admin,
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 u.Scheme != "http" && u.Scheme != "https" {
return errors.New("chat bridge url must be http or https")
}
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
}