+1
-1
.env.appview.example
+1
-1
.env.appview.example
+10
.env.hold.example
+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
+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
+1
-1
deploy/.env.prod.template
+4
deploy/docker-compose.prod.yml
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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")