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:
Evan Jarrett
2025-10-24 23:51:32 -05:00
parent 0c4d1cae8f
commit f75d9ceafb
33 changed files with 852 additions and 462 deletions
+146 -18
View File
@@ -9,15 +9,19 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/distribution/distribution/v3/configuration"
"github.com/distribution/distribution/v3/registry"
"github.com/distribution/distribution/v3/registry/handlers"
"github.com/spf13/cobra"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/auth/token"
@@ -206,7 +210,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
fmt.Println("README cache initialized for manifest push refresh")
// Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache)
uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache)
// Create OAuth server
oauthServer := oauth.NewServer(oauthApp)
@@ -216,15 +220,120 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
if uiSessionStore != nil {
oauthServer.SetUISessionStore(uiSessionStore)
}
// Connect database for user avatar management
oauthServer.SetDatabase(uiDatabase)
// Set default hold DID on OAuth server (extracted earlier)
// This is used to create sailor profiles on first login
if defaultHoldDID != "" {
oauthServer.SetDefaultHoldDID(defaultHoldDID)
fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID)
}
// Register OAuth post-auth callback for AppView business logic
// This decouples the OAuth package from AppView-specific dependencies
oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error {
fmt.Printf("DEBUG [appview/callback]: OAuth post-auth callback for DID=%s\n", did)
// Parse DID for session resume
didParsed, err := syntax.ParseDID(did)
if err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to parse DID %s: %v\n", did, err)
return nil // Non-fatal
}
// Resume OAuth session to get authenticated client
session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID)
if err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to resume session for DID=%s: %v\n", did, err)
// Fallback: update user without avatar
_ = db.UpsertUser(uiDatabase, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: "",
LastSeen: time.Now(),
})
return nil // Non-fatal
}
// Create authenticated atproto client using the indigo session's API client
client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient())
// Ensure sailor profile exists (creates with default hold if configured)
fmt.Printf("DEBUG [appview/callback]: Ensuring profile exists for %s (defaultHold=%s)\n", did, defaultHoldDID)
if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err)
// Continue anyway - profile creation is not critical for avatar fetch
} else {
fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s\n", did)
}
// Fetch user's profile record from PDS (contains blob references)
profileRecord, err := client.GetProfileRecord(ctx, did)
if err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to fetch profile record for DID=%s: %v\n", did, err)
// Still update user without avatar
_ = db.UpsertUser(uiDatabase, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: "",
LastSeen: time.Now(),
})
return nil // Non-fatal
}
// Construct avatar URL from blob CID using imgs.blue CDN
var avatarURL string
if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link)
fmt.Printf("DEBUG [appview/callback]: Constructed avatar URL: %s\n", avatarURL)
}
// Store user with avatar in database
err = db.UpsertUser(uiDatabase, &db.User{
DID: did,
Handle: handle,
PDSEndpoint: pdsEndpoint,
Avatar: avatarURL,
LastSeen: time.Now(),
})
if err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to store user in database: %v\n", err)
return nil // Non-fatal
}
fmt.Printf("DEBUG [appview/callback]: Stored user with avatar for DID=%s\n", did)
// Migrate profile URL→DID if needed
profile, err := storage.GetProfile(ctx, client)
if err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to get profile for %s: %v\n", did, err)
return nil // Non-fatal
}
var holdDID string
if profile != nil && profile.DefaultHold != "" {
// Check if defaultHold is a URL (needs migration)
if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") {
fmt.Printf("DEBUG [appview/callback]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold)
// Resolve URL to DID
holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold)
// Update profile with DID
profile.DefaultHold = holdDID
if err := storage.UpdateProfile(ctx, client, profile); err != nil {
fmt.Printf("WARNING [appview/callback]: Failed to update profile with hold DID for %s: %v\n", did, err)
} else {
fmt.Printf("DEBUG [appview/callback]: Updated profile with hold DID: %s\n", holdDID)
}
fmt.Printf("DEBUG [oauth/server]: Attempting crew registration for %s at hold %s\n", did, holdDID)
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
} else {
// Already a DID - use it
holdDID = profile.DefaultHold
}
// Register crew regardless of migration (outside the migration block)
fmt.Printf("DEBUG [appview/callback]: Attempting crew registration for %s at hold %s\n", did, holdDID)
storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
}
return nil // All errors are non-fatal, logged for debugging
})
// Initialize auth keys and create token issuer
var issuer *token.Issuer
@@ -284,8 +393,27 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
// Mount auth endpoints if enabled
if issuer != nil {
// Basic Auth token endpoint (supports device secrets and app passwords)
// Reuse defaultHoldDID extracted earlier
tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldDID)
tokenHandler := token.NewHandler(issuer, deviceStore)
// Register token post-auth callback for profile management
// This decouples the token package from AppView-specific dependencies
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
fmt.Printf("DEBUG [appview/callback]: Token post-auth callback for DID=%s\n", did)
// Create ATProto client with validated token
atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken)
// Ensure profile exists (will create with default hold if not exists and default is configured)
if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil {
// Log error but don't fail auth - profile management is not critical
fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err)
} else {
fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s with default hold %s\n", did, defaultHoldDID)
}
return nil // All errors are non-fatal
})
tokenHandler.RegisterRoutes(mux)
// Device authorization endpoints (public)
@@ -401,7 +529,7 @@ func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, erro
// readOnlyDB: read-only connection for public queries (search, user pages, etc.)
// defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io")
// healthChecker: hold endpoint health checker
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) {
func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, oauthStore *db.OAuthStore, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, defaultHoldDID string, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) {
// Check if UI is enabled
uiEnabled := os.Getenv("ATCR_UI_ENABLED")
if uiEnabled == "false" {
@@ -582,12 +710,12 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S
}).Methods("DELETE")
// Logout endpoint (supports both GET and POST)
router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) {
if sessionID, ok := db.GetSessionID(r); ok {
sessionStore.Delete(sessionID)
}
db.ClearCookie(w)
http.Redirect(w, r, "/", http.StatusFound)
// Properly revokes OAuth tokens on PDS side before clearing local session
router.Handle("/auth/logout", &uihandlers.LogoutHandler{
OAuthApp: oauthApp,
Refresher: refresher,
SessionStore: sessionStore,
OAuthStore: oauthStore,
}).Methods("GET", "POST")
// Start Jetstream worker