mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
240 lines
5.5 KiB
Go
240 lines
5.5 KiB
Go
// Package labeler provides a subscription client for consuming labels
|
|
// from an ATProto labeler service.
|
|
package labeler
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// LabelsMessage is the wire format for subscribeLabels events.
|
|
type LabelsMessage struct {
|
|
Seq int64 `json:"seq"`
|
|
Labels []LabelEvent `json:"labels"`
|
|
}
|
|
|
|
// LabelEvent is a single label from the labeler.
|
|
type LabelEvent struct {
|
|
Src string `json:"src"`
|
|
URI string `json:"uri"`
|
|
CID string `json:"cid,omitempty"`
|
|
Val string `json:"val"`
|
|
Neg bool `json:"neg"`
|
|
Cts string `json:"cts"`
|
|
Exp string `json:"exp,omitempty"`
|
|
}
|
|
|
|
// Subscriber connects to a labeler's subscribeLabels endpoint
|
|
// and mirrors labels into the appview database.
|
|
type Subscriber struct {
|
|
labelerURL string
|
|
database *sql.DB
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
// NewSubscriber creates a new labeler subscriber.
|
|
func NewSubscriber(labelerURL string, database *sql.DB) *Subscriber {
|
|
return &Subscriber{
|
|
labelerURL: labelerURL,
|
|
database: database,
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins the subscription loop in a goroutine.
|
|
func (s *Subscriber) Start() {
|
|
go s.run()
|
|
}
|
|
|
|
// Stop signals the subscriber to shut down.
|
|
func (s *Subscriber) Stop() {
|
|
close(s.stopCh)
|
|
}
|
|
|
|
func (s *Subscriber) run() {
|
|
backoff := time.Second
|
|
|
|
for {
|
|
select {
|
|
case <-s.stopCh:
|
|
return
|
|
default:
|
|
}
|
|
|
|
if err := s.connect(); err != nil {
|
|
slog.Warn("Labeler subscription error, reconnecting",
|
|
"error", err,
|
|
"backoff", backoff,
|
|
)
|
|
select {
|
|
case <-s.stopCh:
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if backoff < 30*time.Second {
|
|
backoff *= 2
|
|
}
|
|
} else {
|
|
backoff = time.Second
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Subscriber) connect() error {
|
|
// Get cursor from DB
|
|
// Use the labeler URL as src identifier
|
|
labelerDID := extractDIDFromURL(s.labelerURL)
|
|
cursor, err := db.GetLabelCursor(s.database, labelerDID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get cursor: %w", err)
|
|
}
|
|
|
|
// Build WebSocket URL
|
|
wsURL := toWebSocketURL(s.labelerURL) + "/xrpc/com.atproto.label.subscribeLabels"
|
|
if cursor > 0 {
|
|
wsURL += fmt.Sprintf("?cursor=%d", cursor)
|
|
}
|
|
|
|
slog.Info("Connecting to labeler", "url", wsURL, "cursor", cursor)
|
|
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("websocket dial failed: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
slog.Info("Connected to labeler", "url", s.labelerURL)
|
|
|
|
for {
|
|
select {
|
|
case <-s.stopCh:
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
var msg LabelsMessage
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
return fmt.Errorf("read error: %w", err)
|
|
}
|
|
|
|
for _, le := range msg.Labels {
|
|
cts, _ := time.Parse(time.RFC3339, le.Cts)
|
|
did, repo := extractSubjectFromURI(le.URI)
|
|
|
|
label := &db.Label{
|
|
Src: le.Src,
|
|
URI: le.URI,
|
|
Val: le.Val,
|
|
Neg: le.Neg,
|
|
Cts: cts,
|
|
SubjectDID: did,
|
|
SubjectRepo: repo,
|
|
Seq: msg.Seq,
|
|
}
|
|
|
|
if err := db.UpsertLabel(s.database, label); err != nil {
|
|
slog.Warn("Failed to upsert label", "uri", le.URI, "error", err)
|
|
continue
|
|
}
|
|
|
|
slog.Info("Mirrored label",
|
|
"uri", le.URI,
|
|
"val", le.Val,
|
|
"neg", le.Neg,
|
|
"subject_did", did,
|
|
"subject_repo", repo,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractSubjectFromURI extracts the DID and repository from an AT URI.
|
|
// Examples:
|
|
//
|
|
// at://did:plc:xyz → (did:plc:xyz, "")
|
|
// at://did:plc:xyz/io.atcr.manifest/abc → (did:plc:xyz, "") - repo extracted from record
|
|
// at://did:plc:xyz/io.atcr.repo/myimage → (did:plc:xyz, "myimage")
|
|
func extractSubjectFromURI(uri string) (did, repo string) {
|
|
trimmed := strings.TrimPrefix(uri, "at://")
|
|
parts := strings.SplitN(trimmed, "/", 3)
|
|
if len(parts) == 0 {
|
|
return "", ""
|
|
}
|
|
did = parts[0]
|
|
|
|
// For repo-level summary labels: at://did/io.atcr.repo/reponame
|
|
if len(parts) >= 3 && parts[1] == "io.atcr.repo" {
|
|
repo = parts[2]
|
|
}
|
|
return did, repo
|
|
}
|
|
|
|
// extractDIDFromURL derives a did:web from a labeler URL.
|
|
func extractDIDFromURL(labelerURL string) string {
|
|
u, err := url.Parse(labelerURL)
|
|
if err != nil {
|
|
return labelerURL
|
|
}
|
|
host := u.Hostname()
|
|
if port := u.Port(); port != "" {
|
|
host += "%3A" + port
|
|
}
|
|
return "did:web:" + host
|
|
}
|
|
|
|
// toWebSocketURL converts an HTTP URL to a WebSocket URL.
|
|
func toWebSocketURL(httpURL string) string {
|
|
u, err := url.Parse(httpURL)
|
|
if err != nil {
|
|
return httpURL
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
u.Scheme = "wss"
|
|
default:
|
|
u.Scheme = "ws"
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// ParseLabelerURL parses a labeler DID or URL into an HTTP URL.
|
|
func ParseLabelerURL(labelerDIDOrURL string) string {
|
|
if strings.HasPrefix(labelerDIDOrURL, "http://") || strings.HasPrefix(labelerDIDOrURL, "https://") {
|
|
return labelerDIDOrURL
|
|
}
|
|
if strings.HasPrefix(labelerDIDOrURL, "did:web:") {
|
|
host := strings.TrimPrefix(labelerDIDOrURL, "did:web:")
|
|
host = strings.ReplaceAll(host, "%3A", ":")
|
|
return "https://" + host
|
|
}
|
|
return labelerDIDOrURL
|
|
}
|
|
|
|
// SubscriberFromConfig creates a Subscriber from a labeler DID/URL config value.
|
|
// Returns nil if labelerDIDOrURL is empty.
|
|
func SubscriberFromConfig(labelerDIDOrURL string, database *sql.DB) *Subscriber {
|
|
if labelerDIDOrURL == "" {
|
|
return nil
|
|
}
|
|
labelerURL := ParseLabelerURL(labelerDIDOrURL)
|
|
return NewSubscriber(labelerURL, database)
|
|
}
|
|
|
|
// DecodeLabelsFromJSON decodes a JSON-encoded labels message.
|
|
func DecodeLabelsFromJSON(data []byte) (*LabelsMessage, error) {
|
|
var msg LabelsMessage
|
|
if err := json.Unmarshal(data, &msg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &msg, nil
|
|
}
|