diff --git a/.env.example b/.env.example index 8796bd9..3f3c8f5 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ HOLD_PUBLIC_URL=http://127.0.0.1:8080 # Storage driver type (s3, filesystem) # Default: s3 -STORAGE_DRIVER=s3 +STORAGE_DRIVER=filesystem # For S3/Storj/Minio: AWS_ACCESS_KEY_ID=your_access_key @@ -50,7 +50,7 @@ HOLD_PUBLIC=false # Your ATProto DID (REQUIRED for registration) # Get your DID: https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.bsky.social # -# On first run with HOLD_CREW_OWNER set: +# On first run with HOLD_OWNER set: # 1. Hold service will print an OAuth URL to the logs # 2. Visit the URL in your browser to authorize # 3. Hold service creates hold + crew records in your PDS @@ -60,4 +60,4 @@ HOLD_PUBLIC=false # - Hold service checks if already registered # - Skips OAuth if records exist # -HOLD_CREW_OWNER=did:plc:your-did-here +HOLD_OWNER=did:plc:your-did-here diff --git a/CLAUDE.md b/CLAUDE.md index 49bb921..1b9b537 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ export ATPROTO_ACCESS_TOKEN=your-token export HOLD_PUBLIC_URL=http://127.0.0.1:8080 export STORAGE_DRIVER=filesystem export STORAGE_ROOT_DIR=/tmp/atcr-hold -export HOLD_CREW_OWNER=did:plc:your-did-here +export HOLD_OWNER=did:plc:your-did-here ./atcr-hold # Check logs for OAuth URL, visit in browser to complete registration ``` @@ -299,7 +299,7 @@ Key insight: "Private" gates anonymous access, not authenticated access. This re - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - S3 credentials - `S3_BUCKET`, `S3_ENDPOINT` - S3 configuration - `HOLD_PUBLIC` - Allow public reads (default: false) -- `HOLD_CREW_OWNER` - DID for auto-registration (optional) +- `HOLD_OWNER` - DID for auto-registration (optional) **Deployment:** Can run on Fly.io, Railway, Docker, Kubernetes, etc. @@ -386,7 +386,7 @@ This ensures: - Storage driver config via env vars: `STORAGE_DRIVER`, `AWS_*`, `S3_*` - Authorization: Based on PDS records (`hold.public`, crew records) - Server settings: `HOLD_SERVER_ADDR`, `HOLD_PUBLIC_URL`, `HOLD_PUBLIC` -- Auto-registration: `HOLD_CREW_OWNER` (optional) +- Auto-registration: `HOLD_OWNER` (optional) **Credential Helper**: - Token storage: `~/.atcr/oauth-token.json` @@ -439,7 +439,7 @@ When writing tests: **Adding BYOS support for a user**: 1. User sets environment variables (storage credentials, public URL) -2. User runs hold service with `HOLD_CREW_OWNER` set - auto-registration via OAuth +2. User runs hold service with `HOLD_OWNER` set - auto-registration via OAuth 3. Hold service creates `io.atcr.hold` + `io.atcr.hold.crew` records in PDS 4. AppView automatically queries PDS and routes blobs to user's storage 5. No AppView changes needed - fully decentralized diff --git a/SPEC.md b/SPEC.md index cdff788..9884f3c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -431,7 +431,7 @@ ATProto OAuth Implementation Plan Unified Model - Every hold service requires HOLD_CREW_OWNER: + Every hold service requires HOLD_OWNER: - Owner's PDS has the io.atcr.hold record - Owner's PDS has all io.atcr.hold.crew records - Authorization is always governed by PDS records diff --git a/cmd/hold/main.go b/cmd/hold/main.go index a7ff4df..55e2563 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -34,7 +34,7 @@ type Config struct { // RegistrationConfig defines auto-registration settings type RegistrationConfig struct { - // OwnerDID is the owner's ATProto DID (from env: HOLD_CREW_OWNER) + // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER) // If set, auto-registration is enabled OwnerDID string `yaml:"owner_did"` } @@ -55,6 +55,9 @@ type ServerConfig struct { // Public controls whether this hold allows public blob reads without auth (from env: HOLD_PUBLIC) Public bool `yaml:"public"` + // TestMode uses localhost for OAuth redirects while storing real URL in hold record (from env: TEST_MODE) + TestMode bool `yaml:"test_mode"` + // ReadTimeout for HTTP requests ReadTimeout time.Duration `yaml:"read_timeout"` @@ -64,8 +67,12 @@ type ServerConfig struct { // HoldService provides presigned URLs for blob storage in a hold type HoldService struct { - driver storagedriver.StorageDriver - config *Config + driver storagedriver.StorageDriver + config *Config + oauthCodeCh chan string + oauthErrCh chan error + oauthState string + codeVerifier string } // NewHoldService creates a new hold service @@ -78,8 +85,10 @@ func NewHoldService(cfg *Config) (*HoldService, error) { } return &HoldService{ - driver: driver, - config: cfg, + driver: driver, + config: cfg, + oauthCodeCh: make(chan string, 1), + oauthErrCh: make(chan error, 1), }, nil } @@ -183,7 +192,7 @@ func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Reque ctx := context.Background() expiry := time.Now().Add(15 * time.Minute) - url, err := s.getUploadURL(ctx, req.Digest, req.Size) + url, err := s.getUploadURL(ctx, req.Digest, req.Size, req.DID) if err != nil { http.Error(w, fmt.Sprintf("failed to generate URL: %v", err), http.StatusInternalServerError) return @@ -200,7 +209,7 @@ func (s *HoldService) HandlePutPresignedURL(w http.ResponseWriter, r *http.Reque // HandleProxyGet proxies a blob download through the service func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } @@ -228,10 +237,23 @@ func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { return } - // Read blob from storage ctx := r.Context() - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) + path := blobPath(digest) + // For HEAD requests, just check if blob exists + if r.Method == http.MethodHead { + stat, err := s.driver.Stat(ctx, path) + if err != nil { + http.Error(w, "blob not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + w.WriteHeader(http.StatusOK) + return + } + + // For GET requests, read and return the blob content, err := s.driver.GetContent(ctx, path) if err != nil { http.Error(w, "blob not found", http.StatusNotFound) @@ -244,6 +266,8 @@ func (s *HoldService) HandleProxyGet(w http.ResponseWriter, r *http.Request) { // HandleProxyPut proxies a blob upload through the service func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { + log.Printf("HandleProxyPut: method=%s, path=%s, query=%s", r.Method, r.URL.Path, r.URL.RawQuery) + if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -260,11 +284,17 @@ func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { did = r.Header.Get("X-ATCR-DID") } + log.Printf("HandleProxyPut: digest=%s, did=%s", digest, did) + // Authorize WRITE access - if !s.isAuthorizedWrite(did) { + authorized := s.isAuthorizedWrite(did) + log.Printf("HandleProxyPut: authorization check: did=%s, authorized=%v", did, authorized) + if !authorized { if did == "" { + log.Printf("HandleProxyPut: rejecting - no DID provided") http.Error(w, "unauthorized: authentication required", http.StatusUnauthorized) } else { + log.Printf("HandleProxyPut: rejecting - DID not authorized for write") http.Error(w, "forbidden: write access denied", http.StatusForbidden) } return @@ -272,19 +302,23 @@ func (s *HoldService) HandleProxyPut(w http.ResponseWriter, r *http.Request) { // Write blob to storage ctx := r.Context() - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) + path := blobPath(digest) content, err := io.ReadAll(r.Body) if err != nil { + log.Printf("HandleProxyPut: failed to read body: %v", err) http.Error(w, "failed to read body", http.StatusBadRequest) return } + log.Printf("HandleProxyPut: writing blob to path=%s, size=%d bytes", path, len(content)) if err := s.driver.PutContent(ctx, path, content); err != nil { + log.Printf("HandleProxyPut: failed to store blob: %v", err) http.Error(w, "failed to store blob", http.StatusInternalServerError) return } + log.Printf("HandleProxyPut: successfully stored blob digest=%s, size=%d", digest, len(content)) w.WriteHeader(http.StatusCreated) } @@ -400,7 +434,7 @@ func (s *HoldService) isCrewMember(did string) (bool, error) { // getDownloadURL generates a download URL for a blob func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string, error) { // Check if blob exists - path := fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) + path := blobPath(digest) _, err := s.driver.Stat(ctx, path) if err != nil { return "", fmt.Errorf("blob not found: %w", err) @@ -408,14 +442,15 @@ func (s *HoldService) getDownloadURL(ctx context.Context, digest string) (string // For drivers that support presigned URLs (S3), use those // For now, return a proxy URL through this service - return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil + return fmt.Sprintf("%s/blobs/%s", s.config.Server.PublicURL, digest), nil } // getUploadURL generates an upload URL for a blob -func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64) (string, error) { +// Note: This is called from HandlePutPresignedURL which has the DID in the request +func (s *HoldService) getUploadURL(ctx context.Context, digest string, size int64, did string) (string, error) { // For drivers that support presigned URLs (S3), use those - // For now, return a proxy URL through this service - return fmt.Sprintf("http://%s/blobs/%s", s.config.Server.Addr, digest), nil + // For now, return a proxy URL through this service with DID for authorization + return fmt.Sprintf("%s/blobs/%s?did=%s", s.config.Server.PublicURL, digest, did), nil } // RegisterRequest represents a request to register this hold in a user's PDS @@ -515,6 +550,34 @@ func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) { }) } +// HandleOAuthCallback handles OAuth callback from authorization server +func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + receivedState := r.URL.Query().Get("state") + + if receivedState != s.oauthState { + s.oauthErrCh <- fmt.Errorf("invalid state parameter") + http.Error(w, "Invalid state", http.StatusBadRequest) + return + } + + if code == "" { + s.oauthErrCh <- fmt.Errorf("no authorization code received") + http.Error(w, "No code", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `
You can close this window and return to the terminal.
`) + + // Send code to registration flow + select { + case s.oauthCodeCh <- code: + default: + // Channel already has a value or nobody is listening + } +} + func main() { // Load configuration from environment variables cfg, err := loadConfigFromEnv() @@ -528,24 +591,22 @@ func main() { log.Fatalf("Failed to create hold service: %v", err) } - // Auto-register if owner DID is set - if cfg.Registration.OwnerDID != "" { - if err := service.AutoRegister(); err != nil { - log.Printf("WARNING: Auto-registration failed: %v", err) - log.Printf("You can register manually later using the /register endpoint") - } else { - log.Printf("Successfully registered hold service in PDS") - } - } - // Setup HTTP routes mux := http.NewServeMux() mux.HandleFunc("/health", service.HealthHandler) mux.HandleFunc("/register", service.HandleRegister) mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL) mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL) + mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port + + // OAuth client metadata endpoint for ATProto OAuth + clientID := cfg.Server.PublicURL + "/client-metadata.json" + clientMetadata := oauth.NewClientMetadata(clientID, []string{cfg.Server.PublicURL + "/oauth/callback"}) + clientMetadata.ClientName = "ATCR Hold Service" + clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service + mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata)) mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { + if r.Method == http.MethodGet || r.Method == http.MethodHead { service.HandleProxyGet(w, r) } else if r.Method == http.MethodPut { service.HandleProxyPut(w, r) @@ -562,8 +623,30 @@ func main() { WriteTimeout: cfg.Server.WriteTimeout, } - log.Printf("Starting hold service on %s", cfg.Server.Addr) - if err := server.ListenAndServe(); err != nil { + // Start server in goroutine so we can do auto-registration after it's running + serverErr := make(chan error, 1) + go func() { + log.Printf("Starting hold service on %s", cfg.Server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- err + } + }() + + // Give server a moment to start + time.Sleep(100 * time.Millisecond) + + // Auto-register if owner DID is set (now that server is running) + if cfg.Registration.OwnerDID != "" { + if err := service.AutoRegister(); err != nil { + log.Printf("WARNING: Auto-registration failed: %v", err) + log.Printf("You can register manually later using the /register endpoint") + } else { + log.Printf("Successfully registered hold service in PDS") + } + } + + // Wait for server error or shutdown + if err := <-serverErr; err != nil { log.Fatalf("Server failed: %v", err) } } @@ -581,11 +664,12 @@ func loadConfigFromEnv() (*Config, error) { return nil, fmt.Errorf("HOLD_PUBLIC_URL is required") } cfg.Server.Public = os.Getenv("HOLD_PUBLIC") == "true" - cfg.Server.ReadTimeout = 30 * time.Second - cfg.Server.WriteTimeout = 30 * time.Second + cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" + cfg.Server.ReadTimeout = 5 * time.Minute // Increased for large blob uploads + cfg.Server.WriteTimeout = 5 * time.Minute // Increased for large blob uploads // Registration configuration (optional) - cfg.Registration.OwnerDID = os.Getenv("HOLD_CREW_OWNER") + cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER") // Storage configuration - build from env vars based on storage type storageType := getEnvOrDefault("STORAGE_DRIVER", "s3") @@ -647,6 +731,29 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } +// blobPath converts a digest (e.g., "sha256:abc123...") to a storage path +// Distribution stores blobs as: /docker/registry/v2/blobs/{algorithm}/{xx}/{hash}/data +// where xx is the first 2 characters of the hash for directory sharding +// NOTE: Path must start with / for filesystem driver +func blobPath(digest string) string { + // Split digest into algorithm and hash + parts := strings.SplitN(digest, ":", 2) + if len(parts) != 2 { + // Fallback for malformed digest + return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest) + } + + algorithm := parts[0] + hash := parts[1] + + // Use first 2 characters for sharding + if len(hash) < 2 { + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash) + } + + return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash) +} + // 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 @@ -685,7 +792,7 @@ func (s *HoldService) AutoRegister() error { } if reg.OwnerDID == "" { - return fmt.Errorf("HOLD_CREW_OWNER not set - required for registration") + return fmt.Errorf("HOLD_OWNER not set - required for registration") } ctx := context.Background() @@ -730,12 +837,43 @@ func (s *HoldService) AutoRegister() error { // registerWithOAuth performs OAuth flow and registers the hold func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error { - // Use 127.0.0.1 for localhost callback (works better than "localhost") - callbackAddr := "127.0.0.1:8888" - redirectURI := fmt.Sprintf("http://%s/callback", callbackAddr) + // Extract port from publicURL for test mode + var redirectURI string + var clientID string + + // Define the scopes we need for hold registration + // Need create and update permissions for hold and crew collections + scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update", + atproto.HoldCollection, atproto.HoldCollection, + atproto.HoldCrewCollection, atproto.HoldCrewCollection) + + 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 + } + redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) + clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", + url.QueryEscape(redirectURI), url.QueryEscape(scopes)) + } else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") { + // Localhost development mode per ATProto OAuth spec + redirectURI = publicURL + "/oauth/callback" + clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s", + url.QueryEscape(redirectURI), url.QueryEscape(scopes)) + } else { + // Production mode - use client metadata URL + redirectURI = publicURL + "/oauth/callback" + clientID = publicURL + "/client-metadata.json" + } // Create OAuth client - oauthClient, err := oauth.NewClient("http://hold-service", redirectURI) + oauthClient, err := oauth.NewClient(clientID, redirectURI) if err != nil { return fmt.Errorf("failed to create OAuth client: %w", err) } @@ -746,12 +884,22 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri return fmt.Errorf("failed to initialize OAuth: %w", err) } + // Set the scopes we need for hold registration (create and update) + oauthClient.SetScopes([]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), + }) + // Generate authorization URL - state := "hold-registration" - authURL, codeVerifier, err := oauthClient.AuthorizeURL(state) + s.oauthState = "hold-registration" + authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState) if err != nil { return fmt.Errorf("failed to generate auth URL: %w", err) } + s.codeVerifier = codeVerifier // Print the OAuth URL for user to visit log.Print("\n" + strings.Repeat("=", 80)) @@ -762,61 +910,21 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri log.Printf("Waiting for authorization...") log.Print(strings.Repeat("=", 80) + "\n") - // Start temporary HTTP server for callback - codeChan := make(chan string, 1) - errChan := make(chan error, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") - receivedState := r.URL.Query().Get("state") - - if receivedState != state { - errChan <- fmt.Errorf("invalid state parameter") - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - - if code == "" { - errChan <- fmt.Errorf("no authorization code received") - http.Error(w, "No code", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprintf(w, `You can close this window and return to the terminal.
`) - codeChan <- code - }) - - server := &http.Server{ - Addr: callbackAddr, - Handler: mux, - } - - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errChan <- err - } - }() - - // Wait for callback or error + // Wait for callback or error (callback happens on main server) var code string select { - case code = <-codeChan: - // Got the code, shutdown callback server - server.Shutdown(context.Background()) - case err := <-errChan: - server.Shutdown(context.Background()) + case code = <-s.oauthCodeCh: + // Got the code from callback + case err := <-s.oauthErrCh: return err case <-time.After(5 * time.Minute): - server.Shutdown(context.Background()) return fmt.Errorf("OAuth timeout - no response after 5 minutes") } log.Printf("Authorization received, exchanging code for token...") // Exchange code for token - token, err := oauthClient.Exchange(ctx, code, codeVerifier) + token, err := oauthClient.Exchange(ctx, code, s.codeVerifier) if err != nil { return fmt.Errorf("failed to exchange code: %w", err) } @@ -825,12 +933,19 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri log.Printf("DID: %s", did) log.Printf("PDS: %s", pdsEndpoint) - // Now register with the token - return s.registerWithToken(publicURL, did, pdsEndpoint, token.AccessToken) + // Now register with the token using DPoP + // Create ATProto client with DPoP transport from OAuth client + dpopKey := oauthClient.DPoPKey() + dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey) + // Set the access token in the transport for "ath" claim computation + dpopTransport.SetAccessToken(token.AccessToken) + client := atproto.NewClientWithDPoP(pdsEndpoint, did, token.AccessToken, dpopKey, dpopTransport) + + return s.registerWithClient(publicURL, did, client) } -// registerWithToken registers the hold using an access token -func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken string) error { +// 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 { @@ -841,9 +956,6 @@ func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken ctx := context.Background() - // Create ATProto client with owner's credentials - client := atproto.NewClient(pdsEndpoint, did, accessToken) - // Create HoldRecord holdRecord := atproto.NewHoldRecord(publicURL, did, s.config.Server.Public) @@ -866,6 +978,28 @@ func (s *HoldService) registerWithToken(publicURL, did, pdsEndpoint, accessToken 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)) diff --git a/cmd/profile-update/main.go b/cmd/profile-update/main.go new file mode 100644 index 0000000..40c55ef --- /dev/null +++ b/cmd/profile-update/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + atprotoAuth "atcr.io/pkg/auth/atproto" + "atcr.io/pkg/atproto" +) + +// DockerConfig represents ~/.docker/config.json +type DockerConfig struct { + Auths map[string]AuthEntry `json:"auths"` +} + +type AuthEntry struct { + Auth string `json:"auth"` // base64(username:password) +} + +func main() { + var defaultHold string + var registryURL string + + flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)") + flag.StringVar(®istryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config") + flag.Parse() + + // Read Docker config + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("Failed to get home directory: %v", err) + } + dockerConfigPath := filepath.Join(home, ".docker", "config.json") + + configData, err := os.ReadFile(dockerConfigPath) + if err != nil { + log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL) + } + + var dockerConfig DockerConfig + if err := json.Unmarshal(configData, &dockerConfig); err != nil { + log.Fatalf("Failed to parse Docker config: %v", err) + } + + // Get auth for registry + authEntry, ok := dockerConfig.Auths[registryURL] + if !ok { + log.Fatalf("No auth found for registry %s in Docker config", registryURL) + } + + // Decode base64 auth (format: "username:password") + authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth) + if err != nil { + log.Fatalf("Failed to decode auth: %v", err) + } + + parts := strings.SplitN(string(authBytes), ":", 2) + if len(parts) != 2 { + log.Fatalf("Invalid auth format") + } + + handle := parts[0] + password := parts[1] // This should be an app password + + fmt.Printf("Handle: %s\n", handle) + + // Create session validator and get access token + validator := atprotoAuth.NewSessionValidator() + ctx := context.Background() + + did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password) + if err != nil { + log.Fatalf("Failed to authenticate: %v", err) + } + + fmt.Printf("DID: %s\n", did) + fmt.Printf("PDS: %s\n\n", pdsEndpoint) + + // Create client with the access token from createSession + client := atproto.NewClient(pdsEndpoint, did, accessToken) + + // Get current profile + profile, err := atproto.GetProfile(ctx, client) + if err != nil { + log.Fatalf("Failed to get current profile: %v", err) + } + + if profile == nil { + if defaultHold == "" { + fmt.Println("No existing profile found.") + fmt.Println("\nTo create profile with default hold, use: -default-hold