-6
.golangci.yml
-6
.golangci.yml
+1
-1
.tangled/workflows/lint.yaml
+1
-1
.tangled/workflows/lint.yaml
+2
go.mod
+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
+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
+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
+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
+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
+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
+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
+1
-1
pkg/hold/admin/handlers_auth.go
+1
-1
pkg/hold/admin/handlers_crew.go
+1
-1
pkg/hold/admin/handlers_crew.go
-44
pkg/hold/oci/helpers_test.go
-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
-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
+39
pkg/hold/oci/multipart_test.go
···
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
+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
-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) {
+43
-175
pkg/hold/pds/xrpc.go
+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
}