A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

use chi/render to simplify returned json

evan.jarrett.net 9704fe09 c82dad81

verified
-6
.golangci.yml
··· 26 26 - errcheck 27 27 28 28 # TODO: fix issues and remove these paths one by one 29 - - path: pkg/auth 30 - linters: 31 - - errcheck 32 - - path: pkg/appview 33 - linters: 34 - - errcheck 35 29 - path: cmd/credential-helper 36 30 linters: 37 31 - errcheck
+1 -1
.tangled/workflows/lint.yaml
··· 14 14 CGO_ENABLED: 1 15 15 command: | 16 16 go mod download 17 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 17 18 go generate ./... 18 19 19 20 - name: Run Linter 20 21 environment: 21 22 CGO_ENABLED: 1 22 23 command: | 23 - curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.2 24 24 golangci-lint run ./...
+2
go.mod
··· 9 9 github.com/distribution/reference v0.6.0 10 10 github.com/earthboundkid/versioninfo/v2 v2.24.1 11 11 github.com/go-chi/chi/v5 v5.2.3 12 + github.com/go-chi/render v1.0.3 12 13 github.com/goki/freetype v1.0.5 13 14 github.com/golang-jwt/jwt/v5 v5.2.2 14 15 github.com/google/uuid v1.6.0 ··· 40 41 41 42 require ( 42 43 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 44 + github.com/ajg/form v1.5.1 // indirect 43 45 github.com/aymerick/douceur v0.2.0 // indirect 44 46 github.com/beorn7/perks v1.0.1 // indirect 45 47 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect
+4
go.sum
··· 3 3 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 4 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 5 5 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 6 + github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 7 + github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 6 8 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 7 9 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 8 10 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= ··· 70 72 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 71 73 github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 72 74 github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 75 + github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 76 + github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 73 77 github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= 74 78 github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= 75 79 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+10 -17
pkg/appview/handlers/api.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 7 "log/slog" ··· 15 14 "atcr.io/pkg/auth/oauth" 16 15 "github.com/bluesky-social/indigo/atproto/identity" 17 16 "github.com/go-chi/chi/v5" 17 + "github.com/go-chi/render" 18 18 ) 19 19 20 20 // StarRepositoryHandler handles starring a repository ··· 66 66 } 67 67 68 68 // Return success 69 - w.Header().Set("Content-Type", "application/json") 70 69 w.WriteHeader(http.StatusCreated) 71 - json.NewEncoder(w).Encode(map[string]bool{"starred": true}) 70 + render.JSON(w, r, map[string]bool{"starred": true}) 72 71 } 73 72 74 73 // UnstarRepositoryHandler handles unstarring a repository ··· 122 121 } 123 122 124 123 // Return success 125 - w.Header().Set("Content-Type", "application/json") 126 - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 124 + render.JSON(w, r, map[string]bool{"starred": false}) 127 125 } 128 126 129 127 // CheckStarHandler checks if current user has starred a repository ··· 138 136 user := middleware.GetUser(r) 139 137 if user == nil { 140 138 // Not authenticated - return not starred 141 - w.Header().Set("Content-Type", "application/json") 142 - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 139 + render.JSON(w, r, map[string]bool{"starred": false}) 143 140 return 144 141 } 145 142 ··· 166 163 // Check if OAuth error - if so, invalidate sessions 167 164 if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 168 165 // For a read operation, just return not starred instead of error 169 - w.Header().Set("Content-Type", "application/json") 170 - json.NewEncoder(w).Encode(map[string]bool{"starred": false}) 166 + render.JSON(w, r, map[string]bool{"starred": false}) 171 167 return 172 168 } 173 169 174 170 starred := err == nil 175 171 176 172 // Return result 177 - w.Header().Set("Content-Type", "application/json") 178 - json.NewEncoder(w).Encode(map[string]bool{"starred": starred}) 173 + render.JSON(w, r, map[string]bool{"starred": starred}) 179 174 } 180 175 181 176 // GetStatsHandler returns repository statistics ··· 204 199 } 205 200 206 201 // Return stats as JSON 207 - w.Header().Set("Content-Type", "application/json") 208 - json.NewEncoder(w).Encode(stats) 202 + render.JSON(w, r, stats) 209 203 } 210 204 211 205 // ManifestDetailHandler returns detailed manifest information including platforms ··· 240 234 } 241 235 242 236 // Return manifest as JSON 243 - w.Header().Set("Content-Type", "application/json") 244 - json.NewEncoder(w).Encode(manifest) 237 + render.JSON(w, r, manifest) 245 238 } 246 239 247 240 // CredentialHelperVersionResponse is the response for the credential helper version API ··· 297 290 Checksums: h.Checksums, 298 291 } 299 292 300 - w.Header().Set("Content-Type", "application/json") 293 + render.SetContentType(render.ContentTypeJSON) 301 294 w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes 302 - json.NewEncoder(w).Encode(response) 295 + render.JSON(w, r, response) 303 296 }
+12 -20
pkg/appview/handlers/device.go
··· 1 1 package handlers 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 "html/template" 7 6 "log/slog" ··· 9 8 "net/url" 10 9 "strings" 11 10 11 + "atcr.io/pkg/appview/db" 12 12 "github.com/go-chi/chi/v5" 13 - 14 - "atcr.io/pkg/appview/db" 13 + "github.com/go-chi/render" 15 14 ) 16 15 17 16 // DeviceCodeRequest is the request to start device authorization ··· 41 40 } 42 41 43 42 var req DeviceCodeRequest 44 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 43 + if err := render.Decode(r, &req); err != nil { 45 44 http.Error(w, "invalid request", http.StatusBadRequest) 46 45 return 47 46 } ··· 73 72 Interval: 5, // Poll every 5 seconds 74 73 } 75 74 76 - w.Header().Set("Content-Type", "application/json") 77 - json.NewEncoder(w).Encode(resp) 75 + render.JSON(w, r, resp) 78 76 } 79 77 80 78 // DeviceTokenRequest is the request to poll for device authorization ··· 102 100 } 103 101 104 102 var req DeviceTokenRequest 105 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 103 + if err := render.Decode(r, &req); err != nil { 106 104 http.Error(w, "invalid request", http.StatusBadRequest) 107 105 return 108 106 } ··· 113 111 resp := DeviceTokenResponse{ 114 112 Error: "expired_token", 115 113 } 116 - w.Header().Set("Content-Type", "application/json") 117 - json.NewEncoder(w).Encode(resp) 114 + render.JSON(w, r, resp) 118 115 return 119 116 } 120 117 ··· 124 121 resp := DeviceTokenResponse{ 125 122 Error: "authorization_pending", 126 123 } 127 - w.Header().Set("Content-Type", "application/json") 128 - json.NewEncoder(w).Encode(resp) 124 + render.JSON(w, r, resp) 129 125 return 130 126 } 131 127 ··· 147 143 DID: *pending.ApprovedDID, 148 144 } 149 145 150 - w.Header().Set("Content-Type", "application/json") 151 - json.NewEncoder(w).Encode(resp) 146 + render.JSON(w, r, resp) 152 147 } 153 148 154 149 // DeviceApprovalPageHandler handles GET /device ··· 257 252 } 258 253 259 254 var req DeviceApproveRequest 260 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 255 + if err := render.Decode(r, &req); err != nil { 261 256 http.Error(w, "invalid request", http.StatusBadRequest) 262 257 return 263 258 } 264 259 265 260 if !req.Approve { 266 261 // User denied 267 - w.Header().Set("Content-Type", "application/json") 268 - json.NewEncoder(w).Encode(map[string]string{"status": "denied"}) 262 + render.JSON(w, r, map[string]string{"status": "denied"}) 269 263 return 270 264 } 271 265 ··· 276 270 http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) 277 271 return 278 272 } 279 - w.Header().Set("Content-Type", "application/json") 280 - json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) 273 + render.JSON(w, r, map[string]string{"status": "approved"}) 281 274 } 282 275 283 276 // ListDevicesHandler handles GET /api/devices ··· 308 301 // Get devices for this user 309 302 devices := h.Store.ListDevices(sess.DID) 310 303 311 - w.Header().Set("Content-Type", "application/json") 312 - json.NewEncoder(w).Encode(devices) 304 + render.JSON(w, r, devices) 313 305 } 314 306 315 307 // RevokeDeviceHandler handles DELETE /api/devices/{id}
+4 -5
pkg/appview/handlers/images.go
··· 15 15 "atcr.io/pkg/atproto" 16 16 "atcr.io/pkg/auth/oauth" 17 17 "github.com/go-chi/chi/v5" 18 + "github.com/go-chi/render" 18 19 ) 19 20 20 21 // DeleteTagHandler handles deleting a tag ··· 93 94 return 94 95 } 95 96 96 - w.Header().Set("Content-Type", "application/json") 97 - w.WriteHeader(http.StatusConflict) 98 - json.NewEncoder(w).Encode(map[string]any{ 97 + render.Status(r, http.StatusConflict) 98 + render.JSON(w, r, map[string]any{ 99 99 "error": "confirmation_required", 100 100 "message": "This manifest has associated tags that will also be deleted", 101 101 "tags": tags, ··· 266 266 267 267 // Return new avatar URL 268 268 avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link) 269 - w.Header().Set("Content-Type", "application/json") 270 - json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL}) 269 + render.JSON(w, r, map[string]string{"avatarURL": avatarURL}) 271 270 }
+3 -7
pkg/auth/token/handler.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "errors" 7 6 "fmt" 8 7 "log/slog" ··· 13 12 "atcr.io/pkg/appview/db" 14 13 "atcr.io/pkg/atproto" 15 14 "atcr.io/pkg/auth" 15 + "github.com/go-chi/render" 16 16 ) 17 17 18 18 // PostAuthCallback is called after successful Basic Auth authentication. ··· 120 120 Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.", 121 121 LoginURL: loginURL, 122 122 } 123 - json.NewEncoder(w).Encode(resp) 123 + render.JSON(w, r, resp) 124 124 } 125 125 126 126 // ServeHTTP handles the token request ··· 269 269 IssuedAt: now.Format(time.RFC3339), 270 270 } 271 271 272 - w.Header().Set("Content-Type", "application/json") 273 - if err := json.NewEncoder(w).Encode(resp); err != nil { 274 - http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 275 - return 276 - } 272 + render.JSON(w, r, resp) 277 273 }
+3 -11
pkg/hold/admin/handlers.go
··· 1 1 package admin 2 2 3 3 import ( 4 - "encoding/json" 5 4 "log/slog" 6 5 "net/http" 7 6 "sort" 8 7 "strconv" 9 8 10 9 "atcr.io/pkg/atproto" 10 + "github.com/go-chi/render" 11 11 ) 12 12 13 13 // DashboardStats contains dashboard statistics ··· 122 122 } 123 123 124 124 // Otherwise return JSON 125 - w.Header().Set("Content-Type", "application/json") 126 - if err := json.NewEncoder(w).Encode(stats); err != nil { 127 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 128 - w.WriteHeader(http.StatusInternalServerError) 129 - } 125 + render.JSON(w, r, stats) 130 126 } 131 127 132 128 // UserUsage represents storage usage for a user ··· 194 190 } 195 191 196 192 // Otherwise return JSON 197 - w.Header().Set("Content-Type", "application/json") 198 - if err := json.NewEncoder(w).Encode(users); err != nil { 199 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 200 - w.WriteHeader(http.StatusInternalServerError) 201 - } 193 + render.JSON(w, r, users) 202 194 }
+1 -1
pkg/hold/admin/handlers_auth.go
··· 120 120 token, err := ui.createSession(did, handle) 121 121 if err != nil { 122 122 slog.Error("failed to create session token", "error", err, "path", r.URL.Path) 123 - w.WriteHeader(http.StatusInternalServerError) 123 + http.Error(w, "Failed to create session", http.StatusInternalServerError) 124 124 return 125 125 } 126 126 ui.setSessionCookie(w, r, token)
+1 -1
pkg/hold/admin/handlers_crew.go
··· 321 321 if tier != "" { 322 322 if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil { 323 323 slog.Error("failed to update crew member tier", "error", err, "path", r.URL.Path) 324 - w.WriteHeader(http.StatusInternalServerError) 324 + http.Error(w, "Failed to update tier", http.StatusInternalServerError) 325 325 return 326 326 } 327 327 }
-44
pkg/hold/oci/helpers_test.go
··· 1 - package oci 2 - 3 - import ( 4 - "testing" 5 - ) 6 - 7 - // Tests for helper functions 8 - func TestNormalizeETag(t *testing.T) { 9 - tests := []struct { 10 - name string 11 - etag string 12 - expected string 13 - }{ 14 - { 15 - name: "etag without quotes", 16 - etag: "abc123", 17 - expected: "\"abc123\"", 18 - }, 19 - { 20 - name: "etag already has quotes", 21 - etag: "\"abc123\"", 22 - expected: "\"abc123\"", 23 - }, 24 - { 25 - name: "empty etag", 26 - etag: "", 27 - expected: "\"\"", 28 - }, 29 - { 30 - name: "etag with special characters", 31 - etag: "abc-123_def", 32 - expected: "\"abc-123_def\"", 33 - }, 34 - } 35 - 36 - for _, tt := range tests { 37 - t.Run(tt.name, func(t *testing.T) { 38 - result := normalizeETag(tt.etag) 39 - if result != tt.expected { 40 - t.Errorf("Expected %s, got %s", tt.expected, result) 41 - } 42 - }) 43 - } 44 - }
-38
pkg/hold/oci/http_helpers.go
··· 1 - // Package oci provides HTTP helpers for OCI registry endpoints in the hold service. 2 - // It includes utilities for JSON encoding/decoding of request/response bodies 3 - // and standardized error responses for XRPC endpoints. 4 - package oci 5 - 6 - import ( 7 - "encoding/json" 8 - "fmt" 9 - "log/slog" 10 - "net/http" 11 - ) 12 - 13 - // DecodeJSON decodes JSON request body into the provided value 14 - // Returns an error if decoding fails 15 - func DecodeJSON(r *http.Request, v any) error { 16 - if err := json.NewDecoder(r.Body).Decode(v); err != nil { 17 - return fmt.Errorf("invalid JSON body: %w", err) 18 - } 19 - return nil 20 - } 21 - 22 - // RespondJSON writes a JSON response with the given status code 23 - func RespondJSON(w http.ResponseWriter, status int, v any) { 24 - w.Header().Set("Content-Type", "application/json") 25 - w.WriteHeader(status) 26 - if err := json.NewEncoder(w).Encode(v); err != nil { 27 - // If encoding fails, we can't do much since headers are already sent 28 - // Log the error but don't try to send another response 29 - slog.Error("Failed to encode JSON response", "error", err) 30 - } 31 - } 32 - 33 - // RespondError writes a JSON error response with the given status code and message 34 - func RespondError(w http.ResponseWriter, status int, message string) { 35 - RespondJSON(w, status, map[string]string{ 36 - "error": message, 37 - }) 38 - }
+39
pkg/hold/oci/multipart_test.go
··· 227 227 t.Error("Recent session should still exist") 228 228 } 229 229 } 230 + 231 + // Tests for helper functions 232 + func TestNormalizeETag(t *testing.T) { 233 + tests := []struct { 234 + name string 235 + etag string 236 + expected string 237 + }{ 238 + { 239 + name: "etag without quotes", 240 + etag: "abc123", 241 + expected: "\"abc123\"", 242 + }, 243 + { 244 + name: "etag already has quotes", 245 + etag: "\"abc123\"", 246 + expected: "\"abc123\"", 247 + }, 248 + { 249 + name: "empty etag", 250 + etag: "", 251 + expected: "\"\"", 252 + }, 253 + { 254 + name: "etag with special characters", 255 + etag: "abc-123_def", 256 + expected: "\"abc-123_def\"", 257 + }, 258 + } 259 + 260 + for _, tt := range tests { 261 + t.Run(tt.name, func(t *testing.T) { 262 + result := normalizeETag(tt.etag) 263 + if result != tt.expected { 264 + t.Errorf("Expected %s, got %s", tt.expected, result) 265 + } 266 + }) 267 + } 268 + }
+56 -33
pkg/hold/oci/xrpc.go
··· 1 + // Package oci provides OCI registry endpoints for the hold service. 1 2 package oci 2 3 3 4 import ( ··· 13 14 "atcr.io/pkg/s3" 14 15 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 15 16 "github.com/go-chi/chi/v5" 17 + "github.com/go-chi/render" 16 18 ) 17 19 18 20 // XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads ··· 63 65 Digest string `json:"digest"` 64 66 } 65 67 66 - if err := DecodeJSON(r, &req); err != nil { 67 - RespondError(w, http.StatusBadRequest, err.Error()) 68 + if err := render.Decode(r, &req); err != nil { 69 + render.Status(r, http.StatusBadRequest) 70 + render.JSON(w, r, map[string]string{"error": err.Error()}) 68 71 return 69 72 } 70 73 71 74 if req.Digest == "" { 72 - RespondError(w, http.StatusBadRequest, "digest is required") 75 + render.Status(r, http.StatusBadRequest) 76 + render.JSON(w, r, map[string]string{"error": "digest is required"}) 73 77 return 74 78 } 75 79 76 80 uploadID, _, err := h.StartMultipartUploadWithManager(r.Context(), req.Digest) 77 81 if err != nil { 78 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to initiate upload: %v", err)) 82 + render.Status(r, http.StatusInternalServerError) 83 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to initiate upload: %v", err)}) 79 84 return 80 85 } 81 86 82 - RespondJSON(w, http.StatusOK, map[string]any{ 87 + render.JSON(w, r, map[string]any{ 83 88 "uploadId": uploadID, 84 89 }) 85 90 } ··· 92 97 PartNumber int `json:"partNumber"` 93 98 } 94 99 95 - if err := DecodeJSON(r, &req); err != nil { 96 - RespondError(w, http.StatusBadRequest, err.Error()) 100 + if err := render.Decode(r, &req); err != nil { 101 + render.Status(r, http.StatusBadRequest) 102 + render.JSON(w, r, map[string]string{"error": err.Error()}) 97 103 return 98 104 } 99 105 100 106 if req.UploadID == "" || req.PartNumber == 0 { 101 - RespondError(w, http.StatusBadRequest, "uploadId and partNumber are required") 107 + render.Status(r, http.StatusBadRequest) 108 + render.JSON(w, r, map[string]string{"error": "uploadId and partNumber are required"}) 102 109 return 103 110 } 104 111 105 112 uploadInfo, err := h.GetPartUploadURL(r.Context(), req.UploadID, req.PartNumber) 106 113 if err != nil { 107 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get part upload URL: %v", err)) 114 + render.Status(r, http.StatusInternalServerError) 115 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to get part upload URL: %v", err)}) 108 116 return 109 117 } 110 118 111 - RespondJSON(w, http.StatusOK, uploadInfo) 119 + render.JSON(w, r, uploadInfo) 112 120 } 113 121 114 122 // HandleUploadPart handles direct buffered part uploads ··· 118 126 partNumberStr := r.Header.Get("X-Part-Number") 119 127 120 128 if uploadID == "" || partNumberStr == "" { 121 - RespondError(w, http.StatusBadRequest, "X-Upload-Id and X-Part-Number headers are required") 129 + render.Status(r, http.StatusBadRequest) 130 + render.JSON(w, r, map[string]string{"error": "X-Upload-Id and X-Part-Number headers are required"}) 122 131 return 123 132 } 124 133 125 134 partNumber, err := strconv.Atoi(partNumberStr) 126 135 if err != nil { 127 - RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid part number: %v", err)) 136 + render.Status(r, http.StatusBadRequest) 137 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid part number: %v", err)}) 128 138 return 129 139 } 130 140 131 141 data, err := io.ReadAll(r.Body) 132 142 if err != nil { 133 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read part data: %v", err)) 143 + render.Status(r, http.StatusInternalServerError) 144 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to read part data: %v", err)}) 134 145 return 135 146 } 136 147 137 148 etag, err := h.HandleBufferedPartUpload(r.Context(), uploadID, partNumber, data) 138 149 if err != nil { 139 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to upload part: %v", err)) 150 + render.Status(r, http.StatusInternalServerError) 151 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to upload part: %v", err)}) 140 152 return 141 153 } 142 154 143 - RespondJSON(w, http.StatusOK, map[string]any{ 155 + render.JSON(w, r, map[string]any{ 144 156 "etag": etag, 145 157 }) 146 158 } ··· 154 166 Parts []PartInfo `json:"parts"` 155 167 } 156 168 157 - if err := DecodeJSON(r, &req); err != nil { 158 - RespondError(w, http.StatusBadRequest, err.Error()) 169 + if err := render.Decode(r, &req); err != nil { 170 + render.Status(r, http.StatusBadRequest) 171 + render.JSON(w, r, map[string]string{"error": err.Error()}) 159 172 return 160 173 } 161 174 162 175 if req.UploadID == "" || req.Digest == "" || len(req.Parts) == 0 { 163 - RespondError(w, http.StatusBadRequest, "uploadId, digest, and parts are required") 176 + render.Status(r, http.StatusBadRequest) 177 + render.JSON(w, r, map[string]string{"error": "uploadId, digest, and parts are required"}) 164 178 return 165 179 } 166 180 167 181 err := h.CompleteMultipartUploadWithManager(r.Context(), req.UploadID, req.Digest, req.Parts) 168 182 if err != nil { 169 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to complete upload: %v", err)) 183 + render.Status(r, http.StatusInternalServerError) 184 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to complete upload: %v", err)}) 170 185 return 171 186 } 172 187 173 - RespondJSON(w, http.StatusOK, map[string]any{ 188 + render.JSON(w, r, map[string]any{ 174 189 "status": "completed", 175 190 "digest": req.Digest, 176 191 }) ··· 183 198 UploadID string `json:"uploadId"` 184 199 } 185 200 186 - if err := DecodeJSON(r, &req); err != nil { 187 - RespondError(w, http.StatusBadRequest, err.Error()) 201 + if err := render.Decode(r, &req); err != nil { 202 + render.Status(r, http.StatusBadRequest) 203 + render.JSON(w, r, map[string]string{"error": err.Error()}) 188 204 return 189 205 } 190 206 191 207 if req.UploadID == "" { 192 - RespondError(w, http.StatusBadRequest, "uploadId is required") 208 + render.Status(r, http.StatusBadRequest) 209 + render.JSON(w, r, map[string]string{"error": "uploadId is required"}) 193 210 return 194 211 } 195 212 196 213 err := h.AbortMultipartUploadWithManager(r.Context(), req.UploadID) 197 214 if err != nil { 198 - RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to abort upload: %v", err)) 215 + render.Status(r, http.StatusInternalServerError) 216 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to abort upload: %v", err)}) 199 217 return 200 218 } 201 219 202 - RespondJSON(w, http.StatusOK, map[string]any{ 220 + render.JSON(w, r, map[string]any{ 203 221 "status": "aborted", 204 222 }) 205 223 } ··· 214 232 // Validate service token (same auth as blob:write endpoints) 215 233 validatedUser, err := pds.ValidateBlobWriteAccess(r, h.pds, h.httpClient) 216 234 if err != nil { 217 - RespondError(w, http.StatusForbidden, fmt.Sprintf("authorization failed: %v", err)) 235 + render.Status(r, http.StatusForbidden) 236 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("authorization failed: %v", err)}) 218 237 return 219 238 } 220 239 ··· 248 267 } `json:"manifest"` 249 268 } 250 269 251 - if err := DecodeJSON(r, &req); err != nil { 252 - RespondError(w, http.StatusBadRequest, err.Error()) 270 + if err := render.Decode(r, &req); err != nil { 271 + render.Status(r, http.StatusBadRequest) 272 + render.JSON(w, r, map[string]string{"error": err.Error()}) 253 273 return 254 274 } 255 275 ··· 261 281 262 282 // Validate operation 263 283 if operation != "push" && operation != "pull" { 264 - RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation)) 284 + render.Status(r, http.StatusBadRequest) 285 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation)}) 265 286 return 266 287 } 267 288 ··· 269 290 // For pulls: userDID is the repo owner (for stats), but the token belongs to the puller 270 291 // This allows anyone to pull from a public repo and have stats tracked under the owner 271 292 if operation == "push" && req.UserDID != validatedUser.DID { 272 - RespondError(w, http.StatusForbidden, "user DID mismatch") 293 + render.Status(r, http.StatusForbidden) 294 + render.JSON(w, r, map[string]string{"error": "user DID mismatch"}) 273 295 return 274 296 } 275 297 ··· 290 312 "repository", req.Repository, 291 313 "tag", req.Tag, 292 314 ) 293 - RespondError(w, http.StatusForbidden, fmt.Sprintf( 315 + render.Status(r, http.StatusForbidden) 316 + render.JSON(w, r, map[string]string{"error": fmt.Sprintf( 294 317 "quota exceeded: current=%d bytes, limit=%d bytes. Delete images to free space.", 295 318 stats.TotalSize, *stats.Limit, 296 - )) 319 + )}) 297 320 return 298 321 } 299 322 ··· 409 432 } 410 433 } 411 434 412 - RespondJSON(w, http.StatusOK, resp) 435 + render.JSON(w, r, resp) 413 436 } 414 437 415 438 // requireBlobWriteAccess middleware - validates DPoP + OAuth and checks for blob:write permission
-3
pkg/hold/pds/records.go
··· 41 41 CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did); 42 42 ` 43 43 44 - // Schema version for migration detection 45 - const recordsSchemaVersion = 2 46 - 47 44 // NewRecordsIndex creates or opens a records index 48 45 // If the schema is outdated (missing did column), drops and rebuilds the table 49 46 func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
+43 -175
pkg/hold/pds/xrpc.go
··· 14 14 "github.com/bluesky-social/indigo/repo" 15 15 "github.com/distribution/distribution/v3/registry/storage/driver" 16 16 "github.com/go-chi/chi/v5" 17 + "github.com/go-chi/render" 17 18 "github.com/gorilla/websocket" 18 19 "github.com/ipfs/go-cid" 19 20 "github.com/ipld/go-car" ··· 202 203 203 204 // HandleHealth returns health check information 204 205 func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { 205 - response := map[string]any{ 206 + render.JSON(w, r, map[string]any{ 206 207 "version": "0.4.999", 207 - } 208 - 209 - w.Header().Set("Content-Type", "application/json") 210 - if err := json.NewEncoder(w).Encode(response); err != nil { 211 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 212 - w.WriteHeader(http.StatusInternalServerError) 213 - } 208 + }) 214 209 } 215 210 216 211 // HandleDescribeServer returns server metadata ··· 223 218 hostname, _, _ = strings.Cut(hostname, "/") // Remove path 224 219 hostname, _, _ = strings.Cut(hostname, ":") // Remove port 225 220 226 - response := map[string]any{ 221 + render.JSON(w, r, map[string]any{ 227 222 "did": h.pds.DID(), 228 223 "availableUserDomains": []string{"." + hostname}, 229 224 "inviteCodeRequired": true, // Single-user PDS, no account creation 230 - } 231 - 232 - w.Header().Set("Content-Type", "application/json") 233 - if err := json.NewEncoder(w).Encode(response); err != nil { 234 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 235 - w.WriteHeader(http.StatusInternalServerError) 236 - } 225 + }) 237 226 } 238 227 239 228 // HandleResolveHandle resolves a handle to a DID ··· 256 245 } 257 246 258 247 // Return the DID 259 - response := map[string]string{ 248 + render.JSON(w, r, map[string]string{ 260 249 "did": h.pds.DID(), 261 - } 262 - 263 - w.Header().Set("Content-Type", "application/json") 264 - if err := json.NewEncoder(w).Encode(response); err != nil { 265 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 266 - w.WriteHeader(http.StatusInternalServerError) 267 - } 250 + }) 268 251 } 269 252 270 253 // HandleGetProfile returns aggregated profile information ··· 296 279 } 297 280 298 281 // Build profile response using shared function 299 - response := h.buildProfileResponse(r.Context()) 300 - 301 - w.Header().Set("Content-Type", "application/json") 302 - if err := json.NewEncoder(w).Encode(response); err != nil { 303 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 304 - w.WriteHeader(http.StatusInternalServerError) 305 - } 282 + render.JSON(w, r, h.buildProfileResponse(r.Context())) 306 283 } 307 284 308 285 // HandleGetProfiles returns aggregated profile information for multiple actors ··· 348 325 } 349 326 350 327 // Return profiles array 351 - response := map[string]any{ 328 + render.JSON(w, r, map[string]any{ 352 329 "profiles": profiles, 353 - } 354 - 355 - w.Header().Set("Content-Type", "application/json") 356 - if err := json.NewEncoder(w).Encode(response); err != nil { 357 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 358 - w.WriteHeader(http.StatusInternalServerError) 359 - } 330 + }) 360 331 } 361 332 362 333 // buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles) ··· 441 412 } 442 413 443 414 // Note: For did:web, the handle IS the DID (not just hostname) 444 - response := map[string]any{ 415 + render.JSON(w, r, map[string]any{ 445 416 "did": h.pds.DID(), 446 417 "handle": h.pds.DID(), 447 418 "didDoc": didDoc, 448 419 "collections": collections, 449 420 "handleIsCorrect": true, 450 - } 451 - 452 - w.Header().Set("Content-Type", "application/json") 453 - if err := json.NewEncoder(w).Encode(response); err != nil { 454 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 455 - w.WriteHeader(http.StatusInternalServerError) 456 - } 421 + }) 457 422 } 458 423 459 424 // HandleGetRecord retrieves a record from the repository ··· 490 455 return 491 456 } 492 457 493 - response := map[string]any{ 458 + render.JSON(w, r, map[string]any{ 494 459 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey), 495 460 "cid": recordCID.String(), 496 461 "value": recordValue, 497 - } 498 - 499 - w.Header().Set("Content-Type", "application/json") 500 - if err := json.NewEncoder(w).Encode(response); err != nil { 501 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 502 - w.WriteHeader(http.StatusInternalServerError) 503 - } 462 + }) 504 463 } 505 464 506 465 // HandleListRecords lists records in a collection ··· 570 529 571 530 if !head.Defined() { 572 531 // Empty repo, return empty list 573 - response := map[string]any{"records": []any{}} 574 - w.Header().Set("Content-Type", "application/json") 575 - if err := json.NewEncoder(w).Encode(response); err != nil { 576 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 577 - w.WriteHeader(http.StatusInternalServerError) 578 - } 532 + render.JSON(w, r, map[string]any{"records": []any{}}) 579 533 return 580 534 } 581 535 ··· 621 575 response["cursor"] = nextCursor 622 576 } 623 577 624 - w.Header().Set("Content-Type", "application/json") 625 - if err := json.NewEncoder(w).Encode(response); err != nil { 626 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 627 - w.WriteHeader(http.StatusInternalServerError) 628 - } 578 + render.JSON(w, r, response) 629 579 } 630 580 631 581 // handleListRecordsMST uses the legacy MST-based listing (fallback for tests) ··· 645 595 646 596 if !head.Defined() { 647 597 // Empty repo, return empty list 648 - response := map[string]any{"records": []any{}} 649 - w.Header().Set("Content-Type", "application/json") 650 - if err := json.NewEncoder(w).Encode(response); err != nil { 651 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 652 - w.WriteHeader(http.StatusInternalServerError) 653 - } 598 + render.JSON(w, r, map[string]any{"records": []any{}}) 654 599 return 655 600 } 656 601 ··· 758 703 response["cursor"] = nextCursor 759 704 } 760 705 761 - w.Header().Set("Content-Type", "application/json") 762 - if err := json.NewEncoder(w).Encode(response); err != nil { 763 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 764 - w.WriteHeader(http.StatusInternalServerError) 765 - } 706 + render.JSON(w, r, response) 766 707 } 767 708 768 709 // HandleDeleteRecord deletes a record from the repository ··· 831 772 832 773 if !currentCID.Equals(swapRecordCID) { 833 774 // Swap failed - record CID doesn't match 834 - w.WriteHeader(http.StatusBadRequest) 835 - response := map[string]any{ 775 + render.Status(r, http.StatusBadRequest) 776 + render.JSON(w, r, map[string]any{ 836 777 "error": "InvalidSwap", 837 778 "message": "record CID does not match swapRecord", 838 - } 839 - if err := json.NewEncoder(w).Encode(response); err != nil { 840 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 841 - w.WriteHeader(http.StatusInternalServerError) 842 - } 779 + }) 843 780 return 844 781 } 845 782 } ··· 875 812 } 876 813 877 814 // Return commit response (per spec) 878 - response := map[string]any{ 815 + render.JSON(w, r, map[string]any{ 879 816 "commit": map[string]any{ 880 817 "cid": head.String(), 881 818 "rev": rev, 882 819 }, 883 - } 884 - 885 - w.Header().Set("Content-Type", "application/json") 886 - if err := json.NewEncoder(w).Encode(response); err != nil { 887 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 888 - w.WriteHeader(http.StatusInternalServerError) 889 - } 820 + }) 890 821 } 891 822 892 823 // HandleSyncGetRecord returns a single record as a CAR file for sync ··· 941 872 942 873 // Write the CAR data to the response 943 874 if _, err := w.Write(buf.Bytes()); err != nil { 944 - slog.Error("failed to write car to http response", "error", err, "path", r.URL.Path) 945 - w.WriteHeader(http.StatusInternalServerError) 875 + slog.Error("failed to write CAR to http response", "error", err, "path", r.URL.Path) 946 876 } 947 877 } 948 878 ··· 1094 1024 } 1095 1025 1096 1026 // Return ATProto-compliant blob response 1097 - response := map[string]any{ 1027 + render.JSON(w, r, map[string]any{ 1098 1028 "blob": map[string]any{ 1099 1029 "$type": "blob", 1100 1030 "ref": map[string]any{ ··· 1103 1033 "mimeType": "application/octet-stream", 1104 1034 "size": size, 1105 1035 }, 1106 - } 1107 - 1108 - w.Header().Set("Content-Type", "application/json") 1109 - if err := json.NewEncoder(w).Encode(response); err != nil { 1110 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1111 - w.WriteHeader(http.StatusInternalServerError) 1112 - } 1036 + }) 1113 1037 } 1114 1038 1115 1039 // HandleGetBlob routes blob requests to appropriate handlers based on blob type ··· 1181 1105 "url", presignedURL) 1182 1106 1183 1107 // Return JSON response with presigned URL (AppView expects this format) 1184 - response := map[string]string{ 1108 + render.JSON(w, r, map[string]string{ 1185 1109 "url": presignedURL, 1186 - } 1187 - w.Header().Set("Content-Type", "application/json") 1188 - if err := json.NewEncoder(w).Encode(response); err != nil { 1189 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1190 - w.WriteHeader(http.StatusInternalServerError) 1191 - } 1110 + }) 1192 1111 } 1193 1112 1194 1113 // handleGetATProtoBlob handles standard ATProto blob requests ··· 1238 1157 head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid) 1239 1158 if err != nil { 1240 1159 // If no repo exists yet, return empty list 1241 - response := map[string]any{ 1242 - "repos": []any{}, 1243 - } 1244 - w.Header().Set("Content-Type", "application/json") 1245 - if err := json.NewEncoder(w).Encode(response); err != nil { 1246 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1247 - w.WriteHeader(http.StatusInternalServerError) 1248 - } 1160 + render.JSON(w, r, map[string]any{"repos": []any{}}) 1249 1161 return 1250 1162 } 1251 1163 ··· 1253 1165 if err != nil || rev == "" { 1254 1166 // No commits yet, return empty list 1255 1167 // Don't expose repos with no revision (empty/uninitialized) 1256 - response := map[string]any{ 1257 - "repos": []any{}, 1258 - } 1259 - w.Header().Set("Content-Type", "application/json") 1260 - if err := json.NewEncoder(w).Encode(response); err != nil { 1261 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1262 - w.WriteHeader(http.StatusInternalServerError) 1263 - } 1168 + render.JSON(w, r, map[string]any{"repos": []any{}}) 1264 1169 return 1265 1170 } 1266 1171 ··· 1273 1178 }, 1274 1179 } 1275 1180 1276 - response := map[string]any{ 1181 + render.JSON(w, r, map[string]any{ 1277 1182 "repos": repos, 1278 - } 1279 - 1280 - w.Header().Set("Content-Type", "application/json") 1281 - if err := json.NewEncoder(w).Encode(response); err != nil { 1282 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1283 - w.WriteHeader(http.StatusInternalServerError) 1284 - } 1183 + }) 1285 1184 } 1286 1185 1287 1186 // HandleGetRepoStatus returns the hosting status for a repository ··· 1305 1204 if err != nil || rev == "" { 1306 1205 // Repo exists (DID matches) but no commits yet 1307 1206 // Per ATProto spec, return active=true even if empty 1308 - response := map[string]any{ 1207 + render.JSON(w, r, map[string]any{ 1309 1208 "did": did, 1310 1209 "active": true, 1311 - } 1312 - w.Header().Set("Content-Type", "application/json") 1313 - if err := json.NewEncoder(w).Encode(response); err != nil { 1314 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1315 - w.WriteHeader(http.StatusInternalServerError) 1316 - } 1210 + }) 1317 1211 return 1318 1212 } 1319 1213 1320 1214 // Return status with revision 1321 - response := map[string]any{ 1215 + render.JSON(w, r, map[string]any{ 1322 1216 "did": did, 1323 1217 "active": true, 1324 1218 "rev": rev, 1325 - } 1326 - 1327 - w.Header().Set("Content-Type", "application/json") 1328 - if err := json.NewEncoder(w).Encode(response); err != nil { 1329 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1330 - w.WriteHeader(http.StatusInternalServerError) 1331 - } 1219 + }) 1332 1220 } 1333 1221 1334 1222 // HandleDIDDocument returns the DID document ··· 1339 1227 return 1340 1228 } 1341 1229 1342 - w.Header().Set("Content-Type", "application/json") 1343 - if err := json.NewEncoder(w).Encode(doc); err != nil { 1344 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1345 - w.WriteHeader(http.StatusInternalServerError) 1346 - } 1230 + render.JSON(w, r, doc) 1347 1231 } 1348 1232 1349 1233 // HandleAtprotoDID returns the DID for handle resolution ··· 1434 1318 slog.Debug("User is already a crew member", 1435 1319 "did", user.DID, 1436 1320 "rkey", member.Rkey) 1437 - response := map[string]any{ 1321 + render.JSON(w, r, map[string]any{ 1438 1322 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey), 1439 1323 "cid": member.Cid.String(), 1440 1324 "status": "already_member", 1441 1325 "message": "User is already a crew member", 1442 - } 1443 - w.Header().Set("Content-Type", "application/json") 1444 - w.WriteHeader(http.StatusOK) 1445 - if err := json.NewEncoder(w).Encode(response); err != nil { 1446 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1447 - w.WriteHeader(http.StatusInternalServerError) 1448 - } 1326 + }) 1449 1327 return 1450 1328 } 1451 1329 } ··· 1470 1348 // Return success response 1471 1349 // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it 1472 1350 // For now, return just the CID. In production, AddCrewMember should return both CID and rkey 1473 - response := map[string]any{ 1351 + render.Status(r, http.StatusCreated) 1352 + render.JSON(w, r, map[string]any{ 1474 1353 "cid": recordCID.String(), 1475 1354 "status": "created", 1476 1355 "message": "Successfully added to crew", 1477 - } 1478 - 1479 - w.Header().Set("Content-Type", "application/json") 1480 - w.WriteHeader(http.StatusCreated) 1481 - if err := json.NewEncoder(w).Encode(response); err != nil { 1482 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1483 - w.WriteHeader(http.StatusInternalServerError) 1484 - } 1356 + }) 1485 1357 } 1486 1358 1487 1359 // GetPresignedURL generates a presigned URL for GET, HEAD, or PUT operations ··· 1618 1490 return 1619 1491 } 1620 1492 1621 - w.Header().Set("Content-Type", "application/json") 1622 - if err := json.NewEncoder(w).Encode(stats); err != nil { 1623 - slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path) 1624 - w.WriteHeader(http.StatusInternalServerError) 1625 - } 1493 + render.JSON(w, r, stats) 1626 1494 }