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

Configure Feed

Select the types of activity you want to include in your feed.

use chi/render to simplify returned json

+179 -362
-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 }