use chi/render to simplify returned json

This commit is contained in:
Evan Jarrett
2026-01-06 22:47:21 -06:00
parent c82dad81f7
commit 9704fe091d
17 changed files with 179 additions and 362 deletions
-6
View File
@@ -26,12 +26,6 @@ linters:
- errcheck
# TODO: fix issues and remove these paths one by one
- path: pkg/auth
linters:
- errcheck
- path: pkg/appview
linters:
- errcheck
- path: cmd/credential-helper
linters:
- errcheck
+1 -1
View File
@@ -14,11 +14,11 @@ steps:
CGO_ENABLED: 1
command: |
go mod download
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2
go generate ./...
- name: Run Linter
environment:
CGO_ENABLED: 1
command: |
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.2
golangci-lint run ./...
+2
View File
@@ -9,6 +9,7 @@ require (
github.com/distribution/reference v0.6.0
github.com/earthboundkid/versioninfo/v2 v2.24.1
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/render v1.0.3
github.com/goki/freetype v1.0.5
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
@@ -40,6 +41,7 @@ require (
require (
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect
+4
View File
@@ -3,6 +3,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU=
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM=
@@ -70,6 +72,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+10 -17
View File
@@ -2,7 +2,6 @@ package handlers
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -15,6 +14,7 @@ import (
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// StarRepositoryHandler handles starring a repository
@@ -66,9 +66,8 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Return success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]bool{"starred": true})
render.JSON(w, r, map[string]bool{"starred": true})
}
// UnstarRepositoryHandler handles unstarring a repository
@@ -122,8 +121,7 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
}
// Return success
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": false})
render.JSON(w, r, map[string]bool{"starred": false})
}
// CheckStarHandler checks if current user has starred a repository
@@ -138,8 +136,7 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
// Not authenticated - return not starred
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": false})
render.JSON(w, r, map[string]bool{"starred": false})
return
}
@@ -166,16 +163,14 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if OAuth error - if so, invalidate sessions
if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
// For a read operation, just return not starred instead of error
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": false})
render.JSON(w, r, map[string]bool{"starred": false})
return
}
starred := err == nil
// Return result
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"starred": starred})
render.JSON(w, r, map[string]bool{"starred": starred})
}
// GetStatsHandler returns repository statistics
@@ -204,8 +199,7 @@ func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Return stats as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
render.JSON(w, r, stats)
}
// ManifestDetailHandler returns detailed manifest information including platforms
@@ -240,8 +234,7 @@ func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
}
// Return manifest as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(manifest)
render.JSON(w, r, manifest)
}
// CredentialHelperVersionResponse is the response for the credential helper version API
@@ -297,7 +290,7 @@ func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *htt
Checksums: h.Checksums,
}
w.Header().Set("Content-Type", "application/json")
render.SetContentType(render.ContentTypeJSON)
w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
json.NewEncoder(w).Encode(response)
render.JSON(w, r, response)
}
+12 -20
View File
@@ -1,7 +1,6 @@
package handlers
import (
"encoding/json"
"fmt"
"html/template"
"log/slog"
@@ -9,9 +8,9 @@ import (
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"atcr.io/pkg/appview/db"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// DeviceCodeRequest is the request to start device authorization
@@ -41,7 +40,7 @@ func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
var req DeviceCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
@@ -73,8 +72,7 @@ func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Interval: 5, // Poll every 5 seconds
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
render.JSON(w, r, resp)
}
// DeviceTokenRequest is the request to poll for device authorization
@@ -102,7 +100,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
var req DeviceTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
@@ -113,8 +111,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := DeviceTokenResponse{
Error: "expired_token",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
render.JSON(w, r, resp)
return
}
@@ -124,8 +121,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := DeviceTokenResponse{
Error: "authorization_pending",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
render.JSON(w, r, resp)
return
}
@@ -147,8 +143,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
DID: *pending.ApprovedDID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
render.JSON(w, r, resp)
}
// DeviceApprovalPageHandler handles GET /device
@@ -257,15 +252,14 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
}
var req DeviceApproveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := render.Decode(r, &req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if !req.Approve {
// User denied
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "denied"})
render.JSON(w, r, map[string]string{"status": "denied"})
return
}
@@ -276,8 +270,7 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "approved"})
render.JSON(w, r, map[string]string{"status": "approved"})
}
// ListDevicesHandler handles GET /api/devices
@@ -308,8 +301,7 @@ func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get devices for this user
devices := h.Store.ListDevices(sess.DID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(devices)
render.JSON(w, r, devices)
}
// RevokeDeviceHandler handles DELETE /api/devices/{id}
+4 -5
View File
@@ -15,6 +15,7 @@ import (
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// DeleteTagHandler handles deleting a tag
@@ -93,9 +94,8 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]any{
render.Status(r, http.StatusConflict)
render.JSON(w, r, map[string]any{
"error": "confirmation_required",
"message": "This manifest has associated tags that will also be deleted",
"tags": tags,
@@ -266,6 +266,5 @@ func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// Return new avatar URL
avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL})
render.JSON(w, r, map[string]string{"avatarURL": avatarURL})
}
+3 -7
View File
@@ -2,7 +2,6 @@ package token
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -13,6 +12,7 @@ import (
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"github.com/go-chi/render"
)
// PostAuthCallback is called after successful Basic Auth authentication.
@@ -120,7 +120,7 @@ func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) {
Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.",
LoginURL: loginURL,
}
json.NewEncoder(w).Encode(resp)
render.JSON(w, r, resp)
}
// ServeHTTP handles the token request
@@ -269,9 +269,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
IssuedAt: now.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError)
return
}
render.JSON(w, r, resp)
}
+3 -11
View File
@@ -1,13 +1,13 @@
package admin
import (
"encoding/json"
"log/slog"
"net/http"
"sort"
"strconv"
"atcr.io/pkg/atproto"
"github.com/go-chi/render"
)
// DashboardStats contains dashboard statistics
@@ -122,11 +122,7 @@ func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
}
// Otherwise return JSON
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, stats)
}
// UserUsage represents storage usage for a user
@@ -194,9 +190,5 @@ func (ui *AdminUI) handleTopUsersAPI(w http.ResponseWriter, r *http.Request) {
}
// Otherwise return JSON
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(users); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, users)
}
+1 -1
View File
@@ -120,7 +120,7 @@ func (ui *AdminUI) handleCallback(w http.ResponseWriter, r *http.Request) {
token, err := ui.createSession(did, handle)
if err != nil {
slog.Error("failed to create session token", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
ui.setSessionCookie(w, r, token)
+1 -1
View File
@@ -321,7 +321,7 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
if tier != "" {
if err := ui.pds.UpdateCrewMemberTier(ctx, current.Member, tier); err != nil {
slog.Error("failed to update crew member tier", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
http.Error(w, "Failed to update tier", http.StatusInternalServerError)
return
}
}
-44
View File
@@ -1,44 +0,0 @@
package oci
import (
"testing"
)
// Tests for helper functions
func TestNormalizeETag(t *testing.T) {
tests := []struct {
name string
etag string
expected string
}{
{
name: "etag without quotes",
etag: "abc123",
expected: "\"abc123\"",
},
{
name: "etag already has quotes",
etag: "\"abc123\"",
expected: "\"abc123\"",
},
{
name: "empty etag",
etag: "",
expected: "\"\"",
},
{
name: "etag with special characters",
etag: "abc-123_def",
expected: "\"abc-123_def\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeETag(tt.etag)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
-38
View File
@@ -1,38 +0,0 @@
// Package oci provides HTTP helpers for OCI registry endpoints in the hold service.
// It includes utilities for JSON encoding/decoding of request/response bodies
// and standardized error responses for XRPC endpoints.
package oci
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
// DecodeJSON decodes JSON request body into the provided value
// Returns an error if decoding fails
func DecodeJSON(r *http.Request, v any) error {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
}
return nil
}
// RespondJSON writes a JSON response with the given status code
func RespondJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// If encoding fails, we can't do much since headers are already sent
// Log the error but don't try to send another response
slog.Error("Failed to encode JSON response", "error", err)
}
}
// RespondError writes a JSON error response with the given status code and message
func RespondError(w http.ResponseWriter, status int, message string) {
RespondJSON(w, status, map[string]string{
"error": message,
})
}
+39
View File
@@ -227,3 +227,42 @@ func TestCleanupExpiredSessions(t *testing.T) {
t.Error("Recent session should still exist")
}
}
// Tests for helper functions
func TestNormalizeETag(t *testing.T) {
tests := []struct {
name string
etag string
expected string
}{
{
name: "etag without quotes",
etag: "abc123",
expected: "\"abc123\"",
},
{
name: "etag already has quotes",
etag: "\"abc123\"",
expected: "\"abc123\"",
},
{
name: "empty etag",
etag: "",
expected: "\"\"",
},
{
name: "etag with special characters",
etag: "abc-123_def",
expected: "\"abc-123_def\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeETag(tt.etag)
if result != tt.expected {
t.Errorf("Expected %s, got %s", tt.expected, result)
}
})
}
}
+56 -33
View File
@@ -1,3 +1,4 @@
// Package oci provides OCI registry endpoints for the hold service.
package oci
import (
@@ -13,6 +14,7 @@ import (
"atcr.io/pkg/s3"
storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// XRPCHandler handles OCI-specific XRPC endpoints for multipart uploads
@@ -63,23 +65,26 @@ func (h *XRPCHandler) HandleInitiateUpload(w http.ResponseWriter, r *http.Reques
Digest string `json:"digest"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
if err := render.Decode(r, &req); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
if req.Digest == "" {
RespondError(w, http.StatusBadRequest, "digest is required")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "digest is required"})
return
}
uploadID, _, err := h.StartMultipartUploadWithManager(r.Context(), req.Digest)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to initiate upload: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to initiate upload: %v", err)})
return
}
RespondJSON(w, http.StatusOK, map[string]any{
render.JSON(w, r, map[string]any{
"uploadId": uploadID,
})
}
@@ -92,23 +97,26 @@ func (h *XRPCHandler) HandleGetPartUploadURL(w http.ResponseWriter, r *http.Requ
PartNumber int `json:"partNumber"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
if err := render.Decode(r, &req); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
if req.UploadID == "" || req.PartNumber == 0 {
RespondError(w, http.StatusBadRequest, "uploadId and partNumber are required")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "uploadId and partNumber are required"})
return
}
uploadInfo, err := h.GetPartUploadURL(r.Context(), req.UploadID, req.PartNumber)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to get part upload URL: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to get part upload URL: %v", err)})
return
}
RespondJSON(w, http.StatusOK, uploadInfo)
render.JSON(w, r, uploadInfo)
}
// HandleUploadPart handles direct buffered part uploads
@@ -118,29 +126,33 @@ func (h *XRPCHandler) HandleUploadPart(w http.ResponseWriter, r *http.Request) {
partNumberStr := r.Header.Get("X-Part-Number")
if uploadID == "" || partNumberStr == "" {
RespondError(w, http.StatusBadRequest, "X-Upload-Id and X-Part-Number headers are required")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "X-Upload-Id and X-Part-Number headers are required"})
return
}
partNumber, err := strconv.Atoi(partNumberStr)
if err != nil {
RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid part number: %v", err))
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid part number: %v", err)})
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read part data: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to read part data: %v", err)})
return
}
etag, err := h.HandleBufferedPartUpload(r.Context(), uploadID, partNumber, data)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to upload part: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to upload part: %v", err)})
return
}
RespondJSON(w, http.StatusOK, map[string]any{
render.JSON(w, r, map[string]any{
"etag": etag,
})
}
@@ -154,23 +166,26 @@ func (h *XRPCHandler) HandleCompleteUpload(w http.ResponseWriter, r *http.Reques
Parts []PartInfo `json:"parts"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
if err := render.Decode(r, &req); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
if req.UploadID == "" || req.Digest == "" || len(req.Parts) == 0 {
RespondError(w, http.StatusBadRequest, "uploadId, digest, and parts are required")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "uploadId, digest, and parts are required"})
return
}
err := h.CompleteMultipartUploadWithManager(r.Context(), req.UploadID, req.Digest, req.Parts)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to complete upload: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to complete upload: %v", err)})
return
}
RespondJSON(w, http.StatusOK, map[string]any{
render.JSON(w, r, map[string]any{
"status": "completed",
"digest": req.Digest,
})
@@ -183,23 +198,26 @@ func (h *XRPCHandler) HandleAbortUpload(w http.ResponseWriter, r *http.Request)
UploadID string `json:"uploadId"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
if err := render.Decode(r, &req); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
if req.UploadID == "" {
RespondError(w, http.StatusBadRequest, "uploadId is required")
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": "uploadId is required"})
return
}
err := h.AbortMultipartUploadWithManager(r.Context(), req.UploadID)
if err != nil {
RespondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to abort upload: %v", err))
render.Status(r, http.StatusInternalServerError)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("failed to abort upload: %v", err)})
return
}
RespondJSON(w, http.StatusOK, map[string]any{
render.JSON(w, r, map[string]any{
"status": "aborted",
})
}
@@ -214,7 +232,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
// Validate service token (same auth as blob:write endpoints)
validatedUser, err := pds.ValidateBlobWriteAccess(r, h.pds, h.httpClient)
if err != nil {
RespondError(w, http.StatusForbidden, fmt.Sprintf("authorization failed: %v", err))
render.Status(r, http.StatusForbidden)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("authorization failed: %v", err)})
return
}
@@ -248,8 +267,9 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
} `json:"manifest"`
}
if err := DecodeJSON(r, &req); err != nil {
RespondError(w, http.StatusBadRequest, err.Error())
if err := render.Decode(r, &req); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
@@ -261,7 +281,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
// Validate operation
if operation != "push" && operation != "pull" {
RespondError(w, http.StatusBadRequest, fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation))
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf("invalid operation: %s (must be 'push' or 'pull')", operation)})
return
}
@@ -269,7 +290,8 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
// For pulls: userDID is the repo owner (for stats), but the token belongs to the puller
// This allows anyone to pull from a public repo and have stats tracked under the owner
if operation == "push" && req.UserDID != validatedUser.DID {
RespondError(w, http.StatusForbidden, "user DID mismatch")
render.Status(r, http.StatusForbidden)
render.JSON(w, r, map[string]string{"error": "user DID mismatch"})
return
}
@@ -290,10 +312,11 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
"repository", req.Repository,
"tag", req.Tag,
)
RespondError(w, http.StatusForbidden, fmt.Sprintf(
render.Status(r, http.StatusForbidden)
render.JSON(w, r, map[string]string{"error": fmt.Sprintf(
"quota exceeded: current=%d bytes, limit=%d bytes. Delete images to free space.",
stats.TotalSize, *stats.Limit,
))
)})
return
}
@@ -409,7 +432,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
}
}
RespondJSON(w, http.StatusOK, resp)
render.JSON(w, r, resp)
}
// requireBlobWriteAccess middleware - validates DPoP + OAuth and checks for blob:write permission
-3
View File
@@ -41,9 +41,6 @@ CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rk
CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did);
`
// Schema version for migration detection
const recordsSchemaVersion = 2
// NewRecordsIndex creates or opens a records index
// If the schema is outdated (missing did column), drops and rebuilds the table
func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
+43 -175
View File
@@ -14,6 +14,7 @@ import (
"github.com/bluesky-social/indigo/repo"
"github.com/distribution/distribution/v3/registry/storage/driver"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/gorilla/websocket"
"github.com/ipfs/go-cid"
"github.com/ipld/go-car"
@@ -202,15 +203,9 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
// HandleHealth returns health check information
func (h *XRPCHandler) HandleHealth(w http.ResponseWriter, r *http.Request) {
response := map[string]any{
render.JSON(w, r, map[string]any{
"version": "0.4.999",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleDescribeServer returns server metadata
@@ -223,17 +218,11 @@ func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Reques
hostname, _, _ = strings.Cut(hostname, "/") // Remove path
hostname, _, _ = strings.Cut(hostname, ":") // Remove port
response := map[string]any{
render.JSON(w, r, map[string]any{
"did": h.pds.DID(),
"availableUserDomains": []string{"." + hostname},
"inviteCodeRequired": true, // Single-user PDS, no account creation
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleResolveHandle resolves a handle to a DID
@@ -256,15 +245,9 @@ func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request
}
// Return the DID
response := map[string]string{
render.JSON(w, r, map[string]string{
"did": h.pds.DID(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleGetProfile returns aggregated profile information
@@ -296,13 +279,7 @@ func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) {
}
// Build profile response using shared function
response := h.buildProfileResponse(r.Context())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, h.buildProfileResponse(r.Context()))
}
// HandleGetProfiles returns aggregated profile information for multiple actors
@@ -348,15 +325,9 @@ func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request)
}
// Return profiles array
response := map[string]any{
render.JSON(w, r, map[string]any{
"profiles": profiles,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles)
@@ -441,19 +412,13 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
}
// Note: For did:web, the handle IS the DID (not just hostname)
response := map[string]any{
render.JSON(w, r, map[string]any{
"did": h.pds.DID(),
"handle": h.pds.DID(),
"didDoc": didDoc,
"collections": collections,
"handleIsCorrect": true,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleGetRecord retrieves a record from the repository
@@ -490,17 +455,11 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
return
}
response := map[string]any{
render.JSON(w, r, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
"cid": recordCID.String(),
"value": recordValue,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleListRecords lists records in a collection
@@ -570,12 +529,7 @@ func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Re
if !head.Defined() {
// Empty repo, return empty list
response := map[string]any{"records": []any{}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, map[string]any{"records": []any{}})
return
}
@@ -621,11 +575,7 @@ func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Re
response["cursor"] = nextCursor
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, response)
}
// handleListRecordsMST uses the legacy MST-based listing (fallback for tests)
@@ -645,12 +595,7 @@ func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Reques
if !head.Defined() {
// Empty repo, return empty list
response := map[string]any{"records": []any{}}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, map[string]any{"records": []any{}})
return
}
@@ -758,11 +703,7 @@ func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Reques
response["cursor"] = nextCursor
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, response)
}
// HandleDeleteRecord deletes a record from the repository
@@ -831,15 +772,11 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request)
if !currentCID.Equals(swapRecordCID) {
// Swap failed - record CID doesn't match
w.WriteHeader(http.StatusBadRequest)
response := map[string]any{
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]any{
"error": "InvalidSwap",
"message": "record CID does not match swapRecord",
}
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
return
}
}
@@ -875,18 +812,12 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request)
}
// Return commit response (per spec)
response := map[string]any{
render.JSON(w, r, map[string]any{
"commit": map[string]any{
"cid": head.String(),
"rev": rev,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleSyncGetRecord returns a single record as a CAR file for sync
@@ -941,8 +872,7 @@ func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request
// Write the CAR data to the response
if _, err := w.Write(buf.Bytes()); err != nil {
slog.Error("failed to write car to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
slog.Error("failed to write CAR to http response", "error", err, "path", r.URL.Path)
}
}
@@ -1094,7 +1024,7 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
}
// Return ATProto-compliant blob response
response := map[string]any{
render.JSON(w, r, map[string]any{
"blob": map[string]any{
"$type": "blob",
"ref": map[string]any{
@@ -1103,13 +1033,7 @@ func (h *XRPCHandler) HandleUploadBlob(w http.ResponseWriter, r *http.Request) {
"mimeType": "application/octet-stream",
"size": size,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleGetBlob routes blob requests to appropriate handlers based on blob type
@@ -1181,14 +1105,9 @@ func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, d
"url", presignedURL)
// Return JSON response with presigned URL (AppView expects this format)
response := map[string]string{
render.JSON(w, r, map[string]string{
"url": presignedURL,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// handleGetATProtoBlob handles standard ATProto blob requests
@@ -1238,14 +1157,7 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
head, err := h.pds.repomgr.GetRepoRoot(r.Context(), h.pds.uid)
if err != nil {
// If no repo exists yet, return empty list
response := map[string]any{
"repos": []any{},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, map[string]any{"repos": []any{}})
return
}
@@ -1253,14 +1165,7 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
if err != nil || rev == "" {
// No commits yet, return empty list
// Don't expose repos with no revision (empty/uninitialized)
response := map[string]any{
"repos": []any{},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, map[string]any{"repos": []any{}})
return
}
@@ -1273,15 +1178,9 @@ func (h *XRPCHandler) HandleListRepos(w http.ResponseWriter, r *http.Request) {
},
}
response := map[string]any{
render.JSON(w, r, map[string]any{
"repos": repos,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleGetRepoStatus returns the hosting status for a repository
@@ -1305,30 +1204,19 @@ func (h *XRPCHandler) HandleGetRepoStatus(w http.ResponseWriter, r *http.Request
if err != nil || rev == "" {
// Repo exists (DID matches) but no commits yet
// Per ATProto spec, return active=true even if empty
response := map[string]any{
render.JSON(w, r, map[string]any{
"did": did,
"active": true,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
return
}
// Return status with revision
response := map[string]any{
render.JSON(w, r, map[string]any{
"did": did,
"active": true,
"rev": rev,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// HandleDIDDocument returns the DID document
@@ -1339,11 +1227,7 @@ func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(doc); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, doc)
}
// HandleAtprotoDID returns the DID for handle resolution
@@ -1434,18 +1318,12 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
slog.Debug("User is already a crew member",
"did", user.DID,
"rkey", member.Rkey)
response := map[string]any{
render.JSON(w, r, map[string]any{
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey),
"cid": member.Cid.String(),
"status": "already_member",
"message": "User is already a crew member",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
return
}
}
@@ -1470,18 +1348,12 @@ func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request)
// Return success response
// Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it
// For now, return just the CID. In production, AddCrewMember should return both CID and rkey
response := map[string]any{
render.Status(r, http.StatusCreated)
render.JSON(w, r, map[string]any{
"cid": recordCID.String(),
"status": "created",
"message": "Successfully added to crew",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(response); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
})
}
// GetPresignedURL generates a presigned URL for GET, HEAD, or PUT operations
@@ -1618,9 +1490,5 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
slog.Error("failed to encode json to http response", "error", err, "path", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
render.JSON(w, r, stats)
}