~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
541 lines · 15.0 KB Raw History
package relay

import (
	"cmp"
	"context"
	"crypto/tls"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	comatproto "github.com/bluesky-social/indigo/api/atproto"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/gorilla/websocket"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/events"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/hostport"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpjson"
)

const SubscribePingInterval = 30 * time.Second

var (
	ErrInvalidHostname = errors.New("relay: hostname is required")
	ErrPDSNotFound     = errors.New("relay: could not resolve PDS identity")
	ErrHostNotAllowed  = errors.New("relay: host is outside the relay scope")
	ErrDIDNotAllowed   = errors.New("relay: DID is outside the relay scope")
	ErrClosed          = errors.New("relay: closed")
)

type Config struct {
	Hostname      string
	Version       string
	Logger        *slog.Logger
	Store         Store
	Directory     identity.Directory
	PLCURL        string
	InsecureHTTP  bool
	DisableVerify bool
	StrictVerify  bool
	MaxBacklog    int
	StartSeq      int64
	IdleAfter     time.Duration
	AllowedDIDs   []string
	AllowedHosts  []string
	HTTPClient    *http.Client
	DialContext   func(ctx context.Context, network, address string) (net.Conn, error)
	TLSConfig     *tls.Config
}

type EventSink interface {
	HandleEvent(ctx context.Context, ev Event) error
}

type Event struct {
	Seq    int64
	Frame  Frame
	Commit *comatproto.SyncSubscribeRepos_Commit
}

type Relay struct {
	cfg        Config
	store      Store
	seq        *Sequencer
	log        *slog.Logger
	dir        identity.Directory
	verify     *verifier
	httpClient *http.Client
	handler    http.Handler

	ctx    context.Context
	cancel context.CancelFunc
	wg     sync.WaitGroup

	mu     sync.Mutex
	subs   map[string]*hostSub
	closed bool

	sinksMu sync.RWMutex
	sinks   []EventSink

	hostMu sync.Mutex
}

func New(ctx context.Context, cfg Config) *Relay {
	cfg.Hostname = cmp.Or(cfg.Hostname, "localhost")
	cfg.Version = cmp.Or(cfg.Version, "0.0.0")
	if cfg.Logger == nil {
		cfg.Logger = slog.Default()
	}
	if cfg.Store == nil {
		cfg.Store = NewMemoryStore()
	}
	if cfg.HTTPClient == nil {
		transport := http.DefaultTransport.(*http.Transport).Clone()
		transport.TLSClientConfig = cfg.TLSConfig
		cfg.HTTPClient = &http.Client{Timeout: 15 * time.Second, Transport: transport}
	}
	dir := cfg.Directory
	if dir == nil {
		if cfg.PLCURL == "" {
			dir = identity.DefaultDirectory()
		} else {
			base := &identity.BaseDirectory{
				PLCURL:                 strings.TrimSuffix(cfg.PLCURL, "/"),
				HTTPClient:             http.Client{Timeout: 10 * time.Second},
				PLCClient:              &http.Client{Timeout: 10 * time.Second},
				SkipHandleVerification: true,
			}
			dir = identity.NewCacheDirectory(base, 100_000, time.Hour, 2*time.Minute, 5*time.Minute)
		}
	}
	ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
	r := &Relay{
		cfg:        cfg,
		store:      cfg.Store,
		seq:        NewSequencer(cfg.MaxBacklog),
		log:        cfg.Logger,
		dir:        dir,
		httpClient: cfg.HTTPClient,
		ctx:        ctx,
		cancel:     cancel,
		subs:       map[string]*hostSub{},
	}
	r.seq.Resume(cfg.StartSeq)
	r.verify = &verifier{dir: dir, log: r.log}
	r.handler = withCORS(http.HandlerFunc(r.serveHTTP))
	return r
}

func (r *Relay) Handler() http.Handler { return r.handler }

func (r *Relay) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	r.handler.ServeHTTP(w, req)
}

func (r *Relay) Store() Store { return r.store }

func (r *Relay) Sequencer() *Sequencer { return r.seq }

func (r *Relay) Directory() identity.Directory { return r.dir }

func (r *Relay) AddSink(s EventSink) {
	if s == nil {
		return
	}
	r.sinksMu.Lock()
	r.sinks = append(r.sinks, s)
	r.sinksMu.Unlock()
}

func (r *Relay) Close() {
	r.mu.Lock()
	if r.closed {
		r.mu.Unlock()
		return
	}
	r.closed = true
	subs := make([]*hostSub, 0, len(r.subs))
	for _, s := range r.subs {
		subs = append(subs, s)
	}
	r.subs = map[string]*hostSub{}
	r.mu.Unlock()
	r.cancel()
	for _, s := range subs {
		s.stop()
	}
	r.wg.Wait()
}

func (r *Relay) serveHTTP(w http.ResponseWriter, req *http.Request) {
	path := req.URL.Path
	switch {
	case req.Method == http.MethodGet && path == "/xrpc/_health":
		httpjson.Write(w, http.StatusOK, map[string]string{"version": r.cfg.Version})
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.server.describeServer":
		httpjson.Write(w, http.StatusOK, map[string]any{
			"did":                  hostport.WebDID(r.cfg.Hostname),
			"version":              r.cfg.Version,
			"availableUserDomains": []string{},
			"inviteCodeRequired":   false,
		})

	case req.Method == http.MethodGet && path == "/.well-known/atproto-did":
		w.Header().Set("Content-Type", "text/plain")
		_, _ = w.Write([]byte(hostport.WebDID(r.cfg.Hostname)))
	case req.Method == http.MethodGet && path == "/.well-known/did.json":
		host := hostport.HostnameOnly(hostport.FromRequest(req, r.cfg.Hostname))
		httpjson.Write(w, http.StatusOK, map[string]any{
			"@context": []string{"https://www.w3.org/ns/did/v1"},
			"id":       hostport.WebDID(host),
			"service": []map[string]string{{
				"id":              "#atproto_relay",
				"type":            "AtprotoRelay",
				"serviceEndpoint": "https://" + host,
			}},
		})

	case req.Method == http.MethodPost && path == "/xrpc/com.atproto.sync.requestCrawl":
		r.handleRequestCrawl(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.subscribeRepos":
		r.handleSubscribeRepos(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.getRepo":
		r.handleGetRepo(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.getRepoStatus":
		r.handleGetRepoStatus(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.listRepos":
		r.handleListRepos(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.listReposByCollection":
		r.handleListReposByCollection(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.getLatestCommit":
		r.handleGetLatestCommit(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.listHosts":
		r.handleListHosts(w, req)
	case req.Method == http.MethodGet && path == "/xrpc/com.atproto.sync.getHostStatus":
		r.handleGetHostStatus(w, req)
	default:
		http.NotFound(w, req)
	}
}

func (r *Relay) handleRequestCrawl(w http.ResponseWriter, req *http.Request) {
	var body struct {
		Hostname string `json:"hostname"`
	}
	dec := json.NewDecoder(req.Body)
	if err := dec.Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "invalid JSON")
		return
	}
	if body.Hostname == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing hostname")
		return
	}
	if err := r.RequestCrawl(req.Context(), body.Hostname); err != nil {
		switch {
		case errors.Is(err, ErrHostNotAllowed), errors.Is(err, ErrDIDNotAllowed):
			writeError(w, http.StatusForbidden, "Forbidden", err.Error())
		case errors.Is(err, ErrPDSNotFound), errors.Is(err, ErrInvalidHostname):
			writeError(w, http.StatusBadGateway, "PdsNotFound", "could not resolve PDS identity")
		default:
			writeError(w, http.StatusBadGateway, "PdsNotFound", "could not resolve PDS identity")
		}
		return
	}
	httpjson.Write(w, http.StatusOK, map[string]any{})
}

func (r *Relay) handleSubscribeRepos(w http.ResponseWriter, req *http.Request) {
	query := req.URL.Query()
	var since *int64
	if raw := query.Get("cursor"); raw != "" {
		if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
			since = &n
		}
	}
	filter, err := ParseSubscribeFilter(query)
	if err != nil {
		writeError(w, http.StatusBadRequest, "InvalidRequest", err.Error())
		return
	}
	conn, err := upgrader.Upgrade(w, req, nil)
	if err != nil {
		return
	}
	defer conn.Close()
	conn.SetReadLimit(5_000_000)

	head := r.seq.Head()
	if since != nil && *since > head {
		msg := fmt.Sprintf("cursor %d is past the relay's newest event %d", *since, head)
		r.log.Warn("subscribe_repos_future_cursor", "cursor", *since, "head", head)
		_ = conn.WriteJSON(map[string]string{"error": FutureCursorError, "message": msg})
		_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1000, FutureCursorError), time.Now().Add(time.Second))
		return
	}

	var cursor any = "live"
	if since != nil {
		cursor = *since
	}
	if filter.Active() {
		r.log.Info("subscribe_repos_filter", "wantedCollections", filter.Collections, "wantedDidsCount", len(filter.DIDs),
			"wantedDids", events.LogDIDs(filter.DIDs), "cursor", cursor)
		_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
		if err := conn.WriteJSON(filter.info()); err != nil {
			return
		}
	}
	backlog, live, cancel := r.seq.Watch(since)
	defer cancel()

	done := make(chan struct{})
	go func() {
		defer close(done)
		for {
			if _, _, err := conn.ReadMessage(); err != nil {
				return
			}
		}
	}()
	defer func() {
		_ = conn.Close()
		<-done
	}()

	write := func(frame Frame) bool {
		frame, ok := filter.Apply(frame)
		if !ok {
			return true
		}
		_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
		if err := conn.WriteJSON(frame); err != nil {
			return false
		}
		return true
	}
	for _, frame := range backlog {
		if !write(frame) {
			return
		}
	}
	ping := time.NewTicker(SubscribePingInterval)
	defer ping.Stop()
	for {
		select {
		case <-ping.C:
			if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
				return
			}
		case <-done:
			return
		case <-r.ctx.Done():
			return
		case frame, ok := <-live:
			if !ok {
				_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1011, "internal error"), time.Now().Add(time.Second))
				return
			}
			if !write(frame) {
				return
			}
		}
	}
}

func (r *Relay) handleGetRepo(w http.ResponseWriter, req *http.Request) {
	did := req.URL.Query().Get("did")
	if did == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing did")
		return
	}
	account, ok := r.store.GetAccount(did)
	if !ok {
		writeError(w, http.StatusNotFound, "RepoNotFound", "unknown DID")
		return
	}
	target := "https://" + account.HostHostname + "/xrpc/com.atproto.sync.getRepo?did=" + url.QueryEscape(did)
	http.Redirect(w, req, target, http.StatusTemporaryRedirect)
}

func (r *Relay) handleGetRepoStatus(w http.ResponseWriter, req *http.Request) {
	did := req.URL.Query().Get("did")
	if did == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing did")
		return
	}
	account, ok := r.store.GetAccount(did)
	if !ok {
		writeError(w, http.StatusNotFound, "RepoNotFound", "unknown DID")
		return
	}
	out := map[string]any{
		"did":      account.DID,
		"hostname": account.HostHostname,
		"rev":      account.Rev,
		"seq":      account.Seq,
	}
	httpjson.Write(w, http.StatusOK, out)
}

func (r *Relay) handleListRepos(w http.ResponseWriter, req *http.Request) {
	limit := parseLimit(req.URL.Query().Get("limit"))
	cursor := req.URL.Query().Get("cursor")
	accounts := r.store.ListAccounts()
	start := 0
	if cursor != "" {
		start = len(accounts)
		for i, a := range accounts {
			if a.DID > cursor {
				start = i
				break
			}
		}
	}
	end := min(start+limit, len(accounts))
	page := accounts[start:end]
	repos := make([]map[string]string, 0, len(page))
	for _, a := range page {
		rev := ""
		if a.Rev != nil {
			rev = *a.Rev
		}
		repos = append(repos, map[string]string{"did": a.DID, "head": rev, "rev": rev})
	}
	out := map[string]any{"repos": repos}
	if len(page) == limit && len(page) > 0 {
		out["cursor"] = page[len(page)-1].DID
	}
	httpjson.Write(w, http.StatusOK, out)
}

func (r *Relay) handleListReposByCollection(w http.ResponseWriter, req *http.Request) {
	collection := req.URL.Query().Get("collection")
	if collection == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing collection")
		return
	}
	limit := parseLimit(req.URL.Query().Get("limit"))
	cursor := req.URL.Query().Get("cursor")
	dids, next := r.store.ListCollection(collection, cursor, limit)
	repos := make([]map[string]string, 0, len(dids))
	for _, did := range dids {
		repos = append(repos, map[string]string{"did": did})
	}
	out := map[string]any{"repos": repos}
	if next != "" {
		out["cursor"] = next
	}
	httpjson.Write(w, http.StatusOK, out)
}

func (r *Relay) handleGetLatestCommit(w http.ResponseWriter, req *http.Request) {
	did := req.URL.Query().Get("did")
	if did == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing did")
		return
	}
	account, ok := r.store.GetAccount(did)
	if !ok || account.Rev == nil {
		httpjson.Write(w, http.StatusNotFound, map[string]string{"error": "RepoNotFound"})
		return
	}
	httpjson.Write(w, http.StatusOK, map[string]string{"cid": *account.Rev, "rev": *account.Rev})
}

func (r *Relay) handleListHosts(w http.ResponseWriter, req *http.Request) {
	httpjson.Write(w, http.StatusOK, map[string]any{"hosts": r.store.ListHosts()})
}

func (r *Relay) handleGetHostStatus(w http.ResponseWriter, req *http.Request) {
	hostname := req.URL.Query().Get("hostname")
	if hostname == "" {
		writeError(w, http.StatusBadRequest, "InvalidRequest", "missing hostname")
		return
	}
	host, ok := r.store.GetHost(hostname)
	if !ok {
		httpjson.Write(w, http.StatusNotFound, map[string]string{"error": "HostNotFound"})
		return
	}
	httpjson.Write(w, http.StatusOK, host)
}

func (r *Relay) emit(ctx context.Context, frame Frame, evt *comatproto.SyncSubscribeRepos_Commit) {
	r.sinksMu.RLock()
	sinks := append([]EventSink{}, r.sinks...)
	r.sinksMu.RUnlock()
	if len(sinks) == 0 {
		return
	}
	ev := Event{Seq: frame.Seq, Frame: frame, Commit: evt}
	for _, sink := range sinks {
		if err := sink.HandleEvent(ctx, ev); err != nil {
			r.log.Warn("event sink failed", "err", err)
		}
	}
}

func (r *Relay) hostAllowed(hostname string) bool {
	if len(r.cfg.AllowedHosts) == 0 {
		return true
	}
	hostname = strings.ToLower(hostname)
	for _, h := range r.cfg.AllowedHosts {
		h = strings.ToLower(strings.TrimSpace(h))
		if h == "*" || h == hostname {
			return true
		}
		if suffix, ok := strings.CutPrefix(h, "*."); ok && strings.HasSuffix(hostname, "."+suffix) {
			return true
		}
	}
	return false
}

func (r *Relay) didAllowed(did string) bool {
	if len(r.cfg.AllowedDIDs) == 0 {
		return true
	}
	return slices.Contains(r.cfg.AllowedDIDs, did)
}

func parseLimit(raw string) int {
	limit := 50
	if raw != "" {
		if n, err := strconv.Atoi(raw); err == nil && n > 0 {
			limit = n
		}
	}
	if limit > 1000 {
		limit = 1000
	}
	return limit
}

func writeError(w http.ResponseWriter, status int, name, message string) {
	httpjson.Write(w, status, map[string]string{"error": name, "message": message})
}

func withCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "*")
		if req.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, req)
	})
}