mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
268 lines
8.2 KiB
Go
268 lines
8.2 KiB
Go
package hold
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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 {
|
|
// Define the scopes we need for hold registration
|
|
holdScopes := []string{
|
|
"atproto",
|
|
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
|
|
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
|
|
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
|
|
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
|
|
fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
|
|
fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
|
|
}
|
|
|
|
// Determine base URL based on mode
|
|
// Callback path standardized to /auth/oauth/callback across ATCR
|
|
var baseURL string
|
|
|
|
if s.config.Server.TestMode {
|
|
// Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
|
|
// Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080")
|
|
parsedURL, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse public URL: %w", err)
|
|
}
|
|
port := parsedURL.Port()
|
|
if port == "" {
|
|
port = "8080" // default
|
|
}
|
|
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
|
|
} else {
|
|
baseURL = publicURL
|
|
}
|
|
|
|
// Run interactive OAuth flow with persistent server
|
|
ctx := context.Background()
|
|
|
|
result, err := oauth.InteractiveFlowWithCallback(
|
|
ctx,
|
|
baseURL,
|
|
handle,
|
|
holdScopes, // Pass hold-specific scopes
|
|
func(handler http.HandlerFunc) error {
|
|
// Populate the pre-registered callback handler
|
|
*callbackHandler = handler
|
|
return nil
|
|
},
|
|
func(authURL string) error {
|
|
// Display OAuth URL for user to visit
|
|
log.Print("\n" + strings.Repeat("=", 80))
|
|
log.Printf("OAUTH AUTHORIZATION REQUIRED")
|
|
log.Print(strings.Repeat("=", 80))
|
|
log.Printf("\nPlease visit this URL to authorize the hold service:\n")
|
|
log.Printf(" %s\n", authURL)
|
|
log.Printf("Waiting for authorization...")
|
|
log.Print(strings.Repeat("=", 80) + "\n")
|
|
return nil
|
|
},
|
|
)
|
|
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)
|
|
|
|
// Create ATProto client with indigo's API client (handles DPoP automatically)
|
|
apiClient := result.Session.APIClient()
|
|
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
|
|
|
|
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
|
|
}
|