A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

implement searching. provide read only connection and authorizercallback

+491 -112
+102
cmd/appview/authorizer_test.go
··· 1 + package main 2 + 3 + import ( 4 + "database/sql" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + "atcr.io/pkg/appview/db" 10 + ) 11 + 12 + func TestAuthorizerBlocksSensitiveTables(t *testing.T) { 13 + // Create temporary database 14 + tmpDir := t.TempDir() 15 + dbPath := filepath.Join(tmpDir, "test.db") 16 + 17 + // Set environment for database path 18 + os.Setenv("ATCR_UI_DATABASE_PATH", dbPath) 19 + defer os.Unsetenv("ATCR_UI_DATABASE_PATH") 20 + 21 + // Initialize database (creates schema) 22 + database, err := db.InitDB(dbPath) 23 + if err != nil { 24 + t.Fatalf("Failed to initialize database: %v", err) 25 + } 26 + defer database.Close() 27 + 28 + // Create some test data in sensitive tables 29 + _, err = database.Exec(` 30 + INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at) 31 + VALUES ('test-key', 'did:plc:test', 'test-session', 'secret-token-data', datetime('now'), datetime('now')) 32 + `) 33 + if err != nil { 34 + t.Fatalf("Failed to insert test data: %v", err) 35 + } 36 + 37 + _, err = database.Exec(` 38 + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) 39 + VALUES ('did:plc:test', 'test.user', 'https://pds.example.com', '', datetime('now')) 40 + `) 41 + if err != nil { 42 + t.Fatalf("Failed to insert test user: %v", err) 43 + } 44 + 45 + // Open read-only connection with authorizer (using our custom driver) 46 + readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 47 + if err != nil { 48 + t.Fatalf("Failed to open read-only database: %v", err) 49 + } 50 + defer readOnlyDB.Close() 51 + 52 + // Test 1: Should be able to read from public tables (users) 53 + t.Run("AllowPublicTableRead", func(t *testing.T) { 54 + var handle string 55 + err := readOnlyDB.QueryRow("SELECT handle FROM users WHERE did = ?", "did:plc:test").Scan(&handle) 56 + if err != nil { 57 + t.Errorf("Should be able to read from public table 'users': %v", err) 58 + } 59 + if handle != "test.user" { 60 + t.Errorf("Expected handle 'test.user', got '%s'", handle) 61 + } 62 + }) 63 + 64 + // Test 2: Should NOT be able to read from sensitive tables (oauth_sessions) 65 + t.Run("BlockSensitiveTableRead", func(t *testing.T) { 66 + var sessionData string 67 + err := readOnlyDB.QueryRow("SELECT session_data FROM oauth_sessions WHERE session_key = ?", "test-key").Scan(&sessionData) 68 + if err == nil { 69 + t.Errorf("Should NOT be able to read from sensitive table 'oauth_sessions', but got data: %s", sessionData) 70 + } 71 + // SQLite returns "not authorized" error when authorizer denies access 72 + if err != nil && err.Error() != "not authorized" { 73 + t.Logf("Got expected error (but different message): %v", err) 74 + } 75 + }) 76 + 77 + // Test 3: Should NOT be able to read from ui_sessions 78 + t.Run("BlockUISessionsTableRead", func(t *testing.T) { 79 + rows, err := readOnlyDB.Query("SELECT * FROM ui_sessions LIMIT 1") 80 + if err == nil { 81 + rows.Close() 82 + t.Error("Should NOT be able to read from sensitive table 'ui_sessions'") 83 + } 84 + }) 85 + 86 + // Test 4: Should NOT be able to read from devices 87 + t.Run("BlockDevicesTableRead", func(t *testing.T) { 88 + rows, err := readOnlyDB.Query("SELECT * FROM devices LIMIT 1") 89 + if err == nil { 90 + rows.Close() 91 + t.Error("Should NOT be able to read from sensitive table 'devices'") 92 + } 93 + }) 94 + 95 + // Test 5: Should NOT be able to write to any table (read-only mode + authorizer) 96 + t.Run("BlockAllWrites", func(t *testing.T) { 97 + _, err := readOnlyDB.Exec("INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) VALUES ('did:plc:test2', 'test2', 'https://pds.example.com', '', datetime('now'))") 98 + if err == nil { 99 + t.Error("Should NOT be able to write to any table in read-only mode") 100 + } 101 + }) 102 + }
+88 -19
cmd/appview/serve.go
··· 15 15 "github.com/distribution/distribution/v3/configuration" 16 16 "github.com/distribution/distribution/v3/registry" 17 17 "github.com/distribution/distribution/v3/registry/handlers" 18 + sqlite3 "github.com/mattn/go-sqlite3" 18 19 "github.com/spf13/cobra" 19 20 20 21 "atcr.io/pkg/auth/oauth" ··· 30 31 "github.com/gorilla/mux" 31 32 ) 32 33 34 + // Define sensitive tables that should never be accessible from public queries 35 + var sensitiveTables = map[string]bool{ 36 + "oauth_sessions": true, // OAuth tokens 37 + "ui_sessions": true, // Session IDs 38 + "oauth_auth_requests": true, // OAuth state 39 + "devices": true, // Device secret hashes 40 + "pending_device_auth": true, // Pending device secrets 41 + } 42 + 43 + // readOnlyAuthorizerCallback blocks access to sensitive tables 44 + func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { 45 + // arg1 contains the table name for most operations 46 + tableName := arg1 47 + 48 + // Block any access to sensitive tables 49 + if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || 50 + action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || 51 + action == sqlite3.SQLITE_SELECT { 52 + if sensitiveTables[tableName] { 53 + fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) 54 + return sqlite3.SQLITE_DENY 55 + } 56 + } 57 + 58 + // Allow everything else 59 + return sqlite3.SQLITE_OK 60 + } 61 + 33 62 var serveCmd = &cobra.Command{ 34 63 Use: "serve <config>", 35 64 Short: "Start the ATCR registry server", ··· 39 68 } 40 69 41 70 func init() { 71 + // Register a custom SQLite driver with authorizer for read-only public queries 72 + sql.Register("sqlite3_readonly_public", 73 + &sqlite3.SQLiteDriver{ 74 + ConnectHook: func(conn *sqlite3.SQLiteConn) error { 75 + conn.RegisterAuthorizer(readOnlyAuthorizerCallback) 76 + return nil 77 + }, 78 + }) 79 + 42 80 // Replace the default serve command with our custom one 43 81 for i, cmd := range registry.RootCmd.Commands() { 44 82 if cmd.Name() == "serve" { ··· 65 103 66 104 // Initialize UI database first (required for all stores) 67 105 fmt.Println("Initializing UI database...") 68 - uiDatabase, uiSessionStore := initializeDatabase(config) 106 + uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase() 69 107 if uiDatabase == nil { 70 108 return fmt.Errorf("failed to initialize UI database - required for session storage") 71 109 } ··· 112 150 middleware.SetGlobalDatabase(metricsDB) 113 151 114 152 // 7. Initialize UI routes with OAuth app, refresher, and device store 115 - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) 153 + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) 116 154 117 155 // 8. Create OAuth server 118 156 oauthServer := oauth.NewServer(oauthApp) ··· 324 362 } 325 363 326 364 // initializeDatabase initializes the SQLite database and session store 327 - func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.SessionStore) { 365 + // Returns: (read-write DB, read-only DB, session store) 366 + func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) { 328 367 // Check if UI is enabled (optional configuration) 329 368 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 330 369 if uiEnabled == "false" { 331 - return nil, nil 370 + return nil, nil, nil 332 371 } 333 372 334 373 // Get database path ··· 341 380 dbDir := filepath.Dir(dbPath) 342 381 if err := os.MkdirAll(dbDir, 0700); err != nil { 343 382 fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 344 - return nil, nil 383 + return nil, nil, nil 345 384 } 346 385 347 - // Initialize database 386 + // Initialize read-write database (for writes and auth operations) 348 387 database, err := db.InitDB(dbPath) 349 388 if err != nil { 350 389 fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 351 - return nil, nil 390 + return nil, nil, nil 391 + } 392 + 393 + // Open read-only connection for public queries (search, user pages, etc.) 394 + // Uses custom driver with SQLite authorizer that blocks sensitive tables 395 + // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs 396 + readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") 397 + if err != nil { 398 + fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) 399 + return nil, nil, nil 352 400 } 353 401 354 402 fmt.Printf("UI database initialized at %s\n", dbPath) 403 + fmt.Printf("Read-only connection with authorizer created (blocks: oauth_sessions, ui_sessions, devices, etc.)\n") 355 404 356 405 // Create SQLite-backed session store 357 406 sessionStore := db.NewSessionStore(database) ··· 377 426 } 378 427 }() 379 428 380 - return database, sessionStore 429 + return database, readOnlyDB, sessionStore 381 430 } 382 431 383 432 // initializeUIRoutes initializes the web UI routes 384 - func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) { 433 + // database: read-write connection for auth and writes 434 + // readOnlyDB: read-only connection for public queries (search, user pages, etc.) 435 + func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) { 385 436 // Check if UI is enabled 386 437 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 387 438 if uiEnabled == "false" { ··· 410 461 }).Methods("POST") 411 462 412 463 // Public routes (with optional auth for navbar) 464 + // SECURITY: Public pages use read-only DB 413 465 router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)( 414 466 &uihandlers.HomeHandler{ 415 - DB: database, 467 + DB: readOnlyDB, 416 468 Templates: templates, 417 469 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 418 470 }, ··· 420 472 421 473 router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)( 422 474 &uihandlers.RecentPushesHandler{ 423 - DB: database, 475 + DB: readOnlyDB, 476 + Templates: templates, 477 + RegistryURL: uihandlers.TrimRegistryURL(baseURL), 478 + }, 479 + )).Methods("GET") 480 + 481 + // SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables 482 + router.Handle("/search", appmiddleware.OptionalAuth(sessionStore, database)( 483 + &uihandlers.SearchHandler{ 484 + DB: readOnlyDB, 485 + Templates: templates, 486 + RegistryURL: uihandlers.TrimRegistryURL(baseURL), 487 + }, 488 + )).Methods("GET") 489 + 490 + router.Handle("/api/search-results", appmiddleware.OptionalAuth(sessionStore, database)( 491 + &uihandlers.SearchResultsHandler{ 492 + DB: readOnlyDB, 424 493 Templates: templates, 425 494 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 426 495 }, 427 496 )).Methods("GET") 428 497 429 - // API route for repository stats (public) 498 + // API route for repository stats (public, read-only) 430 499 router.Handle("/api/stats/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 431 500 &uihandlers.GetStatsHandler{ 432 - DB: database, 501 + DB: readOnlyDB, 433 502 Directory: oauthApp.Directory(), 434 503 }, 435 504 )).Methods("GET") ··· 437 506 // API routes for stars (require authentication) 438 507 router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 439 508 &uihandlers.StarRepositoryHandler{ 440 - DB: database, 509 + DB: database, // Needs write access 441 510 Directory: oauthApp.Directory(), 442 511 Refresher: refresher, 443 512 }, ··· 445 514 446 515 router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( 447 516 &uihandlers.UnstarRepositoryHandler{ 448 - DB: database, 517 + DB: database, // Needs write access 449 518 Directory: oauthApp.Directory(), 450 519 Refresher: refresher, 451 520 }, ··· 453 522 454 523 router.Handle("/api/stars/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 455 524 &uihandlers.CheckStarHandler{ 456 - DB: database, 525 + DB: readOnlyDB, // Read-only check 457 526 Directory: oauthApp.Directory(), 458 527 Refresher: refresher, 459 528 }, ··· 461 530 462 531 router.Handle("/u/{handle}", appmiddleware.OptionalAuth(sessionStore, database)( 463 532 &uihandlers.UserPageHandler{ 464 - DB: database, 533 + DB: readOnlyDB, 465 534 Templates: templates, 466 535 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 467 536 }, ··· 469 538 470 539 router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 471 540 &uihandlers.RepositoryPageHandler{ 472 - DB: database, 541 + DB: readOnlyDB, 473 542 Templates: templates, 474 543 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 475 544 }, ··· 480 549 authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) 481 550 482 551 authRouter.Handle("/images", &uihandlers.ImagesHandler{ 483 - DB: database, 552 + DB: readOnlyDB, // Read-only: just displays user's images 484 553 Templates: templates, 485 554 RegistryURL: uihandlers.TrimRegistryURL(baseURL), 486 555 }).Methods("GET")
+81
pkg/appview/db/queries.go
··· 7 7 "time" 8 8 ) 9 9 10 + // escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. 11 + // It also sanitizes the input to prevent injection attacks via special characters. 12 + func escapeLikePattern(s string) string { 13 + // Remove NULL bytes (could truncate query in C-based databases like SQLite) 14 + s = strings.ReplaceAll(s, "\x00", "") 15 + 16 + // Remove other control characters that could cause issues 17 + s = strings.Map(func(r rune) rune { 18 + // Keep printable characters, spaces, and common punctuation 19 + if r < 32 && r != '\t' && r != '\n' && r != '\r' { 20 + return -1 // Remove control characters 21 + } 22 + return r 23 + }, s) 24 + 25 + // Escape LIKE wildcards - order matters! Backslash must be first 26 + s = strings.ReplaceAll(s, "\\", "\\\\") // Escape backslash first 27 + s = strings.ReplaceAll(s, "%", "\\%") // Escape % wildcard 28 + s = strings.ReplaceAll(s, "_", "\\_") // Escape _ wildcard 29 + 30 + return strings.TrimSpace(s) 31 + } 32 + 10 33 // GetRecentPushes fetches recent pushes with pagination 11 34 func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { 12 35 query := ` ··· 52 75 53 76 var total int 54 77 if err := db.QueryRow(countQuery, countArgs...).Scan(&total); err != nil { 78 + return nil, 0, err 79 + } 80 + 81 + return pushes, total, nil 82 + } 83 + 84 + // SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and manifest annotations 85 + func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) { 86 + // Escape LIKE wildcards so they're treated literally 87 + query = escapeLikePattern(query) 88 + 89 + // Prepare search pattern for LIKE queries (case-insensitive) 90 + searchPattern := "%" + query + "%" 91 + 92 + sqlQuery := ` 93 + SELECT DISTINCT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at 94 + FROM tags t 95 + JOIN users u ON t.did = u.did 96 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 97 + WHERE u.handle LIKE ? ESCAPE '\' 98 + OR u.did = ? 99 + OR t.repository LIKE ? ESCAPE '\' 100 + OR m.title LIKE ? ESCAPE '\' 101 + OR m.description LIKE ? ESCAPE '\' 102 + ORDER BY t.created_at DESC 103 + LIMIT ? OFFSET ? 104 + ` 105 + 106 + rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, searchPattern, limit, offset) 107 + if err != nil { 108 + return nil, 0, err 109 + } 110 + defer rows.Close() 111 + 112 + var pushes []Push 113 + for rows.Next() { 114 + var p Push 115 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { 116 + return nil, 0, err 117 + } 118 + pushes = append(pushes, p) 119 + } 120 + 121 + // Get total count 122 + countQuery := ` 123 + SELECT COUNT(DISTINCT t.id) 124 + FROM tags t 125 + JOIN users u ON t.did = u.did 126 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 127 + WHERE u.handle LIKE ? ESCAPE '\' 128 + OR u.did = ? 129 + OR t.repository LIKE ? ESCAPE '\' 130 + OR m.title LIKE ? ESCAPE '\' 131 + OR m.description LIKE ? ESCAPE '\' 132 + ` 133 + 134 + var total int 135 + if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil { 55 136 return nil, 0, err 56 137 } 57 138
+1 -1
pkg/appview/db/schema.go
··· 295 295 } 296 296 297 297 return migrations, nil 298 - } 298 + }
-13
pkg/appview/handlers/api.go
··· 44 44 http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 45 45 return 46 46 } 47 - log.Printf("StarRepository: Resolved %s to DID %s", handle, ownerDID) 48 47 49 48 // Get OAuth session for the authenticated user 50 49 log.Printf("StarRepository: Getting OAuth session for user DID %s", user.DID) ··· 54 53 http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 55 54 return 56 55 } 57 - log.Printf("StarRepository: Got OAuth session for %s", user.DID) 58 56 59 57 // Get user's PDS client (use indigo's API client which handles DPoP automatically) 60 58 apiClient := session.APIClient() ··· 64 62 starRecord := atproto.NewStarRecord(ownerDID, repository) 65 63 rkey := atproto.StarRecordKey(ownerDID, repository) 66 64 67 - log.Printf("StarRepository: Creating star record for %s/%s (rkey: %s)", handle, repository, rkey) 68 - 69 65 // Write star record to user's PDS 70 66 _, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord) 71 67 if err != nil { ··· 73 69 http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError) 74 70 return 75 71 } 76 - 77 - log.Printf("StarRepository: Successfully starred %s/%s", handle, repository) 78 72 79 73 // Return success 80 74 w.Header().Set("Content-Type", "application/json") ··· 109 103 http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 110 104 return 111 105 } 112 - log.Printf("UnstarRepository: Resolved %s to DID %s", handle, ownerDID) 113 106 114 107 // Get OAuth session for the authenticated user 115 108 log.Printf("UnstarRepository: Getting OAuth session for user DID %s", user.DID) ··· 119 112 http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 120 113 return 121 114 } 122 - log.Printf("UnstarRepository: Got OAuth session for %s", user.DID) 123 115 124 116 // Get user's PDS client (use indigo's API client which handles DPoP automatically) 125 117 apiClient := session.APIClient() ··· 137 129 return 138 130 } 139 131 log.Printf("UnstarRepository: Star record not found (already unstarred)") 140 - } else { 141 - log.Printf("UnstarRepository: Successfully unstarred %s/%s", handle, repository) 142 132 } 143 133 144 134 // Return success ··· 177 167 } 178 168 179 169 // Get OAuth session for the authenticated user 180 - log.Printf("CheckStar: Getting OAuth session for user DID %s", user.DID) 181 170 session, err := h.Refresher.GetSession(r.Context(), user.DID) 182 171 if err != nil { 183 172 log.Printf("CheckStar: Failed to get OAuth session for %s: %v", user.DID, err) ··· 193 182 194 183 // Check if star record exists 195 184 rkey := atproto.StarRecordKey(ownerDID, repository) 196 - log.Printf("CheckStar: Checking star record for %s/%s (rkey: %s)", handle, repository, rkey) 197 185 _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) 198 186 199 187 starred := err == nil 200 - log.Printf("CheckStar: Star status for %s/%s: %v (err: %v)", handle, repository, starred, err) 201 188 202 189 // Return result 203 190 w.Header().Set("Content-Type", "application/json")
+22 -22
pkg/appview/handlers/auth.go
··· 89 89 // Found valid OAuth session with all required scopes! Create UI session silently 90 90 fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) 91 91 92 - // Get PDS endpoint from identity 93 - pdsEndpoint := ident.PDSEndpoint() 92 + // Get PDS endpoint from identity 93 + pdsEndpoint := ident.PDSEndpoint() 94 94 95 - // Get OAuth sessionID from refresher 96 - sessionID := h.Refresher.GetSessionID(did) 95 + // Get OAuth sessionID from refresher 96 + sessionID := h.Refresher.GetSessionID(did) 97 97 98 - uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) 99 - if err == nil { 100 - // Set session cookie 101 - http.SetCookie(w, &http.Cookie{ 102 - Name: "atcr_session", 103 - Value: uiSessionID, 104 - Path: "/", 105 - MaxAge: 30 * 86400, // 30 days 106 - HttpOnly: true, 107 - Secure: true, 108 - SameSite: http.SameSiteLaxMode, 109 - }) 98 + uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) 99 + if err == nil { 100 + // Set session cookie 101 + http.SetCookie(w, &http.Cookie{ 102 + Name: "atcr_session", 103 + Value: uiSessionID, 104 + Path: "/", 105 + MaxAge: 30 * 86400, // 30 days 106 + HttpOnly: true, 107 + Secure: true, 108 + SameSite: http.SameSiteLaxMode, 109 + }) 110 110 111 - // Redirect to return URL 112 - fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) 113 - http.Redirect(w, r, returnTo, http.StatusFound) 114 - return 115 - } 111 + // Redirect to return URL 112 + fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) 113 + http.Redirect(w, r, returnTo, http.StatusFound) 114 + return 115 + } 116 116 117 - fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) 117 + fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) 118 118 } 119 119 } else { 120 120 fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err)
+24
pkg/appview/handlers/common.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + 6 + "atcr.io/pkg/appview/db" 7 + "atcr.io/pkg/appview/middleware" 8 + ) 9 + 10 + // PageData contains common fields shared across all page templates 11 + type PageData struct { 12 + User *db.User // Logged-in user (nil if not logged in) 13 + Query string // Search query from URL parameter 14 + RegistryURL string // Base registry URL 15 + } 16 + 17 + // NewPageData creates a PageData struct with common fields populated from the request 18 + func NewPageData(r *http.Request, registryURL string) PageData { 19 + return PageData{ 20 + User: middleware.GetUser(r), 21 + Query: r.URL.Query().Get("q"), 22 + RegistryURL: registryURL, 23 + } 24 + }
+10 -15
pkg/appview/handlers/home.go
··· 7 7 "strconv" 8 8 9 9 "atcr.io/pkg/appview/db" 10 - "atcr.io/pkg/appview/middleware" 11 10 ) 12 11 13 12 // HomeHandler handles the home page ··· 19 18 20 19 func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 20 data := struct { 22 - User *db.User 23 - Query string 24 - RegistryURL string 21 + PageData 25 22 }{ 26 - User: middleware.GetUser(r), 27 - Query: r.URL.Query().Get("q"), 28 - RegistryURL: h.RegistryURL, 23 + PageData: NewPageData(r, h.RegistryURL), 29 24 } 30 25 31 26 if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil { ··· 61 56 } 62 57 63 58 data := struct { 64 - Pushes []db.Push 65 - HasMore bool 66 - NextOffset int 67 - RegistryURL string 59 + PageData 60 + Pushes []db.Push 61 + HasMore bool 62 + NextOffset int 68 63 }{ 69 - Pushes: pushes, 70 - HasMore: offset+limit < total, 71 - NextOffset: offset + limit, 72 - RegistryURL: h.RegistryURL, 64 + PageData: NewPageData(r, h.RegistryURL), 65 + Pushes: pushes, 66 + HasMore: offset+limit < total, 67 + NextOffset: offset + limit, 73 68 } 74 69 75 70 if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil {
+2 -6
pkg/appview/handlers/images.go
··· 32 32 } 33 33 34 34 data := struct { 35 - User *db.User 35 + PageData 36 36 Repositories []db.Repository 37 - Query string 38 - RegistryURL string 39 37 }{ 40 - User: user, 38 + PageData: NewPageData(r, h.RegistryURL), 41 39 Repositories: repos, 42 - Query: r.URL.Query().Get("q"), 43 - RegistryURL: h.RegistryURL, 44 40 } 45 41 46 42 if err := h.Templates.ExecuteTemplate(w, "images", data); err != nil {
+6 -11
pkg/appview/handlers/repository.go
··· 6 6 "net/http" 7 7 8 8 "atcr.io/pkg/appview/db" 9 - "atcr.io/pkg/appview/middleware" 10 9 "github.com/gorilla/mux" 11 10 ) 12 11 ··· 47 46 } 48 47 49 48 data := struct { 50 - User *db.User // Logged-in user (for nav) 51 - Owner *db.User // Repository owner 52 - Repository *db.Repository 53 - Query string 54 - RegistryURL string 49 + PageData 50 + Owner *db.User // Repository owner 51 + Repository *db.Repository 55 52 }{ 56 - User: middleware.GetUser(r), // May be nil if not logged in 57 - Owner: owner, 58 - Repository: repo, 59 - Query: r.URL.Query().Get("q"), 60 - RegistryURL: h.RegistryURL, 53 + PageData: NewPageData(r, h.RegistryURL), 54 + Owner: owner, 55 + Repository: repo, 61 56 } 62 57 63 58 if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
+102
pkg/appview/handlers/search.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "html/template" 6 + "net/http" 7 + "strconv" 8 + "strings" 9 + 10 + "atcr.io/pkg/appview/db" 11 + ) 12 + 13 + // SearchHandler handles the search page 14 + type SearchHandler struct { 15 + DB *sql.DB 16 + Templates *template.Template 17 + RegistryURL string 18 + } 19 + 20 + func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + query := r.URL.Query().Get("q") 22 + 23 + data := struct { 24 + PageData 25 + SearchQuery string 26 + }{ 27 + PageData: NewPageData(r, h.RegistryURL), 28 + SearchQuery: query, 29 + } 30 + 31 + if err := h.Templates.ExecuteTemplate(w, "search", data); err != nil { 32 + http.Error(w, err.Error(), http.StatusInternalServerError) 33 + return 34 + } 35 + } 36 + 37 + // SearchResultsHandler handles the HTMX request for search results 38 + type SearchResultsHandler struct { 39 + DB *sql.DB 40 + Templates *template.Template 41 + RegistryURL string 42 + } 43 + 44 + func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 45 + query := r.URL.Query().Get("q") 46 + 47 + // Validate and sanitize input 48 + query = strings.TrimSpace(query) 49 + if query == "" { 50 + // Return empty results if no query 51 + data := struct { 52 + PageData 53 + Pushes []db.Push 54 + HasMore bool 55 + NextOffset int 56 + }{ 57 + PageData: NewPageData(r, h.RegistryURL), 58 + Pushes: []db.Push{}, 59 + HasMore: false, 60 + } 61 + 62 + if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { 63 + http.Error(w, err.Error(), http.StatusInternalServerError) 64 + } 65 + return 66 + } 67 + 68 + // Limit query length to prevent abuse 69 + if len(query) > 200 { 70 + query = query[:200] 71 + } 72 + 73 + limit := 50 74 + offset := 0 75 + 76 + if o := r.URL.Query().Get("offset"); o != "" { 77 + offset, _ = strconv.Atoi(o) 78 + } 79 + 80 + pushes, total, err := db.SearchPushes(h.DB, query, limit, offset) 81 + if err != nil { 82 + http.Error(w, err.Error(), http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + data := struct { 87 + PageData 88 + Pushes []db.Push 89 + HasMore bool 90 + NextOffset int 91 + }{ 92 + PageData: NewPageData(r, h.RegistryURL), 93 + Pushes: pushes, 94 + HasMore: offset+limit < total, 95 + NextOffset: offset + limit, 96 + } 97 + 98 + if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { 99 + http.Error(w, err.Error(), http.StatusInternalServerError) 100 + return 101 + } 102 + }
+2 -7
pkg/appview/handlers/settings.go
··· 6 6 "net/http" 7 7 "time" 8 8 9 - "atcr.io/pkg/appview/db" 10 9 "atcr.io/pkg/appview/middleware" 11 10 "atcr.io/pkg/atproto" 12 11 "atcr.io/pkg/auth/oauth" ··· 52 51 } 53 52 54 53 data := struct { 55 - User *db.User 54 + PageData 56 55 Profile struct { 57 56 Handle string 58 57 DID string 59 58 PDSEndpoint string 60 59 DefaultHold string 61 60 } 62 - Query string 63 - RegistryURL string 64 61 }{ 65 - User: user, 66 - Query: r.URL.Query().Get("q"), 67 - RegistryURL: h.RegistryURL, 62 + PageData: NewPageData(r, h.RegistryURL), 68 63 } 69 64 70 65 data.Profile.Handle = user.Handle
+6 -11
pkg/appview/handlers/user.go
··· 6 6 "net/http" 7 7 8 8 "atcr.io/pkg/appview/db" 9 - "atcr.io/pkg/appview/middleware" 10 9 "github.com/gorilla/mux" 11 10 ) 12 11 ··· 41 40 } 42 41 43 42 data := struct { 44 - User *db.User // Logged-in user (for nav) 45 - ViewedUser *db.User // User whose page we're viewing 46 - Pushes []db.Push 47 - Query string 48 - RegistryURL string 43 + PageData 44 + ViewedUser *db.User // User whose page we're viewing 45 + Pushes []db.Push 49 46 }{ 50 - User: middleware.GetUser(r), // May be nil if not logged in 51 - ViewedUser: viewedUser, 52 - Pushes: pushes, 53 - Query: r.URL.Query().Get("q"), 54 - RegistryURL: h.RegistryURL, 47 + PageData: NewPageData(r, h.RegistryURL), 48 + ViewedUser: viewedUser, 49 + Pushes: pushes, 55 50 } 56 51 57 52 if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil {
+1 -1
pkg/appview/templates/components/nav.html
··· 5 5 </div> 6 6 7 7 <div class="nav-search"> 8 - <form action="/" method="get"> 8 + <form action="/search" method="get"> 9 9 <input type="text" name="q" placeholder="Search images..." value="{{ .Query }}" /> 10 10 </form> 11 11 </div>
+38
pkg/appview/templates/pages/search.html
··· 1 + {{ define "search" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Search: {{ .SearchQuery }} - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + </head> 11 + <body> 12 + {{ template "nav" . }} 13 + 14 + <main class="container"> 15 + <div class="home-page"> 16 + {{ if .SearchQuery }} 17 + <h1>Search Results for "{{ .SearchQuery }}"</h1> 18 + {{ else }} 19 + <h1>Search</h1> 20 + <p>Enter a search term to find images.</p> 21 + {{ end }} 22 + 23 + <div id="push-list" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML"> 24 + <!-- Initial loading state --> 25 + {{ if .SearchQuery }} 26 + <div class="loading">Searching...</div> 27 + {{ end }} 28 + </div> 29 + </div> 30 + </main> 31 + 32 + <!-- Modal container for HTMX --> 33 + <div id="modal"></div> 34 + 35 + <script src="/static/js/app.js"></script> 36 + </body> 37 + </html> 38 + {{ end }}
+6 -6
pkg/atproto/manifest_store.go
··· 1 1 package atproto 2 2 3 3 import ( 4 - "maps" 5 4 "context" 6 5 "encoding/json" 7 6 "fmt" 7 + "maps" 8 8 "strings" 9 9 10 10 "github.com/distribution/distribution/v3" ··· 21 21 type ManifestStore struct { 22 22 client *Client 23 23 repository string 24 - holdEndpoint string // Hold service endpoint where blobs are stored (for push) 25 - did string // User's DID for cache key 26 - lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 27 - blobStore distribution.BlobStore // Blob store for fetching config during push 28 - database DatabaseMetrics // Database for metrics tracking 24 + holdEndpoint string // Hold service endpoint where blobs are stored (for push) 25 + did string // User's DID for cache key 26 + lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) 27 + blobStore distribution.BlobStore // Blob store for fetching config during push 28 + database DatabaseMetrics // Database for metrics tracking 29 29 } 30 30 31 31 // NewManifestStore creates a new ATProto-backed manifest store