From aff5d7248cb9d46ae015a975ad93d651c904c373 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 22 Oct 2025 19:29:07 -0500 Subject: [PATCH] try and implement getsession and app-password --- cmd/hold/main.go | 5 + go.mod | 1 + go.sum | 2 + pkg/atproto/endpoints.go | 23 +++ pkg/hold/pds/apppassword.go | 138 +++++++++++++ pkg/hold/pds/auth.go | 30 +++ pkg/hold/pds/database.go | 232 ++++++++++++++++++++++ pkg/hold/pds/jwt.go | 249 +++++++++++++++++++++++ pkg/hold/pds/server.go | 40 ++++ pkg/hold/pds/session.go | 381 ++++++++++++++++++++++++++++++++++++ pkg/hold/pds/xrpc.go | 240 ++++++++++++++++++++--- 11 files changed, 1318 insertions(+), 23 deletions(-) create mode 100644 pkg/hold/pds/apppassword.go create mode 100644 pkg/hold/pds/database.go create mode 100644 pkg/hold/pds/jwt.go create mode 100644 pkg/hold/pds/session.go diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 27b3c81..d29fb87 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -100,6 +100,11 @@ func main() { // Add logging middleware to log all HTTP requests r.Use(middleware.Logger) + // Add CORS middleware (must be before routes) + if xrpcHandler != nil { + r.Use(xrpcHandler.CORSMiddleware()) + } + // Root page r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") diff --git a/go.mod b/go.mod index c38c790..8584335 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/docker/go-metrics v0.0.1 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-chi/cors v1.2.2 // indirect github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/go.sum b/go.sum index b3fd7fd..63cf36c 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= diff --git a/pkg/atproto/endpoints.go b/pkg/atproto/endpoints.go index 4574b3b..e85e552 100644 --- a/pkg/atproto/endpoints.go +++ b/pkg/atproto/endpoints.go @@ -120,6 +120,12 @@ const ( // Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."} ServerCreateSession = "/xrpc/com.atproto.server.createSession" + // ServerRefreshSession refreshes an existing session using a refresh token. + // Method: POST + // Headers: Authorization (Bearer ) + // Response: {"accessJwt": "...", "refreshJwt": "...", "did": "...", "handle": "..."} + ServerRefreshSession = "/xrpc/com.atproto.server.refreshSession" + // ServerGetSession validates a session and returns the current session info. // Method: GET // Headers: Authorization (Bearer or DPoP), DPoP (if using DPoP) @@ -179,3 +185,20 @@ const ( // Response: {"did": "did:plc:..."} IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle" ) + +// Bluesky app endpoints (app.bsky.actor.*) +// +// Bluesky-specific actor/profile endpoints. +const ( + // ActorGetProfile retrieves an aggregated profile for an actor. + // Method: GET + // Query: actor={did|handle} + // Response: {"did": "...", "handle": "...", "displayName": "...", "postsCount": ...} + ActorGetProfile = "/xrpc/app.bsky.actor.getProfile" + + // ActorGetProfiles retrieves aggregated profiles for multiple actors. + // Method: GET + // Query: actors={did|handle}&actors={did|handle}... + // Response: {"profiles": [{...}, {...}]} + ActorGetProfiles = "/xrpc/app.bsky.actor.getProfiles" +) diff --git a/pkg/hold/pds/apppassword.go b/pkg/hold/pds/apppassword.go new file mode 100644 index 0000000..4652892 --- /dev/null +++ b/pkg/hold/pds/apppassword.go @@ -0,0 +1,138 @@ +package pds + +import ( + "crypto/rand" + "encoding/base32" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +// GenerateAppPassword creates a random app password in the format: abcd-efgh-ijkl-mnop +// Uses base32 encoding for readable characters (no ambiguous chars like 0/O, 1/l) +func GenerateAppPassword() (string, error) { + // Generate 20 random bytes (160 bits of entropy) + // Base32 encoding gives us 32 characters, we'll format as 4 groups of 4 + randomBytes := make([]byte, 20) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode as base32 and lowercase (base32 alphabet: a-z, 2-7) + encoded := base32.StdEncoding.EncodeToString(randomBytes) + encoded = strings.ToLower(encoded) + + // Remove padding and take first 16 characters + encoded = strings.TrimRight(encoded, "=") + if len(encoded) > 16 { + encoded = encoded[:16] + } + + // Format as: xxxx-xxxx-xxxx-xxxx + parts := []string{ + encoded[0:4], + encoded[4:8], + encoded[8:12], + encoded[12:16], + } + + return strings.Join(parts, "-"), nil +} + +// HashAppPassword hashes an app password using bcrypt +// Cost is set to 12 for good security without excessive CPU usage +func HashAppPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hash), nil +} + +// ValidateAppPassword compares a plaintext password with a bcrypt hash +func ValidateAppPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// CreateAppPassword generates and stores a new app password +func (p *HoldPDS) CreateAppPassword(name string) (string, error) { + // Generate random password + password, err := GenerateAppPassword() + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + + // Hash password + hash, err := HashAppPassword(password) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + + // Store in database + if err := p.authDB.CreateAppPassword(name, hash); err != nil { + return "", fmt.Errorf("failed to store password: %w", err) + } + + return password, nil +} + +// ValidateAppPasswordByName checks if a password matches the stored hash for a given name +func (p *HoldPDS) ValidateAppPasswordByName(name, password string) error { + // Get app password from database + ap, err := p.authDB.GetAppPassword(name) + if err != nil { + return fmt.Errorf("app password not found: %w", err) + } + + // Validate password + if !ValidateAppPassword(password, ap.PasswordHash) { + return fmt.Errorf("invalid password") + } + + // Update last used timestamp + if err := p.authDB.UpdateLastUsed(name); err != nil { + // Log but don't fail - this is not critical + fmt.Printf("Warning: failed to update last used timestamp: %v\n", err) + } + + return nil +} + +// ValidateAnyAppPassword checks if a password matches any stored app password +// Returns the name of the matching app password, or error if none match +func (p *HoldPDS) ValidateAnyAppPassword(password string) (string, error) { + // List all app passwords + passwords, err := p.authDB.ListAppPasswords() + if err != nil { + return "", fmt.Errorf("failed to list app passwords: %w", err) + } + + // Try each one + for _, ap := range passwords { + // Get full record with hash + fullAP, err := p.authDB.GetAppPassword(ap.Name) + if err != nil { + continue + } + + if ValidateAppPassword(password, fullAP.PasswordHash) { + // Update last used + p.authDB.UpdateLastUsed(ap.Name) + return ap.Name, nil + } + } + + return "", fmt.Errorf("invalid app password") +} + +// ListAppPasswords returns a list of app password names (without hashes) +func (p *HoldPDS) ListAppPasswords() ([]AppPassword, error) { + return p.authDB.ListAppPasswords() +} + +// RevokeAppPassword deletes an app password +func (p *HoldPDS) RevokeAppPassword(name string) error { + return p.authDB.DeleteAppPassword(name) +} diff --git a/pkg/hold/pds/auth.go b/pkg/hold/pds/auth.go index a6d9889..daa30bc 100644 --- a/pkg/hold/pds/auth.go +++ b/pkg/hold/pds/auth.go @@ -528,3 +528,33 @@ func fetchPublicKeyFromDID(ctx context.Context, did string, httpClient HTTPClien return publicKey, nil } + +// ValidateJWTAuth validates a request with a JWT access token from createSession +// This is used for authenticated repo operations (createRecord, etc.) +// Returns the validated user DID +func ValidateJWTAuth(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) { + // Extract Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return nil, fmt.Errorf("missing Authorization header") + } + + // Remove "Bearer " prefix + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + if accessToken == authHeader { + return nil, fmt.Errorf("invalid authorization header format (expected Bearer)") + } + + // Validate access token + claims, err := pds.ValidateAccessToken(accessToken) + if err != nil { + return nil, fmt.Errorf("invalid access token: %w", err) + } + + return &ValidatedUser{ + DID: claims.DID, + Handle: claims.Handle, + PDS: "", + Authorized: true, + }, nil +} diff --git a/pkg/hold/pds/database.go b/pkg/hold/pds/database.go new file mode 100644 index 0000000..9903716 --- /dev/null +++ b/pkg/hold/pds/database.go @@ -0,0 +1,232 @@ +package pds + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Database manages app passwords and sessions for the hold PDS +type Database struct { + db *sql.DB +} + +// NewDatabase creates or opens a database for app passwords and sessions +// dbPath should be the directory path (same as carstore) +// It creates a separate "auth.db" file for authentication data +func NewDatabase(dbPath string) (*Database, error) { + // Ensure directory exists + if err := os.MkdirAll(dbPath, 0755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + // Create auth database file alongside carstore database + authDBFile := filepath.Join(dbPath, "auth.db") + + db, err := sql.Open("sqlite3", authDBFile) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Create tables + if err := createTables(db); err != nil { + db.Close() + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return &Database{db: db}, nil +} + +// createTables creates the database schema +func createTables(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS app_passwords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_app_passwords_name ON app_passwords(name); + + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at); + ` + + _, err := db.Exec(schema) + return err +} + +// Close closes the database connection +func (d *Database) Close() error { + return d.db.Close() +} + +// AppPassword represents an app password record +type AppPassword struct { + ID int64 + Name string + PasswordHash string + CreatedAt time.Time + LastUsedAt *time.Time +} + +// CreateAppPassword stores a new app password +func (d *Database) CreateAppPassword(name, passwordHash string) error { + query := `INSERT INTO app_passwords (name, password_hash) VALUES (?, ?)` + _, err := d.db.Exec(query, name, passwordHash) + if err != nil { + return fmt.Errorf("failed to create app password: %w", err) + } + return nil +} + +// GetAppPassword retrieves an app password by name +func (d *Database) GetAppPassword(name string) (*AppPassword, error) { + query := `SELECT id, name, password_hash, created_at, last_used_at FROM app_passwords WHERE name = ?` + + var ap AppPassword + var lastUsedAt sql.NullTime + + err := d.db.QueryRow(query, name).Scan( + &ap.ID, + &ap.Name, + &ap.PasswordHash, + &ap.CreatedAt, + &lastUsedAt, + ) + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("app password not found") + } + if err != nil { + return nil, fmt.Errorf("failed to get app password: %w", err) + } + + if lastUsedAt.Valid { + ap.LastUsedAt = &lastUsedAt.Time + } + + return &ap, nil +} + +// ListAppPasswords returns all app passwords (without hashes) +func (d *Database) ListAppPasswords() ([]AppPassword, error) { + query := `SELECT id, name, created_at, last_used_at FROM app_passwords ORDER BY created_at DESC` + + rows, err := d.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list app passwords: %w", err) + } + defer rows.Close() + + var passwords []AppPassword + for rows.Next() { + var ap AppPassword + var lastUsedAt sql.NullTime + + if err := rows.Scan(&ap.ID, &ap.Name, &ap.CreatedAt, &lastUsedAt); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + if lastUsedAt.Valid { + ap.LastUsedAt = &lastUsedAt.Time + } + + passwords = append(passwords, ap) + } + + return passwords, rows.Err() +} + +// UpdateLastUsed updates the last used timestamp for an app password +func (d *Database) UpdateLastUsed(name string) error { + query := `UPDATE app_passwords SET last_used_at = CURRENT_TIMESTAMP WHERE name = ?` + _, err := d.db.Exec(query, name) + return err +} + +// DeleteAppPassword removes an app password +func (d *Database) DeleteAppPassword(name string) error { + query := `DELETE FROM app_passwords WHERE name = ?` + result, err := d.db.Exec(query, name) + if err != nil { + return fmt.Errorf("failed to delete app password: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected: %w", err) + } + + if rows == 0 { + return fmt.Errorf("app password not found") + } + + return nil +} + +// CreateRefreshToken stores a refresh token +func (d *Database) CreateRefreshToken(tokenHash string, expiresAt time.Time) error { + query := `INSERT INTO refresh_tokens (token_hash, expires_at) VALUES (?, ?)` + _, err := d.db.Exec(query, tokenHash, expiresAt) + if err != nil { + return fmt.Errorf("failed to create refresh token: %w", err) + } + return nil +} + +// ValidateRefreshToken checks if a refresh token exists and is not expired +func (d *Database) ValidateRefreshToken(tokenHash string) (bool, error) { + query := `SELECT expires_at FROM refresh_tokens WHERE token_hash = ?` + + var expiresAt time.Time + err := d.db.QueryRow(query, tokenHash).Scan(&expiresAt) + + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to validate refresh token: %w", err) + } + + // Check if expired + if time.Now().After(expiresAt) { + // Delete expired token + d.DeleteRefreshToken(tokenHash) + return false, nil + } + + // Update last used + updateQuery := `UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?` + d.db.Exec(updateQuery, tokenHash) + + return true, nil +} + +// DeleteRefreshToken removes a refresh token +func (d *Database) DeleteRefreshToken(tokenHash string) error { + query := `DELETE FROM refresh_tokens WHERE token_hash = ?` + _, err := d.db.Exec(query, tokenHash) + return err +} + +// CleanupExpiredTokens removes all expired refresh tokens +func (d *Database) CleanupExpiredTokens() error { + query := `DELETE FROM refresh_tokens WHERE expires_at < CURRENT_TIMESTAMP` + _, err := d.db.Exec(query) + return err +} diff --git a/pkg/hold/pds/jwt.go b/pkg/hold/pds/jwt.go new file mode 100644 index 0000000..4038798 --- /dev/null +++ b/pkg/hold/pds/jwt.go @@ -0,0 +1,249 @@ +package pds + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// Session token types +const ( + TokenTypeAccess = "access" + TokenTypeRefresh = "refresh" +) + +// Token expiration durations +const ( + AccessTokenDuration = 2 * time.Hour // Short-lived access token + RefreshTokenDuration = 90 * 24 * time.Hour // Long-lived refresh token (90 days) +) + +// SessionClaims represents JWT claims for ATProto sessions +type SessionClaims struct { + DID string `json:"sub"` // Subject (DID) + Issuer string `json:"iss"` // Issuer (PDS DID) + Handle string `json:"handle,omitempty"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + IssuedAt int64 `json:"iat"` // Unix timestamp + ExpiresAt int64 `json:"exp"` // Unix timestamp +} + +// IssueAccessToken creates a new access JWT for a session +func (p *HoldPDS) IssueAccessToken(did, handle string) (string, error) { + now := time.Now() + claims := &SessionClaims{ + DID: did, + Issuer: p.did, + Handle: handle, + Scope: "com.atproto.access", + TokenType: TokenTypeAccess, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(AccessTokenDuration).Unix(), + } + + return p.signJWT(claims) +} + +// IssueRefreshToken creates a new refresh JWT for a session +func (p *HoldPDS) IssueRefreshToken(did, handle string) (string, error) { + now := time.Now() + claims := &SessionClaims{ + DID: did, + Issuer: p.did, + Handle: handle, + Scope: "com.atproto.refresh", + TokenType: TokenTypeRefresh, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(RefreshTokenDuration).Unix(), + } + + signedToken, err := p.signJWT(claims) + if err != nil { + return "", err + } + + // Store refresh token hash in database for validation/revocation + tokenHash := hashToken(signedToken) + expiresAt := now.Add(RefreshTokenDuration) + if err := p.authDB.CreateRefreshToken(tokenHash, expiresAt); err != nil { + return "", fmt.Errorf("failed to store refresh token: %w", err) + } + + return signedToken, nil +} + +// ValidateAccessToken validates an access JWT and returns the claims +func (p *HoldPDS) ValidateAccessToken(tokenString string) (*SessionClaims, error) { + return p.validateToken(tokenString, TokenTypeAccess) +} + +// ValidateRefreshToken validates a refresh JWT and returns the claims +// Also checks the database to ensure the token hasn't been revoked +func (p *HoldPDS) ValidateRefreshToken(tokenString string) (*SessionClaims, error) { + // First validate signature and claims + claims, err := p.validateToken(tokenString, TokenTypeRefresh) + if err != nil { + return nil, err + } + + // Check if token is in database (not revoked) + tokenHash := hashToken(tokenString) + valid, err := p.authDB.ValidateRefreshToken(tokenHash) + if err != nil { + return nil, fmt.Errorf("failed to validate refresh token in database: %w", err) + } + if !valid { + return nil, fmt.Errorf("refresh token has been revoked or expired") + } + + return claims, nil +} + +// validateToken validates a JWT token and returns the claims +func (p *HoldPDS) validateToken(tokenString, expectedType string) (*SessionClaims, error) { + // Split token into parts + parts := splitJWT(tokenString) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + + // Decode header + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to decode header: %w", err) + } + + var header map[string]interface{} + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("failed to parse header: %w", err) + } + + // Verify algorithm + alg, ok := header["alg"].(string) + if !ok || alg != "ES256K" { + return nil, fmt.Errorf("unsupported algorithm: %v", alg) + } + + // Decode claims + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode claims: %w", err) + } + + var claims SessionClaims + if err := json.Unmarshal(claimsBytes, &claims); err != nil { + return nil, fmt.Errorf("failed to parse claims: %w", err) + } + + // Verify token type + if claims.TokenType != expectedType { + return nil, fmt.Errorf("invalid token type: expected %s, got %s", expectedType, claims.TokenType) + } + + // Verify issuer + if claims.Issuer != p.did { + return nil, fmt.Errorf("invalid issuer: expected %s, got %s", p.did, claims.Issuer) + } + + // Verify subject matches this hold + if claims.DID != p.did { + return nil, fmt.Errorf("invalid subject: expected %s, got %s", p.did, claims.DID) + } + + // Verify expiration + if time.Now().Unix() > claims.ExpiresAt { + return nil, fmt.Errorf("token has expired") + } + + // Verify signature + signedData := []byte(parts[0] + "." + parts[1]) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, fmt.Errorf("failed to decode signature: %w", err) + } + + publicKey, err := p.signingKey.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + if err := publicKey.HashAndVerify(signedData, signature); err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + return &claims, nil +} + +// RevokeRefreshToken revokes a refresh token by removing it from the database +func (p *HoldPDS) RevokeRefreshToken(tokenString string) error { + tokenHash := hashToken(tokenString) + return p.authDB.DeleteRefreshToken(tokenHash) +} + +// hashToken creates a SHA-256 hash of a token for storage +func hashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +// signJWT creates and signs a JWT using the hold's private key +func (p *HoldPDS) signJWT(claims *SessionClaims) (string, error) { + // Create header + header := map[string]interface{}{ + "typ": "JWT", + "alg": "ES256K", + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("failed to marshal header: %w", err) + } + + // Create payload + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("failed to marshal claims: %w", err) + } + + // Base64url encode header and payload + headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadEncoded := base64.RawURLEncoding.EncodeToString(payloadJSON) + + // Create signing input + signingInput := headerEncoded + "." + payloadEncoded + + // Sign with private key + signature, err := p.signingKey.HashAndSign([]byte(signingInput)) + if err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + // Base64url encode signature + signatureEncoded := base64.RawURLEncoding.EncodeToString(signature) + + // Combine into final JWT + jwt := signingInput + "." + signatureEncoded + + return jwt, nil +} + +// splitJWT splits a JWT string into its three parts +func splitJWT(token string) []string { + // JWT format: header.payload.signature + parts := make([]string, 0, 3) + start := 0 + for i := 0; i < len(token); i++ { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + if start < len(token) { + parts = append(parts, token[start:]) + } + return parts +} diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/server.go index bc409e8..8892619 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/server.go @@ -36,6 +36,7 @@ type HoldPDS struct { dbPath string uid models.Uid signingKey *atcrypto.PrivateKeyK256 + authDB *Database // Authentication database for app passwords and sessions } // NewHoldPDS creates or opens a hold PDS with SQLite carstore @@ -83,6 +84,12 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H fmt.Printf("New hold repo - will be initialized in Bootstrap\n") } + // Create or open authentication database + authDB, err := NewDatabase(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to create auth database: %w", err) + } + return &HoldPDS{ did: did, PublicURL: publicURL, @@ -91,6 +98,7 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string) (*H dbPath: dbPath, uid: uid, signingKey: signingKey, + authDB: authDB, }, nil } @@ -194,6 +202,38 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri } } + // Create bootstrap app password if none exist (one-time setup) + passwords, err := p.authDB.ListAppPasswords() + if err != nil { + return fmt.Errorf("failed to list app passwords: %w", err) + } + + if len(passwords) == 0 { + // No app passwords exist, create one + password, err := p.CreateAppPassword("bootstrap") + if err != nil { + return fmt.Errorf("failed to create bootstrap app password: %w", err) + } + + fmt.Printf("\n") + fmt.Printf("╔════════════════════════════════════════════════════════════════╗\n") + fmt.Printf("║ 🔑 APP PASSWORD CREATED ║\n") + fmt.Printf("╠════════════════════════════════════════════════════════════════╣\n") + fmt.Printf("║ ║\n") + fmt.Printf("║ Password: %-51s ║\n", password) + fmt.Printf("║ ║\n") + fmt.Printf("║ ⚠️ SAVE THIS PASSWORD - it will not be shown again ║\n") + fmt.Printf("║ ║\n") + fmt.Printf("║ Use this password to log into Bluesky app or CLI tools ║\n") + fmt.Printf("║ PDS URL: %-51s ║\n", p.PublicURL) + fmt.Printf("║ Username: %-50s ║\n", p.did) + fmt.Printf("║ ║\n") + fmt.Printf("╚════════════════════════════════════════════════════════════════╝\n") + fmt.Printf("\n") + } else { + fmt.Printf("✅ App passwords already exist (count: %d), skipping auto-generation\n", len(passwords)) + } + return nil } diff --git a/pkg/hold/pds/session.go b/pkg/hold/pds/session.go new file mode 100644 index 0000000..abf21de --- /dev/null +++ b/pkg/hold/pds/session.go @@ -0,0 +1,381 @@ +package pds + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + cbg "github.com/whyrusleeping/cbor-gen" + "github.com/ipfs/go-cid" +) + +// CreateSessionRequest represents a session creation request +type CreateSessionRequest struct { + Identifier string `json:"identifier"` // DID or handle + Password string `json:"password"` // App password +} + +// CreateSessionResponse represents a successful session creation +type CreateSessionResponse struct { + AccessJwt string `json:"accessJwt"` + RefreshJwt string `json:"refreshJwt"` + Handle string `json:"handle"` + DID string `json:"did"` + DIDDoc map[string]interface{} `json:"didDoc,omitempty"` // Optional DID document + Email string `json:"email,omitempty"` // Optional, not used for holds + Active *bool `json:"active,omitempty"` // Optional account status + Status string `json:"status,omitempty"` // Optional account status +} + +// SessionInfo represents session information +type SessionInfo struct { + Handle string `json:"handle"` + DID string `json:"did"` + Email string `json:"email,omitempty"` +} + +// HandleCreateSession handles com.atproto.server.createSession +func (h *XRPCHandler) HandleCreateSession(w http.ResponseWriter, r *http.Request) { + // Parse request + var req CreateSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + return + } + + // Validate required fields + if req.Identifier == "" || req.Password == "" { + http.Error(w, "identifier and password are required", http.StatusBadRequest) + return + } + + // Validate identifier matches this hold's DID or handle + holdDID := h.pds.DID() + // For did:web, handle is the domain part without "did:web:" prefix + // e.g., "did:web:hold01.atcr.io" -> "hold01.atcr.io" + holdHandle := strings.TrimPrefix(holdDID, "did:web:") + + // Normalize the identifier (strip "at://" prefix if present) + identifier := strings.TrimPrefix(req.Identifier, "at://") + + // Accept any of: + // 1. Full DID: "did:web:hold01.atcr.io" + // 2. Handle (domain): "hold01.atcr.io" + // 3. Either with "at://" prefix + isValidIdentifier := identifier == holdDID || identifier == holdHandle + + if !isValidIdentifier { + fmt.Printf("Invalid identifier: got %q, expected DID %q or handle %q\n", req.Identifier, holdDID, holdHandle) + http.Error(w, "invalid identifier", http.StatusUnauthorized) + return + } + + // Validate app password + _, err := h.pds.ValidateAnyAppPassword(req.Password) + if err != nil { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + + // Issue access and refresh tokens + accessToken, err := h.pds.IssueAccessToken(holdDID, holdHandle) + if err != nil { + http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError) + return + } + + refreshToken, err := h.pds.IssueRefreshToken(holdDID, holdHandle) + if err != nil { + http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError) + return + } + + // Return session response + active := true + response := CreateSessionResponse{ + AccessJwt: accessToken, + RefreshJwt: refreshToken, + Handle: holdHandle, + DID: holdDID, + Active: &active, // Account is active + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleRefreshSession handles com.atproto.server.refreshSession +func (h *XRPCHandler) HandleRefreshSession(w http.ResponseWriter, r *http.Request) { + // Extract refresh token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization header required", http.StatusUnauthorized) + return + } + + // Remove "Bearer " prefix + refreshToken := strings.TrimPrefix(authHeader, "Bearer ") + if refreshToken == authHeader { + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) + return + } + + // Validate refresh token + claims, err := h.pds.ValidateRefreshToken(refreshToken) + if err != nil { + http.Error(w, fmt.Sprintf("invalid refresh token: %v", err), http.StatusUnauthorized) + return + } + + // Issue new access token (and optionally new refresh token) + accessToken, err := h.pds.IssueAccessToken(claims.DID, claims.Handle) + if err != nil { + http.Error(w, fmt.Sprintf("failed to issue access token: %v", err), http.StatusInternalServerError) + return + } + + // Issue new refresh token (rotate refresh tokens for security) + newRefreshToken, err := h.pds.IssueRefreshToken(claims.DID, claims.Handle) + if err != nil { + http.Error(w, fmt.Sprintf("failed to issue refresh token: %v", err), http.StatusInternalServerError) + return + } + + // Revoke old refresh token + if err := h.pds.RevokeRefreshToken(refreshToken); err != nil { + // Log but don't fail - new tokens are already issued + fmt.Printf("Warning: failed to revoke old refresh token: %v\n", err) + } + + // Return new tokens + active := true + response := CreateSessionResponse{ + AccessJwt: accessToken, + RefreshJwt: newRefreshToken, + Handle: claims.Handle, + DID: claims.DID, + Active: &active, // Account is active + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleGetSession handles com.atproto.server.getSession +func (h *XRPCHandler) HandleGetSession(w http.ResponseWriter, r *http.Request) { + // Extract access token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization header required", http.StatusUnauthorized) + return + } + + // Remove "Bearer " prefix + accessToken := strings.TrimPrefix(authHeader, "Bearer ") + if accessToken == authHeader { + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) + return + } + + // Validate access token + claims, err := h.pds.ValidateAccessToken(accessToken) + if err != nil { + http.Error(w, fmt.Sprintf("invalid access token: %v", err), http.StatusUnauthorized) + return + } + + // Return session info + response := SessionInfo{ + Handle: claims.Handle, + DID: claims.DID, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// CreateRecordRequest represents a record creation request +type CreateRecordRequest struct { + Repo string `json:"repo"` // DID of the repository + Collection string `json:"collection"` // Collection name (e.g., "app.bsky.feed.post") + Rkey string `json:"rkey,omitempty"` // Optional record key (TID generated if not provided) + Validate *bool `json:"validate,omitempty"` // Optional validation flag + Record interface{} `json:"record"` // The record value (JSON object) +} + +// CreateRecordResponse represents a successful record creation +type CreateRecordResponse struct { + URI string `json:"uri"` // at://did/collection/rkey + CID string `json:"cid"` // Record CID +} + +// RawRecord wraps a record value and implements CBORMarshaler +// This allows us to accept any JSON record and marshal it to CBOR +type RawRecord struct { + Value map[string]interface{} +} + +// MarshalCBOR implements CBORMarshaler for RawRecord +func (r *RawRecord) MarshalCBOR(w io.Writer) error { + // Write CBOR map header + if err := cbg.WriteMajorTypeHeader(w, cbg.MajMap, uint64(len(r.Value))); err != nil { + return err + } + + // Write each key-value pair + for key, val := range r.Value { + // Write key as text string + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(key))); err != nil { + return err + } + if _, err := w.Write([]byte(key)); err != nil { + return err + } + + // Write value (simplified - handles common types) + if err := writeValue(w, val); err != nil { + return err + } + } + + return nil +} + +// writeValue writes a value to CBOR (helper for RawRecord) +func writeValue(w io.Writer, val interface{}) error { + switch v := val.(type) { + case string: + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + _, err := w.Write([]byte(v)) + return err + case int64: + return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v)) + case float64: + // Write as unsigned int for now (simplified) + return cbg.CborWriteHeader(w, cbg.MajUnsignedInt, uint64(v)) + case bool: + if v { + return cbg.WriteBool(w, true) + } + return cbg.WriteBool(w, false) + case map[string]interface{}: + rec := &RawRecord{Value: v} + return rec.MarshalCBOR(w) + case []interface{}: + if err := cbg.WriteMajorTypeHeader(w, cbg.MajArray, uint64(len(v))); err != nil { + return err + } + for _, item := range v { + if err := writeValue(w, item); err != nil { + return err + } + } + return nil + default: + // For unknown types, convert to JSON then write as string + jsonBytes, err := json.Marshal(v) + if err != nil { + return err + } + if err := cbg.WriteMajorTypeHeader(w, cbg.MajTextString, uint64(len(jsonBytes))); err != nil { + return err + } + _, err = w.Write(jsonBytes) + return err + } +} + +// HandleCreateRecord handles com.atproto.repo.createRecord +func (h *XRPCHandler) HandleCreateRecord(w http.ResponseWriter, r *http.Request) { + // Validate JWT authentication + user, err := ValidateJWTAuth(r, h.pds) + if err != nil { + http.Error(w, fmt.Sprintf("authentication required: %v", err), http.StatusUnauthorized) + return + } + + // Parse request + var req CreateRecordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + return + } + + // Validate required fields + if req.Repo == "" || req.Collection == "" || req.Record == nil { + http.Error(w, "repo, collection, and record are required", http.StatusBadRequest) + return + } + + // Verify repo matches authenticated user + if req.Repo != user.DID { + http.Error(w, "repo must match authenticated user DID", http.StatusForbidden) + return + } + + // Verify repo matches this hold's DID + if req.Repo != h.pds.DID() { + http.Error(w, "invalid repo (must be this hold's DID)", http.StatusBadRequest) + return + } + + // Convert record from JSON to CBOR-marshalable format + recordMap, ok := req.Record.(map[string]interface{}) + if !ok { + http.Error(w, "record must be a JSON object", http.StatusBadRequest) + return + } + + // Wrap in RawRecord which implements CBORMarshaler + recordValue := &RawRecord{Value: recordMap} + + // Create record using repomgr + var recordPath string + var recordCID cid.Cid + + if req.Rkey != "" { + // Use PutRecord if rkey is specified + recordPath, recordCID, err = h.pds.repomgr.PutRecord( + r.Context(), + h.pds.uid, + req.Collection, + req.Rkey, + recordValue, + ) + } else { + // Use CreateRecord if no rkey (auto-generates TID) + recordPath, recordCID, err = h.pds.repomgr.CreateRecord( + r.Context(), + h.pds.uid, + req.Collection, + recordValue, + ) + } + + if err != nil { + http.Error(w, fmt.Sprintf("failed to create record: %v", err), http.StatusInternalServerError) + return + } + + // Extract rkey from path (format: "collection/rkey") + parts := strings.Split(recordPath, "/") + if len(parts) < 2 { + http.Error(w, "invalid record path returned", http.StatusInternalServerError) + return + } + actualRkey := parts[len(parts)-1] + + // Return success response + response := CreateRecordResponse{ + URI: fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), req.Collection, actualRkey), + CID: recordCID.String(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} diff --git a/pkg/hold/pds/xrpc.go b/pkg/hold/pds/xrpc.go index ce2020e..a935958 100644 --- a/pkg/hold/pds/xrpc.go +++ b/pkg/hold/pds/xrpc.go @@ -8,6 +8,7 @@ import ( "atcr.io/pkg/atproto" "atcr.io/pkg/s3" + "github.com/bluesky-social/indigo/api/bsky" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/repo" "github.com/distribution/distribution/v3/registry/storage/driver" @@ -73,21 +74,31 @@ func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, storageDriver driver.S } } -// corsMiddleware is chi-compatible middleware that adds CORS headers -func (h *XRPCHandler) corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, DPoP, X-Upload-Id, X-Part-Number, X-ATCR-DID") +// CORSMiddleware returns a simple CORS middleware configured for ATProto +// This should be applied in the main router before registering any routes +func (h *XRPCHandler) CORSMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for all requests + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + w.Header().Set("Access-Control-Expose-Headers", "*") + w.Header().Set("Access-Control-Max-Age", "300") - // Handle preflight OPTIONS requests - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusOK) - return - } + // Handle OPTIONS preflight + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } - next.ServeHTTP(w, r) - }) + next.ServeHTTP(w, r) + }) + } } // requireOwnerOrCrewAdmin middleware - validates owner or crew admin access @@ -131,15 +142,19 @@ func getUserFromContext(r *http.Request) *ValidatedUser { } // RegisterHandlers registers all XRPC endpoints using chi router +// Note: CORS middleware must be applied in the main router before calling this func (h *XRPCHandler) RegisterHandlers(r chi.Router) { - // Public read-only endpoints (CORS only, no auth) + // Public read-only endpoints (no auth) r.Group(func(r chi.Router) { - r.Use(h.corsMiddleware) - // Health and server info r.Get("/xrpc/_health", h.HandleHealth) r.Get(atproto.ServerDescribeServer, h.HandleDescribeServer) + // Session management (public - creates sessions) + r.Post(atproto.ServerCreateSession, h.HandleCreateSession) + r.Post(atproto.ServerRefreshSession, h.HandleRefreshSession) + r.Get(atproto.ServerGetSession, h.HandleGetSession) + // Repository metadata r.Get(atproto.RepoDescribeRepo, h.HandleDescribeRepo) r.Get(atproto.RepoGetRecord, h.HandleGetRecord) @@ -154,33 +169,40 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) { // DID document and handle resolution r.Get("/.well-known/did.json", h.HandleDIDDocument) r.Get("/.well-known/atproto-did", h.HandleAtprotoDID) + + // Identity and profile endpoints + r.Get(atproto.IdentityResolveHandle, h.HandleResolveHandle) + r.Get(atproto.ActorGetProfile, h.HandleGetProfile) + r.Get(atproto.ActorGetProfiles, h.HandleGetProfiles) }) - // Blob read endpoints (CORS + conditional auth based on captain.public) + // Blob read endpoints (conditional auth based on captain.public) // Auth is handled inside HandleGetBlob r.Group(func(r chi.Router) { - r.Use(h.corsMiddleware) - r.Get(atproto.SyncGetBlob, h.HandleGetBlob) r.Head(atproto.SyncGetBlob, h.HandleGetBlob) }) - // Write endpoints (CORS + owner/crew admin auth) + // Write endpoints (owner/crew admin auth) r.Group(func(r chi.Router) { - r.Use(h.corsMiddleware) r.Use(h.requireOwnerOrCrewAdmin) r.Post(atproto.RepoDeleteRecord, h.HandleDeleteRecord) r.Post(atproto.RepoUploadBlob, h.HandleUploadBlob) }) - // Auth-only endpoints (CORS + DPoP auth) + // Auth-only endpoints (DPoP auth) r.Group(func(r chi.Router) { - r.Use(h.corsMiddleware) r.Use(h.requireAuth) r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew) }) + + // JWT-authenticated endpoints (JWT auth from createSession) + // Note: JWT auth is validated inside each handler + r.Group(func(r chi.Router) { + r.Post("/xrpc/com.atproto.repo.createRecord", h.HandleCreateRecord) + }) } // HandleHealth returns health check information @@ -213,6 +235,178 @@ func (h *XRPCHandler) HandleDescribeServer(w http.ResponseWriter, r *http.Reques json.NewEncoder(w).Encode(response) } +// HandleResolveHandle resolves a handle to a DID +func (h *XRPCHandler) HandleResolveHandle(w http.ResponseWriter, r *http.Request) { + // Get handle parameter + handle := r.URL.Query().Get("handle") + if handle == "" { + http.Error(w, "handle parameter required", http.StatusBadRequest) + return + } + + // For this hold PDS, the handle is the domain part of the DID + // e.g., "hold01.atcr.io did:web:hold01.atcr.io" + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") + + // Check if the handle matches + if handle != expectedHandle { + http.Error(w, "handle not found", http.StatusNotFound) + return + } + + // Return the DID + response := map[string]string{ + "did": h.pds.DID(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleGetProfile returns aggregated profile information +func (h *XRPCHandler) HandleGetProfile(w http.ResponseWriter, r *http.Request) { + // Get actor parameter (can be DID or handle) + actor := r.URL.Query().Get("actor") + if actor == "" { + http.Error(w, "actor parameter required", http.StatusBadRequest) + return + } + + // Normalize actor to DID + actorDID := actor + if !strings.HasPrefix(actor, "did:") { + // It's a handle, resolve to DID + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") + if actor == expectedHandle { + actorDID = h.pds.DID() + } else { + http.Error(w, "actor not found", http.StatusNotFound) + return + } + } + + // Verify it's this hold's DID + if actorDID != h.pds.DID() { + http.Error(w, "actor not found", http.StatusNotFound) + return + } + + // Build profile response using shared function + response := h.buildProfileResponse(r.Context()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// HandleGetProfiles returns aggregated profile information for multiple actors +func (h *XRPCHandler) HandleGetProfiles(w http.ResponseWriter, r *http.Request) { + // Get actors parameters (can be multiple) + actors := r.URL.Query()["actors"] + if len(actors) == 0 { + http.Error(w, "actors parameter required", http.StatusBadRequest) + return + } + + // Initialize profiles array (always return array, even if empty) + profiles := []map[string]any{} + + // Expected handle for this hold + expectedHandle := strings.TrimPrefix(h.pds.DID(), "did:web:") + + // Check each actor to see if it matches this hold's DID + for _, actor := range actors { + // Normalize actor to DID + actorDID := actor + if !strings.HasPrefix(actor, "did:") { + // It's a handle, check if it matches + if actor == expectedHandle { + actorDID = h.pds.DID() + } else { + // Not this hold's handle, skip + continue + } + } + + // Check if it's this hold's DID + if actorDID != h.pds.DID() { + // Not this hold, skip + continue + } + + // Build profile for this hold + profile := h.buildProfileResponse(r.Context()) + if profile != nil { + profiles = append(profiles, profile) + } + } + + // Return profiles array + response := map[string]any{ + "profiles": profiles, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// buildProfileResponse builds a profile response map (shared by GetProfile and GetProfiles) +func (h *XRPCHandler) buildProfileResponse(ctx context.Context) map[string]any { + // Get profile record from repo + _, profileVal, err := h.pds.repomgr.GetRecord( + ctx, + h.pds.uid, + "app.bsky.actor.profile", + "self", + cid.Undef, + ) + + // Base response with minimal info + response := map[string]any{ + "did": h.pds.DID(), + "handle": strings.TrimPrefix(h.pds.DID(), "did:web:"), + "postsCount": 0, + "followersCount": 0, + "followsCount": 0, + } + + // Count posts + session, err := h.pds.carstore.ReadOnlySession(h.pds.uid) + if err == nil { + head, err := h.pds.carstore.GetUserRepoHead(ctx, h.pds.uid) + if err == nil && head.Defined() { + repoHandle, err := repo.OpenRepo(ctx, session, head) + if err == nil { + postCount := 0 + _ = repoHandle.ForEach(ctx, "app.bsky.feed.post", func(k string, v cid.Cid) error { + postCount++ + return nil + }) + response["postsCount"] = postCount + } + } + } + + // Add profile fields if profile record exists + if err == nil { + profileRecord, ok := profileVal.(*bsky.ActorProfile) + if ok { + if profileRecord.DisplayName != nil && *profileRecord.DisplayName != "" { + response["displayName"] = *profileRecord.DisplayName + } + if profileRecord.Description != nil && *profileRecord.Description != "" { + response["description"] = *profileRecord.Description + } + if profileRecord.Avatar != nil { + avatarURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", + h.pds.PublicURL, h.pds.DID(), profileRecord.Avatar.Ref.String()) + response["avatar"] = avatarURL + } + } + } + + return response +} + // HandleDescribeRepo returns repository information func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request) { // Get repo parameter