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.

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