mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 01:34:16 +00:00
482 lines
15 KiB
Go
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
|
|
}
|