mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 21:58:46 +00:00
188 lines
4.2 KiB
Go
188 lines
4.2 KiB
Go
package labeler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
// LabelsMessage is the ATProto subscribeLabels wire format.
|
|
type LabelsMessage struct {
|
|
Seq int64 `json:"seq"`
|
|
Labels []LabelOutput `json:"labels"`
|
|
}
|
|
|
|
// LabelOutput is the ATProto label format for subscribeLabels/queryLabels output.
|
|
type LabelOutput 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"`
|
|
}
|
|
|
|
func labelToOutput(l Label) LabelOutput {
|
|
out := LabelOutput{
|
|
Src: l.Src,
|
|
URI: l.URI,
|
|
CID: l.CID,
|
|
Val: l.Val,
|
|
Neg: l.Neg,
|
|
Cts: l.Cts.UTC().Format(time.RFC3339),
|
|
}
|
|
if l.Exp != nil {
|
|
out.Exp = l.Exp.UTC().Format(time.RFC3339)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleSubscribeLabels implements com.atproto.label.subscribeLabels (WebSocket).
|
|
func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
|
|
cursorStr := r.URL.Query().Get("cursor")
|
|
var cursor int64
|
|
if cursorStr != "" {
|
|
var err error
|
|
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid cursor", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
slog.Error("WebSocket upgrade failed", "error", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
slog.Info("subscribeLabels client connected", "cursor", cursor)
|
|
|
|
// Send historical labels since cursor
|
|
labels, err := GetLabelsSince(s.db, cursor, 1000)
|
|
if err != nil {
|
|
slog.Error("Failed to get labels", "error", err)
|
|
return
|
|
}
|
|
|
|
for _, l := range labels {
|
|
msg := LabelsMessage{
|
|
Seq: l.ID,
|
|
Labels: []LabelOutput{labelToOutput(l)},
|
|
}
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
return
|
|
}
|
|
cursor = l.ID
|
|
}
|
|
|
|
// Poll for new labels
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
// Read pump (detect client disconnect)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for {
|
|
if _, _, err := conn.ReadMessage(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
labels, err := GetLabelsSince(s.db, cursor, 100)
|
|
if err != nil {
|
|
slog.Error("Failed to poll labels", "error", err)
|
|
continue
|
|
}
|
|
for _, l := range labels {
|
|
msg := LabelsMessage{
|
|
Seq: l.ID,
|
|
Labels: []LabelOutput{labelToOutput(l)},
|
|
}
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
return
|
|
}
|
|
cursor = l.ID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleQueryLabels implements com.atproto.label.queryLabels (HTTP GET).
|
|
func (s *Server) handleQueryLabels(w http.ResponseWriter, r *http.Request) {
|
|
uriPatterns := r.URL.Query()["uriPatterns"]
|
|
cursorStr := r.URL.Query().Get("cursor")
|
|
limitStr := r.URL.Query().Get("limit")
|
|
|
|
var cursor int64
|
|
if cursorStr != "" {
|
|
cursor, _ = strconv.ParseInt(cursorStr, 10, 64)
|
|
}
|
|
limit := 50
|
|
if limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 250 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
labels, err := GetLabelsSince(s.db, cursor, limit)
|
|
if err != nil {
|
|
http.Error(w, "failed to query labels", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Filter by URI patterns if provided
|
|
var filtered []LabelOutput
|
|
for _, l := range labels {
|
|
if len(uriPatterns) == 0 || matchesAnyPattern(l.URI, uriPatterns) {
|
|
filtered = append(filtered, labelToOutput(l))
|
|
}
|
|
}
|
|
|
|
var nextCursor string
|
|
if len(labels) > 0 {
|
|
nextCursor = strconv.FormatInt(labels[len(labels)-1].ID, 10)
|
|
}
|
|
|
|
resp := struct {
|
|
Cursor string `json:"cursor,omitempty"`
|
|
Labels []LabelOutput `json:"labels"`
|
|
}{
|
|
Cursor: nextCursor,
|
|
Labels: filtered,
|
|
}
|
|
if resp.Labels == nil {
|
|
resp.Labels = []LabelOutput{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func matchesAnyPattern(uri string, patterns []string) bool {
|
|
for _, p := range patterns {
|
|
// Simple prefix matching (ATProto spec allows glob-like patterns)
|
|
if p == uri || (len(p) > 0 && p[len(p)-1] == '*' && len(uri) >= len(p)-1 && uri[:len(p)-1] == p[:len(p)-1]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|