mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-12 21:26:06 +00:00
big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix
This commit is contained in:
@@ -38,15 +38,17 @@ func LoadConfigFromEnv() (*configuration.Configuration, error) {
|
||||
// Storage (fake in-memory placeholder - all real storage is proxied)
|
||||
config.Storage = buildStorageConfig()
|
||||
|
||||
// Get base URL for error messages and auth config
|
||||
baseURL := GetBaseURL(httpConfig.Addr)
|
||||
|
||||
// Middleware (ATProto resolver)
|
||||
defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID")
|
||||
if defaultHoldDID == "" {
|
||||
return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required")
|
||||
}
|
||||
config.Middleware = buildMiddlewareConfig(defaultHoldDID)
|
||||
config.Middleware = buildMiddlewareConfig(defaultHoldDID, baseURL)
|
||||
|
||||
// Auth
|
||||
baseURL := GetBaseURL(httpConfig.Addr)
|
||||
authConfig, err := buildAuthConfig(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build auth config: %w", err)
|
||||
@@ -128,7 +130,7 @@ func buildStorageConfig() configuration.Storage {
|
||||
}
|
||||
|
||||
// buildMiddlewareConfig creates middleware configuration
|
||||
func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware {
|
||||
func buildMiddlewareConfig(defaultHoldDID string, baseURL string) map[string][]configuration.Middleware {
|
||||
// Check test mode
|
||||
testMode := os.Getenv("TEST_MODE") == "true"
|
||||
|
||||
@@ -139,6 +141,7 @@ func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Mid
|
||||
Options: configuration.Parameters{
|
||||
"default_hold_did": defaultHoldDID,
|
||||
"test_mode": testMode,
|
||||
"base_url": baseURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -368,6 +368,7 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultHoldDID string
|
||||
baseURL string
|
||||
testMode bool
|
||||
setTestMode bool
|
||||
wantTestMode bool
|
||||
@@ -375,12 +376,14 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
{
|
||||
name: "normal mode",
|
||||
defaultHoldDID: "did:web:hold01.atcr.io",
|
||||
baseURL: "https://atcr.io",
|
||||
setTestMode: false,
|
||||
wantTestMode: false,
|
||||
},
|
||||
{
|
||||
name: "test mode enabled",
|
||||
defaultHoldDID: "did:web:hold01.atcr.io",
|
||||
baseURL: "https://atcr.io",
|
||||
testMode: true,
|
||||
setTestMode: true,
|
||||
wantTestMode: true,
|
||||
@@ -395,7 +398,7 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
os.Unsetenv("TEST_MODE")
|
||||
}
|
||||
|
||||
got := buildMiddlewareConfig(tt.defaultHoldDID)
|
||||
got := buildMiddlewareConfig(tt.defaultHoldDID, tt.baseURL)
|
||||
|
||||
registryMW, ok := got["registry"]
|
||||
if !ok {
|
||||
@@ -415,6 +418,10 @@ func TestBuildMiddlewareConfig(t *testing.T) {
|
||||
t.Errorf("default_hold_did = %v, want %v", mw.Options["default_hold_did"], tt.defaultHoldDID)
|
||||
}
|
||||
|
||||
if mw.Options["base_url"] != tt.baseURL {
|
||||
t.Errorf("base_url = %v, want %v", mw.Options["base_url"], tt.baseURL)
|
||||
}
|
||||
|
||||
if mw.Options["test_mode"] != tt.wantTestMode {
|
||||
t.Errorf("test_mode = %v, want %v", mw.Options["test_mode"], tt.wantTestMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// LogoutHandler handles user logout with proper OAuth token revocation
|
||||
type LogoutHandler struct {
|
||||
OAuthApp *oauth.App
|
||||
Refresher *oauth.Refresher
|
||||
SessionStore *db.SessionStore
|
||||
OAuthStore *db.OAuthStore
|
||||
}
|
||||
|
||||
func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get UI session ID from cookie
|
||||
uiSessionID, hasSession := db.GetSessionID(r)
|
||||
if !hasSession {
|
||||
// No session to logout from, just redirect
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get UI session to extract OAuth session ID and user info
|
||||
uiSession, ok := h.SessionStore.Get(uiSessionID)
|
||||
if ok && uiSession != nil && uiSession.DID != "" {
|
||||
// Parse DID for OAuth logout
|
||||
did, err := syntax.ParseDID(uiSession.DID)
|
||||
if err != nil {
|
||||
fmt.Printf("WARNING [logout]: Failed to parse DID %s: %v\n", uiSession.DID, err)
|
||||
} else {
|
||||
// Attempt to revoke OAuth tokens on PDS side
|
||||
if uiSession.OAuthSessionID != "" {
|
||||
// Call indigo's Logout to revoke tokens on PDS
|
||||
if err := h.OAuthApp.GetClientApp().Logout(r.Context(), did, uiSession.OAuthSessionID); err != nil {
|
||||
// Log error but don't block logout - best effort revocation
|
||||
fmt.Printf("WARNING [logout]: Failed to revoke OAuth tokens for %s on PDS: %v\n", uiSession.DID, err)
|
||||
} else {
|
||||
fmt.Printf("INFO [logout]: Successfully revoked OAuth tokens for %s on PDS\n", uiSession.DID)
|
||||
}
|
||||
|
||||
// Invalidate refresher cache to clear local access tokens
|
||||
h.Refresher.InvalidateSession(uiSession.DID)
|
||||
fmt.Printf("INFO [logout]: Invalidated local OAuth cache for %s\n", uiSession.DID)
|
||||
|
||||
// Delete OAuth session from database (cleanup, might already be done by Logout)
|
||||
if err := h.OAuthStore.DeleteSession(r.Context(), did, uiSession.OAuthSessionID); err != nil {
|
||||
fmt.Printf("WARNING [logout]: Failed to delete OAuth session from database: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("WARNING [logout]: No OAuth session ID found for user %s\n", uiSession.DID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always delete UI session and clear cookie, even if OAuth revocation failed
|
||||
h.SessionStore.Delete(uiSessionID)
|
||||
db.ClearCookie(w)
|
||||
|
||||
// Redirect to home page
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
@@ -41,7 +42,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
|
||||
|
||||
// Fetch sailor profile
|
||||
profile, err := atproto.GetProfile(r.Context(), client)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
// Error fetching profile - log out user
|
||||
fmt.Printf("WARNING [settings]: Failed to fetch profile for %s: %v - logging out\n", user.DID, err)
|
||||
@@ -111,7 +112,7 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
|
||||
|
||||
// Fetch existing profile or create new one
|
||||
profile, err := atproto.GetProfile(r.Context(), client)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil || profile == nil {
|
||||
// Profile doesn't exist, create new one
|
||||
profile = atproto.NewSailorProfileRecord(holdEndpoint)
|
||||
@@ -122,7 +123,7 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
// Save profile
|
||||
if err := atproto.UpdateProfile(r.Context(), client, profile); err != nil {
|
||||
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
|
||||
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// HealthStatus represents the health status of a hold endpoint
|
||||
@@ -53,7 +53,7 @@ func (c *Checker) CheckHealth(ctx context.Context, endpoint string) (bool, error
|
||||
// Convert DID to HTTP URL if needed
|
||||
// did:web:hold.example.com → https://hold.example.com
|
||||
// https://hold.example.com → https://hold.example.com (passthrough)
|
||||
httpURL := appview.ResolveHoldURL(endpoint)
|
||||
httpURL := atproto.ResolveHoldURL(endpoint)
|
||||
|
||||
// Build health check URL
|
||||
healthURL := httpURL + "/xrpc/_health"
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
@@ -327,7 +326,7 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
}
|
||||
|
||||
// Resolve hold DID to URL
|
||||
holdURL := appview.ResolveHoldURL(holdDID)
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
|
||||
// Create client for hold's PDS
|
||||
holdClient := atproto.NewClient(holdURL, holdDID, "")
|
||||
|
||||
@@ -4,12 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
@@ -73,6 +69,7 @@ type NamespaceResolver struct {
|
||||
distribution.Namespace
|
||||
directory identity.Directory
|
||||
defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
|
||||
baseURL string // Base URL for error messages (e.g., "https://atcr.io")
|
||||
testMode bool // If true, fallback to default hold when user's hold is unreachable
|
||||
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
|
||||
refresher *oauth.Refresher // OAuth session manager (copied from global on init)
|
||||
@@ -93,6 +90,12 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
|
||||
defaultHoldDID = holdDID
|
||||
}
|
||||
|
||||
// Get base URL from config (for error messages)
|
||||
baseURL := ""
|
||||
if url, ok := options["base_url"].(string); ok {
|
||||
baseURL = url
|
||||
}
|
||||
|
||||
// Check test mode from options (passed via env var)
|
||||
testMode := false
|
||||
if tm, ok := options["test_mode"].(bool); ok {
|
||||
@@ -105,6 +108,7 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
|
||||
Namespace: ns,
|
||||
directory: directory,
|
||||
defaultHoldDID: defaultHoldDID,
|
||||
baseURL: baseURL,
|
||||
testMode: testMode,
|
||||
refresher: globalRefresher,
|
||||
database: globalDatabase,
|
||||
@@ -113,6 +117,13 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authErrorMessage creates a user-friendly auth error with login URL
|
||||
func (nr *NamespaceResolver) authErrorMessage(message string) error {
|
||||
loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
|
||||
fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
|
||||
return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
|
||||
}
|
||||
|
||||
// Repository resolves the repository name and delegates to underlying namespace
|
||||
// Handles names like:
|
||||
// - atcr.io/alice/myimage → resolve alice to DID
|
||||
@@ -160,99 +171,14 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
ctx = context.WithValue(ctx, holdDIDKey, holdDID)
|
||||
|
||||
// Get service token for hold authentication
|
||||
// Check cache first to avoid unnecessary PDS calls on every request
|
||||
var serviceToken string
|
||||
if nr.refresher != nil {
|
||||
cachedToken, expiresAt := token.GetServiceToken(did, holdDID)
|
||||
|
||||
// Use cached token if it exists and has > 10s remaining
|
||||
if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
|
||||
fmt.Printf("DEBUG [registry/middleware]: Using cached service token for DID=%s (expires in %v)\n",
|
||||
did, time.Until(expiresAt).Round(time.Second))
|
||||
serviceToken = cachedToken
|
||||
} else {
|
||||
// Cache miss or expiring soon - validate OAuth and get new service token
|
||||
if cachedToken == "" {
|
||||
fmt.Printf("DEBUG [registry/middleware]: Cache miss, fetching service token for DID=%s\n", did)
|
||||
} else {
|
||||
fmt.Printf("DEBUG [registry/middleware]: Token expiring soon, proactively renewing for DID=%s\n", did)
|
||||
}
|
||||
|
||||
session, err := nr.refresher.GetSession(ctx, did)
|
||||
if err != nil {
|
||||
// OAuth session unavailable - fail fast with proper auth error
|
||||
nr.refresher.InvalidateSession(did)
|
||||
token.InvalidateServiceToken(did, holdDID)
|
||||
fmt.Printf("ERROR [registry/middleware]: Failed to get OAuth session for DID=%s: %v\n", did, err)
|
||||
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
|
||||
}
|
||||
|
||||
// Call com.atproto.server.getServiceAuth on the user's PDS
|
||||
// Request 5-minute expiry (PDS may grant less)
|
||||
// exp must be absolute Unix timestamp, not relative duration
|
||||
// Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
|
||||
expiryTime := time.Now().Unix() + 300 // 5 minutes from now
|
||||
serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
|
||||
pdsEndpoint,
|
||||
atproto.ServerGetServiceAuth,
|
||||
url.QueryEscape(holdDID),
|
||||
url.QueryEscape("com.atproto.repo.getRecord"),
|
||||
expiryTime,
|
||||
)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [registry/middleware]: Failed to create service auth request: %v\n", err)
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
|
||||
}
|
||||
|
||||
// Use OAuth session to authenticate to PDS (with DPoP)
|
||||
resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
|
||||
if err != nil {
|
||||
// Invalidate session on auth errors (may indicate corrupted session or expired tokens)
|
||||
nr.refresher.InvalidateSession(did)
|
||||
token.InvalidateServiceToken(did, holdDID)
|
||||
fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: %v\n", did, err)
|
||||
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Invalidate session on auth failures
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
nr.refresher.InvalidateSession(did)
|
||||
token.InvalidateServiceToken(did, holdDID)
|
||||
fmt.Printf("ERROR [registry/middleware]: OAuth validation failed for DID=%s: status %d, body: %s\n",
|
||||
did, resp.StatusCode, string(bodyBytes))
|
||||
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session expired - please re-authenticate")
|
||||
}
|
||||
|
||||
// Parse response to get service token
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
fmt.Printf("ERROR [registry/middleware]: Failed to decode service auth response: %v\n", err)
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
|
||||
}
|
||||
|
||||
if result.Token == "" {
|
||||
fmt.Printf("ERROR [registry/middleware]: Empty token in service auth response\n")
|
||||
return nil, errcode.ErrorCodeUnauthorized.WithDetail("OAuth session validation failed")
|
||||
}
|
||||
|
||||
serviceToken = result.Token
|
||||
|
||||
// Cache the token (parses JWT to extract actual expiry)
|
||||
if err := token.SetServiceToken(did, holdDID, serviceToken); err != nil {
|
||||
fmt.Printf("WARN [registry/middleware]: Failed to cache service token: %v\n", err)
|
||||
// Non-fatal - we have the token, just won't be cached
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [registry/middleware]: OAuth validation succeeded for DID=%s\n", did)
|
||||
var err error
|
||||
serviceToken, err = token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [registry/middleware]: Failed to get service token for DID=%s: %v\n", did, err)
|
||||
fmt.Printf("ERROR [registry/middleware]: User needs to re-authenticate via credential helper\n")
|
||||
return nil, nr.authErrorMessage("OAuth session expired")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +292,7 @@ func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint s
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
|
||||
// Check for sailor profile
|
||||
profile, err := atproto.GetProfile(ctx, client)
|
||||
profile, err := storage.GetProfile(ctx, client)
|
||||
if err != nil {
|
||||
// Error reading profile (not a 404) - log and continue
|
||||
fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/auth/token"
|
||||
)
|
||||
|
||||
// EnsureCrewMembership attempts to register the user as a crew member on their default hold.
|
||||
// The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, existing membership, etc).
|
||||
// This is best-effort and does not fail on errors.
|
||||
func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, defaultHoldDID string) {
|
||||
if defaultHoldDID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize URL to DID if needed
|
||||
holdDID := atproto.ResolveHoldDIDFromURL(defaultHoldDID)
|
||||
if holdDID == "" {
|
||||
slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve hold DID to HTTP endpoint
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
|
||||
// Get service token for the hold
|
||||
// Only works with OAuth (refresher required) - app passwords can't get service tokens
|
||||
if refresher == nil {
|
||||
slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap the refresher to match OAuthSessionRefresher interface
|
||||
serviceToken, err := token.GetOrFetchServiceToken(ctx, refresher, client.DID(), holdDID, client.PDSEndpoint())
|
||||
if err != nil {
|
||||
slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call requestCrew endpoint - it handles all the logic:
|
||||
// - Checks allowAllCrew flag
|
||||
// - Checks if already a crew member (returns success if so)
|
||||
// - Creates crew record if authorized
|
||||
if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
|
||||
slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
|
||||
}
|
||||
|
||||
// requestCrewMembership calls the hold's requestCrew endpoint
|
||||
// The endpoint handles all authorization and duplicate checking internally
|
||||
func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
|
||||
url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("requestCrew failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ProfileRKey is always "self" per lexicon
|
||||
const ProfileRKey = "self"
|
||||
|
||||
// Global map to track in-flight profile migrations (DID -> true)
|
||||
// Used to prevent duplicate migration goroutines
|
||||
var migrationLocks sync.Map
|
||||
|
||||
// EnsureProfile checks if a user's profile exists and creates it if needed
|
||||
// This should be called during authentication (OAuth exchange or token service)
|
||||
// If defaultHoldDID is provided, creates profile with that default (or empty if not provided)
|
||||
// Expected format: "did:web:hold01.atcr.io"
|
||||
// Normalizes URLs to DIDs for consistency (for backward compatibility)
|
||||
func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID string) error {
|
||||
// Check if profile already exists
|
||||
profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
|
||||
if err == nil && profile != nil {
|
||||
// Profile exists, nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize to DID if it's a URL (or pass through if already a DID)
|
||||
// This ensures we store DIDs consistently in new profiles
|
||||
normalizedDID := ""
|
||||
if defaultHoldDID != "" {
|
||||
normalizedDID = atproto.ResolveHoldDIDFromURL(defaultHoldDID)
|
||||
}
|
||||
|
||||
// Profile doesn't exist - create it
|
||||
newProfile := atproto.NewSailorProfileRecord(normalizedDID)
|
||||
|
||||
_, err = client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, newProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sailor profile: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", normalizedDID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProfile retrieves the user's profile from their PDS
|
||||
// Returns nil if profile doesn't exist
|
||||
// Automatically migrates old URL-based defaultHold values to DIDs
|
||||
func GetProfile(ctx context.Context, client *atproto.Client) (*atproto.SailorProfileRecord, error) {
|
||||
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
|
||||
if err != nil {
|
||||
// Check if it's a 404 (profile doesn't exist)
|
||||
if errors.Is(err, atproto.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get profile: %w", err)
|
||||
}
|
||||
|
||||
// Parse the profile record
|
||||
var profile atproto.SailorProfileRecord
|
||||
if err := json.Unmarshal(record.Value, &profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse profile: %w", err)
|
||||
}
|
||||
|
||||
// Migrate old URL-based defaultHold to DID format
|
||||
// This ensures backward compatibility with profiles created before DID migration
|
||||
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
|
||||
// Convert URL to DID transparently
|
||||
migratedDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
||||
profile.DefaultHold = migratedDID
|
||||
|
||||
// Persist the migration to PDS in a background goroutine
|
||||
// Use a lock to ensure only one goroutine migrates this DID
|
||||
did := client.DID()
|
||||
if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded {
|
||||
// We got the lock - launch goroutine to persist the migration
|
||||
go func() {
|
||||
// Clean up lock when done (after a short delay to batch requests)
|
||||
defer func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
migrationLocks.Delete(did)
|
||||
}()
|
||||
|
||||
// Create a new context with timeout for the background operation
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Update the profile on the PDS
|
||||
profile.UpdatedAt = time.Now()
|
||||
if err := UpdateProfile(ctx, client, &profile); err != nil {
|
||||
fmt.Printf("WARNING [profile]: Failed to persist URL-to-DID migration for %s: %v\n", did, err)
|
||||
} else {
|
||||
fmt.Printf("DEBUG [profile]: Persisted defaultHold migration to DID: %s (for DID: %s)\n", migratedDID, did)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's profile
|
||||
// Normalizes defaultHold to DID format before saving
|
||||
func UpdateProfile(ctx context.Context, client *atproto.Client, profile *atproto.SailorProfileRecord) error {
|
||||
// Normalize defaultHold to DID if it's a URL
|
||||
// This ensures we always store DIDs, even if user provides a URL
|
||||
if profile.DefaultHold != "" && !atproto.IsDID(profile.DefaultHold) {
|
||||
profile.DefaultHold = atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
|
||||
fmt.Printf("DEBUG [profile]: Normalized defaultHold to DID: %s\n", profile.DefaultHold)
|
||||
}
|
||||
|
||||
_, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, profile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// TestEnsureProfile_Create tests creating a new profile when one doesn't exist
|
||||
func TestEnsureProfile_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultHoldDID string
|
||||
wantNormalized string // Expected defaultHold value after normalization
|
||||
}{
|
||||
{
|
||||
name: "with DID",
|
||||
defaultHoldDID: "did:web:hold01.atcr.io",
|
||||
wantNormalized: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "with URL - should normalize to DID",
|
||||
defaultHoldDID: "https://hold01.atcr.io",
|
||||
wantNormalized: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "empty default hold",
|
||||
defaultHoldDID: "",
|
||||
wantNormalized: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var createdProfile *atproto.SailorProfileRecord
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// First request: GetRecord (should 404)
|
||||
if r.Method == "GET" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Second request: PutRecord (create profile)
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
// Verify profile data
|
||||
recordData := body["record"].(map[string]any)
|
||||
if recordData["$type"] != atproto.SailorProfileCollection {
|
||||
t.Errorf("$type = %v, want %v", recordData["$type"], atproto.SailorProfileCollection)
|
||||
}
|
||||
|
||||
// Check defaultHold normalization
|
||||
defaultHold := recordData["defaultHold"]
|
||||
// Handle empty string (may be nil in JSON)
|
||||
defaultHoldStr := ""
|
||||
if defaultHold != nil {
|
||||
defaultHoldStr = defaultHold.(string)
|
||||
}
|
||||
if defaultHoldStr != tt.wantNormalized {
|
||||
t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized)
|
||||
}
|
||||
|
||||
// Store for later verification
|
||||
profileBytes, _ := json.Marshal(recordData)
|
||||
json.Unmarshal(profileBytes, &createdProfile)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
err := EnsureProfile(context.Background(), client, tt.defaultHoldDID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureProfile() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify created profile
|
||||
if createdProfile == nil {
|
||||
t.Fatal("Profile was not created")
|
||||
}
|
||||
|
||||
if createdProfile.Type != atproto.SailorProfileCollection {
|
||||
t.Errorf("Type = %v, want %v", createdProfile.Type, atproto.SailorProfileCollection)
|
||||
}
|
||||
|
||||
if createdProfile.DefaultHold != tt.wantNormalized {
|
||||
t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles
|
||||
func TestEnsureProfile_Exists(t *testing.T) {
|
||||
putRecordCalled := false
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetRecord: profile exists
|
||||
if r.Method == "GET" {
|
||||
response := `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"cid": "bafytest",
|
||||
"value": {
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "did:web:hold01.atcr.io",
|
||||
"createdAt": "2025-01-01T00:00:00Z",
|
||||
"updatedAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
return
|
||||
}
|
||||
|
||||
// PutRecord: should not be called
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||
putRecordCalled = true
|
||||
t.Error("PutRecord should not be called when profile exists")
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureProfile() error = %v", err)
|
||||
}
|
||||
|
||||
if putRecordCalled {
|
||||
t.Error("PutRecord was called when profile already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProfile tests retrieving a user's profile
|
||||
func TestGetProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverResponse string
|
||||
serverStatus int
|
||||
wantProfile *atproto.SailorProfileRecord
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
expectMigration bool // Whether URL-to-DID migration should happen
|
||||
originalHoldURL string
|
||||
expectedHoldDID string
|
||||
}{
|
||||
{
|
||||
name: "profile with DID (no migration needed)",
|
||||
serverResponse: `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"value": {
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "did:web:hold01.atcr.io",
|
||||
"createdAt": "2025-01-01T00:00:00Z",
|
||||
"updatedAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}`,
|
||||
serverStatus: http.StatusOK,
|
||||
wantNil: false,
|
||||
wantErr: false,
|
||||
expectMigration: false,
|
||||
expectedHoldDID: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "profile with URL (migration needed)",
|
||||
serverResponse: `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"value": {
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "https://hold01.atcr.io",
|
||||
"createdAt": "2025-01-01T00:00:00Z",
|
||||
"updatedAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}`,
|
||||
serverStatus: http.StatusOK,
|
||||
wantNil: false,
|
||||
wantErr: false,
|
||||
expectMigration: true,
|
||||
originalHoldURL: "https://hold01.atcr.io",
|
||||
expectedHoldDID: "did:web:hold01.atcr.io",
|
||||
},
|
||||
{
|
||||
name: "profile doesn't exist - return nil",
|
||||
serverResponse: "",
|
||||
serverStatus: http.StatusNotFound,
|
||||
wantNil: true,
|
||||
wantErr: false,
|
||||
expectMigration: false,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
serverResponse: `{"error":"InternalServerError"}`,
|
||||
serverStatus: http.StatusInternalServerError,
|
||||
wantNil: false,
|
||||
wantErr: true,
|
||||
expectMigration: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Clear migration locks before each test
|
||||
migrationLocks = sync.Map{}
|
||||
|
||||
putRecordCalled := false
|
||||
var migrationRequest map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetRecord
|
||||
if r.Method == "GET" {
|
||||
w.WriteHeader(tt.serverStatus)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
return
|
||||
}
|
||||
|
||||
// PutRecord (migration)
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||
putRecordCalled = true
|
||||
json.NewDecoder(r.Body).Decode(&migrationRequest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
profile, err := GetProfile(context.Background(), client)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetProfile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantNil {
|
||||
if profile != nil {
|
||||
t.Errorf("GetProfile() = %v, want nil", profile)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
if profile == nil {
|
||||
t.Fatal("GetProfile() returned nil, want profile")
|
||||
}
|
||||
|
||||
// Check that defaultHold is migrated to DID in returned profile
|
||||
if profile.DefaultHold != tt.expectedHoldDID {
|
||||
t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID)
|
||||
}
|
||||
|
||||
if tt.expectMigration {
|
||||
// Give goroutine time to execute
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if !putRecordCalled {
|
||||
t.Error("Expected migration PutRecord to be called")
|
||||
}
|
||||
|
||||
if migrationRequest != nil {
|
||||
recordData := migrationRequest["record"].(map[string]any)
|
||||
migratedHold := recordData["defaultHold"]
|
||||
if migratedHold != tt.expectedHoldDID {
|
||||
t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProfile_MigrationLocking tests that concurrent migrations don't happen
|
||||
func TestGetProfile_MigrationLocking(t *testing.T) {
|
||||
// Clear migration locks
|
||||
migrationLocks = sync.Map{}
|
||||
|
||||
putRecordCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetRecord - return profile with URL
|
||||
if r.Method == "GET" {
|
||||
response := `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"value": {
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "https://hold01.atcr.io",
|
||||
"createdAt": "2025-01-01T00:00:00Z",
|
||||
"updatedAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
return
|
||||
}
|
||||
|
||||
// PutRecord - count migrations
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||
mu.Lock()
|
||||
putRecordCount++
|
||||
mu.Unlock()
|
||||
|
||||
// Add small delay to ensure concurrent requests
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
|
||||
// Make 5 concurrent GetProfile calls
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := GetProfile(context.Background(), client)
|
||||
if err != nil {
|
||||
t.Errorf("GetProfile() error = %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Give migrations time to complete
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Only one migration should have been persisted due to locking
|
||||
mu.Lock()
|
||||
count := putRecordCount
|
||||
mu.Unlock()
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("PutRecord called %d times, want 1 (locking should prevent concurrent migrations)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfile tests updating a user's profile
|
||||
func TestUpdateProfile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *atproto.SailorProfileRecord
|
||||
wantNormalized string // Expected defaultHold after normalization
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "update with DID",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "did:web:hold02.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
wantNormalized: "did:web:hold02.atcr.io",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "update with URL - should normalize",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "https://hold02.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
wantNormalized: "did:web:hold02.atcr.io",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "clear default hold",
|
||||
profile: &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
wantNormalized: "",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var sentProfile map[string]any
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
sentProfile = body
|
||||
|
||||
// Verify rkey is "self"
|
||||
if body["rkey"] != ProfileRKey {
|
||||
t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
err := UpdateProfile(context.Background(), client, tt.profile)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("UpdateProfile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
// Verify normalization happened
|
||||
recordData := sentProfile["record"].(map[string]any)
|
||||
defaultHold := recordData["defaultHold"]
|
||||
// Handle empty string (may be nil in JSON)
|
||||
defaultHoldStr := ""
|
||||
if defaultHold != nil {
|
||||
defaultHoldStr = defaultHold.(string)
|
||||
}
|
||||
if defaultHoldStr != tt.wantNormalized {
|
||||
t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized)
|
||||
}
|
||||
|
||||
// Verify normalization also updated the profile object
|
||||
if tt.profile.DefaultHold != tt.wantNormalized {
|
||||
t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileRKey tests that profile record key is always "self"
|
||||
func TestProfileRKey(t *testing.T) {
|
||||
if ProfileRKey != "self" {
|
||||
t.Errorf("ProfileRKey = %v, want self", ProfileRKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureProfile_Error tests error handling during profile creation
|
||||
func TestEnsureProfile_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// GetRecord: profile doesn't exist
|
||||
if r.Method == "GET" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// PutRecord: fail with server error
|
||||
if r.Method == "POST" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"InternalServerError"}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io")
|
||||
|
||||
if err == nil {
|
||||
t.Error("EnsureProfile() should return error when PutRecord fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProfile_InvalidJSON tests handling of invalid profile JSON
|
||||
func TestGetProfile_InvalidJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"value": "not-valid-json-object"
|
||||
}`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
_, err := GetProfile(context.Background(), client)
|
||||
|
||||
if err == nil {
|
||||
t.Error("GetProfile() should return error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProfile_EmptyDefaultHold tests profile with empty defaultHold
|
||||
func TestGetProfile_EmptyDefaultHold(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `{
|
||||
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||
"value": {
|
||||
"$type": "io.atcr.sailor.profile",
|
||||
"defaultHold": "",
|
||||
"createdAt": "2025-01-01T00:00:00Z",
|
||||
"updatedAt": "2025-01-01T00:00:00Z"
|
||||
}
|
||||
}`
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
profile, err := GetProfile(context.Background(), client)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile() error = %v", err)
|
||||
}
|
||||
|
||||
if profile.DefaultHold != "" {
|
||||
t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfile_ServerError tests error handling in UpdateProfile
|
||||
func TestUpdateProfile_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error":"InternalServerError"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
profile := &atproto.SailorProfileRecord{
|
||||
Type: atproto.SailorProfileCollection,
|
||||
DefaultHold: "did:web:hold01.atcr.io",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
err := UpdateProfile(context.Background(), client, profile)
|
||||
|
||||
if err == nil {
|
||||
t.Error("UpdateProfile() should return error when server fails")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/distribution/distribution/v3"
|
||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||
@@ -40,7 +39,7 @@ type ProxyBlobStore struct {
|
||||
// NewProxyBlobStore creates a new proxy blob store
|
||||
func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
|
||||
// Resolve DID to URL once at construction time
|
||||
holdURL := appview.ResolveHoldURL(ctx.HoldDID)
|
||||
holdURL := atproto.ResolveHoldURL(ctx.HoldDID)
|
||||
|
||||
fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n",
|
||||
ctx.HoldDID, holdURL, ctx.DID, ctx.Repository)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/token"
|
||||
"github.com/opencontainers/go-digest"
|
||||
@@ -219,7 +218,7 @@ func TestResolveHoldURL(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := appview.ResolveHoldURL(tt.holdDID)
|
||||
result := atproto.ResolveHoldURL(tt.holdDID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package appview
|
||||
|
||||
import "strings"
|
||||
|
||||
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
|
||||
// Handles both formats for backward compatibility:
|
||||
// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
|
||||
// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
|
||||
// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
|
||||
func ResolveHoldURL(holdIdentifier string) string {
|
||||
// If it's already a URL (has scheme), return as-is
|
||||
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
|
||||
return holdIdentifier
|
||||
}
|
||||
|
||||
// If it's a DID, convert to URL
|
||||
if strings.HasPrefix(holdIdentifier, "did:web:") {
|
||||
hostname := strings.TrimPrefix(holdIdentifier, "did:web:")
|
||||
|
||||
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
|
||||
if strings.Contains(hostname, ":") ||
|
||||
strings.Contains(hostname, "127.0.0.1") ||
|
||||
strings.Contains(hostname, "localhost") ||
|
||||
// Check if it's an IP address (contains only digits and dots in first part)
|
||||
(len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
|
||||
return "http://" + hostname
|
||||
}
|
||||
return "https://" + hostname
|
||||
}
|
||||
|
||||
// Fallback: assume it's a hostname and use HTTPS
|
||||
return "https://" + holdIdentifier
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package appview
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
func TestResolveHoldURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -52,7 +56,7 @@ func TestResolveHoldURL(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ResolveHoldURL(tt.input)
|
||||
result := atproto.ResolveHoldURL(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ResolveHoldURL(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user