A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

slog and refactor config in appview

evan.jarrett.net e17600db 35ba417a

verified
+1 -1
.env.appview.example
··· 66 66 # ============================================================================== 67 67 68 68 # Log level: debug, info, warn, error (default: info) 69 - # ATCR_LOG_LEVEL=info 69 + ATCR_LOG_LEVEL=debug 70 70 71 71 # Log formatter: text, json (default: text) 72 72 # ATCR_LOG_FORMATTER=text
+10
.env.hold.example
··· 110 110 # - Skips OAuth if records exist 111 111 # 112 112 HOLD_OWNER=did:plc:your-did-here 113 + 114 + # ============================================================================== 115 + # Logging Configuration 116 + # ============================================================================== 117 + 118 + # Log level: debug, info, warn, error (default: info) 119 + ATCR_LOG_LEVEL=debug 120 + 121 + # Log formatter: text, json (default: text) 122 + # ATCR_LOG_FORMATTER=text
+102 -151
cmd/appview/serve.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "html/template" 9 + "log/slog" 9 10 "net/http" 10 11 "os" 11 12 "os/signal" ··· 14 15 "time" 15 16 16 17 "github.com/bluesky-social/indigo/atproto/syntax" 17 - "github.com/distribution/distribution/v3/configuration" 18 18 "github.com/distribution/distribution/v3/registry" 19 19 "github.com/distribution/distribution/v3/registry/handlers" 20 20 "github.com/spf13/cobra" ··· 59 59 } 60 60 61 61 func serveRegistry(cmd *cobra.Command, args []string) error { 62 - // Initialize structured logging 63 - logging.InitLogger(appview.GetLogLevel()) 64 - 65 62 // Load configuration from environment variables 66 - fmt.Println("Loading configuration from environment variables...") 67 - config, err := appview.LoadConfigFromEnv() 63 + cfg, err := appview.LoadConfigFromEnv() 68 64 if err != nil { 69 65 return fmt.Errorf("failed to load config from environment: %w", err) 70 66 } 71 - fmt.Println("Configuration loaded successfully from environment") 67 + 68 + // Initialize structured logging 69 + logging.InitLogger(cfg.LogLevel) 70 + 71 + slog.Info("Configuration loaded successfully from environment") 72 72 73 73 // Initialize UI database first (required for all stores) 74 - fmt.Println("Initializing UI database...") 75 - uiEnabled := appview.GetUIEnabled() 76 - dbPath := appview.GetUIDatabasePath() 77 - uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(uiEnabled, dbPath) 74 + slog.Info("Initializing UI database", "path", cfg.UI.DatabasePath) 75 + uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(cfg.UI.Enabled, cfg.UI.DatabasePath) 78 76 if uiDatabase == nil { 79 77 return fmt.Errorf("failed to initialize UI database - required for session storage") 80 78 } 81 79 82 80 // Initialize hold health checker 83 - fmt.Println("Initializing hold health checker...") 84 - cacheTTL := appview.GetHealthCacheTTL() 85 - healthChecker := holdhealth.NewChecker(cacheTTL) 81 + slog.Info("Initializing hold health checker", "cache_ttl", cfg.Health.CacheTTL) 82 + healthChecker := holdhealth.NewChecker(cfg.Health.CacheTTL) 86 83 87 84 // Initialize README cache 88 - fmt.Println("Initializing README cache...") 89 - readmeCacheTTL := appview.GetReadmeCacheTTL() 90 - readmeCache := readme.NewCache(uiDatabase, readmeCacheTTL) 85 + slog.Info("Initializing README cache", "cache_ttl", cfg.Health.ReadmeCacheTTL) 86 + readmeCache := readme.NewCache(uiDatabase, cfg.Health.ReadmeCacheTTL) 91 87 92 88 // Start background health check worker 93 - refreshInterval := appview.GetHealthCheckInterval() 94 89 startupDelay := 5 * time.Second // Wait for hold services to start (Docker compose) 95 90 dbAdapter := holdhealth.NewDBAdapter(uiDatabase) 96 - healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, refreshInterval, startupDelay) 91 + healthWorker := holdhealth.NewWorkerWithStartupDelay(healthChecker, dbAdapter, cfg.Health.CheckInterval, startupDelay) 97 92 98 93 // Create context for worker lifecycle management 99 94 workerCtx, workerCancel := context.WithCancel(context.Background()) 100 95 defer workerCancel() // Ensure context is cancelled on all exit paths 101 96 healthWorker.Start(workerCtx) 102 - fmt.Printf("Hold health worker started (5s startup delay, %s refresh interval, %s cache TTL)\n", refreshInterval, cacheTTL) 97 + slog.Info("Hold health worker started", "startup_delay", startupDelay, "refresh_interval", cfg.Health.CheckInterval, "cache_ttl", cfg.Health.CacheTTL) 103 98 104 99 // Initialize OAuth components 105 - fmt.Println("Initializing OAuth components...") 100 + slog.Info("Initializing OAuth components") 106 101 107 102 // Create OAuth session storage (SQLite-backed) 108 103 oauthStore := db.NewOAuthStore(uiDatabase) 109 - fmt.Println("Using SQLite for OAuth session storage") 104 + slog.Info("Using SQLite for OAuth session storage") 110 105 111 106 // Create device store (SQLite-backed) 112 107 deviceStore := db.NewDeviceStore(uiDatabase) 113 - fmt.Println("Using SQLite for device storage") 114 - 115 - // Get base URL from config or environment 116 - baseURL := appview.GetBaseURL(config.HTTP.Addr) 117 - fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) 108 + slog.Info("Using SQLite for device storage") 118 109 119 - // Extract default hold DID for OAuth server and backfill worker 120 - // This is used to create sailor profiles on first login and cache captain records 121 - // Expected format: "did:web:hold01.atcr.io" 122 - // To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json 123 - // The extraction function normalizes URLs to DIDs for consistency 124 - defaultHoldDID := appview.ExtractDefaultHoldDID(config) 110 + // Get base URL and default hold DID from config 111 + baseURL := cfg.Server.BaseURL 112 + defaultHoldDID := cfg.Server.DefaultHoldDID 113 + testMode := cfg.Server.TestMode 125 114 126 - // Extract test mode from config (needed for OAuth scope configuration) 127 - testMode := appview.ExtractTestMode(config) 115 + slog.Debug("Base URL for OAuth", "base_url", baseURL) 128 116 if testMode { 129 - fmt.Println("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") 117 + slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope") 130 118 } 131 119 132 120 // Create OAuth app (indigo client) ··· 135 123 return fmt.Errorf("failed to create OAuth app: %w", err) 136 124 } 137 125 if testMode { 138 - fmt.Println("Using OAuth scopes with transition:generic (test mode)") 126 + slog.Info("Using OAuth scopes with transition:generic (test mode)") 139 127 } else { 140 - fmt.Println("Using OAuth scopes with RPC scope (production mode)") 128 + slog.Info("Using OAuth scopes with RPC scope (production mode)") 141 129 } 142 130 143 131 // Invalidate sessions with mismatched scopes on startup ··· 145 133 desiredScopes := oauth.GetDefaultScopes(defaultHoldDID, testMode) 146 134 invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) 147 135 if err != nil { 148 - fmt.Printf("Warning: Failed to invalidate sessions with mismatched scopes: %v\n", err) 136 + slog.Warn("Failed to invalidate sessions with mismatched scopes", "error", err) 149 137 } else if invalidatedCount > 0 { 150 - fmt.Printf("Invalidated %d OAuth session(s) due to scope changes\n", invalidatedCount) 138 + slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount) 151 139 } 152 140 153 141 // Create oauth token refresher ··· 168 156 // Create RemoteHoldAuthorizer for hold authorization with caching 169 157 holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase, testMode) 170 158 middleware.SetGlobalAuthorizer(holdAuthorizer) 171 - fmt.Println("Hold authorizer initialized with database caching") 159 + slog.Info("Hold authorizer initialized with database caching") 172 160 173 161 // Set global readme cache for middleware 174 162 middleware.SetGlobalReadmeCache(readmeCache) 175 - fmt.Println("README cache initialized for manifest push refresh") 163 + slog.Info("README cache initialized for manifest push refresh") 164 + 165 + // Initialize Jetstream workers (background services before HTTP routes) 166 + initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) 176 167 177 168 // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache 178 - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache) 169 + uiTemplates, uiRouter := initializeUIRoutes(cfg.UI.Enabled, uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, oauthStore, refresher, baseURL, deviceStore, healthChecker, readmeCache) 179 170 180 171 // Create OAuth server 181 172 oauthServer := oauth.NewServer(oauthApp) ··· 189 180 // Register OAuth post-auth callback for AppView business logic 190 181 // This decouples the OAuth package from AppView-specific dependencies 191 182 oauthServer.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, sessionID string) error { 192 - fmt.Printf("DEBUG [appview/callback]: OAuth post-auth callback for DID=%s\n", did) 183 + slog.Debug("OAuth post-auth callback", "component", "appview/callback", "did", did) 193 184 194 185 // Parse DID for session resume 195 186 didParsed, err := syntax.ParseDID(did) 196 187 if err != nil { 197 - fmt.Printf("WARNING [appview/callback]: Failed to parse DID %s: %v\n", did, err) 188 + slog.Warn("Failed to parse DID", "component", "appview/callback", "did", did, "error", err) 198 189 return nil // Non-fatal 199 190 } 200 191 201 192 // Resume OAuth session to get authenticated client 202 193 session, err := oauthApp.ResumeSession(ctx, didParsed, sessionID) 203 194 if err != nil { 204 - fmt.Printf("WARNING [appview/callback]: Failed to resume session for DID=%s: %v\n", did, err) 195 + slog.Warn("Failed to resume session", "component", "appview/callback", "did", did, "error", err) 205 196 // Fallback: update user without avatar 206 197 _ = db.UpsertUser(uiDatabase, &db.User{ 207 198 DID: did, ··· 217 208 client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient()) 218 209 219 210 // Ensure sailor profile exists (creates with default hold if configured) 220 - fmt.Printf("DEBUG [appview/callback]: Ensuring profile exists for %s (defaultHold=%s)\n", did, defaultHoldDID) 211 + slog.Debug("Ensuring profile exists", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID) 221 212 if err := storage.EnsureProfile(ctx, client, defaultHoldDID); err != nil { 222 - fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err) 213 + slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err) 223 214 // Continue anyway - profile creation is not critical for avatar fetch 224 215 } else { 225 - fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s\n", did) 216 + slog.Debug("Profile ensured", "component", "appview/callback", "did", did) 226 217 } 227 218 228 219 // Fetch user's profile record from PDS (contains blob references) 229 220 profileRecord, err := client.GetProfileRecord(ctx, did) 230 221 if err != nil { 231 - fmt.Printf("WARNING [appview/callback]: Failed to fetch profile record for DID=%s: %v\n", did, err) 222 + slog.Warn("Failed to fetch profile record", "component", "appview/callback", "did", did, "error", err) 232 223 // Still update user without avatar 233 224 _ = db.UpsertUser(uiDatabase, &db.User{ 234 225 DID: did, ··· 244 235 var avatarURL string 245 236 if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 246 237 avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link) 247 - fmt.Printf("DEBUG [appview/callback]: Constructed avatar URL: %s\n", avatarURL) 238 + slog.Debug("Constructed avatar URL", "component", "appview/callback", "avatar_url", avatarURL) 248 239 } 249 240 250 241 // Store user with avatar in database ··· 256 247 LastSeen: time.Now(), 257 248 }) 258 249 if err != nil { 259 - fmt.Printf("WARNING [appview/callback]: Failed to store user in database: %v\n", err) 250 + slog.Warn("Failed to store user in database", "component", "appview/callback", "error", err) 260 251 return nil // Non-fatal 261 252 } 262 253 263 - fmt.Printf("DEBUG [appview/callback]: Stored user with avatar for DID=%s\n", did) 254 + slog.Debug("Stored user with avatar", "component", "appview/callback", "did", did) 264 255 265 256 // Migrate profile URL→DID if needed 266 257 profile, err := storage.GetProfile(ctx, client) 267 258 if err != nil { 268 - fmt.Printf("WARNING [appview/callback]: Failed to get profile for %s: %v\n", did, err) 259 + slog.Warn("Failed to get profile", "component", "appview/callback", "did", did, "error", err) 269 260 return nil // Non-fatal 270 261 } 271 262 ··· 273 264 if profile != nil && profile.DefaultHold != "" { 274 265 // Check if defaultHold is a URL (needs migration) 275 266 if strings.HasPrefix(profile.DefaultHold, "http://") || strings.HasPrefix(profile.DefaultHold, "https://") { 276 - fmt.Printf("DEBUG [appview/callback]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) 267 + slog.Debug("Migrating hold URL to DID", "component", "appview/callback", "did", did, "hold_url", profile.DefaultHold) 277 268 278 269 // Resolve URL to DID 279 270 holdDID := atproto.ResolveHoldDIDFromURL(profile.DefaultHold) ··· 281 272 // Update profile with DID 282 273 profile.DefaultHold = holdDID 283 274 if err := storage.UpdateProfile(ctx, client, profile); err != nil { 284 - fmt.Printf("WARNING [appview/callback]: Failed to update profile with hold DID for %s: %v\n", did, err) 275 + slog.Warn("Failed to update profile with hold DID", "component", "appview/callback", "did", did, "error", err) 285 276 } else { 286 - fmt.Printf("DEBUG [appview/callback]: Updated profile with hold DID: %s\n", holdDID) 277 + slog.Debug("Updated profile with hold DID", "component", "appview/callback", "hold_did", holdDID) 287 278 } 288 - fmt.Printf("DEBUG [oauth/server]: Attempting crew registration for %s at hold %s\n", did, holdDID) 279 + slog.Debug("Attempting crew registration", "component", "oauth/server", "did", did, "hold_did", holdDID) 289 280 storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 290 281 } else { 291 282 // Already a DID - use it 292 283 holdDID = profile.DefaultHold 293 284 } 294 285 // Register crew regardless of migration (outside the migration block) 295 - fmt.Printf("DEBUG [appview/callback]: Attempting crew registration for %s at hold %s\n", did, holdDID) 286 + slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID) 296 287 storage.EnsureCrewMembership(ctx, client, refresher, holdDID) 297 288 298 289 } ··· 300 291 return nil // All errors are non-fatal, logged for debugging 301 292 }) 302 293 303 - // Initialize auth keys and create token issuer 294 + // Create token issuer (also initializes auth keys if needed) 304 295 var issuer *token.Issuer 305 - if config.Auth["token"] != nil { 306 - if err := initializeAuthKeys(config); err != nil { 307 - return fmt.Errorf("failed to initialize auth keys: %w", err) 308 - } 309 - 310 - // Create token issuer for auth handlers 311 - issuer, err = createTokenIssuer(config) 296 + if cfg.Distribution.Auth["token"] != nil { 297 + issuer, err = createTokenIssuer(cfg) 312 298 if err != nil { 313 299 return fmt.Errorf("failed to create token issuer: %w", err) 314 300 } 301 + 302 + // Log successful initialization 303 + slog.Info("Auth keys initialized", "path", cfg.Auth.KeyPath) 315 304 } 316 305 317 306 // Create registry app (returns http.Handler) 318 307 ctx := context.Background() 319 - app := handlers.NewApp(ctx, config) 308 + app := handlers.NewApp(ctx, cfg.Distribution) 320 309 321 310 // Create main HTTP mux 322 311 mux := http.NewServeMux() ··· 332 321 // Mount UI routes directly at root level 333 322 mux.Handle("/", uiRouter) 334 323 335 - fmt.Printf("UI enabled:\n") 336 - fmt.Printf(" - Home: /\n") 337 - fmt.Printf(" - Settings: /settings\n") 324 + slog.Info("UI enabled", "home", "/", "settings", "/settings") 338 325 } 339 326 340 327 // Mount OAuth endpoints ··· 363 350 // Register token post-auth callback for profile management 364 351 // This decouples the token package from AppView-specific dependencies 365 352 tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error { 366 - fmt.Printf("DEBUG [appview/callback]: Token post-auth callback for DID=%s\n", did) 353 + slog.Debug("Token post-auth callback", "component", "appview/callback", "did", did) 367 354 368 355 // Create ATProto client with validated token 369 356 atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) ··· 371 358 // Ensure profile exists (will create with default hold if not exists and default is configured) 372 359 if err := storage.EnsureProfile(ctx, atprotoClient, defaultHoldDID); err != nil { 373 360 // Log error but don't fail auth - profile management is not critical 374 - fmt.Printf("WARNING [appview/callback]: Failed to ensure profile for %s: %v\n", did, err) 361 + slog.Warn("Failed to ensure profile", "component", "appview/callback", "did", did, "error", err) 375 362 } else { 376 - fmt.Printf("DEBUG [appview/callback]: Profile ensured for %s with default hold %s\n", did, defaultHoldDID) 363 + slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID) 377 364 } 378 365 379 366 return nil // All errors are non-fatal ··· 390 377 Store: deviceStore, 391 378 }) 392 379 393 - fmt.Printf("Auth endpoints enabled:\n") 394 - fmt.Printf(" - Basic Auth: /auth/token (device secrets + app passwords)\n") 395 - fmt.Printf(" - Device Auth: /auth/device/code\n") 396 - fmt.Printf(" - Device Auth: /auth/device/token\n") 397 - fmt.Printf(" - OAuth: /auth/oauth/authorize\n") 398 - fmt.Printf(" - OAuth: /auth/oauth/callback\n") 399 - fmt.Printf(" - OAuth Meta: /client-metadata.json\n") 380 + slog.Info("Auth endpoints enabled", 381 + "basic_auth", "/auth/token", 382 + "device_code", "/auth/device/code", 383 + "device_token", "/auth/device/token", 384 + "oauth_authorize", "/auth/oauth/authorize", 385 + "oauth_callback", "/auth/oauth/callback", 386 + "oauth_metadata", "/client-metadata.json") 400 387 } 401 388 402 389 // Create HTTP server 403 390 server := &http.Server{ 404 - Addr: config.HTTP.Addr, 391 + Addr: cfg.Server.Addr, 405 392 Handler: mux, 406 393 } 407 394 ··· 412 399 // Start server in goroutine 413 400 errChan := make(chan error, 1) 414 401 go func() { 415 - fmt.Printf("Starting registry server on %s\n", config.HTTP.Addr) 402 + slog.Info("Starting registry server", "addr", cfg.Server.Addr) 416 403 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 417 404 errChan <- err 418 405 } ··· 421 408 // Wait for shutdown signal or error 422 409 select { 423 410 case <-stop: 424 - fmt.Println("Shutting down registry server...") 411 + slog.Info("Shutting down registry server") 425 412 426 413 // Stop health worker first 427 - fmt.Println("Stopping hold health worker...") 414 + slog.Info("Stopping hold health worker") 428 415 healthWorker.Stop() 429 416 430 417 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ··· 442 429 return nil 443 430 } 444 431 445 - // initializeAuthKeys creates the auth keys if they don't exist 446 - func initializeAuthKeys(config *configuration.Configuration) error { 447 - tokenParams, ok := config.Auth["token"] 448 - if !ok { 449 - return nil 450 - } 451 - 452 - privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 453 - issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 454 - service := appview.GetStringParam(tokenParams, "service", "atcr.io") 455 - expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 456 - 457 - // Create issuer (this will generate the key if it doesn't exist) 458 - _, err := token.NewIssuer( 459 - privateKeyPath, 460 - issuerName, 461 - service, 462 - time.Duration(expirationSecs)*time.Second, 463 - ) 464 - if err != nil { 465 - return fmt.Errorf("failed to initialize token issuer: %w", err) 466 - } 467 - 468 - fmt.Printf("Auth keys initialized at %s\n", privateKeyPath) 469 - return nil 470 - } 471 - 472 432 // createTokenIssuer creates a token issuer for auth handlers 473 - func createTokenIssuer(config *configuration.Configuration) (*token.Issuer, error) { 474 - tokenParams, ok := config.Auth["token"] 475 - if !ok { 476 - return nil, fmt.Errorf("token auth not configured") 477 - } 478 - 479 - privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 480 - issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 481 - service := appview.GetStringParam(tokenParams, "service", "atcr.io") 482 - expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 483 - 433 + func createTokenIssuer(cfg *appview.Config) (*token.Issuer, error) { 484 434 return token.NewIssuer( 485 - privateKeyPath, 486 - issuerName, 487 - service, 488 - time.Duration(expirationSecs)*time.Second, 435 + cfg.Auth.KeyPath, 436 + cfg.Auth.ServiceName, // issuer 437 + cfg.Auth.ServiceName, // service 438 + cfg.Auth.TokenExpiration, 489 439 ) 490 440 } 491 441 492 442 // initializeUIRoutes initializes the web UI routes 443 + // uiEnabled: whether UI is enabled (from Config.UI.Enabled) 493 444 // database: read-write connection for auth and writes 494 445 // readOnlyDB: read-only connection for public queries (search, user pages, etc.) 495 - // defaultHoldDID: DID of the default hold service (e.g., "did:web:hold01.atcr.io") 496 446 // healthChecker: hold endpoint health checker 497 - 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) { 447 + // readmeCache: README cache for repository pages 448 + func initializeUIRoutes(uiEnabled bool, database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, oauthStore *db.OAuthStore, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore, healthChecker *holdhealth.Checker, readmeCache *readme.Cache) (*template.Template, *mux.Router) { 498 449 // Check if UI is enabled 499 - if !appview.GetUIEnabled() { 450 + if !uiEnabled { 500 451 return nil, nil 501 452 } 502 453 503 454 // Load templates 504 455 templates, err := appview.Templates() 505 456 if err != nil { 506 - fmt.Printf("Warning: Failed to load UI templates: %v\n", err) 457 + slog.Warn("Failed to load UI templates", "error", err) 507 458 return nil, nil 508 459 } 509 460 ··· 682 633 OAuthStore: oauthStore, 683 634 }).Methods("GET", "POST") 684 635 636 + return templates, router 637 + } 638 + 639 + // initializeJetstream initializes the Jetstream workers for real-time events and backfill 640 + func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) { 685 641 // Start Jetstream worker 686 - jetstreamURL := appview.GetJetstreamURL() 642 + jetstreamURL := jetstreamCfg.URL 687 643 688 644 // Start real-time Jetstream worker with cursor tracking for reconnects 689 645 go func() { ··· 693 649 if err := worker.Start(context.Background()); err != nil { 694 650 // Save cursor from this connection for next reconnect 695 651 lastCursor = worker.GetLastCursor() 696 - fmt.Printf("Jetstream: Real-time worker error: %v, reconnecting in 10s...\n", err) 652 + slog.Warn("Jetstream real-time worker error, reconnecting", "component", "jetstream", "error", err, "reconnect_delay", "10s") 697 653 time.Sleep(10 * time.Second) 698 654 } 699 655 } 700 656 }() 701 - fmt.Println("Jetstream: Real-time worker started") 657 + slog.Info("Jetstream real-time worker started", "component", "jetstream") 702 658 703 659 // Start backfill worker (enabled by default, set ATCR_BACKFILL_ENABLED=false to disable) 704 - if appview.GetBackfillEnabled() { 660 + if jetstreamCfg.BackfillEnabled { 705 661 // Get relay endpoint for sync API (defaults to Bluesky's relay) 706 - relayEndpoint := appview.GetRelayEndpoint() 707 - 708 - // Check test mode 709 - testMode := appview.GetTestMode() 662 + relayEndpoint := jetstreamCfg.RelayEndpoint 710 663 711 664 backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode) 712 665 if err != nil { 713 - fmt.Printf("Warning: Failed to create backfill worker: %v\n", err) 666 + slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err) 714 667 } else { 715 668 // Run initial backfill with startup delay for Docker compose 716 669 go func() { 717 670 // Wait for hold service to be ready (Docker startup race condition) 718 671 startupDelay := 5 * time.Second 719 - fmt.Printf("Backfill: Waiting %s for services to be ready...\n", startupDelay) 672 + slog.Info("Waiting for services to be ready", "component", "jetstream/backfill", "startup_delay", startupDelay) 720 673 time.Sleep(startupDelay) 721 674 722 - fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint) 675 + slog.Info("Starting sync-based backfill", "component", "jetstream/backfill", "relay_endpoint", relayEndpoint) 723 676 if err := backfillWorker.Start(context.Background()); err != nil { 724 - fmt.Printf("Backfill: Finished with error: %v\n", err) 677 + slog.Warn("Backfill finished with error", "component", "jetstream/backfill", "error", err) 725 678 } else { 726 - fmt.Println("Backfill: Completed successfully!") 679 + slog.Info("Backfill completed successfully", "component", "jetstream/backfill") 727 680 } 728 681 }() 729 682 730 683 // Start periodic backfill scheduler 731 - interval := appview.GetBackfillInterval() 684 + interval := jetstreamCfg.BackfillInterval 732 685 733 686 go func() { 734 687 ticker := time.NewTicker(interval) 735 688 defer ticker.Stop() 736 689 737 690 for range ticker.C { 738 - fmt.Printf("Backfill: Starting periodic backfill (runs every %s)...\n", interval) 691 + slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval) 739 692 if err := backfillWorker.Start(context.Background()); err != nil { 740 - fmt.Printf("Backfill: Periodic backfill finished with error: %v\n", err) 693 + slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err) 741 694 } else { 742 - fmt.Println("Backfill: Periodic backfill completed successfully!") 695 + slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill") 743 696 } 744 697 } 745 698 }() 746 - fmt.Printf("Backfill: Periodic scheduler started (interval: %s)\n", interval) 699 + slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval) 747 700 } 748 701 } 749 - 750 - return templates, router 751 702 }
+1 -1
deploy/.env.prod.template
··· 161 161 162 162 # Log level: debug, info, warn, error 163 163 # Default: info 164 - ATCR_LOG_LEVEL=info 164 + ATCR_LOG_LEVEL=debug 165 165 166 166 # Log formatter: text, json 167 167 # Default: text
+4
deploy/docker-compose.prod.yml
··· 114 114 S3_ENDPOINT: ${S3_ENDPOINT:-} 115 115 S3_REGION_ENDPOINT: ${S3_REGION_ENDPOINT:-} 116 116 117 + # Logging 118 + ATCR_LOG_LEVEL: ${ATCR_LOG_LEVEL:-debug} 119 + ATCR_LOG_FORMATTER: ${ATCR_LOG_FORMATTER:-text} 120 + 117 121 # Optional: Filesystem storage (comment out S3 vars above) 118 122 # STORAGE_DRIVER: filesystem 119 123 # STORAGE_ROOT_DIR: /var/lib/atcr/hold
+3 -1
docker-compose.yml
··· 20 20 # Test mode - fallback to default hold when user's hold is unreachable 21 21 TEST_MODE: true 22 22 # Logging 23 - ATCR_LOG_LEVEL: info 23 + ATCR_LOG_LEVEL: debug 24 24 volumes: 25 25 # Auth keys (JWT signing keys) 26 26 # - atcr-auth:/var/lib/atcr/auth ··· 50 50 # STORAGE_ROOT_DIR: /var/lib/atcr/hold 51 51 TEST_MODE: true 52 52 # DISABLE_PRESIGNED_URLS: true 53 + # Logging 54 + ATCR_LOG_LEVEL: debug 53 55 # Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*) 54 56 build: 55 57 context: .
+191 -266
pkg/appview/config.go
··· 17 17 "github.com/distribution/distribution/v3/configuration" 18 18 ) 19 19 20 + // Config represents the AppView service configuration 21 + type Config struct { 22 + Version string `yaml:"version"` 23 + LogLevel string `yaml:"log_level"` 24 + Server ServerConfig `yaml:"server"` 25 + UI UIConfig `yaml:"ui"` 26 + Health HealthConfig `yaml:"health"` 27 + Jetstream JetstreamConfig `yaml:"jetstream"` 28 + Auth AuthConfig `yaml:"auth"` 29 + Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 30 + } 31 + 32 + // ServerConfig defines server settings 33 + type ServerConfig struct { 34 + // Addr is the HTTP listen address (from env: ATCR_HTTP_ADDR, default: ":5000") 35 + Addr string `yaml:"addr"` 36 + 37 + // BaseURL is the public URL for OAuth/JWT realm (from env: ATCR_BASE_URL) 38 + // Auto-detected from Addr if not set 39 + BaseURL string `yaml:"base_url"` 40 + 41 + // DefaultHoldDID is the default hold DID for blob storage (from env: ATCR_DEFAULT_HOLD_DID) 42 + // REQUIRED - e.g., "did:web:hold01.atcr.io" 43 + DefaultHoldDID string `yaml:"default_hold_did"` 44 + 45 + // TestMode enables HTTP for local DID resolution and transition:generic scope (from env: TEST_MODE) 46 + TestMode bool `yaml:"test_mode"` 47 + 48 + // DebugAddr is the debug/pprof HTTP listen address (from env: ATCR_DEBUG_ADDR, default: ":5001") 49 + DebugAddr string `yaml:"debug_addr"` 50 + } 51 + 52 + // UIConfig defines web UI settings 53 + type UIConfig struct { 54 + // Enabled controls whether the web UI is enabled (from env: ATCR_UI_ENABLED, default: true) 55 + Enabled bool `yaml:"enabled"` 56 + 57 + // DatabasePath is the path to the UI SQLite database (from env: ATCR_UI_DATABASE_PATH, default: "/var/lib/atcr/ui.db") 58 + DatabasePath string `yaml:"database_path"` 59 + } 60 + 61 + // HealthConfig defines health check and cache settings 62 + type HealthConfig struct { 63 + // CacheTTL is the hold health check cache TTL (from env: ATCR_HEALTH_CACHE_TTL, default: 15m) 64 + CacheTTL time.Duration `yaml:"cache_ttl"` 65 + 66 + // CheckInterval is the hold health check refresh interval (from env: ATCR_HEALTH_CHECK_INTERVAL, default: 15m) 67 + CheckInterval time.Duration `yaml:"check_interval"` 68 + 69 + // ReadmeCacheTTL is the README cache TTL (from env: ATCR_README_CACHE_TTL, default: 1h) 70 + ReadmeCacheTTL time.Duration `yaml:"readme_cache_ttl"` 71 + } 72 + 73 + // JetstreamConfig defines ATProto Jetstream settings 74 + type JetstreamConfig struct { 75 + // URL is the Jetstream WebSocket URL (from env: JETSTREAM_URL, default: wss://jetstream2.us-west.bsky.network/subscribe) 76 + URL string `yaml:"url"` 77 + 78 + // BackfillEnabled controls whether backfill is enabled (from env: ATCR_BACKFILL_ENABLED, default: true) 79 + BackfillEnabled bool `yaml:"backfill_enabled"` 80 + 81 + // BackfillInterval is the backfill interval (from env: ATCR_BACKFILL_INTERVAL, default: 1h) 82 + BackfillInterval time.Duration `yaml:"backfill_interval"` 83 + 84 + // RelayEndpoint is the relay endpoint for sync API (from env: ATCR_RELAY_ENDPOINT, default: https://relay1.us-east.bsky.network) 85 + RelayEndpoint string `yaml:"relay_endpoint"` 86 + } 87 + 88 + // AuthConfig defines authentication settings 89 + type AuthConfig struct { 90 + // KeyPath is the JWT signing key path (from env: ATCR_AUTH_KEY_PATH, default: "/var/lib/atcr/auth/private-key.pem") 91 + KeyPath string `yaml:"key_path"` 92 + 93 + // CertPath is the JWT certificate path (from env: ATCR_AUTH_CERT_PATH, default: "/var/lib/atcr/auth/private-key.crt") 94 + CertPath string `yaml:"cert_path"` 95 + 96 + // TokenExpiration is the JWT expiration duration (from env: ATCR_TOKEN_EXPIRATION, default: 300s) 97 + TokenExpiration time.Duration `yaml:"token_expiration"` 98 + 99 + // ServiceName is the service name used for JWT issuer and service fields 100 + // Derived from ATCR_SERVICE_NAME env var or extracted from base URL (e.g., "atcr.io") 101 + ServiceName string `yaml:"service_name"` 102 + } 103 + 20 104 // LoadConfigFromEnv builds a complete configuration from environment variables 21 105 // This follows the same pattern as the hold service (no config files, only env vars) 22 - func LoadConfigFromEnv() (*configuration.Configuration, error) { 23 - config := &configuration.Configuration{} 106 + func LoadConfigFromEnv() (*Config, error) { 107 + cfg := &Config{ 108 + Version: "0.1", 109 + } 24 110 25 - // Version 26 - config.Version = configuration.MajorMinorVersion(0, 1) 111 + // Logging configuration 112 + cfg.LogLevel = getEnvOrDefault("ATCR_LOG_LEVEL", "info") 27 113 28 - // Logging 29 - config.Log = buildLogConfig() 114 + // Server configuration 115 + cfg.Server.Addr = getEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 116 + cfg.Server.DebugAddr = getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 117 + cfg.Server.DefaultHoldDID = os.Getenv("ATCR_DEFAULT_HOLD_DID") 118 + if cfg.Server.DefaultHoldDID == "" { 119 + return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 120 + } 121 + cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true" 30 122 31 - // HTTP server 32 - httpConfig, err := buildHTTPConfig() 33 - if err != nil { 34 - return nil, fmt.Errorf("failed to build HTTP config: %w", err) 123 + // Auto-detect base URL if not explicitly set 124 + cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL") 125 + if cfg.Server.BaseURL == "" { 126 + cfg.Server.BaseURL = autoDetectBaseURL(cfg.Server.Addr) 35 127 } 36 - config.HTTP = httpConfig 128 + 129 + // UI configuration 130 + cfg.UI.Enabled = os.Getenv("ATCR_UI_ENABLED") != "false" 131 + cfg.UI.DatabasePath = getEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db") 37 132 38 - // Storage (fake in-memory placeholder - all real storage is proxied) 39 - config.Storage = buildStorageConfig() 133 + // Health and cache configuration 134 + cfg.Health.CacheTTL = getDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute) 135 + cfg.Health.CheckInterval = getDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute) 136 + cfg.Health.ReadmeCacheTTL = getDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour) 137 + 138 + // Jetstream configuration 139 + cfg.Jetstream.URL = getEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe") 140 + cfg.Jetstream.BackfillEnabled = os.Getenv("ATCR_BACKFILL_ENABLED") != "false" 141 + cfg.Jetstream.BackfillInterval = getDurationOrDefault("ATCR_BACKFILL_INTERVAL", 1*time.Hour) 142 + cfg.Jetstream.RelayEndpoint = getEnvOrDefault("ATCR_RELAY_ENDPOINT", "https://relay1.us-east.bsky.network") 40 143 41 - // Get base URL for error messages and auth config 42 - baseURL := GetBaseURL(httpConfig.Addr) 144 + // Auth configuration 145 + cfg.Auth.KeyPath = getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 146 + cfg.Auth.CertPath = getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 43 147 44 - // Middleware (ATProto resolver) 45 - defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID") 46 - if defaultHoldDID == "" { 47 - return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") 148 + // Parse token expiration (default: 300 seconds = 5 minutes) 149 + expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 150 + expirationSecs, err := strconv.Atoi(expirationStr) 151 + if err != nil { 152 + return nil, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) 48 153 } 49 - config.Middleware = buildMiddlewareConfig(defaultHoldDID, baseURL) 154 + cfg.Auth.TokenExpiration = time.Duration(expirationSecs) * time.Second 155 + 156 + // Derive service name from base URL or env var (used for JWT issuer and service) 157 + cfg.Auth.ServiceName = getServiceName(cfg.Server.BaseURL) 50 158 51 - // Auth 52 - authConfig, err := buildAuthConfig(baseURL) 159 + // Build distribution configuration for compatibility with distribution library 160 + distConfig, err := buildDistributionConfig(cfg) 53 161 if err != nil { 54 - return nil, fmt.Errorf("failed to build auth config: %w", err) 162 + return nil, fmt.Errorf("failed to build distribution config: %w", err) 55 163 } 56 - config.Auth = authConfig 57 - 58 - // Health checks 59 - config.Health = buildHealthConfig() 164 + cfg.Distribution = distConfig 60 165 61 - return config, nil 166 + return cfg, nil 62 167 } 63 168 64 - // buildLogConfig creates logging configuration from environment variables 65 - func buildLogConfig() configuration.Log { 66 - level := GetEnvOrDefault("ATCR_LOG_LEVEL", "info") 67 - formatter := GetEnvOrDefault("ATCR_LOG_FORMATTER", "text") 169 + // buildDistributionConfig creates a distribution Configuration from our Config 170 + // This maintains compatibility with the distribution library 171 + func buildDistributionConfig(cfg *Config) (*configuration.Configuration, error) { 172 + distConfig := &configuration.Configuration{} 68 173 69 - return configuration.Log{ 70 - Level: configuration.Loglevel(level), 71 - Formatter: formatter, 174 + // Version 175 + distConfig.Version = configuration.MajorMinorVersion(0, 1) 176 + 177 + // Logging 178 + distConfig.Log = configuration.Log{ 179 + Level: configuration.Loglevel(cfg.LogLevel), 180 + Formatter: getEnvOrDefault("ATCR_LOG_FORMATTER", "text"), 72 181 Fields: map[string]any{ 73 182 "service": "atcr-appview", 74 183 }, 75 184 } 76 - } 77 185 78 - // buildHTTPConfig creates HTTP server configuration from environment variables 79 - func buildHTTPConfig() (configuration.HTTP, error) { 80 - addr := GetEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 81 - debugAddr := GetEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 82 - 83 - // HTTP secret - only needed for multipart uploads in distribution's storage driver 84 - // Since AppView is stateless and routes all storage through middleware, this isn't 85 - // actually used, but we generate a random secret for defense in depth 186 + // HTTP server 86 187 httpSecret := os.Getenv("REGISTRY_HTTP_SECRET") 87 188 if httpSecret == "" { 88 189 // Generate a random 32-byte secret 89 190 randomBytes := make([]byte, 32) 90 191 if _, err := rand.Read(randomBytes); err != nil { 91 - return configuration.HTTP{}, fmt.Errorf("failed to generate random secret: %w", err) 192 + return nil, fmt.Errorf("failed to generate random secret: %w", err) 92 193 } 93 194 httpSecret = hex.EncodeToString(randomBytes) 94 195 } 95 196 96 - return configuration.HTTP{ 97 - Addr: addr, 197 + distConfig.HTTP = configuration.HTTP{ 198 + Addr: cfg.Server.Addr, 98 199 Secret: httpSecret, 99 200 Headers: map[string][]string{ 100 201 "X-Content-Type-Options": {"nosniff"}, 101 202 }, 102 203 Debug: configuration.Debug{ 103 - Addr: debugAddr, 204 + Addr: cfg.Server.DebugAddr, 205 + }, 206 + } 207 + 208 + // Storage (fake in-memory placeholder - all real storage is proxied) 209 + distConfig.Storage = buildStorageConfig() 210 + 211 + // Middleware (ATProto resolver) 212 + distConfig.Middleware = buildMiddlewareConfig(cfg.Server.DefaultHoldDID, cfg.Server.BaseURL) 213 + 214 + // Auth (use values from cfg.Auth) 215 + realm := cfg.Server.BaseURL + "/auth/token" 216 + 217 + distConfig.Auth = configuration.Auth{ 218 + "token": configuration.Parameters{ 219 + "realm": realm, 220 + "service": cfg.Auth.ServiceName, 221 + "issuer": cfg.Auth.ServiceName, 222 + "rootcertbundle": cfg.Auth.CertPath, 223 + "privatekey": cfg.Auth.KeyPath, 224 + "expiration": int(cfg.Auth.TokenExpiration.Seconds()), 104 225 }, 105 - }, nil 226 + } 227 + 228 + // Health checks 229 + distConfig.Health = buildHealthConfig() 230 + 231 + return distConfig, nil 232 + } 233 + 234 + // autoDetectBaseURL determines the base URL for the service from the HTTP address 235 + func autoDetectBaseURL(httpAddr string) string { 236 + // Auto-detect from HTTP addr 237 + if httpAddr[0] == ':' { 238 + // Just a port, assume localhost 239 + return fmt.Sprintf("http://127.0.0.1%s", httpAddr) 240 + } 241 + 242 + // Full address provided 243 + return fmt.Sprintf("http://%s", httpAddr) 106 244 } 107 245 108 246 // buildStorageConfig creates a fake in-memory storage config ··· 148 286 } 149 287 } 150 288 151 - // buildAuthConfig creates authentication configuration from environment variables 152 - func buildAuthConfig(baseURL string) (configuration.Auth, error) { 153 - // Token configuration 154 - privateKeyPath := GetEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 155 - certPath := GetEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 156 - 157 - // Token expiration in seconds (default: 5 minutes) 158 - expirationStr := GetEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 159 - expiration, err := strconv.Atoi(expirationStr) 160 - if err != nil { 161 - return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) 162 - } 163 - 164 - // Auto-derive service name from base URL or use env var 165 - serviceName := getServiceName(baseURL) 166 - 167 - // Auto-derive realm from base URL 168 - realm := baseURL + "/auth/token" 169 - 170 - return configuration.Auth{ 171 - "token": configuration.Parameters{ 172 - "realm": realm, 173 - "service": serviceName, 174 - "issuer": serviceName, 175 - "rootcertbundle": certPath, 176 - "privatekey": privateKeyPath, 177 - "expiration": expiration, 178 - }, 179 - }, nil 180 - } 181 - 182 289 // buildHealthConfig creates health check configuration 183 290 func buildHealthConfig() configuration.Health { 184 291 return configuration.Health{ ··· 190 297 } 191 298 } 192 299 193 - // GetBaseURL determines the base URL for the service 194 - // Priority: ATCR_BASE_URL env var, then derived from HTTP addr 195 - func GetBaseURL(httpAddr string) string { 196 - baseURL := os.Getenv("ATCR_BASE_URL") 197 - if baseURL != "" { 198 - return baseURL 199 - } 200 - 201 - // Auto-detect from HTTP addr 202 - if httpAddr[0] == ':' { 203 - // Just a port, assume localhost 204 - return fmt.Sprintf("http://127.0.0.1%s", httpAddr) 205 - } 206 - 207 - // Full address provided 208 - return fmt.Sprintf("http://%s", httpAddr) 209 - } 210 - 211 300 // getServiceName extracts service name from base URL or uses env var 212 301 func getServiceName(baseURL string) string { 213 302 // Check env var first ··· 232 321 return "atcr.io" 233 322 } 234 323 235 - // GetEnvOrDefault gets an environment variable or returns a default value 236 - func GetEnvOrDefault(key, defaultValue string) string { 324 + // getEnvOrDefault gets an environment variable or returns a default value 325 + func getEnvOrDefault(key, defaultValue string) string { 237 326 if val := os.Getenv(key); val != "" { 238 327 return val 239 328 } 240 329 return defaultValue 241 330 } 242 331 243 - // GetLogLevel returns the configured log level from environment 244 - // Centralizes ATCR_LOG_LEVEL env var reading 245 - func GetLogLevel() string { 246 - return GetEnvOrDefault("ATCR_LOG_LEVEL", "info") 247 - } 248 - 249 - // GetStringParam extracts a string parameter from configuration.Parameters 250 - func GetStringParam(params configuration.Parameters, key, defaultValue string) string { 251 - if v, ok := params[key]; ok { 252 - if s, ok := v.(string); ok { 253 - return s 254 - } 255 - } 256 - return defaultValue 257 - } 258 - 259 - // GetIntParam extracts an int parameter from configuration.Parameters 260 - func GetIntParam(params configuration.Parameters, key string, defaultValue int) int { 261 - if v, ok := params[key]; ok { 262 - if i, ok := v.(int); ok { 263 - return i 264 - } 265 - } 266 - return defaultValue 267 - } 268 - 269 - // ExtractDefaultHoldDID extracts the default hold DID from middleware config 270 - // Returns a DID (e.g., "did:web:hold01.atcr.io") 271 - // To find a hold's DID, visit: https://hold-url/.well-known/did.json 272 - func ExtractDefaultHoldDID(config *configuration.Configuration) string { 273 - // Navigate through: middleware.registry[].options.default_hold_did 274 - registryMiddleware, ok := config.Middleware["registry"] 275 - if !ok { 276 - return "" 277 - } 278 - 279 - // Find atproto-resolver middleware 280 - for _, mw := range registryMiddleware { 281 - // Check if this is the atproto-resolver 282 - if mw.Name != "atproto-resolver" { 283 - continue 284 - } 285 - 286 - // Extract options - options is configuration.Parameters which is map[string]any 287 - if mw.Options != nil { 288 - if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 289 - return holdDID 290 - } 291 - } 292 - } 293 - 294 - return "" 295 - } 296 - 297 - // ExtractTestMode extracts the test_mode flag from middleware config 298 - // Returns true if TEST_MODE=true, false otherwise 299 - func ExtractTestMode(config *configuration.Configuration) bool { 300 - // Navigate through: middleware.registry[].options.test_mode 301 - registryMiddleware, ok := config.Middleware["registry"] 302 - if !ok { 303 - return false 304 - } 305 - 306 - // Find atproto-resolver middleware 307 - for _, mw := range registryMiddleware { 308 - // Check if this is the atproto-resolver 309 - if mw.Name != "atproto-resolver" { 310 - continue 311 - } 312 - 313 - // Extract options - options is configuration.Parameters which is map[string]any 314 - if mw.Options != nil { 315 - if testMode, ok := mw.Options["test_mode"].(bool); ok { 316 - return testMode 317 - } 318 - } 319 - } 320 - 321 - return false 322 - } 323 - 324 - // GetDurationOrDefault parses a duration from environment variable or returns default 332 + // getDurationOrDefault parses a duration from environment variable or returns default 325 333 // Logs a warning if parsing fails 326 - func GetDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration { 334 + func getDurationOrDefault(envKey string, defaultValue time.Duration) time.Duration { 327 335 envVal := os.Getenv(envKey) 328 336 if envVal == "" { 329 337 return defaultValue ··· 337 345 338 346 return parsed 339 347 } 340 - 341 - // GetBoolOrDefault returns a boolean from environment variable or returns default 342 - // Treats "false" as false, everything else (including empty) as the default value 343 - func GetBoolOrDefault(envKey string, defaultValue bool) bool { 344 - envVal := os.Getenv(envKey) 345 - if envVal == "" { 346 - return defaultValue 347 - } 348 - 349 - // Explicit false check 350 - if envVal == "false" { 351 - return false 352 - } 353 - 354 - // Explicit true check 355 - if envVal == "true" { 356 - return true 357 - } 358 - 359 - // For any other value, return default 360 - return defaultValue 361 - } 362 - 363 - // UI Configuration 364 - 365 - // GetUIEnabled returns whether the UI is enabled (default: true) 366 - func GetUIEnabled() bool { 367 - // UI is enabled unless explicitly set to "false" 368 - return os.Getenv("ATCR_UI_ENABLED") != "false" 369 - } 370 - 371 - // GetUIDatabasePath returns the path to the UI database (default: /var/lib/atcr/ui.db) 372 - func GetUIDatabasePath() string { 373 - return GetEnvOrDefault("ATCR_UI_DATABASE_PATH", "/var/lib/atcr/ui.db") 374 - } 375 - 376 - // Health & Cache Configuration 377 - 378 - // GetHealthCacheTTL returns the hold health check cache TTL (default: 15m) 379 - func GetHealthCacheTTL() time.Duration { 380 - return GetDurationOrDefault("ATCR_HEALTH_CACHE_TTL", 15*time.Minute) 381 - } 382 - 383 - // GetReadmeCacheTTL returns the README cache TTL (default: 1h) 384 - func GetReadmeCacheTTL() time.Duration { 385 - return GetDurationOrDefault("ATCR_README_CACHE_TTL", 1*time.Hour) 386 - } 387 - 388 - // GetHealthCheckInterval returns the hold health check refresh interval (default: 15m) 389 - func GetHealthCheckInterval() time.Duration { 390 - return GetDurationOrDefault("ATCR_HEALTH_CHECK_INTERVAL", 15*time.Minute) 391 - } 392 - 393 - // Jetstream Configuration 394 - 395 - // GetJetstreamURL returns the Jetstream WebSocket URL (default: wss://jetstream2.us-west.bsky.network/subscribe) 396 - func GetJetstreamURL() string { 397 - return GetEnvOrDefault("JETSTREAM_URL", "wss://jetstream2.us-west.bsky.network/subscribe") 398 - } 399 - 400 - // GetBackfillEnabled returns whether backfill is enabled (default: true) 401 - func GetBackfillEnabled() bool { 402 - // Backfill is enabled unless explicitly set to "false" 403 - return os.Getenv("ATCR_BACKFILL_ENABLED") != "false" 404 - } 405 - 406 - // GetRelayEndpoint returns the relay endpoint for sync API (default: https://relay1.us-east.bsky.network) 407 - func GetRelayEndpoint() string { 408 - return GetEnvOrDefault("ATCR_RELAY_ENDPOINT", "https://relay1.us-east.bsky.network") 409 - } 410 - 411 - // GetBackfillInterval returns the backfill interval (default: 1h) 412 - func GetBackfillInterval() time.Duration { 413 - return GetDurationOrDefault("ATCR_BACKFILL_INTERVAL", 1*time.Hour) 414 - } 415 - 416 - // Test Mode Configuration 417 - 418 - // GetTestMode returns whether test mode is enabled (default: false) 419 - // Test mode enables HTTP for local DID resolution and transition:generic scope 420 - func GetTestMode() bool { 421 - return os.Getenv("TEST_MODE") == "true" 422 - }
+24 -1331
pkg/appview/config_test.go
··· 4 4 "os" 5 5 "testing" 6 6 "time" 7 - 8 - "github.com/distribution/distribution/v3/configuration" 9 7 ) 10 8 11 - func TestGetEnvOrDefault(t *testing.T) { 12 - tests := []struct { 13 - name string 14 - key string 15 - defaultValue string 16 - envValue string 17 - setEnv bool 18 - want string 19 - }{ 20 - { 21 - name: "env var not set", 22 - key: "TEST_VAR_NOT_SET", 23 - defaultValue: "default", 24 - setEnv: false, 25 - want: "default", 26 - }, 27 - { 28 - name: "env var set to value", 29 - key: "TEST_VAR_SET", 30 - defaultValue: "default", 31 - envValue: "custom", 32 - setEnv: true, 33 - want: "custom", 34 - }, 35 - { 36 - name: "env var set to empty string", 37 - key: "TEST_VAR_EMPTY", 38 - defaultValue: "default", 39 - envValue: "", 40 - setEnv: true, 41 - want: "default", 42 - }, 43 - } 44 - 45 - for _, tt := range tests { 46 - t.Run(tt.name, func(t *testing.T) { 47 - if tt.setEnv { 48 - t.Setenv(tt.key, tt.envValue) 49 - } 50 - 51 - got := GetEnvOrDefault(tt.key, tt.defaultValue) 52 - if got != tt.want { 53 - t.Errorf("GetEnvOrDefault() = %v, want %v", got, tt.want) 54 - } 55 - }) 56 - } 57 - } 58 - 59 - func TestGetBaseURL(t *testing.T) { 60 - tests := []struct { 61 - name string 62 - httpAddr string 63 - envBaseURL string 64 - setEnv bool 65 - want string 66 - }{ 67 - { 68 - name: "env var set", 69 - httpAddr: ":5000", 70 - envBaseURL: "https://registry.example.com", 71 - setEnv: true, 72 - want: "https://registry.example.com", 73 - }, 74 - { 75 - name: "port only - auto detect localhost", 76 - httpAddr: ":5000", 77 - setEnv: false, 78 - want: "http://127.0.0.1:5000", 79 - }, 80 - { 81 - name: "full address", 82 - httpAddr: "0.0.0.0:5000", 83 - setEnv: false, 84 - want: "http://0.0.0.0:5000", 85 - }, 86 - { 87 - name: "custom port", 88 - httpAddr: ":8080", 89 - setEnv: false, 90 - want: "http://127.0.0.1:8080", 91 - }, 92 - } 93 - 94 - for _, tt := range tests { 95 - t.Run(tt.name, func(t *testing.T) { 96 - if tt.setEnv { 97 - t.Setenv("ATCR_BASE_URL", tt.envBaseURL) 98 - } else { 99 - os.Unsetenv("ATCR_BASE_URL") 100 - } 101 - 102 - got := GetBaseURL(tt.httpAddr) 103 - if got != tt.want { 104 - t.Errorf("GetBaseURL() = %v, want %v", got, tt.want) 105 - } 106 - }) 107 - } 108 - } 109 - 110 9 func Test_getServiceName(t *testing.T) { 111 10 tests := []struct { 112 11 name string ··· 170 69 } 171 70 } 172 71 173 - func TestBuildLogConfig(t *testing.T) { 174 - tests := []struct { 175 - name string 176 - envLevel string 177 - envFormatter string 178 - setLevel bool 179 - setFormatter bool 180 - wantLevel configuration.Loglevel 181 - wantFormatter string 182 - }{ 183 - { 184 - name: "defaults", 185 - setLevel: false, 186 - setFormatter: false, 187 - wantLevel: "info", 188 - wantFormatter: "text", 189 - }, 190 - { 191 - name: "custom level", 192 - envLevel: "debug", 193 - setLevel: true, 194 - setFormatter: false, 195 - wantLevel: "debug", 196 - wantFormatter: "text", 197 - }, 198 - { 199 - name: "custom formatter", 200 - envLevel: "info", 201 - envFormatter: "json", 202 - setLevel: true, 203 - setFormatter: true, 204 - wantLevel: "info", 205 - wantFormatter: "json", 206 - }, 207 - } 72 + // TestBuildLogConfig removed - buildLogConfig is now an internal function 208 73 209 - for _, tt := range tests { 210 - t.Run(tt.name, func(t *testing.T) { 211 - if tt.setLevel { 212 - t.Setenv("ATCR_LOG_LEVEL", tt.envLevel) 213 - } else { 214 - os.Unsetenv("ATCR_LOG_LEVEL") 215 - } 216 - 217 - if tt.setFormatter { 218 - t.Setenv("ATCR_LOG_FORMATTER", tt.envFormatter) 219 - } else { 220 - os.Unsetenv("ATCR_LOG_FORMATTER") 221 - } 222 - 223 - got := buildLogConfig() 224 - if got.Level != tt.wantLevel { 225 - t.Errorf("buildLogConfig().Level = %v, want %v", got.Level, tt.wantLevel) 226 - } 227 - if got.Formatter != tt.wantFormatter { 228 - t.Errorf("buildLogConfig().Formatter = %v, want %v", got.Formatter, tt.wantFormatter) 229 - } 230 - if got.Fields["service"] != "atcr-appview" { 231 - t.Errorf("buildLogConfig().Fields[service] = %v, want atcr-appview", got.Fields["service"]) 232 - } 233 - }) 234 - } 235 - } 236 - 237 - func TestBuildHTTPConfig(t *testing.T) { 238 - tests := []struct { 239 - name string 240 - envAddr string 241 - envDebugAddr string 242 - envSecret string 243 - setAddr bool 244 - setDebugAddr bool 245 - setSecret bool 246 - wantAddr string 247 - wantDebug string 248 - wantSecret string // empty means "should be generated" 249 - }{ 250 - { 251 - name: "defaults", 252 - setAddr: false, 253 - wantAddr: ":5000", 254 - wantDebug: ":5001", 255 - wantSecret: "", // generated 256 - }, 257 - { 258 - name: "custom addr", 259 - envAddr: ":8080", 260 - setAddr: true, 261 - setDebugAddr: false, 262 - wantAddr: ":8080", 263 - wantDebug: ":5001", 264 - wantSecret: "", 265 - }, 266 - { 267 - name: "custom debug addr", 268 - envDebugAddr: ":9001", 269 - setAddr: false, 270 - setDebugAddr: true, 271 - wantAddr: ":5000", 272 - wantDebug: ":9001", 273 - wantSecret: "", 274 - }, 275 - { 276 - name: "custom secret", 277 - envSecret: "my-custom-secret", 278 - setAddr: false, 279 - setSecret: true, 280 - wantAddr: ":5000", 281 - wantDebug: ":5001", 282 - wantSecret: "my-custom-secret", 283 - }, 284 - } 285 - 286 - for _, tt := range tests { 287 - t.Run(tt.name, func(t *testing.T) { 288 - if tt.setAddr { 289 - t.Setenv("ATCR_HTTP_ADDR", tt.envAddr) 290 - } else { 291 - os.Unsetenv("ATCR_HTTP_ADDR") 292 - } 293 - 294 - if tt.setDebugAddr { 295 - t.Setenv("ATCR_DEBUG_ADDR", tt.envDebugAddr) 296 - } else { 297 - os.Unsetenv("ATCR_DEBUG_ADDR") 298 - } 299 - 300 - if tt.setSecret { 301 - t.Setenv("REGISTRY_HTTP_SECRET", tt.envSecret) 302 - } else { 303 - os.Unsetenv("REGISTRY_HTTP_SECRET") 304 - } 305 - 306 - got, err := buildHTTPConfig() 307 - if err != nil { 308 - t.Fatalf("buildHTTPConfig() error = %v", err) 309 - } 310 - 311 - if got.Addr != tt.wantAddr { 312 - t.Errorf("buildHTTPConfig().Addr = %v, want %v", got.Addr, tt.wantAddr) 313 - } 314 - 315 - if got.Debug.Addr != tt.wantDebug { 316 - t.Errorf("buildHTTPConfig().Debug.Addr = %v, want %v", got.Debug.Addr, tt.wantDebug) 317 - } 318 - 319 - if tt.wantSecret == "" { 320 - // Should be generated (64 hex chars = 32 bytes) 321 - if len(got.Secret) != 64 { 322 - t.Errorf("buildHTTPConfig().Secret length = %v, want 64", len(got.Secret)) 323 - } 324 - } else { 325 - if got.Secret != tt.wantSecret { 326 - t.Errorf("buildHTTPConfig().Secret = %v, want %v", got.Secret, tt.wantSecret) 327 - } 328 - } 329 - 330 - // Verify headers 331 - if got.Headers["X-Content-Type-Options"][0] != "nosniff" { 332 - t.Error("buildHTTPConfig() missing X-Content-Type-Options header") 333 - } 334 - }) 335 - } 336 - } 74 + // TestBuildHTTPConfig removed - buildHTTPConfig is now an internal function 337 75 338 76 func TestBuildStorageConfig(t *testing.T) { 339 77 got := buildStorageConfig() ··· 430 168 } 431 169 } 432 170 433 - func TestBuildAuthConfig(t *testing.T) { 434 - tests := []struct { 435 - name string 436 - baseURL string 437 - envKeyPath string 438 - envCertPath string 439 - envExpiration string 440 - setKeyPath bool 441 - setCertPath bool 442 - setExpiration bool 443 - wantKeyPath string 444 - wantCertPath string 445 - wantExpiration int 446 - wantRealm string 447 - wantService string 448 - wantError bool 449 - }{ 450 - { 451 - name: "defaults", 452 - baseURL: "http://127.0.0.1:5000", 453 - setKeyPath: false, 454 - setCertPath: false, 455 - setExpiration: false, 456 - wantKeyPath: "/var/lib/atcr/auth/private-key.pem", 457 - wantCertPath: "/var/lib/atcr/auth/private-key.crt", 458 - wantExpiration: 300, 459 - wantRealm: "http://127.0.0.1:5000/auth/token", 460 - wantService: "atcr.io", 461 - wantError: false, 462 - }, 463 - { 464 - name: "custom values", 465 - baseURL: "https://registry.example.com", 466 - envKeyPath: "/custom/key.pem", 467 - envCertPath: "/custom/cert.crt", 468 - envExpiration: "600", 469 - setKeyPath: true, 470 - setCertPath: true, 471 - setExpiration: true, 472 - wantKeyPath: "/custom/key.pem", 473 - wantCertPath: "/custom/cert.crt", 474 - wantExpiration: 600, 475 - wantRealm: "https://registry.example.com/auth/token", 476 - wantService: "registry.example.com", 477 - wantError: false, 478 - }, 479 - { 480 - name: "invalid expiration", 481 - baseURL: "http://127.0.0.1:5000", 482 - envExpiration: "not-a-number", 483 - setExpiration: true, 484 - wantError: true, 485 - }, 486 - } 487 - 488 - for _, tt := range tests { 489 - t.Run(tt.name, func(t *testing.T) { 490 - if tt.setKeyPath { 491 - t.Setenv("ATCR_AUTH_KEY_PATH", tt.envKeyPath) 492 - } else { 493 - os.Unsetenv("ATCR_AUTH_KEY_PATH") 494 - } 495 - 496 - if tt.setCertPath { 497 - t.Setenv("ATCR_AUTH_CERT_PATH", tt.envCertPath) 498 - } else { 499 - os.Unsetenv("ATCR_AUTH_CERT_PATH") 500 - } 501 - 502 - if tt.setExpiration { 503 - t.Setenv("ATCR_TOKEN_EXPIRATION", tt.envExpiration) 504 - } else { 505 - os.Unsetenv("ATCR_TOKEN_EXPIRATION") 506 - } 507 - 508 - // Clear service name env var 509 - os.Unsetenv("ATCR_SERVICE_NAME") 510 - 511 - got, err := buildAuthConfig(tt.baseURL) 512 - if (err != nil) != tt.wantError { 513 - t.Errorf("buildAuthConfig() error = %v, wantError %v", err, tt.wantError) 514 - return 515 - } 516 - 517 - if tt.wantError { 518 - return 519 - } 520 - 521 - tokenParams, ok := got["token"] 522 - if !ok { 523 - t.Fatal("buildAuthConfig() missing token params") 524 - } 525 - 526 - if tokenParams["privatekey"] != tt.wantKeyPath { 527 - t.Errorf("privatekey = %v, want %v", tokenParams["privatekey"], tt.wantKeyPath) 528 - } 529 - 530 - if tokenParams["rootcertbundle"] != tt.wantCertPath { 531 - t.Errorf("rootcertbundle = %v, want %v", tokenParams["rootcertbundle"], tt.wantCertPath) 532 - } 533 - 534 - if tokenParams["expiration"] != tt.wantExpiration { 535 - t.Errorf("expiration = %v, want %v", tokenParams["expiration"], tt.wantExpiration) 536 - } 537 - 538 - if tokenParams["realm"] != tt.wantRealm { 539 - t.Errorf("realm = %v, want %v", tokenParams["realm"], tt.wantRealm) 540 - } 541 - 542 - if tokenParams["service"] != tt.wantService { 543 - t.Errorf("service = %v, want %v", tokenParams["service"], tt.wantService) 544 - } 545 - 546 - if tokenParams["issuer"] != tt.wantService { 547 - t.Errorf("issuer = %v, want %v", tokenParams["issuer"], tt.wantService) 548 - } 549 - }) 550 - } 551 - } 552 - 553 171 func TestBuildHealthConfig(t *testing.T) { 554 172 got := buildHealthConfig() 555 173 ··· 566 184 } 567 185 } 568 186 569 - func TestGetStringParam(t *testing.T) { 570 - tests := []struct { 571 - name string 572 - params configuration.Parameters 573 - key string 574 - defaultValue string 575 - want string 576 - }{ 577 - { 578 - name: "string value exists", 579 - params: configuration.Parameters{ 580 - "foo": "bar", 581 - }, 582 - key: "foo", 583 - defaultValue: "default", 584 - want: "bar", 585 - }, 586 - { 587 - name: "key does not exist", 588 - params: configuration.Parameters{}, 589 - key: "foo", 590 - defaultValue: "default", 591 - want: "default", 592 - }, 593 - { 594 - name: "value is not a string", 595 - params: configuration.Parameters{ 596 - "foo": 123, 597 - }, 598 - key: "foo", 599 - defaultValue: "default", 600 - want: "default", 601 - }, 602 - { 603 - name: "empty string value", 604 - params: configuration.Parameters{ 605 - "foo": "", 606 - }, 607 - key: "foo", 608 - defaultValue: "default", 609 - want: "", 610 - }, 611 - } 612 - 613 - for _, tt := range tests { 614 - t.Run(tt.name, func(t *testing.T) { 615 - got := GetStringParam(tt.params, tt.key, tt.defaultValue) 616 - if got != tt.want { 617 - t.Errorf("GetStringParam() = %v, want %v", got, tt.want) 618 - } 619 - }) 620 - } 621 - } 622 - 623 - func TestGetIntParam(t *testing.T) { 624 - tests := []struct { 625 - name string 626 - params configuration.Parameters 627 - key string 628 - defaultValue int 629 - want int 630 - }{ 631 - { 632 - name: "int value exists", 633 - params: configuration.Parameters{ 634 - "foo": 42, 635 - }, 636 - key: "foo", 637 - defaultValue: 100, 638 - want: 42, 639 - }, 640 - { 641 - name: "key does not exist", 642 - params: configuration.Parameters{}, 643 - key: "foo", 644 - defaultValue: 100, 645 - want: 100, 646 - }, 647 - { 648 - name: "value is not an int", 649 - params: configuration.Parameters{ 650 - "foo": "not-an-int", 651 - }, 652 - key: "foo", 653 - defaultValue: 100, 654 - want: 100, 655 - }, 656 - { 657 - name: "zero value", 658 - params: configuration.Parameters{ 659 - "foo": 0, 660 - }, 661 - key: "foo", 662 - defaultValue: 100, 663 - want: 0, 664 - }, 665 - } 666 - 667 - for _, tt := range tests { 668 - t.Run(tt.name, func(t *testing.T) { 669 - got := GetIntParam(tt.params, tt.key, tt.defaultValue) 670 - if got != tt.want { 671 - t.Errorf("GetIntParam() = %v, want %v", got, tt.want) 672 - } 673 - }) 674 - } 675 - } 676 - 677 - func TestExtractDefaultHoldDID(t *testing.T) { 678 - tests := []struct { 679 - name string 680 - config *configuration.Configuration 681 - want string 682 - }{ 683 - { 684 - name: "valid config with hold DID", 685 - config: &configuration.Configuration{ 686 - Middleware: map[string][]configuration.Middleware{ 687 - "registry": { 688 - { 689 - Name: "atproto-resolver", 690 - Options: configuration.Parameters{ 691 - "default_hold_did": "did:web:hold01.atcr.io", 692 - }, 693 - }, 694 - }, 695 - }, 696 - }, 697 - want: "did:web:hold01.atcr.io", 698 - }, 699 - { 700 - name: "no registry middleware", 701 - config: &configuration.Configuration{ 702 - Middleware: map[string][]configuration.Middleware{}, 703 - }, 704 - want: "", 705 - }, 706 - { 707 - name: "no atproto-resolver middleware", 708 - config: &configuration.Configuration{ 709 - Middleware: map[string][]configuration.Middleware{ 710 - "registry": { 711 - { 712 - Name: "other-middleware", 713 - Options: configuration.Parameters{ 714 - "foo": "bar", 715 - }, 716 - }, 717 - }, 718 - }, 719 - }, 720 - want: "", 721 - }, 722 - { 723 - name: "atproto-resolver without default_hold_did", 724 - config: &configuration.Configuration{ 725 - Middleware: map[string][]configuration.Middleware{ 726 - "registry": { 727 - { 728 - Name: "atproto-resolver", 729 - Options: configuration.Parameters{ 730 - "other_option": "value", 731 - }, 732 - }, 733 - }, 734 - }, 735 - }, 736 - want: "", 737 - }, 738 - { 739 - name: "default_hold_did is not a string", 740 - config: &configuration.Configuration{ 741 - Middleware: map[string][]configuration.Middleware{ 742 - "registry": { 743 - { 744 - Name: "atproto-resolver", 745 - Options: configuration.Parameters{ 746 - "default_hold_did": 123, 747 - }, 748 - }, 749 - }, 750 - }, 751 - }, 752 - want: "", 753 - }, 754 - { 755 - name: "nil options", 756 - config: &configuration.Configuration{ 757 - Middleware: map[string][]configuration.Middleware{ 758 - "registry": { 759 - { 760 - Name: "atproto-resolver", 761 - Options: nil, 762 - }, 763 - }, 764 - }, 765 - }, 766 - want: "", 767 - }, 768 - } 769 - 770 - for _, tt := range tests { 771 - t.Run(tt.name, func(t *testing.T) { 772 - got := ExtractDefaultHoldDID(tt.config) 773 - if got != tt.want { 774 - t.Errorf("ExtractDefaultHoldDID() = %v, want %v", got, tt.want) 775 - } 776 - }) 777 - } 778 - } 779 - 780 - func TestExtractTestMode(t *testing.T) { 781 - tests := []struct { 782 - name string 783 - config *configuration.Configuration 784 - want bool 785 - }{ 786 - { 787 - name: "test mode enabled", 788 - config: &configuration.Configuration{ 789 - Middleware: map[string][]configuration.Middleware{ 790 - "registry": { 791 - { 792 - Name: "atproto-resolver", 793 - Options: configuration.Parameters{ 794 - "test_mode": true, 795 - }, 796 - }, 797 - }, 798 - }, 799 - }, 800 - want: true, 801 - }, 802 - { 803 - name: "test mode disabled", 804 - config: &configuration.Configuration{ 805 - Middleware: map[string][]configuration.Middleware{ 806 - "registry": { 807 - { 808 - Name: "atproto-resolver", 809 - Options: configuration.Parameters{ 810 - "test_mode": false, 811 - }, 812 - }, 813 - }, 814 - }, 815 - }, 816 - want: false, 817 - }, 818 - { 819 - name: "no registry middleware", 820 - config: &configuration.Configuration{ 821 - Middleware: map[string][]configuration.Middleware{}, 822 - }, 823 - want: false, 824 - }, 825 - { 826 - name: "no atproto-resolver middleware", 827 - config: &configuration.Configuration{ 828 - Middleware: map[string][]configuration.Middleware{ 829 - "registry": { 830 - { 831 - Name: "other-middleware", 832 - Options: configuration.Parameters{ 833 - "foo": "bar", 834 - }, 835 - }, 836 - }, 837 - }, 838 - }, 839 - want: false, 840 - }, 841 - { 842 - name: "atproto-resolver without test_mode", 843 - config: &configuration.Configuration{ 844 - Middleware: map[string][]configuration.Middleware{ 845 - "registry": { 846 - { 847 - Name: "atproto-resolver", 848 - Options: configuration.Parameters{ 849 - "other_option": "value", 850 - }, 851 - }, 852 - }, 853 - }, 854 - }, 855 - want: false, 856 - }, 857 - { 858 - name: "test_mode is not a bool", 859 - config: &configuration.Configuration{ 860 - Middleware: map[string][]configuration.Middleware{ 861 - "registry": { 862 - { 863 - Name: "atproto-resolver", 864 - Options: configuration.Parameters{ 865 - "test_mode": "true", 866 - }, 867 - }, 868 - }, 869 - }, 870 - }, 871 - want: false, 872 - }, 873 - { 874 - name: "nil options", 875 - config: &configuration.Configuration{ 876 - Middleware: map[string][]configuration.Middleware{ 877 - "registry": { 878 - { 879 - Name: "atproto-resolver", 880 - Options: nil, 881 - }, 882 - }, 883 - }, 884 - }, 885 - want: false, 886 - }, 887 - } 888 - 889 - for _, tt := range tests { 890 - t.Run(tt.name, func(t *testing.T) { 891 - got := ExtractTestMode(tt.config) 892 - if got != tt.want { 893 - t.Errorf("ExtractTestMode() = %v, want %v", got, tt.want) 894 - } 895 - }) 896 - } 897 - } 898 - 899 187 func TestLoadConfigFromEnv(t *testing.T) { 900 188 tests := []struct { 901 189 name string ··· 939 227 } 940 228 941 229 // Verify config structure 942 - if got.Version.Major() != 0 || got.Version.Minor() != 1 { 230 + if got.Version != "0.1" { 943 231 t.Errorf("version = %v, want 0.1", got.Version) 944 232 } 945 233 946 - if got.Log.Level != "info" { 947 - t.Errorf("log level = %v, want info", got.Log.Level) 234 + if got.LogLevel != "info" { 235 + t.Errorf("log level = %v, want info", got.LogLevel) 948 236 } 949 237 950 - if got.HTTP.Addr != ":5000" { 951 - t.Errorf("HTTP addr = %v, want :5000", got.HTTP.Addr) 238 + if got.Server.Addr != ":5000" { 239 + t.Errorf("HTTP addr = %v, want :5000", got.Server.Addr) 952 240 } 953 241 954 - if _, ok := got.Storage["inmemory"]; !ok { 955 - t.Error("storage missing inmemory driver") 242 + if got.Server.DefaultHoldDID != tt.envHoldDID { 243 + t.Errorf("default hold DID = %v, want %v", got.Server.DefaultHoldDID, tt.envHoldDID) 956 244 } 957 245 958 - if _, ok := got.Middleware["registry"]; !ok { 959 - t.Error("middleware missing registry") 246 + if got.UI.DatabasePath != "/var/lib/atcr/ui.db" { 247 + t.Errorf("UI database path = %v, want /var/lib/atcr/ui.db", got.UI.DatabasePath) 960 248 } 961 249 962 - if _, ok := got.Auth["token"]; !ok { 963 - t.Error("auth missing token config") 250 + if got.Health.CacheTTL != 15*time.Minute { 251 + t.Errorf("health cache TTL = %v, want 15m", got.Health.CacheTTL) 964 252 } 965 253 966 - if !got.Health.StorageDriver.Enabled { 967 - t.Error("health storage driver not enabled") 254 + if got.Jetstream.URL != "wss://jetstream2.us-west.bsky.network/subscribe" { 255 + t.Errorf("jetstream URL = %v, want default", got.Jetstream.URL) 968 256 } 969 - }) 970 - } 971 - } 972 257 973 - func TestGetDurationOrDefault(t *testing.T) { 974 - tests := []struct { 975 - name string 976 - envKey string 977 - envValue string 978 - setEnv bool 979 - defaultValue string 980 - want string 981 - }{ 982 - { 983 - name: "env var not set", 984 - envKey: "TEST_DURATION", 985 - setEnv: false, 986 - defaultValue: "5m", 987 - want: "5m", 988 - }, 989 - { 990 - name: "env var set to valid duration", 991 - envKey: "TEST_DURATION", 992 - envValue: "10m", 993 - setEnv: true, 994 - defaultValue: "5m", 995 - want: "10m", 996 - }, 997 - { 998 - name: "env var set to invalid duration", 999 - envKey: "TEST_DURATION", 1000 - envValue: "invalid", 1001 - setEnv: true, 1002 - defaultValue: "5m", 1003 - want: "5m", // Falls back to default 1004 - }, 1005 - { 1006 - name: "env var set to empty string", 1007 - envKey: "TEST_DURATION", 1008 - envValue: "", 1009 - setEnv: true, 1010 - defaultValue: "15m", 1011 - want: "15m", 1012 - }, 1013 - } 1014 - 1015 - for _, tt := range tests { 1016 - t.Run(tt.name, func(t *testing.T) { 1017 - if tt.setEnv { 1018 - t.Setenv(tt.envKey, tt.envValue) 1019 - } else { 1020 - os.Unsetenv(tt.envKey) 258 + // Verify distribution config was built 259 + if got.Distribution == nil { 260 + t.Error("distribution config is nil") 1021 261 } 1022 262 1023 - defaultDur := parseDuration(t, tt.defaultValue) 1024 - wantDur := parseDuration(t, tt.want) 1025 - 1026 - got := GetDurationOrDefault(tt.envKey, defaultDur) 1027 - if got != wantDur { 1028 - t.Errorf("GetDurationOrDefault() = %v, want %v", got, wantDur) 263 + if _, ok := got.Distribution.Storage["inmemory"]; !ok { 264 + t.Error("distribution storage missing inmemory driver") 1029 265 } 1030 - }) 1031 - } 1032 - } 1033 266 1034 - func TestGetBoolOrDefault(t *testing.T) { 1035 - tests := []struct { 1036 - name string 1037 - envKey string 1038 - envValue string 1039 - setEnv bool 1040 - defaultValue bool 1041 - want bool 1042 - }{ 1043 - { 1044 - name: "env var not set - default true", 1045 - envKey: "TEST_BOOL", 1046 - setEnv: false, 1047 - defaultValue: true, 1048 - want: true, 1049 - }, 1050 - { 1051 - name: "env var not set - default false", 1052 - envKey: "TEST_BOOL", 1053 - setEnv: false, 1054 - defaultValue: false, 1055 - want: false, 1056 - }, 1057 - { 1058 - name: "env var set to true", 1059 - envKey: "TEST_BOOL", 1060 - envValue: "true", 1061 - setEnv: true, 1062 - defaultValue: false, 1063 - want: true, 1064 - }, 1065 - { 1066 - name: "env var set to false", 1067 - envKey: "TEST_BOOL", 1068 - envValue: "false", 1069 - setEnv: true, 1070 - defaultValue: true, 1071 - want: false, 1072 - }, 1073 - { 1074 - name: "env var set to invalid value - use default true", 1075 - envKey: "TEST_BOOL", 1076 - envValue: "invalid", 1077 - setEnv: true, 1078 - defaultValue: true, 1079 - want: true, 1080 - }, 1081 - { 1082 - name: "env var set to invalid value - use default false", 1083 - envKey: "TEST_BOOL", 1084 - envValue: "invalid", 1085 - setEnv: true, 1086 - defaultValue: false, 1087 - want: false, 1088 - }, 1089 - { 1090 - name: "env var set to empty string - use default", 1091 - envKey: "TEST_BOOL", 1092 - envValue: "", 1093 - setEnv: true, 1094 - defaultValue: true, 1095 - want: true, 1096 - }, 1097 - } 1098 - 1099 - for _, tt := range tests { 1100 - t.Run(tt.name, func(t *testing.T) { 1101 - if tt.setEnv { 1102 - t.Setenv(tt.envKey, tt.envValue) 1103 - } else { 1104 - os.Unsetenv(tt.envKey) 267 + if _, ok := got.Distribution.Middleware["registry"]; !ok { 268 + t.Error("distribution middleware missing registry") 1105 269 } 1106 270 1107 - got := GetBoolOrDefault(tt.envKey, tt.defaultValue) 1108 - if got != tt.want { 1109 - t.Errorf("GetBoolOrDefault() = %v, want %v", got, tt.want) 271 + if _, ok := got.Distribution.Auth["token"]; !ok { 272 + t.Error("distribution auth missing token config") 1110 273 } 1111 274 }) 1112 275 } 1113 276 } 1114 - 1115 - func TestGetUIEnabled(t *testing.T) { 1116 - tests := []struct { 1117 - name string 1118 - envValue string 1119 - setEnv bool 1120 - want bool 1121 - }{ 1122 - { 1123 - name: "env var not set - enabled by default", 1124 - setEnv: false, 1125 - want: true, 1126 - }, 1127 - { 1128 - name: "env var set to false", 1129 - envValue: "false", 1130 - setEnv: true, 1131 - want: false, 1132 - }, 1133 - { 1134 - name: "env var set to true", 1135 - envValue: "true", 1136 - setEnv: true, 1137 - want: true, 1138 - }, 1139 - { 1140 - name: "env var set to empty string - enabled by default", 1141 - envValue: "", 1142 - setEnv: true, 1143 - want: true, 1144 - }, 1145 - { 1146 - name: "env var set to any other value - enabled", 1147 - envValue: "yes", 1148 - setEnv: true, 1149 - want: true, 1150 - }, 1151 - } 1152 - 1153 - for _, tt := range tests { 1154 - t.Run(tt.name, func(t *testing.T) { 1155 - if tt.setEnv { 1156 - t.Setenv("ATCR_UI_ENABLED", tt.envValue) 1157 - } else { 1158 - os.Unsetenv("ATCR_UI_ENABLED") 1159 - } 1160 - 1161 - got := GetUIEnabled() 1162 - if got != tt.want { 1163 - t.Errorf("GetUIEnabled() = %v, want %v", got, tt.want) 1164 - } 1165 - }) 1166 - } 1167 - } 1168 - 1169 - func TestGetUIDatabasePath(t *testing.T) { 1170 - tests := []struct { 1171 - name string 1172 - envValue string 1173 - setEnv bool 1174 - want string 1175 - }{ 1176 - { 1177 - name: "env var not set - use default", 1178 - setEnv: false, 1179 - want: "/var/lib/atcr/ui.db", 1180 - }, 1181 - { 1182 - name: "env var set to custom path", 1183 - envValue: "/custom/path/ui.db", 1184 - setEnv: true, 1185 - want: "/custom/path/ui.db", 1186 - }, 1187 - { 1188 - name: "env var set to empty string - use default", 1189 - envValue: "", 1190 - setEnv: true, 1191 - want: "/var/lib/atcr/ui.db", 1192 - }, 1193 - } 1194 - 1195 - for _, tt := range tests { 1196 - t.Run(tt.name, func(t *testing.T) { 1197 - if tt.setEnv { 1198 - t.Setenv("ATCR_UI_DATABASE_PATH", tt.envValue) 1199 - } else { 1200 - os.Unsetenv("ATCR_UI_DATABASE_PATH") 1201 - } 1202 - 1203 - got := GetUIDatabasePath() 1204 - if got != tt.want { 1205 - t.Errorf("GetUIDatabasePath() = %v, want %v", got, tt.want) 1206 - } 1207 - }) 1208 - } 1209 - } 1210 - 1211 - func TestGetHealthCacheTTL(t *testing.T) { 1212 - tests := []struct { 1213 - name string 1214 - envValue string 1215 - setEnv bool 1216 - want string 1217 - }{ 1218 - { 1219 - name: "env var not set - use default 15m", 1220 - setEnv: false, 1221 - want: "15m", 1222 - }, 1223 - { 1224 - name: "env var set to custom duration", 1225 - envValue: "30m", 1226 - setEnv: true, 1227 - want: "30m", 1228 - }, 1229 - { 1230 - name: "env var set to invalid duration - use default", 1231 - envValue: "invalid", 1232 - setEnv: true, 1233 - want: "15m", 1234 - }, 1235 - } 1236 - 1237 - for _, tt := range tests { 1238 - t.Run(tt.name, func(t *testing.T) { 1239 - if tt.setEnv { 1240 - t.Setenv("ATCR_HEALTH_CACHE_TTL", tt.envValue) 1241 - } else { 1242 - os.Unsetenv("ATCR_HEALTH_CACHE_TTL") 1243 - } 1244 - 1245 - wantDur := parseDuration(t, tt.want) 1246 - got := GetHealthCacheTTL() 1247 - if got != wantDur { 1248 - t.Errorf("GetHealthCacheTTL() = %v, want %v", got, wantDur) 1249 - } 1250 - }) 1251 - } 1252 - } 1253 - 1254 - func TestGetReadmeCacheTTL(t *testing.T) { 1255 - tests := []struct { 1256 - name string 1257 - envValue string 1258 - setEnv bool 1259 - want string 1260 - }{ 1261 - { 1262 - name: "env var not set - use default 1h", 1263 - setEnv: false, 1264 - want: "1h", 1265 - }, 1266 - { 1267 - name: "env var set to custom duration", 1268 - envValue: "2h", 1269 - setEnv: true, 1270 - want: "2h", 1271 - }, 1272 - { 1273 - name: "env var set to invalid duration - use default", 1274 - envValue: "invalid", 1275 - setEnv: true, 1276 - want: "1h", 1277 - }, 1278 - } 1279 - 1280 - for _, tt := range tests { 1281 - t.Run(tt.name, func(t *testing.T) { 1282 - if tt.setEnv { 1283 - t.Setenv("ATCR_README_CACHE_TTL", tt.envValue) 1284 - } else { 1285 - os.Unsetenv("ATCR_README_CACHE_TTL") 1286 - } 1287 - 1288 - wantDur := parseDuration(t, tt.want) 1289 - got := GetReadmeCacheTTL() 1290 - if got != wantDur { 1291 - t.Errorf("GetReadmeCacheTTL() = %v, want %v", got, wantDur) 1292 - } 1293 - }) 1294 - } 1295 - } 1296 - 1297 - func TestGetHealthCheckInterval(t *testing.T) { 1298 - tests := []struct { 1299 - name string 1300 - envValue string 1301 - setEnv bool 1302 - want string 1303 - }{ 1304 - { 1305 - name: "env var not set - use default 15m", 1306 - setEnv: false, 1307 - want: "15m", 1308 - }, 1309 - { 1310 - name: "env var set to custom interval", 1311 - envValue: "5m", 1312 - setEnv: true, 1313 - want: "5m", 1314 - }, 1315 - { 1316 - name: "env var set to invalid duration - use default", 1317 - envValue: "invalid", 1318 - setEnv: true, 1319 - want: "15m", 1320 - }, 1321 - } 1322 - 1323 - for _, tt := range tests { 1324 - t.Run(tt.name, func(t *testing.T) { 1325 - if tt.setEnv { 1326 - t.Setenv("ATCR_HEALTH_CHECK_INTERVAL", tt.envValue) 1327 - } else { 1328 - os.Unsetenv("ATCR_HEALTH_CHECK_INTERVAL") 1329 - } 1330 - 1331 - wantDur := parseDuration(t, tt.want) 1332 - got := GetHealthCheckInterval() 1333 - if got != wantDur { 1334 - t.Errorf("GetHealthCheckInterval() = %v, want %v", got, wantDur) 1335 - } 1336 - }) 1337 - } 1338 - } 1339 - 1340 - func TestGetJetstreamURL(t *testing.T) { 1341 - tests := []struct { 1342 - name string 1343 - envValue string 1344 - setEnv bool 1345 - want string 1346 - }{ 1347 - { 1348 - name: "env var not set - use default", 1349 - setEnv: false, 1350 - want: "wss://jetstream2.us-west.bsky.network/subscribe", 1351 - }, 1352 - { 1353 - name: "env var set to custom URL", 1354 - envValue: "wss://custom-jetstream.example.com/subscribe", 1355 - setEnv: true, 1356 - want: "wss://custom-jetstream.example.com/subscribe", 1357 - }, 1358 - { 1359 - name: "env var set to empty string - use default", 1360 - envValue: "", 1361 - setEnv: true, 1362 - want: "wss://jetstream2.us-west.bsky.network/subscribe", 1363 - }, 1364 - } 1365 - 1366 - for _, tt := range tests { 1367 - t.Run(tt.name, func(t *testing.T) { 1368 - if tt.setEnv { 1369 - t.Setenv("JETSTREAM_URL", tt.envValue) 1370 - } else { 1371 - os.Unsetenv("JETSTREAM_URL") 1372 - } 1373 - 1374 - got := GetJetstreamURL() 1375 - if got != tt.want { 1376 - t.Errorf("GetJetstreamURL() = %v, want %v", got, tt.want) 1377 - } 1378 - }) 1379 - } 1380 - } 1381 - 1382 - func TestGetBackfillEnabled(t *testing.T) { 1383 - tests := []struct { 1384 - name string 1385 - envValue string 1386 - setEnv bool 1387 - want bool 1388 - }{ 1389 - { 1390 - name: "env var not set - enabled by default", 1391 - setEnv: false, 1392 - want: true, 1393 - }, 1394 - { 1395 - name: "env var set to false", 1396 - envValue: "false", 1397 - setEnv: true, 1398 - want: false, 1399 - }, 1400 - { 1401 - name: "env var set to true", 1402 - envValue: "true", 1403 - setEnv: true, 1404 - want: true, 1405 - }, 1406 - { 1407 - name: "env var set to empty string - enabled by default", 1408 - envValue: "", 1409 - setEnv: true, 1410 - want: true, 1411 - }, 1412 - { 1413 - name: "env var set to any other value - enabled", 1414 - envValue: "yes", 1415 - setEnv: true, 1416 - want: true, 1417 - }, 1418 - } 1419 - 1420 - for _, tt := range tests { 1421 - t.Run(tt.name, func(t *testing.T) { 1422 - if tt.setEnv { 1423 - t.Setenv("ATCR_BACKFILL_ENABLED", tt.envValue) 1424 - } else { 1425 - os.Unsetenv("ATCR_BACKFILL_ENABLED") 1426 - } 1427 - 1428 - got := GetBackfillEnabled() 1429 - if got != tt.want { 1430 - t.Errorf("GetBackfillEnabled() = %v, want %v", got, tt.want) 1431 - } 1432 - }) 1433 - } 1434 - } 1435 - 1436 - func TestGetRelayEndpoint(t *testing.T) { 1437 - tests := []struct { 1438 - name string 1439 - envValue string 1440 - setEnv bool 1441 - want string 1442 - }{ 1443 - { 1444 - name: "env var not set - use default", 1445 - setEnv: false, 1446 - want: "https://relay1.us-east.bsky.network", 1447 - }, 1448 - { 1449 - name: "env var set to custom endpoint", 1450 - envValue: "https://custom-relay.example.com", 1451 - setEnv: true, 1452 - want: "https://custom-relay.example.com", 1453 - }, 1454 - { 1455 - name: "env var set to empty string - use default", 1456 - envValue: "", 1457 - setEnv: true, 1458 - want: "https://relay1.us-east.bsky.network", 1459 - }, 1460 - } 1461 - 1462 - for _, tt := range tests { 1463 - t.Run(tt.name, func(t *testing.T) { 1464 - if tt.setEnv { 1465 - t.Setenv("ATCR_RELAY_ENDPOINT", tt.envValue) 1466 - } else { 1467 - os.Unsetenv("ATCR_RELAY_ENDPOINT") 1468 - } 1469 - 1470 - got := GetRelayEndpoint() 1471 - if got != tt.want { 1472 - t.Errorf("GetRelayEndpoint() = %v, want %v", got, tt.want) 1473 - } 1474 - }) 1475 - } 1476 - } 1477 - 1478 - func TestGetBackfillInterval(t *testing.T) { 1479 - tests := []struct { 1480 - name string 1481 - envValue string 1482 - setEnv bool 1483 - want string 1484 - }{ 1485 - { 1486 - name: "env var not set - use default 1h", 1487 - setEnv: false, 1488 - want: "1h", 1489 - }, 1490 - { 1491 - name: "env var set to custom interval", 1492 - envValue: "30m", 1493 - setEnv: true, 1494 - want: "30m", 1495 - }, 1496 - { 1497 - name: "env var set to invalid duration - use default", 1498 - envValue: "invalid", 1499 - setEnv: true, 1500 - want: "1h", 1501 - }, 1502 - } 1503 - 1504 - for _, tt := range tests { 1505 - t.Run(tt.name, func(t *testing.T) { 1506 - if tt.setEnv { 1507 - t.Setenv("ATCR_BACKFILL_INTERVAL", tt.envValue) 1508 - } else { 1509 - os.Unsetenv("ATCR_BACKFILL_INTERVAL") 1510 - } 1511 - 1512 - wantDur := parseDuration(t, tt.want) 1513 - got := GetBackfillInterval() 1514 - if got != wantDur { 1515 - t.Errorf("GetBackfillInterval() = %v, want %v", got, wantDur) 1516 - } 1517 - }) 1518 - } 1519 - } 1520 - 1521 - func TestGetTestMode(t *testing.T) { 1522 - tests := []struct { 1523 - name string 1524 - envValue string 1525 - setEnv bool 1526 - want bool 1527 - }{ 1528 - { 1529 - name: "env var not set - disabled by default", 1530 - setEnv: false, 1531 - want: false, 1532 - }, 1533 - { 1534 - name: "env var set to true", 1535 - envValue: "true", 1536 - setEnv: true, 1537 - want: true, 1538 - }, 1539 - { 1540 - name: "env var set to false", 1541 - envValue: "false", 1542 - setEnv: true, 1543 - want: false, 1544 - }, 1545 - { 1546 - name: "env var set to empty string - disabled", 1547 - envValue: "", 1548 - setEnv: true, 1549 - want: false, 1550 - }, 1551 - { 1552 - name: "env var set to any other value - disabled", 1553 - envValue: "yes", 1554 - setEnv: true, 1555 - want: false, 1556 - }, 1557 - } 1558 - 1559 - for _, tt := range tests { 1560 - t.Run(tt.name, func(t *testing.T) { 1561 - if tt.setEnv { 1562 - t.Setenv("TEST_MODE", tt.envValue) 1563 - } else { 1564 - os.Unsetenv("TEST_MODE") 1565 - } 1566 - 1567 - got := GetTestMode() 1568 - if got != tt.want { 1569 - t.Errorf("GetTestMode() = %v, want %v", got, tt.want) 1570 - } 1571 - }) 1572 - } 1573 - } 1574 - 1575 - // parseDuration is a helper function to parse duration strings in tests 1576 - func parseDuration(t *testing.T, s string) time.Duration { 1577 - t.Helper() 1578 - d, err := time.ParseDuration(s) 1579 - if err != nil { 1580 - t.Fatalf("parseDuration(%q) failed: %v", s, err) 1581 - } 1582 - return d 1583 - }
+2 -1
pkg/hold/oci/http_helpers.go
··· 6 6 import ( 7 7 "encoding/json" 8 8 "fmt" 9 + "log/slog" 9 10 "net/http" 10 11 ) 11 12 ··· 25 26 if err := json.NewEncoder(w).Encode(v); err != nil { 26 27 // If encoding fails, we can't do much since headers are already sent 27 28 // Log the error but don't try to send another response 28 - fmt.Printf("ERROR: failed to encode JSON response: %v\n", err) 29 + slog.Error("Failed to encode JSON response", "error", err) 29 30 } 30 31 } 31 32
+59 -21
pkg/hold/oci/multipart.go
··· 5 5 "crypto/sha256" 6 6 "encoding/hex" 7 7 "fmt" 8 - "log" 8 + "log/slog" 9 9 "sort" 10 10 "strings" 11 11 "sync" ··· 97 97 now := time.Now() 98 98 for uploadID, session := range m.sessions { 99 99 if now.Sub(session.LastActivity) > 24*time.Hour { 100 - log.Printf("Cleaning up expired multipart session: uploadID=%s, age=%v", uploadID, now.Sub(session.CreatedAt)) 100 + slog.Debug("Cleaning up expired multipart session", 101 + "uploadID", uploadID, 102 + "age", now.Sub(session.CreatedAt)) 101 103 delete(m.sessions, uploadID) 102 104 } 103 105 } ··· 121 123 m.sessions[uploadID] = session 122 124 m.mu.Unlock() 123 125 124 - log.Printf("Created multipart session: uploadID=%s, digest=%s, mode=%v", uploadID, digest, mode) 126 + slog.Debug("Created multipart session", 127 + "uploadID", uploadID, 128 + "digest", digest, 129 + "mode", mode) 125 130 return session 126 131 } 127 132 ··· 144 149 defer m.mu.Unlock() 145 150 146 151 delete(m.sessions, uploadID) 147 - log.Printf("Deleted multipart session: uploadID=%s", uploadID) 152 + slog.Debug("Deleted multipart session", "uploadID", uploadID) 148 153 } 149 154 150 155 // StorePart stores a part in the session (for Buffered mode) ··· 167 172 s.Parts[partNumber] = part 168 173 s.LastActivity = time.Now() 169 174 170 - log.Printf("Stored part: uploadID=%s, part=%d, size=%d bytes, etag=%s", s.UploadID, partNumber, len(data), etag) 175 + slog.Debug("Stored part", 176 + "uploadID", s.UploadID, 177 + "part", partNumber, 178 + "size", len(data), 179 + "etag", etag) 171 180 return etag 172 181 } 173 182 ··· 205 214 assembled = append(assembled, part.Data...) 206 215 } 207 216 208 - log.Printf("Assembled buffered parts: uploadID=%s, parts=%d, totalSize=%d bytes", s.UploadID, maxPart, totalSize) 217 + slog.Debug("Assembled buffered parts", 218 + "uploadID", s.UploadID, 219 + "parts", maxPart, 220 + "totalSize", totalSize) 209 221 return assembled, totalSize, nil 210 222 } 211 223 ··· 214 226 func (h *XRPCHandler) StartMultipartUploadWithManager(ctx context.Context, digest string) (string, MultipartMode, error) { 215 227 // Check if presigned URLs are disabled for testing 216 228 if h.disablePresignedURLs { 217 - log.Printf("Presigned URLs disabled (DISABLE_PRESIGNED_URLS=true), using buffered mode") 229 + slog.Debug("Presigned URLs disabled, using buffered mode", "reason", "DISABLE_PRESIGNED_URLS=true") 218 230 session := h.MultipartMgr.CreateSession(digest, Buffered, "") 219 - log.Printf("Started buffered multipart: uploadID=%s", session.UploadID) 231 + slog.Debug("Started buffered multipart", "uploadID", session.UploadID) 220 232 return session.UploadID, Buffered, nil 221 233 } 222 234 ··· 239 251 s3UploadID := *result.UploadId 240 252 // S3 native multipart succeeded 241 253 session := h.MultipartMgr.CreateSession(digest, S3Native, s3UploadID) 242 - log.Printf("Started S3 native multipart: digest=%s, uploadID=%s, s3UploadID=%s", digest, session.UploadID, s3UploadID) 254 + slog.Debug("Started S3 native multipart", 255 + "digest", digest, 256 + "uploadID", session.UploadID, 257 + "s3UploadID", s3UploadID) 243 258 return session.UploadID, S3Native, nil 244 259 } 245 - log.Printf("S3 native multipart failed, falling back to buffered mode: %v", err) 260 + slog.Warn("S3 native multipart failed, falling back to buffered mode", "error", err) 246 261 } 247 262 248 263 // Fallback to buffered mode 249 264 session := h.MultipartMgr.CreateSession(digest, Buffered, "") 250 - log.Printf("Started buffered multipart: uploadID=%s", session.UploadID) 265 + slog.Debug("Started buffered multipart", "uploadID", session.UploadID) 251 266 return session.UploadID, Buffered, nil 252 267 } 253 268 ··· 283 298 return nil, err 284 299 } 285 300 286 - log.Printf("Generated part presigned URL: digest=%s, uploadID=%s, part=%d", session.Digest, uploadID, partNumber) 301 + slog.Debug("Generated part presigned URL", 302 + "digest", session.Digest, 303 + "uploadID", uploadID, 304 + "part", partNumber) 287 305 288 306 return &PartUploadInfo{ 289 307 URL: url, ··· 350 368 if err != nil { 351 369 return fmt.Errorf("failed to complete multipart upload: digest=%s, uploadID=%s, err=%v", session.Digest, uploadID, err) 352 370 } 353 - log.Printf("Completed S3 native multipart at temp location: digest=%s, uploadID=%s, parts=%d", session.Digest, session.UploadID, len(s3Parts)) 371 + slog.Info("Completed S3 native multipart at temp location", 372 + "digest", session.Digest, 373 + "uploadID", session.UploadID, 374 + "parts", len(s3Parts)) 354 375 355 376 // Verify the blob exists at temp location before moving 356 377 destPath := blobPath(finalDigest) 357 - log.Printf("[DEBUG] About to move: source=%s, dest=%s", sourcePath, destPath) 378 + slog.Debug("About to move blob", 379 + "source", sourcePath, 380 + "dest", destPath) 358 381 359 382 if _, err := h.driver.Stat(ctx, sourcePath); err != nil { 360 - log.Printf("[ERROR] Source blob not found after multipart complete: path=%s, err=%v", sourcePath, err) 383 + slog.Error("Source blob not found after multipart complete", 384 + "path", sourcePath, 385 + "error", err) 361 386 return fmt.Errorf("source blob not found after multipart complete: %w", err) 362 387 } 363 - log.Printf("[DEBUG] Source blob verified at: %s", sourcePath) 388 + slog.Debug("Source blob verified", "path", sourcePath) 364 389 365 390 // Move from temp to final digest location using driver 366 391 // Driver handles path management correctly (including S3 prefix) 367 392 if err := h.driver.Move(ctx, sourcePath, destPath); err != nil { 368 - log.Printf("[ERROR] Failed to move blob: source=%s, dest=%s, err=%v", sourcePath, destPath, err) 393 + slog.Error("Failed to move blob", 394 + "source", sourcePath, 395 + "dest", destPath, 396 + "error", err) 369 397 return fmt.Errorf("failed to move blob to final location: %w", err) 370 398 } 371 399 372 - log.Printf("Moved blob to final location: %s → %s (driver paths: %s → %s)", session.Digest, finalDigest, sourcePath, destPath) 400 + slog.Info("Moved blob to final location", 401 + "from", session.Digest, 402 + "to", finalDigest, 403 + "sourcePath", sourcePath, 404 + "destPath", destPath) 373 405 return nil 374 406 } 375 407 ··· 396 428 return fmt.Errorf("failed to commit blob: %w", err) 397 429 } 398 430 399 - log.Printf("Completed buffered multipart: uploadID=%s, finalDigest=%s, size=%d bytes, written=%d", session.UploadID, finalDigest, size, written) 431 + slog.Info("Completed buffered multipart", 432 + "uploadID", session.UploadID, 433 + "finalDigest", finalDigest, 434 + "size", size, 435 + "written", written) 400 436 return nil 401 437 } 402 438 ··· 427 463 if err != nil { 428 464 return fmt.Errorf("failed to abort multipart upload: digest=%s, uploadID=%s, err=%v", session.Digest, uploadID, err) 429 465 } 430 - log.Printf("Aborted S3 native multipart: digest=%s, uploadID=%s", session.Digest, session.UploadID) 466 + slog.Debug("Aborted S3 native multipart", 467 + "digest", session.Digest, 468 + "uploadID", session.UploadID) 431 469 return nil 432 470 } 433 471 434 472 // Buffered mode: just delete the session (parts are in memory) 435 - log.Printf("Aborted buffered multipart: uploadID=%s", session.UploadID) 473 + slog.Debug("Aborted buffered multipart", "uploadID", session.UploadID) 436 474 return nil 437 475 } 438 476
+3 -2
pkg/hold/oci/xrpc.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "log/slog" 6 7 "net/http" 7 8 "strconv" 8 9 ··· 268 269 269 270 _, _, err := h.pds.CreateLayerRecord(ctx, record) 270 271 if err != nil { 271 - fmt.Printf("Failed to create layer record: %v\n", err) 272 + slog.Error("Failed to create layer record", "error", err) 272 273 // Continue creating other records 273 274 } else { 274 275 layersCreated++ ··· 302 303 totalSize, 303 304 ) 304 305 if err != nil { 305 - fmt.Printf("Failed to create manifest post: %v\n", err) 306 + slog.Error("Failed to create manifest post", "error", err) 306 307 } else { 307 308 postCreated = true 308 309 }
+3 -3
pkg/hold/pds/auth.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "io" 9 - "log" 9 + "log/slog" 10 10 "net/http" 11 11 "slices" 12 12 "strings" ··· 426 426 return nil, fmt.Errorf("missing token") 427 427 } 428 428 429 - log.Printf("[ValidateServiceToken] Validating service token for hold %s", holdDID) 429 + slog.Debug("Validating service token", "holdDID", holdDID) 430 430 431 431 // Manually parse JWT (bypass golang-jwt since it doesn't support ES256K algorithm used by ATProto) 432 432 // Split token: header.payload.signature ··· 493 493 return nil, fmt.Errorf("signature verification failed: %w", err) 494 494 } 495 495 496 - log.Printf("[ValidateServiceToken] Successfully validated service token for user %s", issuerDID) 496 + slog.Debug("Successfully validated service token", "userDID", issuerDID) 497 497 498 498 // Return validated user 499 499 return &ValidatedUser{
+4 -1
pkg/hold/pds/captain.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "log/slog" 6 7 "time" 7 8 8 9 "atcr.io/pkg/atproto" ··· 32 33 return cid.Undef, fmt.Errorf("failed to create captain record: %w", err) 33 34 } 34 35 35 - fmt.Printf("Created captain record at %s, cid: %s\n", recordPath, recordCID) 36 + slog.Info("Created captain record", 37 + "path", recordPath, 38 + "cid", recordCID.String()) 36 39 return recordCID, nil 37 40 } 38 41
+37 -27
pkg/hold/pds/events.go
··· 6 6 "database/sql" 7 7 "encoding/json" 8 8 "fmt" 9 - "log" 9 + "log/slog" 10 10 "strings" 11 11 "sync" 12 12 "time" ··· 79 79 // Initialize database connection and schema 80 80 if dbPath != "" && dbPath != ":memory:" { 81 81 if err := broadcaster.initDatabase(); err != nil { 82 - log.Printf("Warning: Failed to initialize event database: %v", err) 83 - log.Printf("Events will not persist across restarts") 82 + slog.Warn("Failed to initialize event database", "error", err) 83 + slog.Warn("Events will not persist across restarts") 84 84 } 85 85 } 86 86 ··· 127 127 var lastSeq sql.NullInt64 128 128 err = db.QueryRow("SELECT MAX(seq) FROM firehose_events").Scan(&lastSeq) 129 129 if err != nil { 130 - log.Printf("Warning: Failed to load last event sequence: %v", err) 130 + slog.Warn("Failed to load last event sequence", "error", err) 131 131 } else if lastSeq.Valid { 132 132 b.eventSeq = lastSeq.Int64 133 - log.Printf("Loaded event sequence from database: seq=%d", b.eventSeq) 133 + slog.Info("Loaded event sequence from database", "seq", b.eventSeq) 134 134 } else { 135 135 // Database is empty but might have existing repo records 136 136 // This happens on first deployment after adding persistent events 137 - log.Printf("No events in database - will bootstrap from repo if needed") 137 + slog.Info("No events in database - will bootstrap from repo if needed") 138 138 } 139 139 140 140 return nil ··· 185 185 } 186 186 187 187 if count > 0 { 188 - log.Printf("Database already has %d events, skipping bootstrap", count) 188 + slog.Info("Database already has events, skipping bootstrap", "count", count) 189 189 return nil 190 190 } 191 191 ··· 200 200 head, err := pds.carstore.GetUserRepoHead(ctx, pds.uid) 201 201 if err != nil || !head.Defined() { 202 202 // Empty repo, nothing to bootstrap 203 - log.Printf("Empty repo, no events to bootstrap") 203 + slog.Info("Empty repo, no events to bootstrap") 204 204 return nil 205 205 } 206 206 ··· 215 215 return fmt.Errorf("failed to get repo rev: %w", err) 216 216 } 217 217 218 - log.Printf("Bootstrapping firehose events from current repo state (head=%s, rev=%s)", head.String(), rev) 218 + slog.Info("Bootstrapping firehose events from current repo state", 219 + "head", head.String(), 220 + "rev", rev) 219 221 220 222 var recordCount int64 221 223 ··· 224 226 // Get record value 225 227 _, recBytes, err := repoHandle.GetRecordBytes(ctx, path) 226 228 if err != nil { 227 - log.Printf("Warning: failed to get record bytes for %s: %v", path, err) 229 + slog.Warn("Failed to get record bytes", "path", path, "error", err) 228 230 return nil // Skip this record but continue 229 231 } 230 232 231 233 recordValue, err := lexutil.CborDecodeValue(*recBytes) 232 234 if err != nil { 233 - log.Printf("Warning: failed to decode record %s: %v", path, err) 235 + slog.Warn("Failed to decode record", "path", path, "error", err) 234 236 return nil 235 237 } 236 238 ··· 265 267 Version: 1, 266 268 } 267 269 if err := car.WriteHeader(carHeader, &carBuf); err != nil { 268 - log.Printf("Warning: failed to write CAR header: %v", err) 270 + slog.Warn("Failed to write CAR header", "error", err) 269 271 return nil 270 272 } 271 273 272 274 // Write the record block 273 275 if err := carutil.LdWrite(&carBuf, recordCID.Bytes(), *recBytes); err != nil { 274 - log.Printf("Warning: failed to write record block: %v", err) 276 + slog.Warn("Failed to write record block", "error", err) 275 277 return nil 276 278 } 277 279 ··· 311 313 return fmt.Errorf("failed to walk repo: %w", err) 312 314 } 313 315 314 - log.Printf("✅ Bootstrapped %d events from repo (seq now at %d)", recordCount, b.eventSeq) 316 + slog.Info("Bootstrapped events from repo", 317 + "recordCount", recordCount, 318 + "seq", b.eventSeq) 315 319 return nil 316 320 } 317 321 ··· 349 353 } else if cursor > currentSeq { 350 354 // Relay has cursor ahead of us - server was restarted 351 355 // Database should have the events if we had them before 352 - log.Printf("Relay cursor %d > currentSeq %d (server restarted), attempting database backfill", cursor, currentSeq) 356 + slog.Info("Relay cursor ahead of current seq, attempting database backfill", 357 + "cursor", cursor, 358 + "currentSeq", currentSeq) 353 359 go b.backfillSubscriber(sub, cursor) 354 360 } 355 361 // else cursor == currentSeq: relay is caught up, just stream new events ··· 387 393 // Persist event to database 388 394 if b.db != nil { 389 395 if err := b.persistEvent(commitEvent); err != nil { 390 - log.Printf("Warning: Failed to persist event seq=%d to database: %v", seq, err) 396 + slog.Warn("Failed to persist event to database", 397 + "seq", seq, 398 + "error", err) 391 399 } 392 400 } 393 401 ··· 401 409 // Sent successfully 402 410 default: 403 411 // Subscriber's buffer is full, skip (they'll get disconnected for being too slow) 404 - log.Printf("Warning: subscriber buffer full, skipping event seq=%d", seq) 412 + slog.Warn("Subscriber buffer full, skipping event", "seq", seq) 405 413 } 406 414 } 407 415 } ··· 488 496 // If database is available, use it for backfill 489 497 if b.db != nil { 490 498 if err := b.backfillFromDatabase(sub, cursor); err != nil { 491 - log.Printf("Database backfill failed, falling back to in-memory: %v", err) 499 + slog.Warn("Database backfill failed, falling back to in-memory", "error", err) 492 500 b.backfillFromMemory(sub, cursor) 493 501 } 494 502 return ··· 527 535 ) 528 536 529 537 if err := rows.Scan(&seq, &commitCID, &rev, &sinceRev, &repoSlice, &opsJSON, &createdAt); err != nil { 530 - log.Printf("Error scanning event row: %v", err) 538 + slog.Error("Error scanning event row", "error", err) 531 539 continue 532 540 } 533 541 534 542 // Deserialize ops from JSON 535 543 var ops []*atproto.SyncSubscribeRepos_RepoOp 536 544 if err := json.Unmarshal(opsJSON, &ops); err != nil { 537 - log.Printf("Error unmarshaling ops for seq=%d: %v", seq, err) 545 + slog.Error("Error unmarshaling ops", "seq", seq, "error", err) 538 546 continue 539 547 } 540 548 ··· 562 570 // Sent successfully 563 571 case <-time.After(5 * time.Second): 564 572 // Timeout, subscriber too slow 565 - log.Printf("Backfill timeout for subscriber at seq=%d", seq) 573 + slog.Warn("Backfill timeout for subscriber", "seq", seq) 566 574 return nil 567 575 } 568 576 } ··· 582 590 // Sent 583 591 case <-time.After(5 * time.Second): 584 592 // Timeout, subscriber too slow 585 - log.Printf("Backfill timeout for subscriber at seq=%d", he.Seq) 593 + slog.Warn("Backfill timeout for subscriber", "seq", he.Seq) 586 594 return 587 595 } 588 596 } ··· 606 614 // Get a writer for this message 607 615 wc, err := sub.conn.NextWriter(websocket.BinaryMessage) 608 616 if err != nil { 609 - log.Printf("Failed to get websocket writer: %v", err) 617 + slog.Error("Failed to get websocket writer", "error", err) 610 618 return 611 619 } 612 620 613 621 // Write header as CBOR 614 622 if err := header.MarshalCBOR(wc); err != nil { 615 - log.Printf("Failed to write event header: %v", err) 623 + slog.Error("Failed to write event header", "error", err) 616 624 wc.Close() 617 625 return 618 626 } ··· 623 631 // Write the event as CBOR 624 632 var obj lexutil.CBOR = indigoEvent 625 633 if err := obj.MarshalCBOR(wc); err != nil { 626 - log.Printf("Failed to write event body: %v", err) 634 + slog.Error("Failed to write event body", "error", err) 627 635 wc.Close() 628 636 return 629 637 } 630 638 631 639 // Close the writer to flush the message 632 640 if err := wc.Close(); err != nil { 633 - log.Printf("Failed to close websocket writer: %v", err) 641 + slog.Error("Failed to close websocket writer", "error", err) 634 642 return 635 643 } 636 644 ··· 645 653 // Parse commit CID string to cid.Cid, then convert to LexLink 646 654 commitCID, err := cid.Decode(event.Commit) 647 655 if err != nil { 648 - log.Printf("Warning: failed to parse commit CID %s: %v", event.Commit, err) 656 + slog.Warn("Failed to parse commit CID", 657 + "cid", event.Commit, 658 + "error", err) 649 659 // Create an empty CID as fallback 650 660 commitCID = cid.Undef 651 661 }
+4 -3
pkg/hold/pds/keys.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "log/slog" 5 6 "os" 6 7 "path/filepath" 7 8 ··· 42 43 return nil, fmt.Errorf("failed to write key file: %w", err) 43 44 } 44 45 45 - fmt.Printf("Generated new K-256 signing key at %s\n", keyPath) 46 + slog.Info("Generated new K-256 signing key", "path", keyPath) 46 47 return privateKey, nil 47 48 } 48 49 ··· 59 60 if err != nil { 60 61 // Check if this is an old P-256 PEM key (migration) 61 62 if isPEMFormat(keyBytes) { 62 - fmt.Printf("⚠️ Detected old P-256 key, replacing with K-256...\n") 63 + slog.Warn("Detected old P-256 key, replacing with K-256") 63 64 // Generate new K-256 key (overwrites old P-256) 64 65 return generateKey(keyPath) 65 66 } ··· 67 68 return nil, fmt.Errorf("failed to parse private key: %w", err) 68 69 } 69 70 70 - fmt.Printf("Loaded existing K-256 signing key from %s\n", keyPath) 71 + slog.Info("Loaded existing K-256 signing key", "path", keyPath) 71 72 return privateKey, nil 72 73 } 73 74
+4 -1
pkg/hold/pds/manifest_post.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "log/slog" 6 7 "strings" 7 8 "time" 8 9 ··· 55 56 // Build ATProto URI for the post 56 57 postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey) 57 58 58 - fmt.Printf("Created manifest post: %s (cid: %s)\n", postURI, recordCID) 59 + slog.Info("Created manifest post", 60 + "uri", postURI, 61 + "cid", recordCID.String()) 59 62 60 63 return postURI, nil 61 64 }
+12 -5
pkg/hold/pds/profile.go
··· 6 6 "crypto/sha256" 7 7 "fmt" 8 8 "io" 9 + "log/slog" 9 10 "net/http" 10 11 "time" 11 12 ··· 137 138 138 139 // Download and upload avatar if URL is provided 139 140 if avatarURL != "" { 140 - fmt.Printf("Downloading avatar from %s\n", avatarURL) 141 + slog.Debug("Downloading avatar", "url", avatarURL) 141 142 imageData, mimeType, err := downloadImage(ctx, avatarURL) 142 143 if err != nil { 143 144 return cid.Undef, fmt.Errorf("failed to download avatar: %w", err) 144 145 } 145 146 146 - fmt.Printf("Uploading avatar blob (%d bytes, %s)\n", len(imageData), mimeType) 147 + slog.Debug("Uploading avatar blob", 148 + "size", len(imageData), 149 + "mimeType", mimeType) 147 150 avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType) 148 151 if err != nil { 149 152 return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err) 150 153 } 151 154 152 155 profile.Avatar = avatarBlob 153 - fmt.Printf("Avatar uploaded successfully: %s\n", avatarBlob.Ref.String()) 156 + slog.Info("Avatar uploaded successfully", "ref", avatarBlob.Ref.String()) 154 157 } 155 158 156 159 // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists ··· 159 162 return cid.Undef, fmt.Errorf("failed to create profile record: %w", err) 160 163 } 161 164 162 - fmt.Printf("Created profile record at %s, cid: %s\n", recordPath, recordCID) 165 + slog.Info("Created profile record", 166 + "path", recordPath, 167 + "cid", recordCID.String()) 163 168 return recordCID, nil 164 169 } 165 170 ··· 200 205 return cid.Undef, fmt.Errorf("failed to create tangled profile record: %w", err) 201 206 } 202 207 203 - fmt.Printf("Created tangled profile record at %s, cid: %s\n", recordPath, recordCID) 208 + slog.Info("Created tangled profile record", 209 + "path", recordPath, 210 + "cid", recordCID.String()) 204 211 return recordCID, nil 205 212 } 206 213
+16 -9
pkg/hold/pds/server.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "log/slog" 6 7 "os" 7 8 "path/filepath" 8 9 "strings" ··· 92 93 // Initialize empty repo with first commit 93 94 // RepoManager requires at least one commit to exist 94 95 // We'll create this by doing a dummy operation in Bootstrap 95 - fmt.Printf("New hold repo - will be initialized in Bootstrap\n") 96 + slog.Info("New hold repo - will be initialized in Bootstrap") 96 97 } 97 98 98 99 return &HoldPDS{ ··· 134 135 135 136 if captainExists { 136 137 // Captain record exists, skip captain/crew setup but still create profile if needed 137 - fmt.Printf("✅ Captain record exists, skipping captain/crew setup\n") 138 + slog.Info("Captain record exists, skipping captain/crew setup") 138 139 } else { 139 - fmt.Printf("🚀 Bootstrapping hold PDS with owner: %s\n", ownerDID) 140 + slog.Info("Bootstrapping hold PDS", "owner", ownerDID) 140 141 } 141 142 142 143 if !captainExists { ··· 151 152 if err != nil { 152 153 return fmt.Errorf("failed to initialize repo: %w", err) 153 154 } 154 - fmt.Printf("✅ Initialized empty repo\n") 155 + slog.Info("Initialized empty repo") 155 156 } 156 157 157 158 // Create captain record (hold ownership and settings) ··· 160 161 return fmt.Errorf("failed to create captain record: %w", err) 161 162 } 162 163 163 - fmt.Printf("✅ Created captain record (public=%v, allowAllCrew=%v, enableBlueskyPosts=%v)\n", public, allowAllCrew, p.enableBlueskyPosts) 164 + slog.Info("Created captain record", 165 + "public", public, 166 + "allowAllCrew", allowAllCrew, 167 + "enableBlueskyPosts", p.enableBlueskyPosts) 164 168 165 169 // Add hold owner as first crew member with admin role 166 170 _, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"}) ··· 168 172 return fmt.Errorf("failed to add owner as crew member: %w", err) 169 173 } 170 174 171 - fmt.Printf("✅ Added %s as hold admin\n", ownerDID) 175 + slog.Info("Added owner as hold admin", "did", ownerDID) 172 176 } else { 173 177 // Captain record exists, check if we need to sync settings from env vars 174 178 _, existingCaptain, err := p.GetCaptainRecord(ctx) ··· 184 188 if err != nil { 185 189 return fmt.Errorf("failed to update captain record: %w", err) 186 190 } 187 - fmt.Printf("✅ Synced captain record with env vars (public=%v, allowAllCrew=%v, enableBlueskyPosts=%v)\n", public, allowAllCrew, p.enableBlueskyPosts) 191 + slog.Info("Synced captain record with env vars", 192 + "public", public, 193 + "allowAllCrew", allowAllCrew, 194 + "enableBlueskyPosts", p.enableBlueskyPosts) 188 195 } 189 196 } 190 197 } ··· 203 210 if err != nil { 204 211 return fmt.Errorf("failed to create bluesky profile record: %w", err) 205 212 } 206 - fmt.Printf("✅ Created Bluesky profile record (displayName=%s)\n", displayName) 213 + slog.Info("Created Bluesky profile record", "displayName", displayName) 207 214 } else { 208 - fmt.Printf("✅ Bluesky profile record already exists, skipping\n") 215 + slog.Info("Bluesky profile record already exists, skipping") 209 216 } 210 217 } 211 218
+7 -2
pkg/hold/pds/status.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "log/slog" 6 7 "time" 7 8 8 9 bsky "github.com/bluesky-social/indigo/api/bsky" ··· 19 20 func (p *HoldPDS) SetStatus(ctx context.Context, status string) error { 20 21 // Check if Bluesky posts are enabled 21 22 if !p.enableBlueskyPosts { 22 - fmt.Printf("Bluesky posts disabled, skipping status post: %s\n", status) 23 + slog.Debug("Bluesky posts disabled, skipping status post", "status", status) 23 24 return nil 24 25 } 25 26 ··· 51 52 return fmt.Errorf("failed to create status post: %w", err) 52 53 } 53 54 54 - fmt.Printf("Created status post at %s/%s (rkey: %s), cid: %s, text: %s\n", StatusPostCollection, rkey, rkey, recordCID, text) 55 + slog.Info("Created status post", 56 + "collection", StatusPostCollection, 57 + "rkey", rkey, 58 + "cid", recordCID.String(), 59 + "text", text) 55 60 return nil 56 61 }
+56 -27
pkg/hold/pds/xrpc.go
··· 20 20 21 21 "crypto/sha256" 22 22 "io" 23 - "log" 23 + "log/slog" 24 24 "net/http" 25 25 "strconv" 26 26 "strings" ··· 827 827 if err != nil { 828 828 // Error already written to response by ReadRepo streaming 829 829 // Log it but don't try to write another HTTP error 830 - fmt.Printf("Error streaming repo CAR: %v\n", err) 830 + slog.Error("Error streaming repo CAR", "error", err) 831 831 return 832 832 } 833 833 } ··· 865 865 // Upgrade to WebSocket 866 866 conn, err := upgrader.Upgrade(w, r, nil) 867 867 if err != nil { 868 - fmt.Printf("WebSocket upgrade failed: %v\n", err) 868 + slog.Error("WebSocket upgrade failed", "error", err) 869 869 return 870 870 } 871 871 ··· 970 970 did := r.URL.Query().Get("did") 971 971 cidOrDigest := r.URL.Query().Get("cid") 972 972 973 - log.Printf("[HandleGetBlob] %s request - did=%s, cid=%s", r.Method, did, cidOrDigest) 973 + slog.Debug("HandleGetBlob request", 974 + "method", r.Method, 975 + "did", did, 976 + "cid", cidOrDigest) 974 977 975 978 if did == "" || cidOrDigest == "" { 976 979 http.Error(w, "missing required parameters", http.StatusBadRequest) ··· 992 995 // Returns JSON with presigned URL for AppView integration 993 996 // Authorization: Protected by hold access control (captain.public or crew with blob:read) 994 997 func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, did, digest string) { 995 - log.Printf("[handleGetOCIBlob] Processing OCI blob: %s", digest) 998 + slog.Debug("Processing OCI blob", "digest", digest) 996 999 997 1000 // Validate blob read access (hold access control) 998 1001 // If captain.public = true, returns nil (public access allowed) 999 1002 // If captain.public = false, validates auth and checks for blob:read permission 1000 1003 _, err := ValidateBlobReadAccess(r, h.pds, h.httpClient) 1001 1004 if err != nil { 1002 - log.Printf("[handleGetOCIBlob] Authorization failed: %v", err) 1005 + slog.Warn("OCI blob authorization failed", "error", err, "digest", digest) 1003 1006 http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden) 1004 1007 return 1005 1008 } ··· 1014 1017 // Generate presigned URL (use empty DID for content-addressed storage) 1015 1018 presignedURL, err := h.GetPresignedURL(r.Context(), operation, digest, "") 1016 1019 if err != nil { 1017 - log.Printf("[handleGetOCIBlob] Failed to get presigned %s URL: %v", operation, err) 1020 + slog.Error("Failed to get presigned URL for OCI blob", 1021 + "error", err, 1022 + "operation", operation, 1023 + "digest", digest) 1018 1024 http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) 1019 1025 return 1020 1026 } 1021 1027 1022 - log.Printf("[handleGetOCIBlob] Returning presigned %s URL: %s", operation, presignedURL) 1028 + slog.Debug("Returning presigned URL for OCI blob", 1029 + "operation", operation, 1030 + "digest", digest, 1031 + "url", presignedURL) 1023 1032 1024 1033 // Return JSON response with presigned URL (AppView expects this format) 1025 1034 response := map[string]string{ ··· 1033 1042 // Returns 307 redirect to presigned URL (standard ATProto behavior) 1034 1043 // Authorization: Public per ATProto spec (no auth required) 1035 1044 func (h *XRPCHandler) handleGetATProtoBlob(w http.ResponseWriter, r *http.Request, did, cid string) { 1036 - log.Printf("[handleGetATProtoBlob] Processing ATProto blob: %s", cid) 1045 + slog.Debug("Processing ATProto blob", "cid", cid) 1037 1046 1038 1047 // Validate DID (ATProto blobs are stored per-DID for data sovereignty) 1039 1048 if did != h.pds.DID() { 1040 - log.Printf("[handleGetATProtoBlob] DID mismatch: got %s, expected %s", did, h.pds.DID()) 1049 + slog.Warn("ATProto blob DID mismatch", 1050 + "got", did, 1051 + "expected", h.pds.DID()) 1041 1052 http.Error(w, "invalid did", http.StatusBadRequest) 1042 1053 return 1043 1054 } ··· 1051 1062 // Generate presigned URL (use DID for per-DID storage path) 1052 1063 presignedURL, err := h.GetPresignedURL(r.Context(), operation, cid, did) 1053 1064 if err != nil { 1054 - log.Printf("[handleGetATProtoBlob] Failed to get presigned %s URL: %v", operation, err) 1065 + slog.Error("Failed to get presigned URL for ATProto blob", 1066 + "error", err, 1067 + "operation", operation, 1068 + "cid", cid, 1069 + "did", did) 1055 1070 http.Error(w, "failed to get presigned URL", http.StatusInternalServerError) 1056 1071 return 1057 1072 } ··· 1170 1185 // This endpoint allows authenticated users to request crew membership 1171 1186 // Authorization is checked against captain record settings 1172 1187 func (h *XRPCHandler) HandleRequestCrew(w http.ResponseWriter, r *http.Request) { 1173 - log.Printf("[HandleRequestCrew] Starting crew membership request") 1188 + slog.Debug("Starting crew membership request") 1174 1189 1175 1190 // Get authenticated user from context (if coming through middleware) 1176 1191 // Otherwise validate directly (for tests or direct handler calls) ··· 1179 1194 var err error 1180 1195 user, err = ValidateDPoPRequest(r, h.httpClient) 1181 1196 if err != nil { 1182 - log.Printf("[HandleRequestCrew] Authentication failed: %v", err) 1197 + slog.Warn("Crew request authentication failed", "error", err) 1183 1198 http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized) 1184 1199 return 1185 1200 } 1186 1201 } 1187 - log.Printf("[HandleRequestCrew] Authenticated user: %s", user.DID) 1202 + slog.Debug("Authenticated user for crew request", "did", user.DID) 1188 1203 1189 1204 // Parse request body (optional parameters) 1190 1205 var req struct { ··· 1195 1210 // Body is optional - if empty, just use defaults 1196 1211 if r.Body != nil && r.ContentLength > 0 { 1197 1212 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1198 - log.Printf("[HandleRequestCrew] Failed to parse request body: %v", err) 1213 + slog.Warn("Failed to parse crew request body", "error", err) 1199 1214 http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) 1200 1215 return 1201 1216 } 1202 1217 } 1203 1218 1204 1219 // Get captain record to check authorization settings 1205 - log.Printf("[HandleRequestCrew] Getting captain record...") 1220 + slog.Debug("Getting captain record for crew request") 1206 1221 _, captain, err := h.pds.GetCaptainRecord(r.Context()) 1207 1222 if err != nil { 1208 - log.Printf("[HandleRequestCrew] Failed to get captain record: %v", err) 1223 + slog.Error("Failed to get captain record", "error", err) 1209 1224 http.Error(w, fmt.Sprintf("failed to get captain record: %v", err), http.StatusInternalServerError) 1210 1225 return 1211 1226 } 1212 - log.Printf("[HandleRequestCrew] Captain record retrieved: owner=%s, allowAllCrew=%v", captain.Owner, captain.AllowAllCrew) 1227 + slog.Debug("Captain record retrieved", 1228 + "owner", captain.Owner, 1229 + "allowAllCrew", captain.AllowAllCrew) 1213 1230 1214 1231 // Check authorization: 1215 1232 // 1. If allowAllCrew is true, any authenticated user can join ··· 1231 1248 1232 1249 // Check if user is already a crew member 1233 1250 // List all crew members and check if this DID is already present 1234 - log.Printf("[HandleRequestCrew] Checking existing crew membership...") 1251 + slog.Debug("Checking existing crew membership") 1235 1252 crew, err := h.pds.ListCrewMembers(r.Context()) 1236 1253 if err != nil { 1237 - log.Printf("[HandleRequestCrew] Failed to list crew members: %v", err) 1254 + slog.Error("Failed to list crew members", "error", err) 1238 1255 http.Error(w, fmt.Sprintf("failed to list crew members: %v", err), http.StatusInternalServerError) 1239 1256 return 1240 1257 } 1241 - log.Printf("[HandleRequestCrew] Found %d existing crew members", len(crew)) 1258 + slog.Debug("Found existing crew members", "count", len(crew)) 1242 1259 1243 1260 for _, member := range crew { 1244 1261 if member.Record.Member == user.DID { 1245 1262 // Already a crew member, return success with existing record 1246 - log.Printf("[HandleRequestCrew] User is already a crew member (rkey=%s)", member.Rkey) 1263 + slog.Debug("User is already a crew member", 1264 + "did", user.DID, 1265 + "rkey", member.Rkey) 1247 1266 response := map[string]any{ 1248 1267 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), atproto.CrewCollection, member.Rkey), 1249 1268 "cid": member.Cid.String(), ··· 1258 1277 } 1259 1278 1260 1279 // Create new crew record 1261 - log.Printf("[HandleRequestCrew] Creating new crew record for user %s (role=%s, permissions=%v)", user.DID, req.Role, req.Permissions) 1280 + slog.Debug("Creating new crew record", 1281 + "did", user.DID, 1282 + "role", req.Role, 1283 + "permissions", req.Permissions) 1262 1284 recordCID, err := h.pds.AddCrewMember(r.Context(), user.DID, req.Role, req.Permissions) 1263 1285 if err != nil { 1264 - log.Printf("[HandleRequestCrew] Failed to create crew record: %v", err) 1286 + slog.Error("Failed to create crew record", 1287 + "error", err, 1288 + "did", user.DID) 1265 1289 http.Error(w, fmt.Sprintf("failed to create crew record: %v", err), http.StatusInternalServerError) 1266 1290 return 1267 1291 } 1268 - log.Printf("[HandleRequestCrew] Successfully created crew record (CID=%s)", recordCID.String()) 1292 + slog.Info("Successfully created crew record", 1293 + "did", user.DID, 1294 + "cid", recordCID.String()) 1269 1295 1270 1296 // Return success response 1271 1297 // Note: rkey is generated by AddCrewMember (TID), we don't have direct access to it ··· 1341 1367 // Generate presigned URL with 15 minute expiry 1342 1368 url, err := req.Presign(15 * time.Minute) 1343 1369 if err != nil { 1344 - log.Printf("[getPresignedURL] Presign FAILED for %s: %v", operation, err) 1345 - log.Printf(" Falling back to XRPC endpoint") 1370 + slog.Warn("Presign failed, falling back to XRPC endpoint", 1371 + "error", err, 1372 + "operation", operation, 1373 + "digest", digest) 1374 + slog.Debug("Using XRPC proxy fallback") 1346 1375 proxyURL := getProxyURL(h.pds.PublicURL, digest, did, operation) 1347 1376 if proxyURL == "" { 1348 1377 return "", fmt.Errorf("presign failed and XRPC proxy not supported for PUT operations")