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