From 9704fe091dbf76aa450607de85a89a627c41f1e6 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 6 Jan 2026 22:47:21 -0600 Subject: [PATCH] use chi/render to simplify returned json --- .golangci.yml | 6 - .tangled/workflows/lint.yaml | 2 +- go.mod | 2 + go.sum | 4 + pkg/appview/handlers/api.go | 27 ++-- pkg/appview/handlers/device.go | 32 ++--- pkg/appview/handlers/images.go | 9 +- pkg/auth/token/handler.go | 10 +- pkg/hold/admin/handlers.go | 14 +- pkg/hold/admin/handlers_auth.go | 2 +- pkg/hold/admin/handlers_crew.go | 2 +- pkg/hold/oci/helpers_test.go | 44 ------- pkg/hold/oci/http_helpers.go | 38 ------ pkg/hold/oci/multipart_test.go | 39 ++++++ pkg/hold/oci/xrpc.go | 89 ++++++++----- pkg/hold/pds/records.go | 3 - pkg/hold/pds/xrpc.go | 218 +++++++------------------------- 17 files changed, 179 insertions(+), 362 deletions(-) delete mode 100644 pkg/hold/oci/helpers_test.go delete mode 100644 pkg/hold/oci/http_helpers.go diff --git a/.golangci.yml b/.golangci.yml index 7df5e82..f68fd17 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,12 +26,6 @@ linters: - errcheck # TODO: fix issues and remove these paths one by one - - path: pkg/auth - linters: - - errcheck - - path: pkg/appview - linters: - - errcheck - path: cmd/credential-helper linters: - errcheck diff --git a/.tangled/workflows/lint.yaml b/.tangled/workflows/lint.yaml index cafefcd..24af4c9 100644 --- a/.tangled/workflows/lint.yaml +++ b/.tangled/workflows/lint.yaml @@ -14,11 +14,11 @@ steps: CGO_ENABLED: 1 command: | go mod download + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 go generate ./... - name: Run Linter environment: CGO_ENABLED: 1 command: | - curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.2 golangci-lint run ./... diff --git a/go.mod b/go.mod index 123cdf3..a3f1f60 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/distribution/reference v0.6.0 github.com/earthboundkid/versioninfo/v2 v2.24.1 github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/render v1.0.3 github.com/goki/freetype v1.0.5 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 @@ -40,6 +41,7 @@ require ( require ( github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect + github.com/ajg/form v1.5.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5347770..0bd7e47 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= @@ -70,6 +72,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go index a5461dd..70c6e8d 100644 --- a/pkg/appview/handlers/api.go +++ b/pkg/appview/handlers/api.go @@ -2,7 +2,6 @@ package handlers import ( "database/sql" - "encoding/json" "errors" "fmt" "log/slog" @@ -15,6 +14,7 @@ import ( "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) // StarRepositoryHandler handles starring a repository @@ -66,9 +66,8 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // Return success - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]bool{"starred": true}) + render.JSON(w, r, map[string]bool{"starred": true}) } // UnstarRepositoryHandler handles unstarring a repository @@ -122,8 +121,7 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque } // Return success - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) + render.JSON(w, r, map[string]bool{"starred": false}) } // CheckStarHandler checks if current user has starred a repository @@ -138,8 +136,7 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r) if user == nil { // Not authenticated - return not starred - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) + render.JSON(w, r, map[string]bool{"starred": false}) return } @@ -166,16 +163,14 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Check if OAuth error - if so, invalidate sessions if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) { // For a read operation, just return not starred instead of error - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) + render.JSON(w, r, map[string]bool{"starred": false}) return } starred := err == nil // Return result - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"starred": starred}) + render.JSON(w, r, map[string]bool{"starred": starred}) } // GetStatsHandler returns repository statistics @@ -204,8 +199,7 @@ func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Return stats as JSON - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(stats) + render.JSON(w, r, stats) } // ManifestDetailHandler returns detailed manifest information including platforms @@ -240,8 +234,7 @@ func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // Return manifest as JSON - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(manifest) + render.JSON(w, r, manifest) } // CredentialHelperVersionResponse is the response for the credential helper version API @@ -297,7 +290,7 @@ func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *htt Checksums: h.Checksums, } - w.Header().Set("Content-Type", "application/json") + render.SetContentType(render.ContentTypeJSON) w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes - json.NewEncoder(w).Encode(response) + render.JSON(w, r, response) } diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go index efce081..03d957b 100644 --- a/pkg/appview/handlers/device.go +++ b/pkg/appview/handlers/device.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "fmt" "html/template" "log/slog" @@ -9,9 +8,9 @@ import ( "net/url" "strings" - "github.com/go-chi/chi/v5" - "atcr.io/pkg/appview/db" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) // DeviceCodeRequest is the request to start device authorization @@ -41,7 +40,7 @@ func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } var req DeviceCodeRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := render.Decode(r, &req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } @@ -73,8 +72,7 @@ func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { Interval: 5, // Poll every 5 seconds } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + render.JSON(w, r, resp) } // DeviceTokenRequest is the request to poll for device authorization @@ -102,7 +100,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } var req DeviceTokenRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := render.Decode(r, &req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } @@ -113,8 +111,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { resp := DeviceTokenResponse{ Error: "expired_token", } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + render.JSON(w, r, resp) return } @@ -124,8 +121,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { resp := DeviceTokenResponse{ Error: "authorization_pending", } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + render.JSON(w, r, resp) return } @@ -147,8 +143,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { DID: *pending.ApprovedDID, } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + render.JSON(w, r, resp) } // DeviceApprovalPageHandler handles GET /device @@ -257,15 +252,14 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } var req DeviceApproveRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := render.Decode(r, &req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if !req.Approve { // User denied - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "denied"}) + render.JSON(w, r, map[string]string{"status": "denied"}) return } @@ -276,8 +270,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) + render.JSON(w, r, map[string]string{"status": "approved"}) } // ListDevicesHandler handles GET /api/devices @@ -308,8 +301,7 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Get devices for this user devices := h.Store.ListDevices(sess.DID) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devices) + render.JSON(w, r, devices) } // RevokeDeviceHandler handles DELETE /api/devices/{id} diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index a137579..912eb7b 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -15,6 +15,7 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) // DeleteTagHandler handles deleting a tag @@ -93,9 +94,8 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]any{ + render.Status(r, http.StatusConflict) + render.JSON(w, r, map[string]any{ "error": "confirmation_required", "message": "This manifest has associated tags that will also be deleted", "tags": tags, @@ -266,6 +266,5 @@ func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // Return new avatar URL avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL}) + render.JSON(w, r, map[string]string{"avatarURL": avatarURL}) } diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index 8500f65..eb7aaf7 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -2,7 +2,6 @@ package token import ( "context" - "encoding/json" "errors" "fmt" "log/slog" @@ -13,6 +12,7 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" + "github.com/go-chi/render" ) // PostAuthCallback is called after successful Basic Auth authentication. @@ -120,7 +120,7 @@ func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) { Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.", LoginURL: loginURL, } - json.NewEncoder(w).Encode(resp) + render.JSON(w, r, resp) } // ServeHTTP handles the token request @@ -269,9 +269,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { IssuedAt: now.Format(time.RFC3339), } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) - return - } + render.JSON(w, r, resp) } diff --git a/pkg/hold/admin/handlers.go b/pkg/hold/admin/handlers.go index 6b01559..927b1c4 100644 --- a/pkg/hold/admin/handlers.go +++ b/pkg/hold/admin/handlers.go @@ -1,13 +1,13 @@ package admin import ( - "encoding/json" "log/slog" "net/http" "sort" "strconv" "atcr.io/pkg/atproto" + "github.com/go-chi/render" ) // DashboardStats contains dashboard statistics @@ -122,11 +122,7 @@ func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) { } // Otherwise return JSON - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, stats) } // UserUsage represents storage usage for a user @@ -194,9 +190,5 @@ func (ui *AdminUI) handleTopUsersAPI(w http.ResponseWriter, r *http.Request) { } // Otherwise return JSON - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(users); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, users) } diff --git a/pkg/hold/admin/handlers_auth.go b/pkg/hold/admin/handlers_auth.go index 014991b..6997fee 100644 --- a/pkg/hold/admin/handlers_auth.go +++ b/pkg/hold/admin/handlers_auth.go @@ -120,7 +120,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) { token, err := ui.createSession(did, handle) if err != nil { slog.Error("failed to create session token", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) + http.Error(w, "Failed to create session", http.StatusInternalServerError) return } ui.setSessionCookie(w, r, token) diff --git a/pkg/hold/admin/handlers_crew.go b/pkg/hold/admin/handlers_crew.go index b661c04..0cebac4 100644 --- a/pkg/hold/admin/handlers_crew.go +++ b/pkg/hold/admin/handlers_crew.go @@ -321,7 +321,7 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) { if tier != "" { if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { slog.Error("failed to update crew member tier", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) + http.Error(w, "Failed to update tier", http.StatusInternalServerError) return } } diff --git a/pkg/hold/oci/helpers_test.go b/pkg/hold/oci/helpers_test.go deleted file mode 100644 index 500f34d..0000000 --- a/pkg/hold/oci/helpers_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package oci - -import ( - "testing" -) - -// Tests for helper functions -func TestNormalizeETag(t *testing.T) { - tests := []struct { - name string - etag string - expected string - }{ - { - name: "etag without quotes", - etag: "abc123", - expected: "\"abc123\"", - }, - { - name: "etag already has quotes", - etag: "\"abc123\"", - expected: "\"abc123\"", - }, - { - name: "empty etag", - etag: "", - expected: "\"\"", - }, - { - name: "etag with special characters", - etag: "abc-123_def", - expected: "\"abc-123_def\"", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeETag(tt.etag) - if result != tt.expected { - t.Errorf("Expected %s, got %s", tt.expected, result) - } - }) - } -} diff --git a/pkg/hold/oci/http_helpers.go b/pkg/hold/oci/http_helpers.go deleted file mode 100644 index c23d689..0000000 --- a/pkg/hold/oci/http_helpers.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package oci provides HTTP helpers for OCI registry endpoints in the hold service. -// It includes utilities for JSON encoding/decoding of request/response bodies -// and standardized error responses for XRPC endpoints. -package oci - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" -) - -// DecodeJSON decodes JSON request body into the provided value -// Returns an error if decoding fails -func DecodeJSON(r *http.Request, v any) error { - if err := json.NewDecoder(r.Body).Decode(v); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) - } - return nil -} - -// RespondJSON writes a JSON response with the given status code -func RespondJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(v); err != nil { - // If encoding fails, we can't do much since headers are already sent - // Log the error but don't try to send another response - slog.Error("Failed to encode JSON response", "error", err) - } -} - -// RespondError writes a JSON error response with the given status code and message -func RespondError(w http.ResponseWriter, status int, message string) { - RespondJSON(w, status, map[string]string{ - "error": message, - }) -} diff --git a/pkg/hold/oci/multipart_test.go b/pkg/hold/oci/multipart_test.go index 5f16122..df5ea69 100644 --- a/pkg/hold/oci/multipart_test.go +++ b/pkg/hold/oci/multipart_test.go @@ -227,3 +227,42 @@ func TestCleanupExpiredSessions(t *testing.T) { t.Error("Recent session should still exist") } } + +// Tests for helper functions +func TestNormalizeETag(t *testing.T) { + tests := []struct { + name string + etag string + expected string + }{ + { + name: "etag without quotes", + etag: "abc123", + expected: "\"abc123\"", + }, + { + name: "etag already has quotes", + etag: "\"abc123\"", + expected: "\"abc123\"", + }, + { + name: "empty etag", + etag: "", + expected: "\"\"", + }, + { + name: "etag with special characters", + etag: "abc-123_def", + expected: "\"abc-123_def\"", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeETag(tt.etag) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} diff --git a/pkg/hold/oci/xrpc.go b/pkg/hold/oci/xrpc.go index e8703f0..e8cc478 100644 --- a/pkg/hold/oci/xrpc.go +++ b/pkg/hold/oci/xrpc.go @@ -1,3 +1,4 @@ +// Package oci provides OCI registry endpoints for the hold service. package oci import ( @@ -13,6 +14,7 @@ import ( "atcr.io/pkg/s3" storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" ) // XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads @@ -63,23 +65,26 @@ func (h *XRPCHandler) HandleInitiateUpload(w http.ResponseWriter, r *http.Reques Digest string `json:"digest"` } - if err := DecodeJSON(r, &req); err != nil { - RespondError(w, http.StatusBadRequest, err.Error()) + if err := render.Decode(r, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": err.Error()}) return } if req.Digest == "" { - RespondError(w, http.StatusBadRequest, "digest is required") + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "digest is required"}) return } uploadID, _, err := h.StartMultipartUploadWithManager(r.Context(), req.Digest) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to initiate upload: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to initiate upload: %v", err)}) return } - RespondJSON(w, http.StatusOK, map[string]any{ + render.JSON(w, r, map[string]any{ "uploadId": uploadID, }) } @@ -92,23 +97,26 @@ func (h *XRPCHandler) HandleGetPartUploadURL(w http.ResponseWriter, r *http.Requ PartNumber int `json:"partNumber"` } - if err := DecodeJSON(r, &req); err != nil { - RespondError(w, http.StatusBadRequest, err.Error()) + if err := render.Decode(r, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": err.Error()}) return } if req.UploadID == "" || req.PartNumber == 0 { - RespondError(w, http.StatusBadRequest, "uploadId and partNumber are required") + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "uploadId and partNumber are required"}) return } uploadInfo, err := h.GetPartUploadURL(r.Context(), req.UploadID, req.PartNumber) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get part upload URL: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to get part upload URL: %v", err)}) return } - RespondJSON(w, http.StatusOK, uploadInfo) + render.JSON(w, r, uploadInfo) } // HandleUploadPart handles direct buffered part uploads @@ -118,29 +126,33 @@ func (h *XRPCHandler) HandleUploadPart(w http.ResponseWriter, r *http.Request) { partNumberStr := r.Header.Get("X-Part-Number") if uploadID == "" || partNumberStr == "" { - RespondError(w, http.StatusBadRequest, "X-Upload-Id and X-Part-Number headers are required") + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "X-Upload-Id and X-Part-Number headers are required"}) return } partNumber, err := strconv.Atoi(partNumberStr) if err != nil { - RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid part number: %v", err)) + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid part number: %v", err)}) return } data, err := io.ReadAll(r.Body) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read part data: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to read part data: %v", err)}) return } etag, err := h.HandleBufferedPartUpload(r.Context(), uploadID, partNumber, data) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to upload part: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to upload part: %v", err)}) return } - RespondJSON(w, http.StatusOK, map[string]any{ + render.JSON(w, r, map[string]any{ "etag": etag, }) } @@ -154,23 +166,26 @@ func (h *XRPCHandler) HandleCompleteUpload(w http.ResponseWriter, r *http.Reques Parts []PartInfo `json:"parts"` } - if err := DecodeJSON(r, &req); err != nil { - RespondError(w, http.StatusBadRequest, err.Error()) + if err := render.Decode(r, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": err.Error()}) return } if req.UploadID == "" || req.Digest == "" || len(req.Parts) == 0 { - RespondError(w, http.StatusBadRequest, "uploadId, digest, and parts are required") + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "uploadId, digest, and parts are required"}) return } err := h.CompleteMultipartUploadWithManager(r.Context(), req.UploadID, req.Digest, req.Parts) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to complete upload: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to complete upload: %v", err)}) return } - RespondJSON(w, http.StatusOK, map[string]any{ + render.JSON(w, r, map[string]any{ "status": "completed", "digest": req.Digest, }) @@ -183,23 +198,26 @@ func (h *XRPCHandler) HandleAbortUpload(w http.ResponseWriter, r *http.Request) UploadID string `json:"uploadId"` } - if err := DecodeJSON(r, &req); err != nil { - RespondError(w, http.StatusBadRequest, err.Error()) + if err := render.Decode(r, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": err.Error()}) return } if req.UploadID == "" { - RespondError(w, http.StatusBadRequest, "uploadId is required") + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": "uploadId is required"}) return } err := h.AbortMultipartUploadWithManager(r.Context(), req.UploadID) if err != nil { - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to abort upload: %v", err)) + render.Status(r, http.StatusInternalServerError) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to abort upload: %v", err)}) return } - RespondJSON(w, http.StatusOK, map[string]any{ + render.JSON(w, r, map[string]any{ "status": "aborted", }) } @@ -214,7 +232,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques // Validate service token (same auth as blob:write endpoints) validatedUser, err := pds.ValidateBlobWriteAccess(r, h.pds, h.httpClient) if err != nil { - RespondError(w, http.StatusForbidden, fmt.Sprintf("authorization failed: %v", err)) + render.Status(r, http.StatusForbidden) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("authorization failed: %v", err)}) return } @@ -248,8 +267,9 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques } `json:"manifest"` } - if err := DecodeJSON(r, &req); err != nil { - RespondError(w, http.StatusBadRequest, err.Error()) + if err := render.Decode(r, &req); err != nil { + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": err.Error()}) return } @@ -261,7 +281,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques // Validate operation if operation != "push" && operation != "pull" { - RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation)) + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation)}) return } @@ -269,7 +290,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques // For pulls: userDID is the repo owner (for stats), but the token belongs to the puller // This allows anyone to pull from a public repo and have stats tracked under the owner if operation == "push" && req.UserDID != validatedUser.DID { - RespondError(w, http.StatusForbidden, "user DID mismatch") + render.Status(r, http.StatusForbidden) + render.JSON(w, r, map[string]string{"error": "user DID mismatch"}) return } @@ -290,10 +312,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques "repository", req.Repository, "tag", req.Tag, ) - RespondError(w, http.StatusForbidden, fmt.Sprintf( + render.Status(r, http.StatusForbidden) + render.JSON(w, r, map[string]string{"error": fmt.Sprintf( "quota exceeded: current=%d bytes, limit=%d bytes. Delete images to free space.", stats.TotalSize, *stats.Limit, - )) + )}) return } @@ -409,7 +432,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques } } - RespondJSON(w, http.StatusOK, resp) + render.JSON(w, r, resp) } // requireBlobWriteAccess middleware - validates DPoP + OAuth and checks for blob:write permission diff --git a/pkg/hold/pds/records.go b/pkg/hold/pds/records.go index 5a55e0e..2e7b6e2 100644 --- a/pkg/hold/pds/records.go +++ b/pkg/hold/pds/records.go @@ -41,9 +41,6 @@ CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rk CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did); ` -// Schema version for migration detection -const recordsSchemaVersion = 2 - // NewRecordsIndex creates or opens a records index // If the schema is outdated (missing did column), drops and rebuilds the table func NewRecordsIndex(dbPath string) (*RecordsIndex, error) { diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index 8f0c62a..23fb6f7 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -14,6 +14,7 @@ import ( "github.com/bluesky-social/indigo/repo" "github.com/distribution/distribution/v3/registry/storage/driver" "github.com/go-chi/chi/v5" + "github.com/go-chi/render" "github.com/gorilla/websocket" "github.com/ipfs/go-cid" "github.com/ipld/go-car" @@ -202,15 +203,9 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) { // HandleHealth returns health check information func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { - response := map[string]any{ + render.JSON(w, r, map[string]any{ "version": "0.4.999", - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleDescribeServer returns server metadata @@ -223,17 +218,11 @@ func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Reques hostname, _, _ = strings.Cut(hostname, "/") // Remove path hostname, _, _ = strings.Cut(hostname, ":") // Remove port - response := map[string]any{ + render.JSON(w, r, map[string]any{ "did": h.pds.DID(), "availableUserDomains": []string{"." + hostname}, "inviteCodeRequired": true, // Single-user PDS, no account creation - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleResolveHandle resolves a handle to a DID @@ -256,15 +245,9 @@ func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request } // Return the DID - response := map[string]string{ + render.JSON(w, r, map[string]string{ "did": h.pds.DID(), - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleGetProfile returns aggregated profile information @@ -296,13 +279,7 @@ func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) { } // Build profile response using shared function - response := h.buildProfileResponse(r.Context()) - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, h.buildProfileResponse(r.Context())) } // HandleGetProfiles returns aggregated profile information for multiple actors @@ -348,15 +325,9 @@ func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request) } // Return profiles array - response := map[string]any{ + render.JSON(w, r, map[string]any{ "profiles": profiles, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles) @@ -441,19 +412,13 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) } // Note: For did:web, the handle IS the DID (not just hostname) - response := map[string]any{ + render.JSON(w, r, map[string]any{ "did": h.pds.DID(), "handle": h.pds.DID(), "didDoc": didDoc, "collections": collections, "handleIsCorrect": true, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleGetRecord retrieves a record from the repository @@ -490,17 +455,11 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) { return } - response := map[string]any{ + render.JSON(w, r, map[string]any{ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey), "cid": recordCID.String(), "value": recordValue, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleListRecords lists records in a collection @@ -570,12 +529,7 @@ func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Re if !head.Defined() { // Empty repo, return empty list - response := map[string]any{"records": []any{}} - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, map[string]any{"records": []any{}}) return } @@ -621,11 +575,7 @@ func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Re response["cursor"] = nextCursor } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, response) } // handleListRecordsMST uses the legacy MST-based listing (fallback for tests) @@ -645,12 +595,7 @@ func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Reques if !head.Defined() { // Empty repo, return empty list - response := map[string]any{"records": []any{}} - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, map[string]any{"records": []any{}}) return } @@ -758,11 +703,7 @@ func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Reques response["cursor"] = nextCursor } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, response) } // HandleDeleteRecord deletes a record from the repository @@ -831,15 +772,11 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) if !currentCID.Equals(swapRecordCID) { // Swap failed - record CID doesn't match - w.WriteHeader(http.StatusBadRequest) - response := map[string]any{ + render.Status(r, http.StatusBadRequest) + render.JSON(w, r, map[string]any{ "error": "InvalidSwap", "message": "record CID does not match swapRecord", - } - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) return } } @@ -875,18 +812,12 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) } // Return commit response (per spec) - response := map[string]any{ + render.JSON(w, r, map[string]any{ "commit": map[string]any{ "cid": head.String(), "rev": rev, }, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleSyncGetRecord returns a single record as a CAR file for sync @@ -941,8 +872,7 @@ func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request // Write the CAR data to the response if _, err := w.Write(buf.Bytes()); err != nil { - slog.Error("failed to write car to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) + slog.Error("failed to write CAR to http response", "error", err, "path", r.URL.Path) } } @@ -1094,7 +1024,7 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) { } // Return ATProto-compliant blob response - response := map[string]any{ + render.JSON(w, r, map[string]any{ "blob": map[string]any{ "$type": "blob", "ref": map[string]any{ @@ -1103,13 +1033,7 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) { "mimeType": "application/octet-stream", "size": size, }, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleGetBlob routes blob requests to appropriate handlers based on blob type @@ -1181,14 +1105,9 @@ func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, d "url", presignedURL) // Return JSON response with presigned URL (AppView expects this format) - response := map[string]string{ + render.JSON(w, r, map[string]string{ "url": presignedURL, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // handleGetATProtoBlob handles standard ATProto blob requests @@ -1238,14 +1157,7 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) { head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid) if err != nil { // If no repo exists yet, return empty list - response := map[string]any{ - "repos": []any{}, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, map[string]any{"repos": []any{}}) return } @@ -1253,14 +1165,7 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) { if err != nil || rev == "" { // No commits yet, return empty list // Don't expose repos with no revision (empty/uninitialized) - response := map[string]any{ - "repos": []any{}, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, map[string]any{"repos": []any{}}) return } @@ -1273,15 +1178,9 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) { }, } - response := map[string]any{ + render.JSON(w, r, map[string]any{ "repos": repos, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleGetRepoStatus returns the hosting status for a repository @@ -1305,30 +1204,19 @@ func (h *XRPCHandler) HandleGetRepoStatus(w http.ResponseWriter, r *http.Request if err != nil || rev == "" { // Repo exists (DID matches) but no commits yet // Per ATProto spec, return active=true even if empty - response := map[string]any{ + render.JSON(w, r, map[string]any{ "did": did, "active": true, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) return } // Return status with revision - response := map[string]any{ + render.JSON(w, r, map[string]any{ "did": did, "active": true, "rev": rev, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // HandleDIDDocument returns the DID document @@ -1339,11 +1227,7 @@ func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(doc); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, doc) } // HandleAtprotoDID returns the DID for handle resolution @@ -1434,18 +1318,12 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) slog.Debug("User is already a crew member", "did", user.DID, "rkey", member.Rkey) - response := map[string]any{ + render.JSON(w, r, map[string]any{ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey), "cid": member.Cid.String(), "status": "already_member", "message": "User is already a crew member", - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) return } } @@ -1470,18 +1348,12 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) // Return success response // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it // For now, return just the CID. In production, AddCrewMember should return both CID and rkey - response := map[string]any{ + render.Status(r, http.StatusCreated) + render.JSON(w, r, map[string]any{ "cid": recordCID.String(), "status": "created", "message": "Successfully added to crew", - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + }) } // GetPresignedURL generates a presigned URL for GET, HEAD, or PUT operations @@ -1618,9 +1490,5 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(stats); err != nil { - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) - w.WriteHeader(http.StatusInternalServerError) - } + render.JSON(w, r, stats) }