Skip to content
quarry Artifact [f40cf9cbe3]
Login

Artifact [f40cf9cbe3]

Artifact f40cf9cbe333c0cfe8c16df3d119f76b444de04282096a23e2d72f1439a46b98:


// 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"
	"strings"

	"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"`
	// Stopped is when the bridge stopped itself because chat was given
	// to more than named people (ChatOpenTo); empty while it may run.
	Stopped string `json:"stopped,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.Stopped != "":
		return "stopped"
	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)
	// ChatOpenTo names the categories that give the repository's chat
	// to people nobody named ("nobody", "anonymous"); a bridge needs
	// it empty.
	ChatOpenTo(repo string) []string
}

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. Refused while a category gives the channel's chat (C) to every visitor or anyone signed in: a bridge carries another server's chat, so only people named by a role or a team may read it.",
		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 err := d.refuseWithout(ctx, in.Repo, "a", false); 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 open := d.ChatBridges.ChatOpenTo(in.Repo); len(open) > 0 {
				return chatBridgeOut{}, fmt.Errorf("%w: chat on %s is open to %s; close it to named people before bridging it",
					ops.ErrInvalid, in.Repo, openWords(open))
			}
			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.
			in.URL = bridgeBase(in.URL)
			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)
			}
			// The bridge signs in as this login with the setter's
			// password, so what that login writes upstream is theirs.
			d.linkBridgeLogin(in.Repo, in.User, author(ctx))
			b, ok, err := d.ChatBridges.ChatBridgeFor(in.Repo)
			if err != nil || !ok {
				return chatBridgeOut{}, err
			}
			return chatBridgeOut{Bridge: b}, nil
		},
	})
}

// openWords says who a category list opens chat to, in the settings
// page's words.
func openWords(open []string) string {
	words := make([]string, 0, len(open))
	for _, c := range open {
		switch c {
		case "nobody":
			words = append(words, "every visitor")
		case "anonymous":
			words = append(words, "anyone signed in")
		default:
			words = append(words, c)
		}
	}
	return strings.Join(words, " and ")
}

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

type chatBridgeGetOut struct {
	Bridge  ChatBridge `json:"bridge"`
	Bridged bool       `json:"bridged"`
	// State is the bridge's one word: connecting, connected, failing
	// or stopped; empty when there is no bridge.
	State string `json:"state"`
	// OpenTo names the categories that give this channel's chat to
	// people nobody named. Non-empty, a bridge is refused, or stopped.
	OpenTo []string `json:"openTo"`
}

func registerChatBridgeGet(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatBridgeGetIn, chatBridgeGetOut]{
		Name:  "mirror.chatbridge.get",
		Caps:  "a",
		Doc:   "A repository's chat bridge as its settings show it: the bridge if there is one (never its password), its state, and which categories give the channel's chat to people nobody named (`nobody`, `anonymous`), which must be none for a bridge to run.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/mirror/chatbridge",
		Run: func(ctx context.Context, in chatBridgeGetIn) (chatBridgeGetOut, error) {
			if d.ChatBridges == nil {
				return chatBridgeGetOut{}, fmt.Errorf("the chat bridge is not configured: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return chatBridgeGetOut{}, err
			}
			if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
				return chatBridgeGetOut{}, err
			}
			out := chatBridgeGetOut{OpenTo: d.ChatBridges.ChatOpenTo(in.Repo)}
			if out.OpenTo == nil {
				out.OpenTo = []string{}
			}
			b, ok, err := d.ChatBridges.ChatBridgeFor(in.Repo)
			if err != nil {
				return chatBridgeGetOut{}, err
			}
			if ok {
				out.Bridge, out.Bridged, out.State = b, true, b.State()
			}
			return out, 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
}

// bridgeBase is the repository's base URL for an address somebody
// copied from their browser while looking at the chat: the bridge
// appends /chat-poll and /chat-send itself, so ".../r/libfossil/chat"
// polled ".../chat/chat-poll", which fossil answers with its login page
// (libfossil's bridge, 2026-11-02). Anything else is left as given.
func bridgeBase(raw string) string {
	s := strings.TrimRight(strings.TrimSpace(raw), "/")
	if u, err := url.Parse(s); err == nil && u.RawQuery == "" && u.Fragment == "" {
		s = strings.TrimSuffix(s, "/chat")
	}
	return s
}

// 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
}