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

Configure Feed

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

more appview cleanup and test coverage

+2358 -275
+3 -5
cmd/appview/authorizer_test.go pkg/appview/db/readonly_test.go
··· 1 - package main 1 + package db 2 2 3 3 import ( 4 4 "database/sql" 5 5 "os" 6 6 "path/filepath" 7 7 "testing" 8 - 9 - "atcr.io/pkg/appview/db" 10 8 ) 11 9 12 10 func TestAuthorizerBlocksSensitiveTables(t *testing.T) { ··· 19 17 defer os.Unsetenv("ATCR_UI_DATABASE_PATH") 20 18 21 19 // Initialize database (creates schema) 22 - database, err := db.InitDB(dbPath) 20 + database, err := InitDB(dbPath) 23 21 if err != nil { 24 22 t.Fatalf("Failed to initialize database: %v", err) 25 23 } ··· 43 41 } 44 42 45 43 // Open read-only connection with authorizer (using our custom driver) 46 - readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 44 + readOnlyDB, err := sql.Open(ReadOnlyDriverName, "file:"+dbPath+"?mode=ro") 47 45 if err != nil { 48 46 t.Fatalf("Failed to open read-only database: %v", err) 49 47 }
+67 -14
cmd/appview/config.go pkg/appview/config.go
··· 1 - package main 1 + package appview 2 2 3 3 import ( 4 4 "crypto/rand" ··· 12 12 "github.com/distribution/distribution/v3/configuration" 13 13 ) 14 14 15 - // loadConfigFromEnv builds a complete configuration from environment variables 15 + // LoadConfigFromEnv builds a complete configuration from environment variables 16 16 // This follows the same pattern as the hold service (no config files, only env vars) 17 - func loadConfigFromEnv() (*configuration.Configuration, error) { 17 + func LoadConfigFromEnv() (*configuration.Configuration, error) { 18 18 config := &configuration.Configuration{} 19 19 20 20 // Version ··· 56 56 57 57 // buildLogConfig creates logging configuration from environment variables 58 58 func buildLogConfig() configuration.Log { 59 - level := getEnvOrDefault("ATCR_LOG_LEVEL", "info") 60 - formatter := getEnvOrDefault("ATCR_LOG_FORMATTER", "text") 59 + level := GetEnvOrDefault("ATCR_LOG_LEVEL", "info") 60 + formatter := GetEnvOrDefault("ATCR_LOG_FORMATTER", "text") 61 61 62 62 return configuration.Log{ 63 63 Level: configuration.Loglevel(level), ··· 70 70 71 71 // buildHTTPConfig creates HTTP server configuration from environment variables 72 72 func buildHTTPConfig() (configuration.HTTP, error) { 73 - addr := getEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 74 - debugAddr := getEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 73 + addr := GetEnvOrDefault("ATCR_HTTP_ADDR", ":5000") 74 + debugAddr := GetEnvOrDefault("ATCR_DEBUG_ADDR", ":5001") 75 75 76 76 // HTTP secret - only needed for multipart uploads in distribution's storage driver 77 77 // Since AppView is stateless and routes all storage through middleware, this isn't ··· 143 143 // buildAuthConfig creates authentication configuration from environment variables 144 144 func buildAuthConfig(baseURL string) (configuration.Auth, error) { 145 145 // Token configuration 146 - privateKeyPath := getEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 147 - certPath := getEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 146 + privateKeyPath := GetEnvOrDefault("ATCR_AUTH_KEY_PATH", "/var/lib/atcr/auth/private-key.pem") 147 + certPath := GetEnvOrDefault("ATCR_AUTH_CERT_PATH", "/var/lib/atcr/auth/private-key.crt") 148 148 149 149 // Token expiration in seconds (default: 5 minutes) 150 - expirationStr := getEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 150 + expirationStr := GetEnvOrDefault("ATCR_TOKEN_EXPIRATION", "300") 151 151 expiration, err := strconv.Atoi(expirationStr) 152 152 if err != nil { 153 153 return configuration.Auth{}, fmt.Errorf("invalid ATCR_TOKEN_EXPIRATION: %w", err) ··· 182 182 } 183 183 } 184 184 185 - // getBaseURL determines the base URL for the service 185 + // GetBaseURL determines the base URL for the service 186 186 // Priority: ATCR_BASE_URL env var, then derived from HTTP addr 187 - func getBaseURL(httpAddr string) string { 187 + func GetBaseURL(httpAddr string) string { 188 188 baseURL := os.Getenv("ATCR_BASE_URL") 189 189 if baseURL != "" { 190 190 return baseURL ··· 198 198 199 199 // Full address provided 200 200 return fmt.Sprintf("http://%s", httpAddr) 201 + } 202 + 203 + // getBaseURL is the internal version used by buildAuthConfig 204 + func getBaseURL(httpAddr string) string { 205 + return GetBaseURL(httpAddr) 201 206 } 202 207 203 208 // getServiceName extracts service name from base URL or uses env var ··· 224 229 return "atcr.io" 225 230 } 226 231 227 - // getEnvOrDefault gets an environment variable or returns a default value 228 - func getEnvOrDefault(key, defaultValue string) string { 232 + // GetEnvOrDefault gets an environment variable or returns a default value 233 + func GetEnvOrDefault(key, defaultValue string) string { 229 234 if val := os.Getenv(key); val != "" { 230 235 return val 231 236 } 232 237 return defaultValue 233 238 } 239 + 240 + // GetStringParam extracts a string parameter from configuration.Parameters 241 + func GetStringParam(params configuration.Parameters, key, defaultValue string) string { 242 + if v, ok := params[key]; ok { 243 + if s, ok := v.(string); ok { 244 + return s 245 + } 246 + } 247 + return defaultValue 248 + } 249 + 250 + // GetIntParam extracts an int parameter from configuration.Parameters 251 + func GetIntParam(params configuration.Parameters, key string, defaultValue int) int { 252 + if v, ok := params[key]; ok { 253 + if i, ok := v.(int); ok { 254 + return i 255 + } 256 + } 257 + return defaultValue 258 + } 259 + 260 + // ExtractDefaultHoldDID extracts the default hold DID from middleware config 261 + // Returns a DID (e.g., "did:web:hold01.atcr.io") 262 + // To find a hold's DID, visit: https://hold-url/.well-known/did.json 263 + func ExtractDefaultHoldDID(config *configuration.Configuration) string { 264 + // Navigate through: middleware.registry[].options.default_hold_did 265 + registryMiddleware, ok := config.Middleware["registry"] 266 + if !ok { 267 + return "" 268 + } 269 + 270 + // Find atproto-resolver middleware 271 + for _, mw := range registryMiddleware { 272 + // Check if this is the atproto-resolver 273 + if mw.Name != "atproto-resolver" { 274 + continue 275 + } 276 + 277 + // Extract options - options is configuration.Parameters which is map[string]any 278 + if mw.Options != nil { 279 + if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 280 + return holdDID 281 + } 282 + } 283 + } 284 + 285 + return "" 286 + }
+16 -164
cmd/appview/serve.go
··· 9 9 "net/http" 10 10 "os" 11 11 "os/signal" 12 - "path/filepath" 13 12 "syscall" 14 13 "time" 15 14 16 15 "github.com/distribution/distribution/v3/configuration" 17 16 "github.com/distribution/distribution/v3/registry" 18 17 "github.com/distribution/distribution/v3/registry/handlers" 19 - sqlite3 "github.com/mattn/go-sqlite3" 20 18 "github.com/spf13/cobra" 21 19 22 20 "atcr.io/pkg/appview/middleware" ··· 32 30 "github.com/gorilla/mux" 33 31 ) 34 32 35 - // Define sensitive tables that should never be accessible from public queries 36 - var sensitiveTables = map[string]bool{ 37 - "oauth_sessions": true, // OAuth tokens 38 - "ui_sessions": true, // Session IDs 39 - "oauth_auth_requests": true, // OAuth state 40 - "devices": true, // Device secret hashes 41 - "pending_device_auth": true, // Pending device secrets 42 - } 43 - 44 - // readOnlyAuthorizerCallback blocks access to sensitive tables 45 - func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { 46 - // arg1 contains the table name for most operations 47 - tableName := arg1 48 - 49 - // Block any access to sensitive tables 50 - if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || 51 - action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || 52 - action == sqlite3.SQLITE_SELECT { 53 - if sensitiveTables[tableName] { 54 - fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) 55 - return sqlite3.SQLITE_DENY 56 - } 57 - } 58 - 59 - // Allow everything else 60 - return sqlite3.SQLITE_OK 61 - } 62 - 63 33 var serveCmd = &cobra.Command{ 64 34 Use: "serve", 65 35 Short: "Start the ATCR registry server", ··· 72 42 } 73 43 74 44 func init() { 75 - // Register a custom SQLite driver with authorizer for read-only public queries 76 - sql.Register("sqlite3_readonly_public", 77 - &sqlite3.SQLiteDriver{ 78 - ConnectHook: func(conn *sqlite3.SQLiteConn) error { 79 - conn.RegisterAuthorizer(readOnlyAuthorizerCallback) 80 - return nil 81 - }, 82 - }) 83 - 84 45 // Replace the default serve command with our custom one 85 46 for i, cmd := range registry.RootCmd.Commands() { 86 47 if cmd.Name() == "serve" { ··· 93 54 func serveRegistry(cmd *cobra.Command, args []string) error { 94 55 // Load configuration from environment variables 95 56 fmt.Println("Loading configuration from environment variables...") 96 - config, err := loadConfigFromEnv() 57 + config, err := appview.LoadConfigFromEnv() 97 58 if err != nil { 98 59 return fmt.Errorf("failed to load config from environment: %w", err) 99 60 } ··· 101 62 102 63 // Initialize UI database first (required for all stores) 103 64 fmt.Println("Initializing UI database...") 104 - uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase() 65 + uiEnabled := os.Getenv("ATCR_UI_ENABLED") != "false" 66 + dbPath := os.Getenv("ATCR_UI_DATABASE_PATH") 67 + if dbPath == "" { 68 + dbPath = "/var/lib/atcr/ui.db" 69 + } 70 + uiDatabase, uiReadOnlyDB, uiSessionStore := db.InitializeDatabase(uiEnabled, dbPath) 105 71 if uiDatabase == nil { 106 72 return fmt.Errorf("failed to initialize UI database - required for session storage") 107 73 } ··· 158 124 // Expected format: "did:web:hold01.atcr.io" 159 125 // To find a hold's DID, visit: https://hold01.atcr.io/.well-known/did.json 160 126 // The extraction function normalizes URLs to DIDs for consistency 161 - defaultHoldDID := extractDefaultHoldDID(config) 127 + defaultHoldDID := appview.ExtractDefaultHoldDID(config) 162 128 163 129 // Initialize UI routes with OAuth app, refresher, and device store 164 130 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID) ··· 304 270 return nil 305 271 } 306 272 307 - privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 308 - issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 309 - service := getStringParam(tokenParams, "service", "atcr.io") 310 - expirationSecs := getIntParam(tokenParams, "expiration", 300) 273 + privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 274 + issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 275 + service := appview.GetStringParam(tokenParams, "service", "atcr.io") 276 + expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 311 277 312 278 // Create issuer (this will generate the key if it doesn't exist) 313 279 _, err := token.NewIssuer( ··· 331 297 return nil, fmt.Errorf("token auth not configured") 332 298 } 333 299 334 - privateKeyPath := getStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 335 - issuerName := getStringParam(tokenParams, "issuer", "atcr.io") 336 - service := getStringParam(tokenParams, "service", "atcr.io") 337 - expirationSecs := getIntParam(tokenParams, "expiration", 300) 300 + privateKeyPath := appview.GetStringParam(tokenParams, "privatekey", "/var/lib/atcr/auth/private-key.pem") 301 + issuerName := appview.GetStringParam(tokenParams, "issuer", "atcr.io") 302 + service := appview.GetStringParam(tokenParams, "service", "atcr.io") 303 + expirationSecs := appview.GetIntParam(tokenParams, "expiration", 300) 338 304 339 305 return token.NewIssuer( 340 306 privateKeyPath, ··· 342 308 service, 343 309 time.Duration(expirationSecs)*time.Second, 344 310 ) 345 - } 346 - 347 - // Helper functions to extract values from config parameters 348 - func getStringParam(params configuration.Parameters, key, defaultValue string) string { 349 - if v, ok := params[key]; ok { 350 - if s, ok := v.(string); ok { 351 - return s 352 - } 353 - } 354 - return defaultValue 355 - } 356 - 357 - func getIntParam(params configuration.Parameters, key string, defaultValue int) int { 358 - if v, ok := params[key]; ok { 359 - if i, ok := v.(int); ok { 360 - return i 361 - } 362 - } 363 - return defaultValue 364 - } 365 - 366 - // extractDefaultHoldDID extracts the default hold DID from middleware config 367 - // Returns a DID (e.g., "did:web:hold01.atcr.io") 368 - // To find a hold's DID, visit: https://hold-url/.well-known/did.json 369 - func extractDefaultHoldDID(config *configuration.Configuration) string { 370 - // Navigate through: middleware.registry[].options.default_hold_did 371 - registryMiddleware, ok := config.Middleware["registry"] 372 - if !ok { 373 - return "" 374 - } 375 - 376 - // Find atproto-resolver middleware 377 - for _, mw := range registryMiddleware { 378 - // Check if this is the atproto-resolver 379 - if mw.Name != "atproto-resolver" { 380 - continue 381 - } 382 - 383 - // Extract options - options is configuration.Parameters which is map[string]any 384 - if mw.Options != nil { 385 - if holdDID, ok := mw.Options["default_hold_did"].(string); ok { 386 - return holdDID 387 - } 388 - } 389 - } 390 - 391 - return "" 392 - } 393 - 394 - // initializeDatabase initializes the SQLite database and session store 395 - // Returns: (read-write DB, read-only DB, session store) 396 - func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) { 397 - // Check if UI is enabled (optional configuration) 398 - uiEnabled := os.Getenv("ATCR_UI_ENABLED") 399 - if uiEnabled == "false" { 400 - return nil, nil, nil 401 - } 402 - 403 - // Get database path 404 - dbPath := os.Getenv("ATCR_UI_DATABASE_PATH") 405 - if dbPath == "" { 406 - dbPath = "/var/lib/atcr/ui.db" 407 - } 408 - 409 - // Ensure directory exists 410 - dbDir := filepath.Dir(dbPath) 411 - if err := os.MkdirAll(dbDir, 0700); err != nil { 412 - fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 413 - return nil, nil, nil 414 - } 415 - 416 - // Initialize read-write database (for writes and auth operations) 417 - database, err := db.InitDB(dbPath) 418 - if err != nil { 419 - fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 420 - return nil, nil, nil 421 - } 422 - 423 - // Open read-only connection for public queries (search, user pages, etc.) 424 - // Uses custom driver with SQLite authorizer that blocks sensitive tables 425 - // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs 426 - readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 427 - if err != nil { 428 - fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) 429 - return nil, nil, nil 430 - } 431 - 432 - fmt.Printf("UI database (readonly) initialized at %s\n", dbPath) 433 - 434 - // Create SQLite-backed session store 435 - sessionStore := db.NewSessionStore(database) 436 - 437 - // Start cleanup goroutines for all SQLite stores 438 - go func() { 439 - ticker := time.NewTicker(5 * time.Minute) 440 - defer ticker.Stop() 441 - for range ticker.C { 442 - ctx := context.Background() 443 - 444 - // Cleanup UI sessions 445 - sessionStore.Cleanup() 446 - 447 - // Cleanup OAuth sessions (older than 30 days) 448 - oauthStore := db.NewOAuthStore(database) 449 - oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) 450 - oauthStore.CleanupExpiredAuthRequests(ctx) 451 - 452 - // Cleanup device pending auths 453 - deviceStore := db.NewDeviceStore(database) 454 - deviceStore.CleanupExpired() 455 - } 456 - }() 457 - 458 - return database, readOnlyDB, sessionStore 459 311 } 460 312 461 313 // initializeUIRoutes initializes the web UI routes
pkg/appview/appview.go pkg/appview/ui.go
+844
pkg/appview/config_test.go
··· 1 + package appview 2 + 3 + import ( 4 + "os" 5 + "testing" 6 + 7 + "github.com/distribution/distribution/v3/configuration" 8 + ) 9 + 10 + func TestGetEnvOrDefault(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + key string 14 + defaultValue string 15 + envValue string 16 + setEnv bool 17 + want string 18 + }{ 19 + { 20 + name: "env var not set", 21 + key: "TEST_VAR_NOT_SET", 22 + defaultValue: "default", 23 + setEnv: false, 24 + want: "default", 25 + }, 26 + { 27 + name: "env var set to value", 28 + key: "TEST_VAR_SET", 29 + defaultValue: "default", 30 + envValue: "custom", 31 + setEnv: true, 32 + want: "custom", 33 + }, 34 + { 35 + name: "env var set to empty string", 36 + key: "TEST_VAR_EMPTY", 37 + defaultValue: "default", 38 + envValue: "", 39 + setEnv: true, 40 + want: "default", 41 + }, 42 + } 43 + 44 + for _, tt := range tests { 45 + t.Run(tt.name, func(t *testing.T) { 46 + if tt.setEnv { 47 + t.Setenv(tt.key, tt.envValue) 48 + } 49 + 50 + got := GetEnvOrDefault(tt.key, tt.defaultValue) 51 + if got != tt.want { 52 + t.Errorf("GetEnvOrDefault() = %v, want %v", got, tt.want) 53 + } 54 + }) 55 + } 56 + } 57 + 58 + func TestGetBaseURL(t *testing.T) { 59 + tests := []struct { 60 + name string 61 + httpAddr string 62 + envBaseURL string 63 + setEnv bool 64 + want string 65 + }{ 66 + { 67 + name: "env var set", 68 + httpAddr: ":5000", 69 + envBaseURL: "https://registry.example.com", 70 + setEnv: true, 71 + want: "https://registry.example.com", 72 + }, 73 + { 74 + name: "port only - auto detect localhost", 75 + httpAddr: ":5000", 76 + setEnv: false, 77 + want: "http://127.0.0.1:5000", 78 + }, 79 + { 80 + name: "full address", 81 + httpAddr: "0.0.0.0:5000", 82 + setEnv: false, 83 + want: "http://0.0.0.0:5000", 84 + }, 85 + { 86 + name: "custom port", 87 + httpAddr: ":8080", 88 + setEnv: false, 89 + want: "http://127.0.0.1:8080", 90 + }, 91 + } 92 + 93 + for _, tt := range tests { 94 + t.Run(tt.name, func(t *testing.T) { 95 + if tt.setEnv { 96 + t.Setenv("ATCR_BASE_URL", tt.envBaseURL) 97 + } else { 98 + os.Unsetenv("ATCR_BASE_URL") 99 + } 100 + 101 + got := GetBaseURL(tt.httpAddr) 102 + if got != tt.want { 103 + t.Errorf("GetBaseURL() = %v, want %v", got, tt.want) 104 + } 105 + }) 106 + } 107 + } 108 + 109 + func Test_getServiceName(t *testing.T) { 110 + tests := []struct { 111 + name string 112 + baseURL string 113 + envService string 114 + setEnv bool 115 + want string 116 + }{ 117 + { 118 + name: "env var set", 119 + baseURL: "http://127.0.0.1:5000", 120 + envService: "custom.registry.io", 121 + setEnv: true, 122 + want: "custom.registry.io", 123 + }, 124 + { 125 + name: "localhost - use default", 126 + baseURL: "http://localhost:5000", 127 + setEnv: false, 128 + want: "atcr.io", 129 + }, 130 + { 131 + name: "127.0.0.1 - use default", 132 + baseURL: "http://127.0.0.1:5000", 133 + setEnv: false, 134 + want: "atcr.io", 135 + }, 136 + { 137 + name: "custom domain", 138 + baseURL: "https://registry.example.com", 139 + setEnv: false, 140 + want: "registry.example.com", 141 + }, 142 + { 143 + name: "domain with port", 144 + baseURL: "https://registry.example.com:443", 145 + setEnv: false, 146 + want: "registry.example.com", 147 + }, 148 + { 149 + name: "invalid URL - use default", 150 + baseURL: "://invalid", 151 + setEnv: false, 152 + want: "atcr.io", 153 + }, 154 + } 155 + 156 + for _, tt := range tests { 157 + t.Run(tt.name, func(t *testing.T) { 158 + if tt.setEnv { 159 + t.Setenv("ATCR_SERVICE_NAME", tt.envService) 160 + } else { 161 + os.Unsetenv("ATCR_SERVICE_NAME") 162 + } 163 + 164 + got := getServiceName(tt.baseURL) 165 + if got != tt.want { 166 + t.Errorf("getServiceName() = %v, want %v", got, tt.want) 167 + } 168 + }) 169 + } 170 + } 171 + 172 + func TestBuildLogConfig(t *testing.T) { 173 + tests := []struct { 174 + name string 175 + envLevel string 176 + envFormatter string 177 + setLevel bool 178 + setFormatter bool 179 + wantLevel configuration.Loglevel 180 + wantFormatter string 181 + }{ 182 + { 183 + name: "defaults", 184 + setLevel: false, 185 + setFormatter: false, 186 + wantLevel: "info", 187 + wantFormatter: "text", 188 + }, 189 + { 190 + name: "custom level", 191 + envLevel: "debug", 192 + setLevel: true, 193 + setFormatter: false, 194 + wantLevel: "debug", 195 + wantFormatter: "text", 196 + }, 197 + { 198 + name: "custom formatter", 199 + envLevel: "info", 200 + envFormatter: "json", 201 + setLevel: true, 202 + setFormatter: true, 203 + wantLevel: "info", 204 + wantFormatter: "json", 205 + }, 206 + } 207 + 208 + for _, tt := range tests { 209 + t.Run(tt.name, func(t *testing.T) { 210 + if tt.setLevel { 211 + t.Setenv("ATCR_LOG_LEVEL", tt.envLevel) 212 + } else { 213 + os.Unsetenv("ATCR_LOG_LEVEL") 214 + } 215 + 216 + if tt.setFormatter { 217 + t.Setenv("ATCR_LOG_FORMATTER", tt.envFormatter) 218 + } else { 219 + os.Unsetenv("ATCR_LOG_FORMATTER") 220 + } 221 + 222 + got := buildLogConfig() 223 + if got.Level != tt.wantLevel { 224 + t.Errorf("buildLogConfig().Level = %v, want %v", got.Level, tt.wantLevel) 225 + } 226 + if got.Formatter != tt.wantFormatter { 227 + t.Errorf("buildLogConfig().Formatter = %v, want %v", got.Formatter, tt.wantFormatter) 228 + } 229 + if got.Fields["service"] != "atcr-appview" { 230 + t.Errorf("buildLogConfig().Fields[service] = %v, want atcr-appview", got.Fields["service"]) 231 + } 232 + }) 233 + } 234 + } 235 + 236 + func TestBuildHTTPConfig(t *testing.T) { 237 + tests := []struct { 238 + name string 239 + envAddr string 240 + envDebugAddr string 241 + envSecret string 242 + setAddr bool 243 + setDebugAddr bool 244 + setSecret bool 245 + wantAddr string 246 + wantDebug string 247 + wantSecret string // empty means "should be generated" 248 + }{ 249 + { 250 + name: "defaults", 251 + setAddr: false, 252 + wantAddr: ":5000", 253 + wantDebug: ":5001", 254 + wantSecret: "", // generated 255 + }, 256 + { 257 + name: "custom addr", 258 + envAddr: ":8080", 259 + setAddr: true, 260 + setDebugAddr: false, 261 + wantAddr: ":8080", 262 + wantDebug: ":5001", 263 + wantSecret: "", 264 + }, 265 + { 266 + name: "custom debug addr", 267 + envDebugAddr: ":9001", 268 + setAddr: false, 269 + setDebugAddr: true, 270 + wantAddr: ":5000", 271 + wantDebug: ":9001", 272 + wantSecret: "", 273 + }, 274 + { 275 + name: "custom secret", 276 + envSecret: "my-custom-secret", 277 + setAddr: false, 278 + setSecret: true, 279 + wantAddr: ":5000", 280 + wantDebug: ":5001", 281 + wantSecret: "my-custom-secret", 282 + }, 283 + } 284 + 285 + for _, tt := range tests { 286 + t.Run(tt.name, func(t *testing.T) { 287 + if tt.setAddr { 288 + t.Setenv("ATCR_HTTP_ADDR", tt.envAddr) 289 + } else { 290 + os.Unsetenv("ATCR_HTTP_ADDR") 291 + } 292 + 293 + if tt.setDebugAddr { 294 + t.Setenv("ATCR_DEBUG_ADDR", tt.envDebugAddr) 295 + } else { 296 + os.Unsetenv("ATCR_DEBUG_ADDR") 297 + } 298 + 299 + if tt.setSecret { 300 + t.Setenv("REGISTRY_HTTP_SECRET", tt.envSecret) 301 + } else { 302 + os.Unsetenv("REGISTRY_HTTP_SECRET") 303 + } 304 + 305 + got, err := buildHTTPConfig() 306 + if err != nil { 307 + t.Fatalf("buildHTTPConfig() error = %v", err) 308 + } 309 + 310 + if got.Addr != tt.wantAddr { 311 + t.Errorf("buildHTTPConfig().Addr = %v, want %v", got.Addr, tt.wantAddr) 312 + } 313 + 314 + if got.Debug.Addr != tt.wantDebug { 315 + t.Errorf("buildHTTPConfig().Debug.Addr = %v, want %v", got.Debug.Addr, tt.wantDebug) 316 + } 317 + 318 + if tt.wantSecret == "" { 319 + // Should be generated (64 hex chars = 32 bytes) 320 + if len(got.Secret) != 64 { 321 + t.Errorf("buildHTTPConfig().Secret length = %v, want 64", len(got.Secret)) 322 + } 323 + } else { 324 + if got.Secret != tt.wantSecret { 325 + t.Errorf("buildHTTPConfig().Secret = %v, want %v", got.Secret, tt.wantSecret) 326 + } 327 + } 328 + 329 + // Verify headers 330 + if got.Headers["X-Content-Type-Options"][0] != "nosniff" { 331 + t.Error("buildHTTPConfig() missing X-Content-Type-Options header") 332 + } 333 + }) 334 + } 335 + } 336 + 337 + func TestBuildStorageConfig(t *testing.T) { 338 + got := buildStorageConfig() 339 + 340 + // Verify inmemory driver exists 341 + if _, ok := got["inmemory"]; !ok { 342 + t.Error("buildStorageConfig() missing inmemory driver") 343 + } 344 + 345 + // Verify maintenance config 346 + maintenance, ok := got["maintenance"] 347 + if !ok { 348 + t.Fatal("buildStorageConfig() missing maintenance config") 349 + } 350 + 351 + uploadPurging, ok := maintenance["uploadpurging"] 352 + if !ok { 353 + t.Fatal("buildStorageConfig() missing uploadpurging config") 354 + } 355 + 356 + // Verify uploadpurging is map[any]any (for distribution validation) 357 + purging, ok := uploadPurging.(map[any]any) 358 + if !ok { 359 + t.Fatalf("uploadpurging is %T, want map[any]any", uploadPurging) 360 + } 361 + 362 + if purging["enabled"] != false { 363 + t.Error("uploadpurging enabled should be false") 364 + } 365 + } 366 + 367 + func TestBuildMiddlewareConfig(t *testing.T) { 368 + tests := []struct { 369 + name string 370 + defaultHoldDID string 371 + testMode bool 372 + setTestMode bool 373 + wantTestMode bool 374 + }{ 375 + { 376 + name: "normal mode", 377 + defaultHoldDID: "did:web:hold01.atcr.io", 378 + setTestMode: false, 379 + wantTestMode: false, 380 + }, 381 + { 382 + name: "test mode enabled", 383 + defaultHoldDID: "did:web:hold01.atcr.io", 384 + testMode: true, 385 + setTestMode: true, 386 + wantTestMode: true, 387 + }, 388 + } 389 + 390 + for _, tt := range tests { 391 + t.Run(tt.name, func(t *testing.T) { 392 + if tt.setTestMode { 393 + t.Setenv("TEST_MODE", "true") 394 + } else { 395 + os.Unsetenv("TEST_MODE") 396 + } 397 + 398 + got := buildMiddlewareConfig(tt.defaultHoldDID) 399 + 400 + registryMW, ok := got["registry"] 401 + if !ok { 402 + t.Fatal("buildMiddlewareConfig() missing registry middleware") 403 + } 404 + 405 + if len(registryMW) != 1 { 406 + t.Fatalf("buildMiddlewareConfig() registry middleware count = %v, want 1", len(registryMW)) 407 + } 408 + 409 + mw := registryMW[0] 410 + if mw.Name != "atproto-resolver" { 411 + t.Errorf("middleware name = %v, want atproto-resolver", mw.Name) 412 + } 413 + 414 + if mw.Options["default_hold_did"] != tt.defaultHoldDID { 415 + t.Errorf("default_hold_did = %v, want %v", mw.Options["default_hold_did"], tt.defaultHoldDID) 416 + } 417 + 418 + if mw.Options["test_mode"] != tt.wantTestMode { 419 + t.Errorf("test_mode = %v, want %v", mw.Options["test_mode"], tt.wantTestMode) 420 + } 421 + }) 422 + } 423 + } 424 + 425 + func TestBuildAuthConfig(t *testing.T) { 426 + tests := []struct { 427 + name string 428 + baseURL string 429 + envKeyPath string 430 + envCertPath string 431 + envExpiration string 432 + setKeyPath bool 433 + setCertPath bool 434 + setExpiration bool 435 + wantKeyPath string 436 + wantCertPath string 437 + wantExpiration int 438 + wantRealm string 439 + wantService string 440 + wantError bool 441 + }{ 442 + { 443 + name: "defaults", 444 + baseURL: "http://127.0.0.1:5000", 445 + setKeyPath: false, 446 + setCertPath: false, 447 + setExpiration: false, 448 + wantKeyPath: "/var/lib/atcr/auth/private-key.pem", 449 + wantCertPath: "/var/lib/atcr/auth/private-key.crt", 450 + wantExpiration: 300, 451 + wantRealm: "http://127.0.0.1:5000/auth/token", 452 + wantService: "atcr.io", 453 + wantError: false, 454 + }, 455 + { 456 + name: "custom values", 457 + baseURL: "https://registry.example.com", 458 + envKeyPath: "/custom/key.pem", 459 + envCertPath: "/custom/cert.crt", 460 + envExpiration: "600", 461 + setKeyPath: true, 462 + setCertPath: true, 463 + setExpiration: true, 464 + wantKeyPath: "/custom/key.pem", 465 + wantCertPath: "/custom/cert.crt", 466 + wantExpiration: 600, 467 + wantRealm: "https://registry.example.com/auth/token", 468 + wantService: "registry.example.com", 469 + wantError: false, 470 + }, 471 + { 472 + name: "invalid expiration", 473 + baseURL: "http://127.0.0.1:5000", 474 + envExpiration: "not-a-number", 475 + setExpiration: true, 476 + wantError: true, 477 + }, 478 + } 479 + 480 + for _, tt := range tests { 481 + t.Run(tt.name, func(t *testing.T) { 482 + if tt.setKeyPath { 483 + t.Setenv("ATCR_AUTH_KEY_PATH", tt.envKeyPath) 484 + } else { 485 + os.Unsetenv("ATCR_AUTH_KEY_PATH") 486 + } 487 + 488 + if tt.setCertPath { 489 + t.Setenv("ATCR_AUTH_CERT_PATH", tt.envCertPath) 490 + } else { 491 + os.Unsetenv("ATCR_AUTH_CERT_PATH") 492 + } 493 + 494 + if tt.setExpiration { 495 + t.Setenv("ATCR_TOKEN_EXPIRATION", tt.envExpiration) 496 + } else { 497 + os.Unsetenv("ATCR_TOKEN_EXPIRATION") 498 + } 499 + 500 + // Clear service name env var 501 + os.Unsetenv("ATCR_SERVICE_NAME") 502 + 503 + got, err := buildAuthConfig(tt.baseURL) 504 + if (err != nil) != tt.wantError { 505 + t.Errorf("buildAuthConfig() error = %v, wantError %v", err, tt.wantError) 506 + return 507 + } 508 + 509 + if tt.wantError { 510 + return 511 + } 512 + 513 + tokenParams, ok := got["token"] 514 + if !ok { 515 + t.Fatal("buildAuthConfig() missing token params") 516 + } 517 + 518 + if tokenParams["privatekey"] != tt.wantKeyPath { 519 + t.Errorf("privatekey = %v, want %v", tokenParams["privatekey"], tt.wantKeyPath) 520 + } 521 + 522 + if tokenParams["rootcertbundle"] != tt.wantCertPath { 523 + t.Errorf("rootcertbundle = %v, want %v", tokenParams["rootcertbundle"], tt.wantCertPath) 524 + } 525 + 526 + if tokenParams["expiration"] != tt.wantExpiration { 527 + t.Errorf("expiration = %v, want %v", tokenParams["expiration"], tt.wantExpiration) 528 + } 529 + 530 + if tokenParams["realm"] != tt.wantRealm { 531 + t.Errorf("realm = %v, want %v", tokenParams["realm"], tt.wantRealm) 532 + } 533 + 534 + if tokenParams["service"] != tt.wantService { 535 + t.Errorf("service = %v, want %v", tokenParams["service"], tt.wantService) 536 + } 537 + 538 + if tokenParams["issuer"] != tt.wantService { 539 + t.Errorf("issuer = %v, want %v", tokenParams["issuer"], tt.wantService) 540 + } 541 + }) 542 + } 543 + } 544 + 545 + func TestBuildHealthConfig(t *testing.T) { 546 + got := buildHealthConfig() 547 + 548 + if !got.StorageDriver.Enabled { 549 + t.Error("buildHealthConfig().StorageDriver.Enabled = false, want true") 550 + } 551 + 552 + if got.StorageDriver.Interval.Seconds() != 10 { 553 + t.Errorf("buildHealthConfig().StorageDriver.Interval = %v, want 10s", got.StorageDriver.Interval) 554 + } 555 + 556 + if got.StorageDriver.Threshold != 3 { 557 + t.Errorf("buildHealthConfig().StorageDriver.Threshold = %v, want 3", got.StorageDriver.Threshold) 558 + } 559 + } 560 + 561 + func TestGetStringParam(t *testing.T) { 562 + tests := []struct { 563 + name string 564 + params configuration.Parameters 565 + key string 566 + defaultValue string 567 + want string 568 + }{ 569 + { 570 + name: "string value exists", 571 + params: configuration.Parameters{ 572 + "foo": "bar", 573 + }, 574 + key: "foo", 575 + defaultValue: "default", 576 + want: "bar", 577 + }, 578 + { 579 + name: "key does not exist", 580 + params: configuration.Parameters{}, 581 + key: "foo", 582 + defaultValue: "default", 583 + want: "default", 584 + }, 585 + { 586 + name: "value is not a string", 587 + params: configuration.Parameters{ 588 + "foo": 123, 589 + }, 590 + key: "foo", 591 + defaultValue: "default", 592 + want: "default", 593 + }, 594 + { 595 + name: "empty string value", 596 + params: configuration.Parameters{ 597 + "foo": "", 598 + }, 599 + key: "foo", 600 + defaultValue: "default", 601 + want: "", 602 + }, 603 + } 604 + 605 + for _, tt := range tests { 606 + t.Run(tt.name, func(t *testing.T) { 607 + got := GetStringParam(tt.params, tt.key, tt.defaultValue) 608 + if got != tt.want { 609 + t.Errorf("GetStringParam() = %v, want %v", got, tt.want) 610 + } 611 + }) 612 + } 613 + } 614 + 615 + func TestGetIntParam(t *testing.T) { 616 + tests := []struct { 617 + name string 618 + params configuration.Parameters 619 + key string 620 + defaultValue int 621 + want int 622 + }{ 623 + { 624 + name: "int value exists", 625 + params: configuration.Parameters{ 626 + "foo": 42, 627 + }, 628 + key: "foo", 629 + defaultValue: 100, 630 + want: 42, 631 + }, 632 + { 633 + name: "key does not exist", 634 + params: configuration.Parameters{}, 635 + key: "foo", 636 + defaultValue: 100, 637 + want: 100, 638 + }, 639 + { 640 + name: "value is not an int", 641 + params: configuration.Parameters{ 642 + "foo": "not-an-int", 643 + }, 644 + key: "foo", 645 + defaultValue: 100, 646 + want: 100, 647 + }, 648 + { 649 + name: "zero value", 650 + params: configuration.Parameters{ 651 + "foo": 0, 652 + }, 653 + key: "foo", 654 + defaultValue: 100, 655 + want: 0, 656 + }, 657 + } 658 + 659 + for _, tt := range tests { 660 + t.Run(tt.name, func(t *testing.T) { 661 + got := GetIntParam(tt.params, tt.key, tt.defaultValue) 662 + if got != tt.want { 663 + t.Errorf("GetIntParam() = %v, want %v", got, tt.want) 664 + } 665 + }) 666 + } 667 + } 668 + 669 + func TestExtractDefaultHoldDID(t *testing.T) { 670 + tests := []struct { 671 + name string 672 + config *configuration.Configuration 673 + want string 674 + }{ 675 + { 676 + name: "valid config with hold DID", 677 + config: &configuration.Configuration{ 678 + Middleware: map[string][]configuration.Middleware{ 679 + "registry": { 680 + { 681 + Name: "atproto-resolver", 682 + Options: configuration.Parameters{ 683 + "default_hold_did": "did:web:hold01.atcr.io", 684 + }, 685 + }, 686 + }, 687 + }, 688 + }, 689 + want: "did:web:hold01.atcr.io", 690 + }, 691 + { 692 + name: "no registry middleware", 693 + config: &configuration.Configuration{ 694 + Middleware: map[string][]configuration.Middleware{}, 695 + }, 696 + want: "", 697 + }, 698 + { 699 + name: "no atproto-resolver middleware", 700 + config: &configuration.Configuration{ 701 + Middleware: map[string][]configuration.Middleware{ 702 + "registry": { 703 + { 704 + Name: "other-middleware", 705 + Options: configuration.Parameters{ 706 + "foo": "bar", 707 + }, 708 + }, 709 + }, 710 + }, 711 + }, 712 + want: "", 713 + }, 714 + { 715 + name: "atproto-resolver without default_hold_did", 716 + config: &configuration.Configuration{ 717 + Middleware: map[string][]configuration.Middleware{ 718 + "registry": { 719 + { 720 + Name: "atproto-resolver", 721 + Options: configuration.Parameters{ 722 + "other_option": "value", 723 + }, 724 + }, 725 + }, 726 + }, 727 + }, 728 + want: "", 729 + }, 730 + { 731 + name: "default_hold_did is not a string", 732 + config: &configuration.Configuration{ 733 + Middleware: map[string][]configuration.Middleware{ 734 + "registry": { 735 + { 736 + Name: "atproto-resolver", 737 + Options: configuration.Parameters{ 738 + "default_hold_did": 123, 739 + }, 740 + }, 741 + }, 742 + }, 743 + }, 744 + want: "", 745 + }, 746 + { 747 + name: "nil options", 748 + config: &configuration.Configuration{ 749 + Middleware: map[string][]configuration.Middleware{ 750 + "registry": { 751 + { 752 + Name: "atproto-resolver", 753 + Options: nil, 754 + }, 755 + }, 756 + }, 757 + }, 758 + want: "", 759 + }, 760 + } 761 + 762 + for _, tt := range tests { 763 + t.Run(tt.name, func(t *testing.T) { 764 + got := ExtractDefaultHoldDID(tt.config) 765 + if got != tt.want { 766 + t.Errorf("ExtractDefaultHoldDID() = %v, want %v", got, tt.want) 767 + } 768 + }) 769 + } 770 + } 771 + 772 + func TestLoadConfigFromEnv(t *testing.T) { 773 + tests := []struct { 774 + name string 775 + envHoldDID string 776 + setHoldDID bool 777 + wantError bool 778 + }{ 779 + { 780 + name: "valid config", 781 + envHoldDID: "did:web:hold01.atcr.io", 782 + setHoldDID: true, 783 + wantError: false, 784 + }, 785 + { 786 + name: "missing default hold DID", 787 + setHoldDID: false, 788 + wantError: true, 789 + }, 790 + } 791 + 792 + for _, tt := range tests { 793 + t.Run(tt.name, func(t *testing.T) { 794 + if tt.setHoldDID { 795 + t.Setenv("ATCR_DEFAULT_HOLD_DID", tt.envHoldDID) 796 + } else { 797 + os.Unsetenv("ATCR_DEFAULT_HOLD_DID") 798 + } 799 + 800 + // Clear other env vars to use defaults 801 + os.Unsetenv("ATCR_BASE_URL") 802 + os.Unsetenv("ATCR_SERVICE_NAME") 803 + 804 + got, err := LoadConfigFromEnv() 805 + if (err != nil) != tt.wantError { 806 + t.Errorf("LoadConfigFromEnv() error = %v, wantError %v", err, tt.wantError) 807 + return 808 + } 809 + 810 + if tt.wantError { 811 + return 812 + } 813 + 814 + // Verify config structure 815 + if got.Version.Major() != 0 || got.Version.Minor() != 1 { 816 + t.Errorf("version = %v, want 0.1", got.Version) 817 + } 818 + 819 + if got.Log.Level != "info" { 820 + t.Errorf("log level = %v, want info", got.Log.Level) 821 + } 822 + 823 + if got.HTTP.Addr != ":5000" { 824 + t.Errorf("HTTP addr = %v, want :5000", got.HTTP.Addr) 825 + } 826 + 827 + if _, ok := got.Storage["inmemory"]; !ok { 828 + t.Error("storage missing inmemory driver") 829 + } 830 + 831 + if _, ok := got.Middleware["registry"]; !ok { 832 + t.Error("middleware missing registry") 833 + } 834 + 835 + if _, ok := got.Auth["token"]; !ok { 836 + t.Error("auth missing token config") 837 + } 838 + 839 + if !got.Health.StorageDriver.Enabled { 840 + t.Error("health storage driver not enabled") 841 + } 842 + }) 843 + } 844 + }
+115
pkg/appview/db/readonly.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "time" 10 + 11 + sqlite3 "github.com/mattn/go-sqlite3" 12 + ) 13 + 14 + const ( 15 + // ReadOnlyDriverName is the name of the custom SQLite driver with table authorization 16 + ReadOnlyDriverName = "sqlite3_readonly_public" 17 + ) 18 + 19 + // sensitiveTables defines tables that should never be accessible from public queries 20 + var sensitiveTables = map[string]bool{ 21 + "oauth_sessions": true, // OAuth tokens 22 + "ui_sessions": true, // Session IDs 23 + "oauth_auth_requests": true, // OAuth state 24 + "devices": true, // Device secret hashes 25 + "pending_device_auth": true, // Pending device secrets 26 + } 27 + 28 + // readOnlyAuthorizerCallback blocks access to sensitive tables 29 + func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { 30 + // arg1 contains the table name for most operations 31 + tableName := arg1 32 + 33 + // Block any access to sensitive tables 34 + if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || 35 + action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || 36 + action == sqlite3.SQLITE_SELECT { 37 + if sensitiveTables[tableName] { 38 + fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) 39 + return sqlite3.SQLITE_DENY 40 + } 41 + } 42 + 43 + // Allow everything else 44 + return sqlite3.SQLITE_OK 45 + } 46 + 47 + func init() { 48 + // Register a custom SQLite driver with authorizer for read-only public queries 49 + sql.Register(ReadOnlyDriverName, 50 + &sqlite3.SQLiteDriver{ 51 + ConnectHook: func(conn *sqlite3.SQLiteConn) error { 52 + conn.RegisterAuthorizer(readOnlyAuthorizerCallback) 53 + return nil 54 + }, 55 + }) 56 + } 57 + 58 + // InitializeDatabase initializes the SQLite database and session store 59 + // Returns: (read-write DB, read-only DB, session store) 60 + func InitializeDatabase(uiEnabled bool, dbPath string) (*sql.DB, *sql.DB, *SessionStore) { 61 + if !uiEnabled { 62 + return nil, nil, nil 63 + } 64 + 65 + // Ensure directory exists 66 + dbDir := filepath.Dir(dbPath) 67 + if err := os.MkdirAll(dbDir, 0700); err != nil { 68 + fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 69 + return nil, nil, nil 70 + } 71 + 72 + // Initialize read-write database (for writes and auth operations) 73 + database, err := InitDB(dbPath) 74 + if err != nil { 75 + fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 76 + return nil, nil, nil 77 + } 78 + 79 + // Open read-only connection for public queries (search, user pages, etc.) 80 + // Uses custom driver with SQLite authorizer that blocks sensitive tables 81 + // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs 82 + readOnlyDB, err := sql.Open(ReadOnlyDriverName, "file:"+dbPath+"?mode=ro") 83 + if err != nil { 84 + fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) 85 + return nil, nil, nil 86 + } 87 + 88 + fmt.Printf("UI database (readonly) initialized at %s\n", dbPath) 89 + 90 + // Create SQLite-backed session store 91 + sessionStore := NewSessionStore(database) 92 + 93 + // Start cleanup goroutines for all SQLite stores 94 + go func() { 95 + ticker := time.NewTicker(5 * time.Minute) 96 + defer ticker.Stop() 97 + for range ticker.C { 98 + ctx := context.Background() 99 + 100 + // Cleanup UI sessions 101 + sessionStore.Cleanup() 102 + 103 + // Cleanup OAuth sessions (older than 30 days) 104 + oauthStore := NewOAuthStore(database) 105 + oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) 106 + oauthStore.CleanupExpiredAuthRequests(ctx) 107 + 108 + // Cleanup device pending auths 109 + deviceStore := NewDeviceStore(database) 110 + deviceStore.CleanupExpired() 111 + } 112 + }() 113 + 114 + return database, readOnlyDB, sessionStore 115 + }
+9
pkg/appview/handlers/common.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "strings" 5 6 6 7 "atcr.io/pkg/appview/db" 7 8 "atcr.io/pkg/appview/middleware" ··· 22 23 RegistryURL: registryURL, 23 24 } 24 25 } 26 + 27 + // TrimRegistryURL removes http:// or https:// prefix from a URL 28 + // for use in Docker commands where only the host:port is needed 29 + func TrimRegistryURL(url string) string { 30 + url = strings.TrimPrefix(url, "https://") 31 + url = strings.TrimPrefix(url, "http://") 32 + return url 33 + }
-11
pkg/appview/handlers/util.go
··· 1 - package handlers 2 - 3 - import "strings" 4 - 5 - // TrimRegistryURL removes http:// or https:// prefix from a URL 6 - // for use in Docker commands where only the host:port is needed 7 - func TrimRegistryURL(url string) string { 8 - url = strings.TrimPrefix(url, "https://") 9 - url = strings.TrimPrefix(url, "http://") 10 - return url 11 - }
+4 -4
pkg/appview/storage/proxy_blob_store.go
··· 76 76 77 77 // Use HTTP for localhost/IP addresses with ports, HTTPS for domains 78 78 if strings.Contains(hostname, ":") || 79 - strings.Contains(hostname, "127.0.0.1") || 80 - strings.Contains(hostname, "localhost") || 81 - // Check if it's an IP address (contains only digits and dots) 82 - (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 79 + strings.Contains(hostname, "127.0.0.1") || 80 + strings.Contains(hostname, "localhost") || 81 + // Check if it's an IP address (contains only digits and dots) 82 + (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { 83 83 return "http://" + hostname 84 84 } 85 85 return "https://" + hostname
+601
pkg/appview/ui_test.go
··· 1 + package appview 2 + 3 + import ( 4 + "bytes" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestTimeAgo(t *testing.T) { 11 + now := time.Now() 12 + 13 + tests := []struct { 14 + name string 15 + time time.Time 16 + expected string 17 + }{ 18 + { 19 + name: "just now - 30 seconds ago", 20 + time: now.Add(-30 * time.Second), 21 + expected: "just now", 22 + }, 23 + { 24 + name: "1 minute ago", 25 + time: now.Add(-1 * time.Minute), 26 + expected: "1 minute ago", 27 + }, 28 + { 29 + name: "5 minutes ago", 30 + time: now.Add(-5 * time.Minute), 31 + expected: "5 minutes ago", 32 + }, 33 + { 34 + name: "45 minutes ago", 35 + time: now.Add(-45 * time.Minute), 36 + expected: "45 minutes ago", 37 + }, 38 + { 39 + name: "1 hour ago", 40 + time: now.Add(-1 * time.Hour), 41 + expected: "1 hour ago", 42 + }, 43 + { 44 + name: "3 hours ago", 45 + time: now.Add(-3 * time.Hour), 46 + expected: "3 hours ago", 47 + }, 48 + { 49 + name: "23 hours ago", 50 + time: now.Add(-23 * time.Hour), 51 + expected: "23 hours ago", 52 + }, 53 + { 54 + name: "1 day ago", 55 + time: now.Add(-24 * time.Hour), 56 + expected: "1 day ago", 57 + }, 58 + { 59 + name: "5 days ago", 60 + time: now.Add(-5 * 24 * time.Hour), 61 + expected: "5 days ago", 62 + }, 63 + { 64 + name: "30 days ago", 65 + time: now.Add(-30 * 24 * time.Hour), 66 + expected: "30 days ago", 67 + }, 68 + } 69 + 70 + for _, tt := range tests { 71 + t.Run(tt.name, func(t *testing.T) { 72 + // Get fresh template for each test case 73 + tmpl, err := Templates() 74 + if err != nil { 75 + t.Fatalf("Templates() error = %v", err) 76 + } 77 + 78 + // Execute template using timeAgo function 79 + templateStr := `{{ timeAgo . }}` 80 + buf := new(bytes.Buffer) 81 + temp, err := tmpl.New("test").Parse(templateStr) 82 + if err != nil { 83 + t.Fatalf("Failed to parse template: %v", err) 84 + } 85 + 86 + err = temp.Execute(buf, tt.time) 87 + if err != nil { 88 + t.Fatalf("Failed to execute template: %v", err) 89 + } 90 + 91 + got := buf.String() 92 + if got != tt.expected { 93 + t.Errorf("timeAgo() = %q, want %q", got, tt.expected) 94 + } 95 + }) 96 + } 97 + } 98 + 99 + func TestHumanizeBytes(t *testing.T) { 100 + tests := []struct { 101 + name string 102 + bytes int64 103 + expected string 104 + }{ 105 + { 106 + name: "0 bytes", 107 + bytes: 0, 108 + expected: "0 B", 109 + }, 110 + { 111 + name: "512 bytes", 112 + bytes: 512, 113 + expected: "512 B", 114 + }, 115 + { 116 + name: "1023 bytes", 117 + bytes: 1023, 118 + expected: "1023 B", 119 + }, 120 + { 121 + name: "1 KB", 122 + bytes: 1024, 123 + expected: "1.0 KB", 124 + }, 125 + { 126 + name: "1.5 KB", 127 + bytes: 1536, 128 + expected: "1.5 KB", 129 + }, 130 + { 131 + name: "1 MB", 132 + bytes: 1024 * 1024, 133 + expected: "1.0 MB", 134 + }, 135 + { 136 + name: "2.5 MB", 137 + bytes: 2621440, // 2.5 * 1024 * 1024 138 + expected: "2.5 MB", 139 + }, 140 + { 141 + name: "1 GB", 142 + bytes: 1024 * 1024 * 1024, 143 + expected: "1.0 GB", 144 + }, 145 + { 146 + name: "5.2 GB", 147 + bytes: 5583457485, // ~5.2 GB 148 + expected: "5.2 GB", 149 + }, 150 + { 151 + name: "1 TB", 152 + bytes: 1024 * 1024 * 1024 * 1024, 153 + expected: "1.0 TB", 154 + }, 155 + { 156 + name: "1.5 PB", 157 + bytes: 1688849860263936, // 1.5 PB 158 + expected: "1.5 PB", 159 + }, 160 + } 161 + 162 + for _, tt := range tests { 163 + t.Run(tt.name, func(t *testing.T) { 164 + // Get fresh template for each test case 165 + tmpl, err := Templates() 166 + if err != nil { 167 + t.Fatalf("Templates() error = %v", err) 168 + } 169 + 170 + templateStr := `{{ humanizeBytes . }}` 171 + buf := new(bytes.Buffer) 172 + temp, err := tmpl.New("test").Parse(templateStr) 173 + if err != nil { 174 + t.Fatalf("Failed to parse template: %v", err) 175 + } 176 + 177 + err = temp.Execute(buf, tt.bytes) 178 + if err != nil { 179 + t.Fatalf("Failed to execute template: %v", err) 180 + } 181 + 182 + got := buf.String() 183 + if got != tt.expected { 184 + t.Errorf("humanizeBytes(%d) = %q, want %q", tt.bytes, got, tt.expected) 185 + } 186 + }) 187 + } 188 + } 189 + 190 + func TestTruncateDigest(t *testing.T) { 191 + tests := []struct { 192 + name string 193 + digest string 194 + length int 195 + expected string 196 + }{ 197 + { 198 + name: "short digest - no truncation needed", 199 + digest: "sha256:abc", 200 + length: 20, 201 + expected: "sha256:abc", 202 + }, 203 + { 204 + name: "truncate to 12 chars", 205 + digest: "sha256:abcdef123456789", 206 + length: 12, 207 + expected: "sha256:abcde...", 208 + }, 209 + { 210 + name: "truncate to 8 chars", 211 + digest: "sha256:1234567890abcdef", 212 + length: 8, 213 + expected: "sha256:1...", 214 + }, 215 + { 216 + name: "exact length match", 217 + digest: "sha256:abc", 218 + length: 10, 219 + expected: "sha256:abc", 220 + }, 221 + { 222 + name: "empty digest", 223 + digest: "", 224 + length: 10, 225 + expected: "", 226 + }, 227 + { 228 + name: "long sha256 digest", 229 + digest: "sha256:f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", 230 + length: 16, 231 + expected: "sha256:f1c8f6a4b...", 232 + }, 233 + } 234 + 235 + for _, tt := range tests { 236 + t.Run(tt.name, func(t *testing.T) { 237 + // Get fresh template for each test case 238 + tmpl, err := Templates() 239 + if err != nil { 240 + t.Fatalf("Templates() error = %v", err) 241 + } 242 + 243 + templateStr := `{{ truncateDigest .Digest .Length }}` 244 + buf := new(bytes.Buffer) 245 + temp, err := tmpl.New("test").Parse(templateStr) 246 + if err != nil { 247 + t.Fatalf("Failed to parse template: %v", err) 248 + } 249 + 250 + data := struct { 251 + Digest string 252 + Length int 253 + }{ 254 + Digest: tt.digest, 255 + Length: tt.length, 256 + } 257 + 258 + err = temp.Execute(buf, data) 259 + if err != nil { 260 + t.Fatalf("Failed to execute template: %v", err) 261 + } 262 + 263 + got := buf.String() 264 + if got != tt.expected { 265 + t.Errorf("truncateDigest(%q, %d) = %q, want %q", tt.digest, tt.length, got, tt.expected) 266 + } 267 + }) 268 + } 269 + } 270 + 271 + func TestFirstChar(t *testing.T) { 272 + tests := []struct { 273 + name string 274 + input string 275 + expected string 276 + }{ 277 + { 278 + name: "normal string", 279 + input: "hello", 280 + expected: "h", 281 + }, 282 + { 283 + name: "uppercase", 284 + input: "World", 285 + expected: "W", 286 + }, 287 + { 288 + name: "single character", 289 + input: "a", 290 + expected: "a", 291 + }, 292 + { 293 + name: "empty string", 294 + input: "", 295 + expected: "?", 296 + }, 297 + { 298 + name: "unicode character", 299 + input: "😀 emoji", 300 + expected: "😀", 301 + }, 302 + { 303 + name: "chinese character", 304 + input: "你好", 305 + expected: "你", 306 + }, 307 + { 308 + name: "number", 309 + input: "123", 310 + expected: "1", 311 + }, 312 + { 313 + name: "special character", 314 + input: "@user", 315 + expected: "@", 316 + }, 317 + } 318 + 319 + for _, tt := range tests { 320 + t.Run(tt.name, func(t *testing.T) { 321 + // Get fresh template for each test case 322 + tmpl, err := Templates() 323 + if err != nil { 324 + t.Fatalf("Templates() error = %v", err) 325 + } 326 + 327 + templateStr := `{{ firstChar . }}` 328 + buf := new(bytes.Buffer) 329 + temp, err := tmpl.New("test").Parse(templateStr) 330 + if err != nil { 331 + t.Fatalf("Failed to parse template: %v", err) 332 + } 333 + 334 + err = temp.Execute(buf, tt.input) 335 + if err != nil { 336 + t.Fatalf("Failed to execute template: %v", err) 337 + } 338 + 339 + got := buf.String() 340 + if got != tt.expected { 341 + t.Errorf("firstChar(%q) = %q, want %q", tt.input, got, tt.expected) 342 + } 343 + }) 344 + } 345 + } 346 + 347 + func TestTrimPrefix(t *testing.T) { 348 + tests := []struct { 349 + name string 350 + prefix string 351 + input string 352 + expected string 353 + }{ 354 + { 355 + name: "trim sha256 prefix", 356 + prefix: "sha256:", 357 + input: "sha256:abcdef123456", 358 + expected: "abcdef123456", 359 + }, 360 + { 361 + name: "no prefix match", 362 + prefix: "sha256:", 363 + input: "md5:abcdef123456", 364 + expected: "md5:abcdef123456", 365 + }, 366 + { 367 + name: "empty prefix", 368 + prefix: "", 369 + input: "hello", 370 + expected: "hello", 371 + }, 372 + { 373 + name: "empty string", 374 + prefix: "prefix:", 375 + input: "", 376 + expected: "", 377 + }, 378 + { 379 + name: "prefix longer than string", 380 + prefix: "very-long-prefix", 381 + input: "short", 382 + expected: "short", 383 + }, 384 + { 385 + name: "exact match", 386 + prefix: "prefix", 387 + input: "prefix", 388 + expected: "", 389 + }, 390 + { 391 + name: "partial prefix match", 392 + prefix: "sha256:", 393 + input: "sha25", 394 + expected: "sha25", 395 + }, 396 + { 397 + name: "trim docker.io prefix", 398 + prefix: "docker.io/", 399 + input: "docker.io/library/alpine", 400 + expected: "library/alpine", 401 + }, 402 + } 403 + 404 + for _, tt := range tests { 405 + t.Run(tt.name, func(t *testing.T) { 406 + // Get fresh template for each test case 407 + tmpl, err := Templates() 408 + if err != nil { 409 + t.Fatalf("Templates() error = %v", err) 410 + } 411 + 412 + templateStr := `{{ trimPrefix .Prefix .Input }}` 413 + buf := new(bytes.Buffer) 414 + temp, err := tmpl.New("test").Parse(templateStr) 415 + if err != nil { 416 + t.Fatalf("Failed to parse template: %v", err) 417 + } 418 + 419 + data := struct { 420 + Prefix string 421 + Input string 422 + }{ 423 + Prefix: tt.prefix, 424 + Input: tt.input, 425 + } 426 + 427 + err = temp.Execute(buf, data) 428 + if err != nil { 429 + t.Fatalf("Failed to execute template: %v", err) 430 + } 431 + 432 + got := buf.String() 433 + if got != tt.expected { 434 + t.Errorf("trimPrefix(%q, %q) = %q, want %q", tt.prefix, tt.input, got, tt.expected) 435 + } 436 + }) 437 + } 438 + } 439 + 440 + func TestTemplates(t *testing.T) { 441 + tmpl, err := Templates() 442 + if err != nil { 443 + t.Fatalf("Templates() error = %v", err) 444 + } 445 + 446 + if tmpl == nil { 447 + t.Fatal("Templates() returned nil template") 448 + } 449 + 450 + // Test that all expected templates are loaded 451 + expectedTemplates := []string{ 452 + "base.html", 453 + "nav", 454 + "repo-card", 455 + "repository", 456 + "home.html", 457 + "search.html", 458 + "user.html", 459 + "login.html", 460 + "settings.html", 461 + "install.html", 462 + "manifest-modal", 463 + "push-list.html", 464 + } 465 + 466 + for _, name := range expectedTemplates { 467 + t.Run("template_"+name, func(t *testing.T) { 468 + temp := tmpl.Lookup(name) 469 + if temp == nil { 470 + t.Errorf("Expected template %q not found", name) 471 + } 472 + }) 473 + } 474 + } 475 + 476 + func TestTemplateExecution_RepoCard(t *testing.T) { 477 + tmpl, err := Templates() 478 + if err != nil { 479 + t.Fatalf("Templates() error = %v", err) 480 + } 481 + 482 + // Sample data for repo-card template 483 + data := struct { 484 + OwnerHandle string 485 + Repository string 486 + IconURL string 487 + Description string 488 + StarCount int 489 + PullCount int 490 + }{ 491 + OwnerHandle: "alice.bsky.social", 492 + Repository: "myapp", 493 + IconURL: "", 494 + Description: "A cool container image", 495 + StarCount: 42, 496 + PullCount: 1337, 497 + } 498 + 499 + buf := new(bytes.Buffer) 500 + err = tmpl.ExecuteTemplate(buf, "repo-card", data) 501 + if err != nil { 502 + t.Fatalf("Failed to execute repo-card template: %v", err) 503 + } 504 + 505 + output := buf.String() 506 + 507 + // Verify expected content in output 508 + expectedContent := []string{ 509 + "alice.bsky.social", 510 + "myapp", 511 + "A cool container image", 512 + "42", // star count 513 + "1337", // pull count 514 + "featured-icon-placeholder", // no icon URL provided 515 + } 516 + 517 + for _, expected := range expectedContent { 518 + if !strings.Contains(output, expected) { 519 + t.Errorf("Template output missing expected content %q", expected) 520 + } 521 + } 522 + 523 + // Verify firstChar function is working 524 + if !strings.Contains(output, ">m<") { // first char of "myapp" 525 + t.Error("Template output missing firstChar result") 526 + } 527 + } 528 + 529 + func TestTemplateExecution_WithFuncMap(t *testing.T) { 530 + // Test that templates can use FuncMap functions 531 + tests := []struct { 532 + name string 533 + templateStr string 534 + data interface{} 535 + expectInOutput string 536 + }{ 537 + { 538 + name: "timeAgo in template", 539 + templateStr: `{{ define "test1" }}{{ timeAgo . }}{{ end }}`, 540 + data: time.Now().Add(-5 * time.Minute), 541 + expectInOutput: "5 minutes ago", 542 + }, 543 + { 544 + name: "humanizeBytes in template", 545 + templateStr: `{{ define "test2" }}{{ humanizeBytes . }}{{ end }}`, 546 + data: int64(1024 * 1024 * 10), // 10 MB 547 + expectInOutput: "10.0 MB", 548 + }, 549 + { 550 + name: "multiple functions in template", 551 + templateStr: `{{ define "test3" }}{{ truncateDigest .Digest 12 }} - {{ firstChar .Name }}{{ end }}`, 552 + data: struct { 553 + Digest string 554 + Name string 555 + }{ 556 + Digest: "sha256:abcdef1234567890", 557 + Name: "myapp", 558 + }, 559 + expectInOutput: "sha256:abcde... - m", 560 + }, 561 + } 562 + 563 + for _, tt := range tests { 564 + t.Run(tt.name, func(t *testing.T) { 565 + // Get fresh template for each test case 566 + tmpl, err := Templates() 567 + if err != nil { 568 + t.Fatalf("Templates() error = %v", err) 569 + } 570 + 571 + temp, err := tmpl.Parse(tt.templateStr) 572 + if err != nil { 573 + t.Fatalf("Failed to parse template: %v", err) 574 + } 575 + 576 + buf := new(bytes.Buffer) 577 + // Extract the template name from the define 578 + templateName := strings.Split(strings.TrimPrefix(tt.templateStr, `{{ define "`), `"`)[0] 579 + err = temp.ExecuteTemplate(buf, templateName, tt.data) 580 + if err != nil { 581 + t.Fatalf("Failed to execute template: %v", err) 582 + } 583 + 584 + output := buf.String() 585 + if !strings.Contains(output, tt.expectInOutput) { 586 + t.Errorf("Template output %q does not contain expected %q", output, tt.expectInOutput) 587 + } 588 + }) 589 + } 590 + } 591 + 592 + func TestStaticHandler(t *testing.T) { 593 + handler := StaticHandler() 594 + if handler == nil { 595 + t.Fatal("StaticHandler() returned nil") 596 + } 597 + 598 + // Test that it returns an http.Handler 599 + // Further testing would require HTTP request/response testing 600 + // which is typically done in integration tests 601 + }
+5 -55
pkg/atproto/lexicon.go
··· 1 1 package atproto 2 2 3 - //go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord 4 - 5 3 import ( 6 4 "encoding/base64" 7 5 "encoding/json" ··· 221 219 } 222 220 } 223 221 224 - // HoldCrewRecord represents membership in a storage hold 225 - // Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control 226 - // Owner can add/remove crew members by creating/deleting these records in their own PDS 227 - // Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs) 228 - type HoldCrewRecord struct { 229 - // Type should be "io.atcr.hold.crew" 230 - Type string `json:"$type"` 231 - 232 - // Hold is the AT URI of the hold record 233 - // e.g., "at://did:plc:owner/io.atcr.hold/hold1" 234 - Hold string `json:"hold"` 235 - 236 - // Member is the DID of the crew member (optional, for explicit access) 237 - // Exactly one of Member or MemberPattern must be set 238 - Member *string `json:"member,omitempty"` 239 - 240 - // MemberPattern is a pattern for matching multiple users (optional, for pattern-based access) 241 - // Supports wildcards: "*" (all users), "*.domain.com" (handle glob) 242 - // Exactly one of Member or MemberPattern must be set 243 - MemberPattern *string `json:"memberPattern,omitempty"` 244 - 245 - // Role defines permissions: "owner", "write", "read" 246 - Role string `json:"role"` 247 - 248 - // ExpiresAt is optional expiration for this membership 249 - ExpiresAt *time.Time `json:"expiresAt,omitempty"` 250 - 251 - // AddedAt timestamp 252 - AddedAt time.Time `json:"createdAt"` 253 - } 254 - 255 - // NewHoldCrewRecord creates a new hold crew record with explicit DID 256 - func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord { 257 - return &HoldCrewRecord{ 258 - Type: HoldCrewCollection, 259 - Hold: hold, 260 - Member: &member, 261 - Role: role, 262 - AddedAt: time.Now(), 263 - } 264 - } 265 - 266 - // NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching 267 - func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord { 268 - return &HoldCrewRecord{ 269 - Type: HoldCrewCollection, 270 - Hold: hold, 271 - MemberPattern: &pattern, 272 - Role: role, 273 - AddedAt: time.Now(), 274 - } 275 - } 276 - 277 222 // SailorProfileRecord represents a user's profile with registry preferences 278 223 // Stored in the user's PDS to configure default hold and other settings 279 224 type SailorProfileRecord struct { ··· 388 333 // Convert to did:web 389 334 // did:web uses hostname directly (port included if non-standard) 390 335 return "did:web:" + hostname 336 + } 337 + 338 + // isDID checks if a string is a DID (starts with "did:") 339 + func isDID(s string) bool { 340 + return len(s) > 4 && s[:4] == "did:" 391 341 } 392 342 393 343 // =============================================================================
+683
pkg/atproto/lexicon_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestNewManifestRecord(t *testing.T) { 11 + validOCIManifest := `{ 12 + "schemaVersion": 2, 13 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 14 + "config": { 15 + "mediaType": "application/vnd.oci.image.config.v1+json", 16 + "digest": "sha256:config123", 17 + "size": 1234 18 + }, 19 + "layers": [ 20 + { 21 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 22 + "digest": "sha256:layer1", 23 + "size": 5678 24 + }, 25 + { 26 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 27 + "digest": "sha256:layer2", 28 + "size": 9012 29 + } 30 + ], 31 + "annotations": { 32 + "org.opencontainers.image.created": "2025-01-01T00:00:00Z" 33 + } 34 + }` 35 + 36 + manifestWithSubject := `{ 37 + "schemaVersion": 2, 38 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 39 + "config": { 40 + "mediaType": "application/vnd.oci.image.config.v1+json", 41 + "digest": "sha256:config123", 42 + "size": 1234 43 + }, 44 + "layers": [], 45 + "subject": { 46 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 47 + "digest": "sha256:subject123", 48 + "size": 4321 49 + } 50 + }` 51 + 52 + tests := []struct { 53 + name string 54 + repository string 55 + digest string 56 + ociManifest string 57 + wantErr bool 58 + checkFunc func(*testing.T, *ManifestRecord) 59 + }{ 60 + { 61 + name: "valid OCI manifest", 62 + repository: "myapp", 63 + digest: "sha256:abc123", 64 + ociManifest: validOCIManifest, 65 + wantErr: false, 66 + checkFunc: func(t *testing.T, record *ManifestRecord) { 67 + if record.Type != ManifestCollection { 68 + t.Errorf("Type = %v, want %v", record.Type, ManifestCollection) 69 + } 70 + if record.Repository != "myapp" { 71 + t.Errorf("Repository = %v, want myapp", record.Repository) 72 + } 73 + if record.Digest != "sha256:abc123" { 74 + t.Errorf("Digest = %v, want sha256:abc123", record.Digest) 75 + } 76 + if record.SchemaVersion != 2 { 77 + t.Errorf("SchemaVersion = %v, want 2", record.SchemaVersion) 78 + } 79 + if record.MediaType != "application/vnd.oci.image.manifest.v1+json" { 80 + t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType) 81 + } 82 + if record.Config.Digest != "sha256:config123" { 83 + t.Errorf("Config.Digest = %v, want sha256:config123", record.Config.Digest) 84 + } 85 + if record.Config.Size != 1234 { 86 + t.Errorf("Config.Size = %v, want 1234", record.Config.Size) 87 + } 88 + if len(record.Layers) != 2 { 89 + t.Fatalf("len(Layers) = %v, want 2", len(record.Layers)) 90 + } 91 + if record.Layers[0].Digest != "sha256:layer1" { 92 + t.Errorf("Layers[0].Digest = %v, want sha256:layer1", record.Layers[0].Digest) 93 + } 94 + if record.Layers[1].Digest != "sha256:layer2" { 95 + t.Errorf("Layers[1].Digest = %v, want sha256:layer2", record.Layers[1].Digest) 96 + } 97 + if record.Annotations["org.opencontainers.image.created"] != "2025-01-01T00:00:00Z" { 98 + t.Errorf("Annotations missing expected key") 99 + } 100 + if record.CreatedAt.IsZero() { 101 + t.Error("CreatedAt should not be zero") 102 + } 103 + if record.Subject != nil { 104 + t.Error("Subject should be nil") 105 + } 106 + }, 107 + }, 108 + { 109 + name: "manifest with subject", 110 + repository: "myapp", 111 + digest: "sha256:abc123", 112 + ociManifest: manifestWithSubject, 113 + wantErr: false, 114 + checkFunc: func(t *testing.T, record *ManifestRecord) { 115 + if record.Subject == nil { 116 + t.Fatal("Subject should not be nil") 117 + } 118 + if record.Subject.Digest != "sha256:subject123" { 119 + t.Errorf("Subject.Digest = %v, want sha256:subject123", record.Subject.Digest) 120 + } 121 + if record.Subject.Size != 4321 { 122 + t.Errorf("Subject.Size = %v, want 4321", record.Subject.Size) 123 + } 124 + }, 125 + }, 126 + { 127 + name: "invalid JSON", 128 + repository: "myapp", 129 + digest: "sha256:abc123", 130 + ociManifest: "not valid json", 131 + wantErr: true, 132 + }, 133 + { 134 + name: "invalid config JSON", 135 + repository: "myapp", 136 + digest: "sha256:abc123", 137 + ociManifest: `{"schemaVersion": 2, "mediaType": "test", "config": "not-an-object", "layers": []}`, 138 + wantErr: true, 139 + }, 140 + } 141 + 142 + for _, tt := range tests { 143 + t.Run(tt.name, func(t *testing.T) { 144 + got, err := NewManifestRecord(tt.repository, tt.digest, []byte(tt.ociManifest)) 145 + if (err != nil) != tt.wantErr { 146 + t.Errorf("NewManifestRecord() error = %v, wantErr %v", err, tt.wantErr) 147 + return 148 + } 149 + 150 + if !tt.wantErr && tt.checkFunc != nil { 151 + tt.checkFunc(t, got) 152 + } 153 + }) 154 + } 155 + } 156 + 157 + func TestNewTagRecord(t *testing.T) { 158 + before := time.Now() 159 + record := NewTagRecord("myapp", "latest", "sha256:abc123") 160 + after := time.Now() 161 + 162 + if record.Type != TagCollection { 163 + t.Errorf("Type = %v, want %v", record.Type, TagCollection) 164 + } 165 + 166 + if record.Repository != "myapp" { 167 + t.Errorf("Repository = %v, want myapp", record.Repository) 168 + } 169 + 170 + if record.Tag != "latest" { 171 + t.Errorf("Tag = %v, want latest", record.Tag) 172 + } 173 + 174 + if record.ManifestDigest != "sha256:abc123" { 175 + t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest) 176 + } 177 + 178 + if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 179 + t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 180 + } 181 + } 182 + 183 + func TestNewHoldRecord(t *testing.T) { 184 + tests := []struct { 185 + name string 186 + endpoint string 187 + owner string 188 + public bool 189 + }{ 190 + { 191 + name: "public hold", 192 + endpoint: "https://hold1.example.com", 193 + owner: "did:plc:alice123", 194 + public: true, 195 + }, 196 + { 197 + name: "private hold", 198 + endpoint: "https://hold2.example.com", 199 + owner: "did:plc:bob456", 200 + public: false, 201 + }, 202 + } 203 + 204 + for _, tt := range tests { 205 + t.Run(tt.name, func(t *testing.T) { 206 + before := time.Now() 207 + record := NewHoldRecord(tt.endpoint, tt.owner, tt.public) 208 + after := time.Now() 209 + 210 + if record.Type != HoldCollection { 211 + t.Errorf("Type = %v, want %v", record.Type, HoldCollection) 212 + } 213 + 214 + if record.Endpoint != tt.endpoint { 215 + t.Errorf("Endpoint = %v, want %v", record.Endpoint, tt.endpoint) 216 + } 217 + 218 + if record.Owner != tt.owner { 219 + t.Errorf("Owner = %v, want %v", record.Owner, tt.owner) 220 + } 221 + 222 + if record.Public != tt.public { 223 + t.Errorf("Public = %v, want %v", record.Public, tt.public) 224 + } 225 + 226 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 227 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 228 + } 229 + }) 230 + } 231 + } 232 + 233 + func TestNewSailorProfileRecord(t *testing.T) { 234 + tests := []struct { 235 + name string 236 + defaultHold string 237 + }{ 238 + { 239 + name: "with default hold DID", 240 + defaultHold: "did:web:hold01.atcr.io", 241 + }, 242 + { 243 + name: "with default hold URL", 244 + defaultHold: "https://hold01.atcr.io", 245 + }, 246 + { 247 + name: "empty default hold", 248 + defaultHold: "", 249 + }, 250 + } 251 + 252 + for _, tt := range tests { 253 + t.Run(tt.name, func(t *testing.T) { 254 + before := time.Now() 255 + record := NewSailorProfileRecord(tt.defaultHold) 256 + after := time.Now() 257 + 258 + if record.Type != SailorProfileCollection { 259 + t.Errorf("Type = %v, want %v", record.Type, SailorProfileCollection) 260 + } 261 + 262 + if record.DefaultHold != tt.defaultHold { 263 + t.Errorf("DefaultHold = %v, want %v", record.DefaultHold, tt.defaultHold) 264 + } 265 + 266 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 267 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 268 + } 269 + 270 + if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 271 + t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 272 + } 273 + 274 + // CreatedAt and UpdatedAt should be equal for new records 275 + if !record.CreatedAt.Equal(record.UpdatedAt) { 276 + t.Errorf("CreatedAt (%v) != UpdatedAt (%v)", record.CreatedAt, record.UpdatedAt) 277 + } 278 + }) 279 + } 280 + } 281 + 282 + func TestNewStarRecord(t *testing.T) { 283 + before := time.Now() 284 + record := NewStarRecord("did:plc:alice123", "myapp") 285 + after := time.Now() 286 + 287 + if record.Type != StarCollection { 288 + t.Errorf("Type = %v, want %v", record.Type, StarCollection) 289 + } 290 + 291 + if record.Subject.DID != "did:plc:alice123" { 292 + t.Errorf("Subject.DID = %v, want did:plc:alice123", record.Subject.DID) 293 + } 294 + 295 + if record.Subject.Repository != "myapp" { 296 + t.Errorf("Subject.Repository = %v, want myapp", record.Subject.Repository) 297 + } 298 + 299 + if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 300 + t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 301 + } 302 + } 303 + 304 + func TestStarRecordKey(t *testing.T) { 305 + tests := []struct { 306 + name string 307 + ownerDID string 308 + repository string 309 + wantPrefix string // Expected prefix for validation 310 + }{ 311 + { 312 + name: "simple key", 313 + ownerDID: "did:plc:alice123", 314 + repository: "myapp", 315 + }, 316 + { 317 + name: "long DID and repo", 318 + ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456", 319 + repository: "my-very-long-repository-name", 320 + }, 321 + { 322 + name: "special characters in repo", 323 + ownerDID: "did:plc:alice123", 324 + repository: "my-app_test.v1", 325 + }, 326 + } 327 + 328 + for _, tt := range tests { 329 + t.Run(tt.name, func(t *testing.T) { 330 + key := StarRecordKey(tt.ownerDID, tt.repository) 331 + 332 + // Key should be non-empty 333 + if key == "" { 334 + t.Error("StarRecordKey() returned empty string") 335 + } 336 + 337 + // Key should be base64 URL-encoded (no padding) 338 + if strings.Contains(key, "=") { 339 + t.Errorf("StarRecordKey() = %v, should not contain padding", key) 340 + } 341 + 342 + // Should be deterministic 343 + key2 := StarRecordKey(tt.ownerDID, tt.repository) 344 + if key != key2 { 345 + t.Errorf("StarRecordKey() not deterministic: %v != %v", key, key2) 346 + } 347 + 348 + // Should be different for different inputs 349 + differentKey := StarRecordKey(tt.ownerDID, tt.repository+"different") 350 + if key == differentKey { 351 + t.Error("StarRecordKey() should be different for different inputs") 352 + } 353 + }) 354 + } 355 + } 356 + 357 + func TestParseStarRecordKey(t *testing.T) { 358 + tests := []struct { 359 + name string 360 + ownerDID string 361 + repository string 362 + wantErr bool 363 + }{ 364 + { 365 + name: "valid key", 366 + ownerDID: "did:plc:alice123", 367 + repository: "myapp", 368 + wantErr: false, 369 + }, 370 + { 371 + name: "key with special characters", 372 + ownerDID: "did:plc:alice123", 373 + repository: "my-app_test.v1", 374 + wantErr: false, 375 + }, 376 + { 377 + name: "long values", 378 + ownerDID: "did:plc:abcdefghijklmnopqrstuvwxyz123456", 379 + repository: "my-very-long-repository-name", 380 + wantErr: false, 381 + }, 382 + } 383 + 384 + for _, tt := range tests { 385 + t.Run(tt.name, func(t *testing.T) { 386 + // Generate key 387 + key := StarRecordKey(tt.ownerDID, tt.repository) 388 + 389 + // Parse it back 390 + gotDID, gotRepo, err := ParseStarRecordKey(key) 391 + if (err != nil) != tt.wantErr { 392 + t.Errorf("ParseStarRecordKey() error = %v, wantErr %v", err, tt.wantErr) 393 + return 394 + } 395 + 396 + if !tt.wantErr { 397 + if gotDID != tt.ownerDID { 398 + t.Errorf("ParseStarRecordKey() DID = %v, want %v", gotDID, tt.ownerDID) 399 + } 400 + if gotRepo != tt.repository { 401 + t.Errorf("ParseStarRecordKey() repository = %v, want %v", gotRepo, tt.repository) 402 + } 403 + } 404 + }) 405 + } 406 + } 407 + 408 + func TestParseStarRecordKey_Invalid(t *testing.T) { 409 + tests := []struct { 410 + name string 411 + rkey string 412 + }{ 413 + { 414 + name: "invalid base64", 415 + rkey: "not!!!valid!!!base64", 416 + }, 417 + { 418 + name: "no separator - base64 encoded text without slash", 419 + rkey: "bm9zZXBhcmF0b3I", // base64 of "noseparator" (no "/" in the decoded value) 420 + }, 421 + { 422 + name: "empty string", 423 + rkey: "", 424 + }, 425 + } 426 + 427 + for _, tt := range tests { 428 + t.Run(tt.name, func(t *testing.T) { 429 + _, _, err := ParseStarRecordKey(tt.rkey) 430 + if err == nil { 431 + t.Error("ParseStarRecordKey() expected error for invalid input") 432 + } 433 + }) 434 + } 435 + } 436 + 437 + func TestResolveHoldDIDFromURL(t *testing.T) { 438 + tests := []struct { 439 + name string 440 + holdURL string 441 + want string 442 + }{ 443 + { 444 + name: "https URL", 445 + holdURL: "https://hold01.atcr.io", 446 + want: "did:web:hold01.atcr.io", 447 + }, 448 + { 449 + name: "http URL", 450 + holdURL: "http://hold01.atcr.io", 451 + want: "did:web:hold01.atcr.io", 452 + }, 453 + { 454 + name: "URL with trailing slash", 455 + holdURL: "https://hold01.atcr.io/", 456 + want: "did:web:hold01.atcr.io", 457 + }, 458 + { 459 + name: "URL with path", 460 + holdURL: "https://hold01.atcr.io/some/path", 461 + want: "did:web:hold01.atcr.io", 462 + }, 463 + { 464 + name: "URL with port", 465 + holdURL: "https://hold01.atcr.io:8080", 466 + want: "did:web:hold01.atcr.io:8080", 467 + }, 468 + { 469 + name: "already a did:web", 470 + holdURL: "did:web:hold01.atcr.io", 471 + want: "did:web:hold01.atcr.io", 472 + }, 473 + { 474 + name: "already a did:plc", 475 + holdURL: "did:plc:abc123", 476 + want: "did:plc:abc123", 477 + }, 478 + { 479 + name: "empty string", 480 + holdURL: "", 481 + want: "", 482 + }, 483 + { 484 + name: "localhost", 485 + holdURL: "http://localhost:8080", 486 + want: "did:web:localhost:8080", 487 + }, 488 + { 489 + name: "IP address", 490 + holdURL: "http://192.168.1.1:8080", 491 + want: "did:web:192.168.1.1:8080", 492 + }, 493 + } 494 + 495 + for _, tt := range tests { 496 + t.Run(tt.name, func(t *testing.T) { 497 + got := ResolveHoldDIDFromURL(tt.holdURL) 498 + if got != tt.want { 499 + t.Errorf("ResolveHoldDIDFromURL() = %v, want %v", got, tt.want) 500 + } 501 + }) 502 + } 503 + } 504 + 505 + func TestIsDID(t *testing.T) { 506 + tests := []struct { 507 + name string 508 + s string 509 + want bool 510 + }{ 511 + { 512 + name: "valid did:web", 513 + s: "did:web:example.com", 514 + want: true, 515 + }, 516 + { 517 + name: "valid did:plc", 518 + s: "did:plc:abc123", 519 + want: true, 520 + }, 521 + { 522 + name: "valid did:key", 523 + s: "did:key:z6Mkfriq", 524 + want: true, 525 + }, 526 + { 527 + name: "not a DID - URL", 528 + s: "https://example.com", 529 + want: false, 530 + }, 531 + { 532 + name: "not a DID - short string", 533 + s: "did", 534 + want: false, 535 + }, 536 + { 537 + name: "not a DID - empty", 538 + s: "", 539 + want: false, 540 + }, 541 + { 542 + name: "not a DID - almost", 543 + s: "did:", 544 + want: false, 545 + }, 546 + { 547 + name: "not a DID - plain text", 548 + s: "hello world", 549 + want: false, 550 + }, 551 + } 552 + 553 + for _, tt := range tests { 554 + t.Run(tt.name, func(t *testing.T) { 555 + got := isDID(tt.s) 556 + if got != tt.want { 557 + t.Errorf("isDID() = %v, want %v", got, tt.want) 558 + } 559 + }) 560 + } 561 + } 562 + 563 + func TestManifestRecord_JSONSerialization(t *testing.T) { 564 + // Create a manifest record 565 + ociManifest := `{ 566 + "schemaVersion": 2, 567 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 568 + "config": { 569 + "mediaType": "application/vnd.oci.image.config.v1+json", 570 + "digest": "sha256:config123", 571 + "size": 1234 572 + }, 573 + "layers": [ 574 + { 575 + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 576 + "digest": "sha256:layer1", 577 + "size": 5678 578 + } 579 + ] 580 + }` 581 + 582 + record, err := NewManifestRecord("myapp", "sha256:abc123", []byte(ociManifest)) 583 + if err != nil { 584 + t.Fatalf("NewManifestRecord() error = %v", err) 585 + } 586 + 587 + // Add hold DID 588 + record.HoldDID = "did:web:hold01.atcr.io" 589 + 590 + // Serialize to JSON 591 + jsonData, err := json.Marshal(record) 592 + if err != nil { 593 + t.Fatalf("json.Marshal() error = %v", err) 594 + } 595 + 596 + // Deserialize from JSON 597 + var decoded ManifestRecord 598 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 599 + t.Fatalf("json.Unmarshal() error = %v", err) 600 + } 601 + 602 + // Verify fields 603 + if decoded.Type != record.Type { 604 + t.Errorf("Type = %v, want %v", decoded.Type, record.Type) 605 + } 606 + if decoded.Repository != record.Repository { 607 + t.Errorf("Repository = %v, want %v", decoded.Repository, record.Repository) 608 + } 609 + if decoded.Digest != record.Digest { 610 + t.Errorf("Digest = %v, want %v", decoded.Digest, record.Digest) 611 + } 612 + if decoded.HoldDID != record.HoldDID { 613 + t.Errorf("HoldDID = %v, want %v", decoded.HoldDID, record.HoldDID) 614 + } 615 + if decoded.Config.Digest != record.Config.Digest { 616 + t.Errorf("Config.Digest = %v, want %v", decoded.Config.Digest, record.Config.Digest) 617 + } 618 + if len(decoded.Layers) != len(record.Layers) { 619 + t.Errorf("len(Layers) = %v, want %v", len(decoded.Layers), len(record.Layers)) 620 + } 621 + } 622 + 623 + func TestBlobReference_JSONSerialization(t *testing.T) { 624 + blob := BlobReference{ 625 + MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", 626 + Digest: "sha256:abc123", 627 + Size: 12345, 628 + URLs: []string{"https://s3.example.com/blob"}, 629 + Annotations: map[string]string{ 630 + "key": "value", 631 + }, 632 + } 633 + 634 + // Serialize 635 + jsonData, err := json.Marshal(blob) 636 + if err != nil { 637 + t.Fatalf("json.Marshal() error = %v", err) 638 + } 639 + 640 + // Deserialize 641 + var decoded BlobReference 642 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 643 + t.Fatalf("json.Unmarshal() error = %v", err) 644 + } 645 + 646 + // Verify 647 + if decoded.MediaType != blob.MediaType { 648 + t.Errorf("MediaType = %v, want %v", decoded.MediaType, blob.MediaType) 649 + } 650 + if decoded.Digest != blob.Digest { 651 + t.Errorf("Digest = %v, want %v", decoded.Digest, blob.Digest) 652 + } 653 + if decoded.Size != blob.Size { 654 + t.Errorf("Size = %v, want %v", decoded.Size, blob.Size) 655 + } 656 + } 657 + 658 + func TestStarSubject_JSONSerialization(t *testing.T) { 659 + subject := StarSubject{ 660 + DID: "did:plc:alice123", 661 + Repository: "myapp", 662 + } 663 + 664 + // Serialize 665 + jsonData, err := json.Marshal(subject) 666 + if err != nil { 667 + t.Fatalf("json.Marshal() error = %v", err) 668 + } 669 + 670 + // Deserialize 671 + var decoded StarSubject 672 + if err := json.Unmarshal(jsonData, &decoded); err != nil { 673 + t.Fatalf("json.Unmarshal() error = %v", err) 674 + } 675 + 676 + // Verify 677 + if decoded.DID != subject.DID { 678 + t.Errorf("DID = %v, want %v", decoded.DID, subject.DID) 679 + } 680 + if decoded.Repository != subject.Repository { 681 + t.Errorf("Repository = %v, want %v", decoded.Repository, subject.Repository) 682 + } 683 + }
+8 -8
pkg/atproto/manifest_store.go
··· 21 21 // ManifestStore implements distribution.ManifestService 22 22 // It stores manifests in ATProto as records 23 23 type ManifestStore struct { 24 - client *Client 25 - repository string 26 - holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 - holdDID string // Hold service DID (primary reference) 28 - did string // User's DID for cache key 29 - lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 - blobStore distribution.BlobStore // Blob store for fetching config during push 31 - database DatabaseMetrics // Database for metrics tracking 24 + client *Client 25 + repository string 26 + holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) 27 + holdDID string // Hold service DID (primary reference) 28 + did string // User's DID for cache key 29 + lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 + blobStore distribution.BlobStore // Blob store for fetching config during push 31 + database DatabaseMetrics // Database for metrics tracking 32 32 } 33 33 34 34 // NewManifestStore creates a new ATProto-backed manifest store
+1 -11
pkg/atproto/profile.go
··· 55 55 record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey) 56 56 if err != nil { 57 57 // Check if it's a 404 (profile doesn't exist) 58 - if isNotFoundError(err) { 58 + if errors.Is(err, ErrRecordNotFound) { 59 59 return nil, nil 60 60 } 61 61 return nil, fmt.Errorf("failed to get profile: %w", err) ··· 104 104 return &profile, nil 105 105 } 106 106 107 - // isDID checks if a string is a DID (starts with "did:") 108 - func isDID(s string) bool { 109 - return len(s) > 4 && s[:4] == "did:" 110 - } 111 - 112 107 // UpdateProfile updates the user's profile 113 108 // Normalizes defaultHold to DID format before saving 114 109 func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error { ··· 125 120 } 126 121 return nil 127 122 } 128 - 129 - // isNotFoundError checks if an error is a record not found error 130 - func isNotFoundError(err error) bool { 131 - return errors.Is(err, ErrRecordNotFound) 132 - }
+2 -2
pkg/hold/blobstore_adapter.go
··· 105 105 URL: fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", b.service.config.Server.PublicURL), 106 106 Method: "PUT", 107 107 Headers: map[string]string{ 108 - "X-Upload-Id": uploadID, 109 - "X-Part-Number": fmt.Sprintf("%d", partNumber), 108 + "X-Upload-Id": uploadID, 109 + "X-Part-Number": fmt.Sprintf("%d", partNumber), 110 110 }, 111 111 }, nil 112 112 }
-1
pkg/hold/multipart.go
··· 333 333 log.Printf("Aborted buffered multipart: uploadID=%s", session.UploadID) 334 334 return nil 335 335 } 336 -