diff --git a/cmd/appview/authorizer_test.go b/cmd/appview/authorizer_test.go new file mode 100644 index 0000000..bc2a4c1 --- /dev/null +++ b/cmd/appview/authorizer_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "atcr.io/pkg/appview/db" +) + +func TestAuthorizerBlocksSensitiveTables(t *testing.T) { + // Create temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Set environment for database path + os.Setenv("ATCR_UI_DATABASE_PATH", dbPath) + defer os.Unsetenv("ATCR_UI_DATABASE_PATH") + + // Initialize database (creates schema) + database, err := db.InitDB(dbPath) + if err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + defer database.Close() + + // Create some test data in sensitive tables + _, err = database.Exec(` + INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at) + VALUES ('test-key', 'did:plc:test', 'test-session', 'secret-token-data', datetime('now'), datetime('now')) + `) + if err != nil { + t.Fatalf("Failed to insert test data: %v", err) + } + + _, err = database.Exec(` + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) + VALUES ('did:plc:test', 'test.user', 'https://pds.example.com', '', datetime('now')) + `) + if err != nil { + t.Fatalf("Failed to insert test user: %v", err) + } + + // Open read-only connection with authorizer (using our custom driver) + readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") + if err != nil { + t.Fatalf("Failed to open read-only database: %v", err) + } + defer readOnlyDB.Close() + + // Test 1: Should be able to read from public tables (users) + t.Run("AllowPublicTableRead", func(t *testing.T) { + var handle string + err := readOnlyDB.QueryRow("SELECT handle FROM users WHERE did = ?", "did:plc:test").Scan(&handle) + if err != nil { + t.Errorf("Should be able to read from public table 'users': %v", err) + } + if handle != "test.user" { + t.Errorf("Expected handle 'test.user', got '%s'", handle) + } + }) + + // Test 2: Should NOT be able to read from sensitive tables (oauth_sessions) + t.Run("BlockSensitiveTableRead", func(t *testing.T) { + var sessionData string + err := readOnlyDB.QueryRow("SELECT session_data FROM oauth_sessions WHERE session_key = ?", "test-key").Scan(&sessionData) + if err == nil { + t.Errorf("Should NOT be able to read from sensitive table 'oauth_sessions', but got data: %s", sessionData) + } + // SQLite returns "not authorized" error when authorizer denies access + if err != nil && err.Error() != "not authorized" { + t.Logf("Got expected error (but different message): %v", err) + } + }) + + // Test 3: Should NOT be able to read from ui_sessions + t.Run("BlockUISessionsTableRead", func(t *testing.T) { + rows, err := readOnlyDB.Query("SELECT * FROM ui_sessions LIMIT 1") + if err == nil { + rows.Close() + t.Error("Should NOT be able to read from sensitive table 'ui_sessions'") + } + }) + + // Test 4: Should NOT be able to read from devices + t.Run("BlockDevicesTableRead", func(t *testing.T) { + rows, err := readOnlyDB.Query("SELECT * FROM devices LIMIT 1") + if err == nil { + rows.Close() + t.Error("Should NOT be able to read from sensitive table 'devices'") + } + }) + + // Test 5: Should NOT be able to write to any table (read-only mode + authorizer) + t.Run("BlockAllWrites", func(t *testing.T) { + _, err := readOnlyDB.Exec("INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) VALUES ('did:plc:test2', 'test2', 'https://pds.example.com', '', datetime('now'))") + if err == nil { + t.Error("Should NOT be able to write to any table in read-only mode") + } + }) +} diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 51bc487..f3ce948 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -15,6 +15,7 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/distribution/distribution/v3/registry" "github.com/distribution/distribution/v3/registry/handlers" + sqlite3 "github.com/mattn/go-sqlite3" "github.com/spf13/cobra" "atcr.io/pkg/auth/oauth" @@ -30,6 +31,34 @@ import ( "github.com/gorilla/mux" ) +// Define sensitive tables that should never be accessible from public queries +var sensitiveTables = map[string]bool{ + "oauth_sessions": true, // OAuth tokens + "ui_sessions": true, // Session IDs + "oauth_auth_requests": true, // OAuth state + "devices": true, // Device secret hashes + "pending_device_auth": true, // Pending device secrets +} + +// readOnlyAuthorizerCallback blocks access to sensitive tables +func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int { + // arg1 contains the table name for most operations + tableName := arg1 + + // Block any access to sensitive tables + if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE || + action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE || + action == sqlite3.SQLITE_SELECT { + if sensitiveTables[tableName] { + fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action) + return sqlite3.SQLITE_DENY + } + } + + // Allow everything else + return sqlite3.SQLITE_OK +} + var serveCmd = &cobra.Command{ Use: "serve ", Short: "Start the ATCR registry server", @@ -39,6 +68,15 @@ var serveCmd = &cobra.Command{ } func init() { + // Register a custom SQLite driver with authorizer for read-only public queries + sql.Register("sqlite3_readonly_public", + &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + conn.RegisterAuthorizer(readOnlyAuthorizerCallback) + return nil + }, + }) + // Replace the default serve command with our custom one for i, cmd := range registry.RootCmd.Commands() { if cmd.Name() == "serve" { @@ -65,7 +103,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Initialize UI database first (required for all stores) fmt.Println("Initializing UI database...") - uiDatabase, uiSessionStore := initializeDatabase(config) + uiDatabase, uiReadOnlyDB, uiSessionStore := initializeDatabase() if uiDatabase == nil { return fmt.Errorf("failed to initialize UI database - required for session storage") } @@ -112,7 +150,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { middleware.SetGlobalDatabase(metricsDB) // 7. Initialize UI routes with OAuth app, refresher, and device store - uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) // 8. Create OAuth server oauthServer := oauth.NewServer(oauthApp) @@ -324,11 +362,12 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string { } // initializeDatabase initializes the SQLite database and session store -func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.SessionStore) { +// Returns: (read-write DB, read-only DB, session store) +func initializeDatabase() (*sql.DB, *sql.DB, *db.SessionStore) { // Check if UI is enabled (optional configuration) uiEnabled := os.Getenv("ATCR_UI_ENABLED") if uiEnabled == "false" { - return nil, nil + return nil, nil, nil } // Get database path @@ -341,17 +380,27 @@ func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.Sessi dbDir := filepath.Dir(dbPath) if err := os.MkdirAll(dbDir, 0700); err != nil { fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) - return nil, nil + return nil, nil, nil } - // Initialize database + // Initialize read-write database (for writes and auth operations) database, err := db.InitDB(dbPath) if err != nil { fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) - return nil, nil + return nil, nil, nil + } + + // Open read-only connection for public queries (search, user pages, etc.) + // Uses custom driver with SQLite authorizer that blocks sensitive tables + // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs + readOnlyDB, err := sql.Open("sqlite3_readonly_public", "file:"+dbPath+"?mode=ro") + if err != nil { + fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err) + return nil, nil, nil } fmt.Printf("UI database initialized at %s\n", dbPath) + fmt.Printf("Read-only connection with authorizer created (blocks: oauth_sessions, ui_sessions, devices, etc.)\n") // Create SQLite-backed session store sessionStore := db.NewSessionStore(database) @@ -377,11 +426,13 @@ func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.Sessi } }() - return database, sessionStore + return database, readOnlyDB, sessionStore } // initializeUIRoutes initializes the web UI routes -func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) { +// database: read-write connection for auth and writes +// readOnlyDB: read-only connection for public queries (search, user pages, etc.) +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) { // Check if UI is enabled uiEnabled := os.Getenv("ATCR_UI_ENABLED") if uiEnabled == "false" { @@ -410,9 +461,10 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp }).Methods("POST") // Public routes (with optional auth for navbar) + // SECURITY: Public pages use read-only DB router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.HomeHandler{ - DB: database, + DB: readOnlyDB, Templates: templates, RegistryURL: uihandlers.TrimRegistryURL(baseURL), }, @@ -420,16 +472,33 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.RecentPushesHandler{ - DB: database, + DB: readOnlyDB, Templates: templates, RegistryURL: uihandlers.TrimRegistryURL(baseURL), }, )).Methods("GET") - // API route for repository stats (public) + // SECURITY: Search uses read-only DB to prevent writes and limit access to sensitive tables + router.Handle("/search", appmiddleware.OptionalAuth(sessionStore, database)( + &uihandlers.SearchHandler{ + DB: readOnlyDB, + Templates: templates, + RegistryURL: uihandlers.TrimRegistryURL(baseURL), + }, + )).Methods("GET") + + router.Handle("/api/search-results", appmiddleware.OptionalAuth(sessionStore, database)( + &uihandlers.SearchResultsHandler{ + DB: readOnlyDB, + Templates: templates, + RegistryURL: uihandlers.TrimRegistryURL(baseURL), + }, + )).Methods("GET") + + // API route for repository stats (public, read-only) router.Handle("/api/stats/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.GetStatsHandler{ - DB: database, + DB: readOnlyDB, Directory: oauthApp.Directory(), }, )).Methods("GET") @@ -437,7 +506,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp // API routes for stars (require authentication) router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( &uihandlers.StarRepositoryHandler{ - DB: database, + DB: database, // Needs write access Directory: oauthApp.Directory(), Refresher: refresher, }, @@ -445,7 +514,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp router.Handle("/api/stars/{handle}/{repository}", appmiddleware.RequireAuth(sessionStore, database)( &uihandlers.UnstarRepositoryHandler{ - DB: database, + DB: database, // Needs write access Directory: oauthApp.Directory(), Refresher: refresher, }, @@ -453,7 +522,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp router.Handle("/api/stars/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.CheckStarHandler{ - DB: database, + DB: readOnlyDB, // Read-only check Directory: oauthApp.Directory(), Refresher: refresher, }, @@ -461,7 +530,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp router.Handle("/u/{handle}", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.UserPageHandler{ - DB: database, + DB: readOnlyDB, Templates: templates, RegistryURL: uihandlers.TrimRegistryURL(baseURL), }, @@ -469,7 +538,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.RepositoryPageHandler{ - DB: database, + DB: readOnlyDB, Templates: templates, RegistryURL: uihandlers.TrimRegistryURL(baseURL), }, @@ -480,7 +549,7 @@ func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthAp authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) authRouter.Handle("/images", &uihandlers.ImagesHandler{ - DB: database, + DB: readOnlyDB, // Read-only: just displays user's images Templates: templates, RegistryURL: uihandlers.TrimRegistryURL(baseURL), }).Methods("GET") diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index a98b497..e4dbddb 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -7,6 +7,29 @@ import ( "time" ) +// escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. +// It also sanitizes the input to prevent injection attacks via special characters. +func escapeLikePattern(s string) string { + // Remove NULL bytes (could truncate query in C-based databases like SQLite) + s = strings.ReplaceAll(s, "\x00", "") + + // Remove other control characters that could cause issues + s = strings.Map(func(r rune) rune { + // Keep printable characters, spaces, and common punctuation + if r < 32 && r != '\t' && r != '\n' && r != '\r' { + return -1 // Remove control characters + } + return r + }, s) + + // Escape LIKE wildcards - order matters! Backslash must be first + s = strings.ReplaceAll(s, "\\", "\\\\") // Escape backslash first + s = strings.ReplaceAll(s, "%", "\\%") // Escape % wildcard + s = strings.ReplaceAll(s, "_", "\\_") // Escape _ wildcard + + return strings.TrimSpace(s) +} + // GetRecentPushes fetches recent pushes with pagination func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { query := ` @@ -58,6 +81,64 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, return pushes, total, nil } +// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and manifest annotations +func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) { + // Escape LIKE wildcards so they're treated literally + query = escapeLikePattern(query) + + // Prepare search pattern for LIKE queries (case-insensitive) + searchPattern := "%" + query + "%" + + sqlQuery := ` + SELECT DISTINCT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at + FROM tags t + JOIN users u ON t.did = u.did + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + WHERE u.handle LIKE ? ESCAPE '\' + OR u.did = ? + OR t.repository LIKE ? ESCAPE '\' + OR m.title LIKE ? ESCAPE '\' + OR m.description LIKE ? ESCAPE '\' + ORDER BY t.created_at DESC + LIMIT ? OFFSET ? + ` + + rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, searchPattern, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var pushes []Push + for rows.Next() { + var p Push + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { + return nil, 0, err + } + pushes = append(pushes, p) + } + + // Get total count + countQuery := ` + SELECT COUNT(DISTINCT t.id) + FROM tags t + JOIN users u ON t.did = u.did + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + WHERE u.handle LIKE ? ESCAPE '\' + OR u.did = ? + OR t.repository LIKE ? ESCAPE '\' + OR m.title LIKE ? ESCAPE '\' + OR m.description LIKE ? ESCAPE '\' + ` + + var total int + if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern, searchPattern).Scan(&total); err != nil { + return nil, 0, err + } + + return pushes, total, nil +} + // GetUserRepositories fetches all repositories for a user func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { // Get repository summary diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go index f11f84c..372e4af 100644 --- a/pkg/appview/db/schema.go +++ b/pkg/appview/db/schema.go @@ -295,4 +295,4 @@ func loadMigrations() ([]Migration, error) { } return migrations, nil -} \ No newline at end of file +} diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go index b11fa22..ff17a2d 100644 --- a/pkg/appview/handlers/api.go +++ b/pkg/appview/handlers/api.go @@ -44,7 +44,6 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) return } - log.Printf("StarRepository: Resolved %s to DID %s", handle, ownerDID) // Get OAuth session for the authenticated user log.Printf("StarRepository: Getting OAuth session for user DID %s", user.DID) @@ -54,7 +53,6 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) return } - log.Printf("StarRepository: Got OAuth session for %s", user.DID) // Get user's PDS client (use indigo's API client which handles DPoP automatically) apiClient := session.APIClient() @@ -64,8 +62,6 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request starRecord := atproto.NewStarRecord(ownerDID, repository) rkey := atproto.StarRecordKey(ownerDID, repository) - log.Printf("StarRepository: Creating star record for %s/%s (rkey: %s)", handle, repository, rkey) - // Write star record to user's PDS _, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord) if err != nil { @@ -74,8 +70,6 @@ func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } - log.Printf("StarRepository: Successfully starred %s/%s", handle, repository) - // Return success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) @@ -109,7 +103,6 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) return } - log.Printf("UnstarRepository: Resolved %s to DID %s", handle, ownerDID) // Get OAuth session for the authenticated user log.Printf("UnstarRepository: Getting OAuth session for user DID %s", user.DID) @@ -119,7 +112,6 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) return } - log.Printf("UnstarRepository: Got OAuth session for %s", user.DID) // Get user's PDS client (use indigo's API client which handles DPoP automatically) apiClient := session.APIClient() @@ -137,8 +129,6 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque return } log.Printf("UnstarRepository: Star record not found (already unstarred)") - } else { - log.Printf("UnstarRepository: Successfully unstarred %s/%s", handle, repository) } // Return success @@ -177,7 +167,6 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Get OAuth session for the authenticated user - log.Printf("CheckStar: Getting OAuth session for user DID %s", user.DID) session, err := h.Refresher.GetSession(r.Context(), user.DID) if err != nil { log.Printf("CheckStar: Failed to get OAuth session for %s: %v", user.DID, err) @@ -193,11 +182,9 @@ func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Check if star record exists rkey := atproto.StarRecordKey(ownerDID, repository) - log.Printf("CheckStar: Checking star record for %s/%s (rkey: %s)", handle, repository, rkey) _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) starred := err == nil - log.Printf("CheckStar: Star status for %s/%s: %v (err: %v)", handle, repository, starred, err) // Return result w.Header().Set("Content-Type", "application/json") diff --git a/pkg/appview/handlers/auth.go b/pkg/appview/handlers/auth.go index b23a455..e9ba923 100644 --- a/pkg/appview/handlers/auth.go +++ b/pkg/appview/handlers/auth.go @@ -89,32 +89,32 @@ func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Found valid OAuth session with all required scopes! Create UI session silently fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) - // Get PDS endpoint from identity - pdsEndpoint := ident.PDSEndpoint() + // Get PDS endpoint from identity + pdsEndpoint := ident.PDSEndpoint() - // Get OAuth sessionID from refresher - sessionID := h.Refresher.GetSessionID(did) + // Get OAuth sessionID from refresher + sessionID := h.Refresher.GetSessionID(did) - uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) - if err == nil { - // Set session cookie - http.SetCookie(w, &http.Cookie{ - Name: "atcr_session", - Value: uiSessionID, - Path: "/", - MaxAge: 30 * 86400, // 30 days - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteLaxMode, - }) + uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) + if err == nil { + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "atcr_session", + Value: uiSessionID, + Path: "/", + MaxAge: 30 * 86400, // 30 days + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) - // Redirect to return URL - fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) - http.Redirect(w, r, returnTo, http.StatusFound) - return - } + // Redirect to return URL + fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) + http.Redirect(w, r, returnTo, http.StatusFound) + return + } - fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) + fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) } } else { fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err) diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go new file mode 100644 index 0000000..37254b0 --- /dev/null +++ b/pkg/appview/handlers/common.go @@ -0,0 +1,24 @@ +package handlers + +import ( + "net/http" + + "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/middleware" +) + +// PageData contains common fields shared across all page templates +type PageData struct { + User *db.User // Logged-in user (nil if not logged in) + Query string // Search query from URL parameter + RegistryURL string // Base registry URL +} + +// NewPageData creates a PageData struct with common fields populated from the request +func NewPageData(r *http.Request, registryURL string) PageData { + return PageData{ + User: middleware.GetUser(r), + Query: r.URL.Query().Get("q"), + RegistryURL: registryURL, + } +} diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index 60ac541..b75416f 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -7,7 +7,6 @@ import ( "strconv" "atcr.io/pkg/appview/db" - "atcr.io/pkg/appview/middleware" ) // HomeHandler handles the home page @@ -19,13 +18,9 @@ type HomeHandler struct { func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { data := struct { - User *db.User - Query string - RegistryURL string + PageData }{ - User: middleware.GetUser(r), - Query: r.URL.Query().Get("q"), - RegistryURL: h.RegistryURL, + PageData: NewPageData(r, h.RegistryURL), } if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil { @@ -61,15 +56,15 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } data := struct { - Pushes []db.Push - HasMore bool - NextOffset int - RegistryURL string + PageData + Pushes []db.Push + HasMore bool + NextOffset int }{ - Pushes: pushes, - HasMore: offset+limit < total, - NextOffset: offset + limit, - RegistryURL: h.RegistryURL, + PageData: NewPageData(r, h.RegistryURL), + Pushes: pushes, + HasMore: offset+limit < total, + NextOffset: offset + limit, } if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 00160ea..cd48713 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -32,15 +32,11 @@ func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } data := struct { - User *db.User + PageData Repositories []db.Repository - Query string - RegistryURL string }{ - User: user, + PageData: NewPageData(r, h.RegistryURL), Repositories: repos, - Query: r.URL.Query().Get("q"), - RegistryURL: h.RegistryURL, } if err := h.Templates.ExecuteTemplate(w, "images", data); err != nil { diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index e29ee65..b59cabe 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -6,7 +6,6 @@ import ( "net/http" "atcr.io/pkg/appview/db" - "atcr.io/pkg/appview/middleware" "github.com/gorilla/mux" ) @@ -47,17 +46,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } data := struct { - User *db.User // Logged-in user (for nav) - Owner *db.User // Repository owner - Repository *db.Repository - Query string - RegistryURL string + PageData + Owner *db.User // Repository owner + Repository *db.Repository }{ - User: middleware.GetUser(r), // May be nil if not logged in - Owner: owner, - Repository: repo, - Query: r.URL.Query().Get("q"), - RegistryURL: h.RegistryURL, + PageData: NewPageData(r, h.RegistryURL), + Owner: owner, + Repository: repo, } if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { diff --git a/pkg/appview/handlers/search.go b/pkg/appview/handlers/search.go new file mode 100644 index 0000000..06f37b9 --- /dev/null +++ b/pkg/appview/handlers/search.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "database/sql" + "html/template" + "net/http" + "strconv" + "strings" + + "atcr.io/pkg/appview/db" +) + +// SearchHandler handles the search page +type SearchHandler struct { + DB *sql.DB + Templates *template.Template + RegistryURL string +} + +func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + + data := struct { + PageData + SearchQuery string + }{ + PageData: NewPageData(r, h.RegistryURL), + SearchQuery: query, + } + + if err := h.Templates.ExecuteTemplate(w, "search", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// SearchResultsHandler handles the HTMX request for search results +type SearchResultsHandler struct { + DB *sql.DB + Templates *template.Template + RegistryURL string +} + +func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + + // Validate and sanitize input + query = strings.TrimSpace(query) + if query == "" { + // Return empty results if no query + data := struct { + PageData + Pushes []db.Push + HasMore bool + NextOffset int + }{ + PageData: NewPageData(r, h.RegistryURL), + Pushes: []db.Push{}, + HasMore: false, + } + + if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + // Limit query length to prevent abuse + if len(query) > 200 { + query = query[:200] + } + + limit := 50 + offset := 0 + + if o := r.URL.Query().Get("offset"); o != "" { + offset, _ = strconv.Atoi(o) + } + + pushes, total, err := db.SearchPushes(h.DB, query, limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + data := struct { + PageData + Pushes []db.Push + HasMore bool + NextOffset int + }{ + PageData: NewPageData(r, h.RegistryURL), + Pushes: pushes, + HasMore: offset+limit < total, + NextOffset: offset + limit, + } + + if err := h.Templates.ExecuteTemplate(w, "push-list.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 6929843..dc87a73 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -6,7 +6,6 @@ import ( "net/http" "time" - "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" @@ -52,19 +51,15 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } data := struct { - User *db.User + PageData Profile struct { Handle string DID string PDSEndpoint string DefaultHold string } - Query string - RegistryURL string }{ - User: user, - Query: r.URL.Query().Get("q"), - RegistryURL: h.RegistryURL, + PageData: NewPageData(r, h.RegistryURL), } data.Profile.Handle = user.Handle diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index 9a358e7..e502701 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -6,7 +6,6 @@ import ( "net/http" "atcr.io/pkg/appview/db" - "atcr.io/pkg/appview/middleware" "github.com/gorilla/mux" ) @@ -41,17 +40,13 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } data := struct { - User *db.User // Logged-in user (for nav) - ViewedUser *db.User // User whose page we're viewing - Pushes []db.Push - Query string - RegistryURL string + PageData + ViewedUser *db.User // User whose page we're viewing + Pushes []db.Push }{ - User: middleware.GetUser(r), // May be nil if not logged in - ViewedUser: viewedUser, - Pushes: pushes, - Query: r.URL.Query().Get("q"), - RegistryURL: h.RegistryURL, + PageData: NewPageData(r, h.RegistryURL), + ViewedUser: viewedUser, + Pushes: pushes, } if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil { diff --git a/pkg/appview/templates/components/nav.html b/pkg/appview/templates/components/nav.html index 57c5bb0..99f3718 100644 --- a/pkg/appview/templates/components/nav.html +++ b/pkg/appview/templates/components/nav.html @@ -5,7 +5,7 @@ diff --git a/pkg/appview/templates/pages/search.html b/pkg/appview/templates/pages/search.html new file mode 100644 index 0000000..0db8262 --- /dev/null +++ b/pkg/appview/templates/pages/search.html @@ -0,0 +1,38 @@ +{{ define "search" }} + + + + + + Search: {{ .SearchQuery }} - ATCR + + + + + {{ template "nav" . }} + +
+
+ {{ if .SearchQuery }} +

Search Results for "{{ .SearchQuery }}"

+ {{ else }} +

Search

+

Enter a search term to find images.

+ {{ end }} + +
+ + {{ if .SearchQuery }} +
Searching...
+ {{ end }} +
+
+
+ + + + + + + +{{ end }} diff --git a/pkg/atproto/manifest_store.go b/pkg/atproto/manifest_store.go index 3964410..38c86f6 100644 --- a/pkg/atproto/manifest_store.go +++ b/pkg/atproto/manifest_store.go @@ -1,10 +1,10 @@ package atproto import ( - "maps" "context" "encoding/json" "fmt" + "maps" "strings" "github.com/distribution/distribution/v3" @@ -21,11 +21,11 @@ type DatabaseMetrics interface { type ManifestStore struct { client *Client repository string - holdEndpoint string // Hold service endpoint where blobs are stored (for push) - did string // User's DID for cache key - lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) - blobStore distribution.BlobStore // Blob store for fetching config during push - database DatabaseMetrics // Database for metrics tracking + holdEndpoint string // Hold service endpoint where blobs are stored (for push) + did string // User's DID for cache key + lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) + blobStore distribution.BlobStore // Blob store for fetching config during push + database DatabaseMetrics // Database for metrics tracking } // NewManifestStore creates a new ATProto-backed manifest store