package main import ( "encoding/json" "fmt" "net/http" "slices" "strconv" "strings" "time" "github.com/bluesky-social/indigo/atproto/auth" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/labeling" "github.com/bluesky-social/indigo/atproto/syntax" ) func (srv *Server) RunAPI(bind string) { svcAuth := auth.ServiceAuthValidator{ Audience: srv.ServiceDID.String(), // TODO: full audience with fragment? Dir: identity.DefaultDirectory(), TimestampLeeway: time.Second * 5, } srv.httpServer = &http.Server{ Addr: bind, ReadTimeout: 20 * time.Second, WriteTimeout: 20 * time.Second, } http.HandleFunc("GET /", HomeEndpoint) if srv.AdminPassword != "" { srv.Logger.Info("configuring admin auth") http.HandleFunc("POST /xrpc/at.nsid.cobalt.label.createLabel", auth.AdminAuthMiddleware(srv.CreateLabelEndpoint, []string{srv.AdminPassword})) http.HandleFunc("POST /xrpc/at.nsid.cobalt.label.negateLabel", auth.AdminAuthMiddleware(srv.NegateLabelEndpoint, []string{srv.AdminPassword})) } else { srv.Logger.Info("configuring service auth", "authorized", srv.AuthorizedDIDs) http.HandleFunc("POST /xrpc/at.nsid.cobalt.label.createLabel", svcAuth.Middleware(srv.CreateLabelEndpoint, true)) http.HandleFunc("POST /xrpc/at.nsid.cobalt.label.negateLabel", svcAuth.Middleware(srv.NegateLabelEndpoint, true)) } http.HandleFunc("GET /xrpc/com.atproto.label.queryLabels", srv.QueryLabelsEndpoint) http.HandleFunc("GET /xrpc/com.atproto.label.subscribeLabels", srv.SubscribeLabelsEndpoint) if err := srv.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { srv.Logger.Error("http server bad shutdown", "err", err) } return } type LabelRequestBody struct { SourceDID *string `json:"src,omitempty"` URI string `json:"uri"` CID *string `json:"cid,omitempty"` Val string `json:"val"` ExpiresAt *string `json:"exp,omitempty"` } func (obj *LabelRequestBody) Validate() error { if obj.SourceDID != nil { _, err := syntax.ParseDID(*obj.SourceDID) if err != nil { return fmt.Errorf("invalid source DID: %s", *obj.SourceDID) } } if obj.CID != nil { _, err := syntax.ParseCID(*obj.CID) if err != nil { return fmt.Errorf("invalid subject CID: %s", *obj.CID) } } if obj.ExpiresAt != nil { _, err := syntax.ParseDatetime(*obj.ExpiresAt) if err != nil { return fmt.Errorf("invalid exp datetime: %s", *obj.ExpiresAt) } } if strings.HasPrefix(obj.URI, "did:") { _, err := syntax.ParseDID(obj.URI) if err != nil { return fmt.Errorf("invalid subjbect uri DID: %s", obj.URI) } } else if strings.HasPrefix(obj.URI, "at:") { _, err := syntax.ParseATURI(obj.URI) if err != nil { return fmt.Errorf("invalid subject uri AT-URI: %s", obj.URI) } } else { // TODO: could allow generic URLs? or just HTTP/S? return fmt.Errorf("unsupported label URI scheme: %s", obj.URI) } // TODO: more label syntax validation? if obj.Val == "" || len([]byte(obj.Val)) > 128 { return fmt.Errorf("label val empty or too long: '%s'", obj.Val) } return nil } func (srv *Server) CreateLabelEndpoint(w http.ResponseWriter, r *http.Request) { srv.mutateLabelEndpoint(w, r, false) } func (srv *Server) NegateLabelEndpoint(w http.ResponseWriter, r *http.Request) { srv.mutateLabelEndpoint(w, r, true) } func (srv *Server) mutateLabelEndpoint(w http.ResponseWriter, r *http.Request, negate bool) { w.Header().Set("Content-Type", "application/json") // verify authenticated DID from auth middleware if did := r.Context().Value("did"); did != nil { did := did.(syntax.DID) if !slices.Contains(srv.AuthorizedDIDs, did) { http.Error(w, fmt.Sprintf(`{"error": "Forbidden", "message": "Not an authorized account."}`), http.StatusForbidden) return } } // parse request body JSON var reqBody LabelRequestBody if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { http.Error(w, fmt.Sprintf(`{"error": "BadRequest", "message": "%s"}`, err.Error()), http.StatusBadRequest) return } if err := reqBody.Validate(); err != nil { http.Error(w, fmt.Sprintf(`{"error": "BadRequest", "message": "%s"}`, err.Error()), http.StatusBadRequest) return } src := srv.ServiceDID.String() if reqBody.SourceDID != nil && *reqBody.SourceDID != src { http.Error(w, fmt.Sprintf(`{"error": "BadRequest", "message": "unsupported src DID: %s"}`, *reqBody.SourceDID), http.StatusBadRequest) return } lbl := labeling.Label{ CID: reqBody.CID, CreatedAt: syntax.DatetimeNow().String(), ExpiresAt: reqBody.ExpiresAt, SourceDID: src, URI: reqBody.URI, Val: reqBody.Val, Version: labeling.ATPROTO_LABEL_VERSION, } if negate { lbl.Negated = &negate } if err := lbl.Sign(srv.SigningKey); err != nil { http.Error(w, fmt.Sprintf(`{"error": "InternalServerError", "message": "%s"}`, err.Error()), http.StatusInternalServerError) return } if err := srv.CreateLabels(r.Context(), []labeling.Label{lbl}); err != nil { http.Error(w, fmt.Sprintf(`{"error": "InternalServerError", "message": "%s"}`, err.Error()), http.StatusInternalServerError) return } if err := json.NewEncoder(w).Encode(lbl); err != nil { http.Error(w, fmt.Sprintf(`{"error": "InternalServerError", "message": "%s"}`, err.Error()), http.StatusInternalServerError) return } } type QueryLabelsResponse struct { Cursor string `json:"cursor,omitempty"` Labels []labeling.Label `json:"labels"` } func (srv *Server) QueryLabelsEndpoint(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // parse query params params := r.URL.Query() uriPatterns := params["uriPatterns"] var sources []syntax.DID for _, raw := range params["sources"] { did, err := syntax.ParseDID(raw) if err != nil { http.Error(w, fmt.Sprintf(`{"error": "BadRequest", "message": "invalid source DID: %s"}`, raw), http.StatusBadRequest) return } sources = append(sources, did) } limit := 50 rawLimit := params.Get("limit") if rawLimit != "" { limit, err := strconv.Atoi(rawLimit) if err != nil || limit < 1 || limit > 250 { http.Error(w, fmt.Sprintf(`{"error": "BadRequest", "message": "invalid limit"}`), http.StatusBadRequest) return } } cursor := params.Get("limit") labels, nextCursor, err := srv.Store.QueryLabels(r.Context(), uriPatterns, sources, cursor, limit) if err != nil { http.Error(w, fmt.Sprintf(`{"error": "InternalServerError", "message": "%s"}`, err.Error()), http.StatusInternalServerError) return } respBody := QueryLabelsResponse{ Labels: labels, Cursor: nextCursor, } if err := json.NewEncoder(w).Encode(respBody); err != nil { http.Error(w, fmt.Sprintf(`{"error": "InternalServerError", "message": "%s"}`, err.Error()), http.StatusInternalServerError) return } } func HomeEndpoint(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Write([]byte(` /$$ /$$ /$$ /$$ | $$ | $$ | $$ | $$ | $$ /$$$$$$ | $$$$$$$ /$$$$$$ | $$ /$$$$$$/$$$$ /$$$$$$ | $$ /$$ /$$$$$$ /$$$$$$ | $$ |____ $$| $$__ $$ /$$__ $$| $$| $$_ $$_ $$ |____ $$| $$ /$$/ /$$__ $$ /$$__ $$ | $$ /$$$$$$$| $$ \ $$| $$$$$$$$| $$| $$ \ $$ \ $$ /$$$$$$$| $$$$$$/ | $$$$$$$$| $$ \__/ | $$ /$$__ $$| $$ | $$| $$_____/| $$| $$ | $$ | $$ /$$__ $$| $$_ $$ | $$_____/| $$ | $$| $$$$$$$| $$$$$$$/| $$$$$$$| $$| $$ | $$ | $$| $$$$$$$| $$ \ $$| $$$$$$$| $$ |__/ \_______/|_______/ \_______/|__/|__/ |__/ |__/ \_______/|__/ \__/ \_______/|__/ This is an AT Protocol labeling service. Most API routes are under /xrpc/ Code: https://tangled.org/bnewbold.net/cobalt/cmd/labelmaker Protocol: https://atproto.com `)) }