Files
at-container-registry/pkg/hold/registration.go
T

482 lines
15 KiB
Go

package hold
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// HealthHandler handles health check requests
func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// isHoldRegistered checks if a hold with the given public URL is already registered in the PDS
func (s *HoldService) isHoldRegistered(ctx context.Context, did, pdsEndpoint, publicURL string) (bool, error) {
// We need to query the PDS without authentication to check public records
// ATProto records are publicly readable, so we can use an unauthenticated client
client := atproto.NewClient(pdsEndpoint, did, "")
// List all hold records for this DID
records, err := client.ListRecords(ctx, atproto.HoldCollection, 100)
if err != nil {
return false, fmt.Errorf("failed to list hold records: %w", err)
}
// Check if any hold record matches our public URL
for _, record := range records {
var holdRecord atproto.HoldRecord
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
continue
}
if holdRecord.Endpoint == publicURL {
return true, nil
}
}
return false, nil
}
// AutoRegister registers this hold service in the owner's PDS
// Checks if already registered first, then does OAuth if needed
func (s *HoldService) AutoRegister(callbackHandler *http.HandlerFunc) error {
reg := &s.config.Registration
publicURL := s.config.Server.PublicURL
if publicURL == "" {
return fmt.Errorf("HOLD_PUBLIC_URL not set")
}
if reg.OwnerDID == "" {
return fmt.Errorf("HOLD_OWNER not set - required for registration")
}
ctx := context.Background()
log.Printf("Checking registration status for DID: %s", reg.OwnerDID)
// Resolve DID to PDS endpoint using indigo
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(reg.OwnerDID)
if err != nil {
return fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return fmt.Errorf("failed to resolve PDS for DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return fmt.Errorf("no PDS endpoint found for DID")
}
log.Printf("PDS endpoint: %s", pdsEndpoint)
// Check if hold is already registered
isRegistered, err := s.isHoldRegistered(ctx, reg.OwnerDID, pdsEndpoint, publicURL)
if err != nil {
log.Printf("Warning: failed to check registration status: %v", err)
log.Printf("Proceeding with OAuth registration...")
} else if isRegistered {
log.Printf("✓ Hold service already registered in PDS")
log.Printf("Public URL: %s", publicURL)
return nil
}
// Not registered, need to do OAuth
log.Printf("Hold not registered, starting OAuth flow...")
// Get handle from DID document (already resolved above)
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return fmt.Errorf("no valid handle found for DID")
}
log.Printf("Resolved handle: %s", handle)
log.Printf("Starting OAuth registration for hold service")
log.Printf("Public URL: %s", publicURL)
return s.registerWithOAuth(publicURL, handle, reg.OwnerDID, pdsEndpoint, callbackHandler)
}
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Hold service registration")
if err != nil {
return err
}
log.Printf("Authorization received!")
log.Printf("OAuth session obtained successfully")
log.Printf("DID: %s", did)
log.Printf("PDS: %s", pdsEndpoint)
return s.registerWithClient(publicURL, did, client)
}
// registerWithClient registers the hold using an authenticated ATProto client
func (s *HoldService) registerWithClient(publicURL, did string, client *atproto.Client) error {
// Derive hold name from URL (hostname)
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname from URL: %w", err)
}
log.Printf("Registering hold service: url=%s, name=%s, owner=%s", publicURL, holdName, did)
ctx := context.Background()
// Create HoldRecord
holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public)
// Use hostname as record key
holdResult, err := client.PutRecord(ctx, atproto.HoldCollection, holdName, holdRecord)
if err != nil {
return fmt.Errorf("failed to create hold record: %w", err)
}
log.Printf("✓ Created hold record: %s", holdResult.URI)
// Create HoldCrewRecord for the owner
crewRecord := atproto.NewHoldCrewRecord(holdResult.URI, did, "owner")
crewRKey := fmt.Sprintf("%s-%s", holdName, did)
crewResult, err := client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create crew record: %w", err)
}
log.Printf("✓ Created crew record: %s", crewResult.URI)
// Update sailor profile to set this as the default hold
profile, err := atproto.GetProfile(ctx, client)
if err != nil {
log.Printf("Warning: failed to get sailor profile: %v", err)
} else {
if profile == nil {
// Create new profile with this hold as default
profile = atproto.NewSailorProfileRecord(publicURL)
} else {
// Update existing profile with new defaultHold
profile.DefaultHold = publicURL
profile.UpdatedAt = time.Now()
}
err = atproto.UpdateProfile(ctx, client, profile)
if err != nil {
log.Printf("Warning: failed to update sailor profile: %v", err)
} else {
log.Printf("✓ Updated sailor profile defaultHold: %s", publicURL)
}
}
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("REGISTRATION COMPLETE")
log.Print(strings.Repeat("=", 80))
log.Printf("Hold service is now registered and ready to use!")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
}
// extractHostname extracts the hostname from a URL to use as the hold name
func extractHostname(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
// Remove port if present
hostname := u.Hostname()
if hostname == "" {
return "", fmt.Errorf("no hostname in URL")
}
return hostname, nil
}
// ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable
// Called on every startup to ensure the PDS record matches the desired configuration
func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
if ownerDID == "" {
// No owner DID configured, skip reconciliation
return nil
}
desiredState := s.config.Registration.AllowAllCrew
log.Printf("Checking allow-all crew state (desired: %v)", desiredState)
// Query PDS for current state
actualState, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
log.Printf("Allow-all crew record exists: %v", actualState)
// States match - nothing to do
if desiredState == actualState {
if desiredState {
log.Printf("✓ Allow-all crew enabled (all authenticated users can push)")
} else {
log.Printf("✓ Allow-all crew disabled (explicit crew membership required)")
}
return nil
}
// State mismatch - need to reconcile
if desiredState && !actualState {
// Need to create wildcard crew record
log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
return s.createAllowAllCrewRecord(callbackHandler)
}
if !desiredState && actualState {
// Need to delete wildcard crew record
log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)")
return s.deleteAllowAllCrewRecord(callbackHandler)
}
return nil
}
// hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS for THIS hold
func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
if ownerDID == "" {
return false, fmt.Errorf("hold owner DID not configured")
}
if publicURL == "" {
return false, fmt.Errorf("hold public URL not configured")
}
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return false, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return false, fmt.Errorf("no PDS endpoint found for owner")
}
// Build hold-specific rkey
holdName, err := extractHostname(publicURL)
if err != nil {
return false, fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Create unauthenticated client to read public records
client := atproto.NewClient(pdsEndpoint, ownerDID, "")
// Query for hold-specific allow-all record
record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
// Record doesn't exist
if errors.Is(err, atproto.ErrRecordNotFound) {
return false, nil
}
return false, fmt.Errorf("failed to get crew record: %w", err)
}
// Verify it's the wildcard record (memberPattern: "*")
var crewRecord atproto.HoldCrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
return false, fmt.Errorf("failed to unmarshal crew record: %w", err)
}
// Check if it's the exact wildcard pattern
if crewRecord.MemberPattern == nil || *crewRecord.MemberPattern != "*" {
return false, nil
}
// Verify it's for this hold (defensive check)
expectedHoldURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
return crewRecord.Hold == expectedHoldURI, nil
}
// createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users
func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Get hold URI
holdName, err := extractHostname(publicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
// Create wildcard crew record
crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write")
// Use hold-specific rkey to support multiple holds with different allow-all settings
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
_, err = client.PutRecord(ctx, atproto.HoldCrewCollection, crewRKey, crewRecord)
if err != nil {
return fmt.Errorf("failed to create allow-all crew record: %w", err)
}
log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
return nil
}
// deleteAllowAllCrewRecord deletes the wildcard crew record for this hold
func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
// Safety check: only delete if it's the exact wildcard pattern for THIS hold
isWildcard, err := s.hasAllowAllCrewRecord()
if err != nil {
return fmt.Errorf("failed to check allow-all crew record: %w", err)
}
if !isWildcard {
log.Printf("Note: 'allow-all' crew record not found for this hold (may exist for other holds)")
return nil
}
// Get hold name for rkey
holdName, err := extractHostname(s.config.Server.PublicURL)
if err != nil {
return fmt.Errorf("failed to extract hostname: %w", err)
}
crewRKey := fmt.Sprintf("allow-all-%s", holdName)
// Run OAuth flow to get authenticated client
client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record")
if err != nil {
return err
}
ctx := context.Background()
// Delete the hold-specific allow-all record
err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, crewRKey)
if err != nil {
return fmt.Errorf("failed to delete allow-all crew record: %w", err)
}
log.Printf("✓ Deleted allow-all crew record for this hold")
return nil
}
// getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management
func getHoldRegistrationScopes() []string {
return []string{
"atproto",
fmt.Sprintf("repo:%s", atproto.HoldCollection),
fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
}
}
// runOAuthFlow performs OAuth flow and returns an authenticated client
// Reusable helper to avoid code duplication across registration and reconciliation
func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) {
ownerDID := s.config.Registration.OwnerDID
publicURL := s.config.Server.PublicURL
ctx := context.Background()
// Resolve owner's PDS endpoint
directory := identity.DefaultDirectory()
ownerDIDParsed, err := syntax.ParseDID(ownerDID)
if err != nil {
return nil, fmt.Errorf("invalid owner DID: %w", err)
}
ident, err := directory.LookupDID(ctx, ownerDIDParsed)
if err != nil {
return nil, fmt.Errorf("failed to resolve owner PDS: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return nil, fmt.Errorf("no PDS endpoint found for owner")
}
handle := ident.Handle.String()
if handle == "" || handle == "handle.invalid" {
return nil, fmt.Errorf("no valid handle found for DID")
}
// Determine base URL for OAuth
var baseURL string
if s.config.Server.TestMode {
parsedURL, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("failed to parse public URL: %w", err)
}
port := parsedURL.Port()
if port == "" {
port = "8080"
}
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
baseURL = publicURL
}
// Run OAuth flow
result, err := oauth.InteractiveFlowWithCallback(
ctx,
baseURL,
handle,
getHoldRegistrationScopes(),
func(handler http.HandlerFunc) error {
*callbackHandler = handler
return nil
},
func(authURL string) error {
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH REQUIRED: %s", purpose)
log.Print(strings.Repeat("=", 80))
log.Printf("\nVisit: %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
return nil
},
)
if err != nil {
return nil, fmt.Errorf("OAuth flow failed: %w", err)
}
// Create authenticated client
apiClient := result.Session.APIClient()
return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil
}