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

Configure Feed

Select the types of activity you want to include in your feed.

slog and refactor config in appview

+543 -1853
+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")