package microcosm import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "provenance/internal/atproto" "provenance/internal/domain" "provenance/internal/upstream" ) const ( DefaultJetstreamURL = "wss://jetstream.fire.hose.cam/subscribe" DefaultSlingshotURL = "https://slingshot.microcosm.blue" ReplayOverlap = 5 * time.Second StreamReadTimeout = 2 * time.Minute MaxEventBytes = 1 << 20 UserAgent = upstream.UserAgent ) type Client struct { streamURL string slingshotURL string http *http.Client softMode domain.SoftMode } func New(streamURL, slingshotURL string) (*Client, error) { return NewWithSoft(streamURL, slingshotURL, domain.SoftDisabled) } func NewWithSoft(streamURL, slingshotURL string, mode domain.SoftMode) (*Client, error) { return NewWithQueue(streamURL, slingshotURL, mode, upstream.Default()) } func NewWithQueue(streamURL, slingshotURL string, mode domain.SoftMode, queue *upstream.Queue) (*Client, error) { if mode < domain.SoftDisabled || mode > domain.SoftAllAuthorLists { return nil, errors.New("invalid soft vouch mode") } if err := validateURL(streamURL, "wss", "ws"); err != nil { return nil, err } if err := validateURL(slingshotURL, "https", "http"); err != nil { return nil, err } u, _ := url.Parse(slingshotURL) if u.Path != "" && u.Path != "/" { return nil, errors.New("Slingshot URL must contain only an origin") } // Endpoint configuration is operator-controlled, so explicit private self-hosted instances are valid. transport := http.DefaultTransport.(*http.Transport).Clone() client := &http.Client{Transport: queue.Transport(transport), Timeout: atproto.RequestTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} return &Client{streamURL: streamURL, slingshotURL: strings.TrimRight(slingshotURL, "/"), http: client, softMode: mode}, nil } func loopback(host string) bool { return host == "127.0.0.1" || host == "::1" || host == "localhost" } func validateURL(raw, secure, development string) error { u, err := url.Parse(raw) if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return errors.New("invalid Microcosm endpoint URL") } if u.Scheme != secure && !(u.Scheme == development && loopback(u.Hostname())) { return errors.New("Microcosm endpoints require encrypted transport or explicit loopback development addresses") } return nil } func (c *Client) record(ctx context.Context, did, key, cid string) (atproto.Record, error) { var record atproto.Record err := c.rawRecord(ctx, did, domain.Collection, key, cid, &record) return record, err } func (c *Client) rawRecord(ctx context.Context, did, source, key, cid string, record any) error { params := url.Values{"repo": {did}, "collection": {source}, "rkey": {key}, "cid": {cid}} req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.slingshotURL+"/xrpc/com.atproto.repo.getRecord?"+params.Encode(), nil) if err != nil { return err } req.Header.Set("User-Agent", UserAgent) resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("Slingshot returned HTTP %d for event hydration", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, atproto.MaxResponseBytes+1)) if err != nil { return err } if len(data) > atproto.MaxResponseBytes { return errors.New("Slingshot response exceeds size limit") } var result struct { URI string `json:"uri"` CID string `json:"cid"` Value json.RawMessage `json:"value"` } if err := json.Unmarshal(data, &result); err != nil { return err } if result.URI != "at://"+did+"/"+source+"/"+key || result.CID != cid { return errors.New("Slingshot record does not match the event URI or CID") } return json.Unmarshal(result.Value, record) }