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