mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
xrpc cleanup
This commit is contained in:
@@ -14,6 +14,7 @@ ATCR (ATProto Container Registry) is an OCI-compliant container registry that us
|
||||
go build -o bin/atcr-appview ./cmd/appview
|
||||
go build -o bin/atcr-hold ./cmd/hold
|
||||
go build -o bin/docker-credential-atcr ./cmd/credential-helper
|
||||
go build -o bin/oauth-helper ./cmd/oauth-helper
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
@@ -192,3 +192,46 @@ func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
|
||||
|
||||
return pdsEndpoint, nil
|
||||
}
|
||||
|
||||
// ValidateOwnerOrCrewAdmin validates that the request has valid DPoP + OAuth tokens
|
||||
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission
|
||||
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) {
|
||||
// Validate DPoP + OAuth token
|
||||
user, err := ValidateDPoPRequest(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
// Get captain record to check owner
|
||||
_, captain, err := pds.GetCaptainRecord(r.Context())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
||||
}
|
||||
|
||||
// Check if user is the owner
|
||||
if user.DID == captain.Owner {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Check if user is crew with admin permission
|
||||
crew, err := pds.ListCrewMembers(r.Context())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check crew membership: %w", err)
|
||||
}
|
||||
|
||||
for _, member := range crew {
|
||||
if member.Record.Member == user.DID {
|
||||
// Check if this crew member has crew:admin permission
|
||||
for _, perm := range member.Record.Permissions {
|
||||
if perm == "crew:admin" {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
// User is crew but doesn't have admin permission
|
||||
return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
|
||||
}
|
||||
}
|
||||
|
||||
// User is neither owner nor authorized crew
|
||||
return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
|
||||
}
|
||||
|
||||
+20
-5
@@ -84,10 +84,21 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er
|
||||
|
||||
// Iterate over all crew records
|
||||
err = r.ForEach(ctx, atproto.CrewCollection, func(k string, v cid.Cid) error {
|
||||
// Extract rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
|
||||
// Extract collection and rkey from full path (k is like "io.atcr.hold.crew/3m37dr2ddit22")
|
||||
parts := strings.Split(k, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil // Skip invalid keys
|
||||
}
|
||||
|
||||
// Extract actual collection and rkey
|
||||
actualCollection := strings.Join(parts[:len(parts)-1], "/")
|
||||
rkey := parts[len(parts)-1]
|
||||
|
||||
// MST keys are sorted, so once we hit a different collection, stop walking
|
||||
if actualCollection != atproto.CrewCollection {
|
||||
return repo.ErrDoneIterating
|
||||
}
|
||||
|
||||
// Get the record directly from the repo we already have open
|
||||
// (calling GetCrewMember would open a new session unnecessarily)
|
||||
recordCID, recBytes, err := r.GetRecordBytes(ctx, k)
|
||||
@@ -110,12 +121,16 @@ func (p *HoldPDS) ListCrewMembers(ctx context.Context) ([]*CrewMemberWithKey, er
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// If the collection doesn't exist yet (empty repo or no records created),
|
||||
// return empty list instead of error
|
||||
if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") {
|
||||
// ErrDoneIterating is expected when we stop walking early
|
||||
if err == repo.ErrDoneIterating {
|
||||
// Successfully stopped at collection boundary
|
||||
} else if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") {
|
||||
// If the collection doesn't exist yet (empty repo or no records created),
|
||||
// return empty list instead of error
|
||||
return []*CrewMemberWithKey{}, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to list crew members: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to list crew members: %w", err)
|
||||
}
|
||||
|
||||
return crew, nil
|
||||
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/carstore"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/bluesky-social/indigo/models"
|
||||
"github.com/bluesky-social/indigo/repo"
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// init registers our custom ATProto types with indigo's lexutil type registry
|
||||
@@ -146,6 +149,53 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, ownerDID string, public bool, a
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCollections returns all collections present in the hold's repository
|
||||
func (p *HoldPDS) ListCollections(ctx context.Context) ([]string, error) {
|
||||
session, err := p.carstore.ReadOnlySession(p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create read-only session: %w", err)
|
||||
}
|
||||
|
||||
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repo head: %w", err)
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
// Empty repo, no collections
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
r, err := repo.OpenRepo(ctx, session, head)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open repo: %w", err)
|
||||
}
|
||||
|
||||
collections := make(map[string]bool)
|
||||
|
||||
// Walk all records in the repo to discover collections
|
||||
err = r.ForEach(ctx, "", func(k string, v cid.Cid) error {
|
||||
// k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
|
||||
parts := strings.Split(k, "/")
|
||||
if len(parts) >= 1 {
|
||||
collections[parts[0]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enumerate collections: %w", err)
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
result := make([]string, 0, len(collections))
|
||||
for collection := range collections {
|
||||
result = append(result, collection)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Close closes the carstore
|
||||
func (p *HoldPDS) Close() error {
|
||||
// TODO: Close session properly
|
||||
|
||||
+160
-35
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/bluesky-social/indigo/repo"
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-car"
|
||||
carutil "github.com/ipld/go-car/util"
|
||||
@@ -79,6 +81,9 @@ func (h *XRPCHandler) RegisterHandlers(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/.well-known/did.json", corsMiddleware(h.HandleDIDDocument))
|
||||
mux.HandleFunc("/.well-known/atproto-did", corsMiddleware(h.HandleAtprotoDID))
|
||||
|
||||
// Write endpoints
|
||||
mux.HandleFunc("/xrpc/com.atproto.repo.deleteRecord", corsMiddleware(h.HandleDeleteRecord))
|
||||
|
||||
// Custom ATCR endpoints
|
||||
mux.HandleFunc("/xrpc/io.atcr.hold.requestCrew", corsMiddleware(h.HandleRequestCrew))
|
||||
}
|
||||
@@ -131,8 +136,8 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Get repo parameter
|
||||
repo := r.URL.Query().Get("repo")
|
||||
if repo == "" || repo != h.pds.DID() {
|
||||
repoDID := r.URL.Query().Get("repo")
|
||||
if repoDID == "" || repoDID != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -144,13 +149,19 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Get actual repo head from carstore
|
||||
// Get actual collections from repo
|
||||
collections, err := h.pds.ListCollections(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to list collections: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: For did:web, the handle IS the DID (not just hostname)
|
||||
response := map[string]any{
|
||||
"did": h.pds.DID(),
|
||||
"handle": h.pds.DID(),
|
||||
"didDoc": didDoc,
|
||||
"collections": []string{atproto.CrewCollection},
|
||||
"collections": collections,
|
||||
"handleIsCorrect": true,
|
||||
}
|
||||
|
||||
@@ -165,36 +176,42 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
repo := r.URL.Query().Get("repo")
|
||||
repoDID := r.URL.Query().Get("repo")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
rkey := r.URL.Query().Get("rkey")
|
||||
|
||||
if repo == "" || collection == "" || rkey == "" {
|
||||
if repoDID == "" || collection == "" || rkey == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if repo != h.pds.DID() {
|
||||
if repoDID != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Only support crew collection for now
|
||||
if collection != atproto.CrewCollection {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
recordCID, crewRecord, err := h.pds.GetCrewMember(r.Context(), rkey)
|
||||
// Use generic repomgr.GetRecord - works for any collection
|
||||
// lexutil type registry automatically unmarshals to correct type
|
||||
recordCID, recordValue, err := h.pds.repomgr.GetRecord(
|
||||
r.Context(),
|
||||
h.pds.uid,
|
||||
collection,
|
||||
rkey,
|
||||
cid.Undef,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusNotFound)
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
http.Error(w, "record not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("failed to get record: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, rkey),
|
||||
"cid": recordCID.String(),
|
||||
"value": crewRecord,
|
||||
"value": recordValue,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -208,37 +225,97 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
repo := r.URL.Query().Get("repo")
|
||||
repoDID := r.URL.Query().Get("repo")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
|
||||
if repo == "" || collection == "" {
|
||||
if repoDID == "" || collection == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if repo != h.pds.DID() {
|
||||
if repoDID != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Only support crew collection for now
|
||||
if collection != atproto.CrewCollection {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
crew, err := h.pds.ListCrewMembers(r.Context())
|
||||
// Generic implementation using repo.ForEach
|
||||
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
|
||||
http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
records := make([]map[string]any, len(crew))
|
||||
for i, member := range crew {
|
||||
records[i] = map[string]any{
|
||||
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), collection, member.Rkey),
|
||||
"cid": member.Cid.String(),
|
||||
"value": member.Record,
|
||||
head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !head.Defined() {
|
||||
// Empty repo, return empty list
|
||||
response := map[string]any{"records": []any{}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
repoHandle, err := repo.OpenRepo(r.Context(), session, head)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var records []map[string]any
|
||||
|
||||
// Iterate over all records in the collection
|
||||
err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error {
|
||||
// k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
|
||||
parts := strings.Split(k, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil // Skip invalid keys
|
||||
}
|
||||
|
||||
// Extract actual collection and rkey from the key path
|
||||
actualCollection := strings.Join(parts[:len(parts)-1], "/")
|
||||
rkey := parts[len(parts)-1]
|
||||
|
||||
// Filter: only include records that match the requested collection
|
||||
// MST keys are sorted lexicographically, so once we hit a different
|
||||
// collection prefix, all remaining keys will also be outside our range
|
||||
if actualCollection != collection {
|
||||
return repo.ErrDoneIterating // Stop walking the tree
|
||||
}
|
||||
|
||||
// Get the record bytes
|
||||
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get record: %w", err)
|
||||
}
|
||||
|
||||
// Decode using lexutil (type registry handles unmarshaling)
|
||||
recordValue, err := lexutil.CborDecodeValue(*recBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode record: %w", err)
|
||||
}
|
||||
|
||||
records = append(records, map[string]any{
|
||||
"uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey),
|
||||
"cid": recordCID.String(),
|
||||
"value": recordValue,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// ErrDoneIterating is expected when we stop walking early (reached collection boundary)
|
||||
if err == repo.ErrDoneIterating {
|
||||
// Successfully stopped at collection boundary, continue with collected records
|
||||
} else if strings.Contains(err.Error(), "not found") {
|
||||
// If the collection doesn't exist yet, return empty list
|
||||
records = []map[string]any{}
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +327,54 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleDeleteRecord deletes a record from the repository
|
||||
func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
repoDID := r.URL.Query().Get("repo")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
rkey := r.URL.Query().Get("rkey")
|
||||
|
||||
if repoDID == "" || collection == "" || rkey == "" {
|
||||
http.Error(w, "missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if repoDID != h.pds.DID() {
|
||||
http.Error(w, "invalid repo", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate DPoP + OAuth and check authorization
|
||||
_, err := ValidateOwnerOrCrewAdmin(r, h.pds)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("unauthorized: %v", err), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the record using repomgr
|
||||
err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, collection, rkey)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
http.Error(w, "record not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("failed to delete record: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]any{
|
||||
"success": true,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleSyncGetRecord returns a single record as a CAR file for sync
|
||||
func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -271,8 +396,8 @@ func (h *XRPCHandler) HandleSyncGetRecord(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Only support crew collection for now
|
||||
if collection != atproto.CrewCollection {
|
||||
// Support both captain and crew collections
|
||||
if collection != atproto.CaptainCollection && collection != atproto.CrewCollection {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user