implement searching. provide read only connection and authorizercallback

This commit is contained in:
Evan Jarrett
2025-10-08 15:31:33 -05:00
parent 6b3223cf04
commit 780c5ad2d5
16 changed files with 491 additions and 112 deletions
+102
View File
@@ -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")
}
})
}
+88 -19
View File
@@ -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 <config>",
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")
+81
View File
@@ -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
+1 -1
View File
@@ -295,4 +295,4 @@ func loadMigrations() ([]Migration, error) {
}
return migrations, nil
}
}
-13
View File
@@ -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")
+22 -22
View File
@@ -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)
+24
View File
@@ -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,
}
}
+10 -15
View File
@@ -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 {
+2 -6
View File
@@ -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 {
+6 -11
View File
@@ -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 {
+102
View File
@@ -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
}
}
+2 -7
View File
@@ -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
+6 -11
View File
@@ -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 {
+1 -1
View File
@@ -5,7 +5,7 @@
</div>
<div class="nav-search">
<form action="/" method="get">
<form action="/search" method="get">
<input type="text" name="q" placeholder="Search images..." value="{{ .Query }}" />
</form>
</div>
+38
View File
@@ -0,0 +1,38 @@
{{ define "search" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search: {{ .SearchQuery }} - ATCR</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
{{ template "nav" . }}
<main class="container">
<div class="home-page">
{{ if .SearchQuery }}
<h1>Search Results for "{{ .SearchQuery }}"</h1>
{{ else }}
<h1>Search</h1>
<p>Enter a search term to find images.</p>
{{ end }}
<div id="push-list" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML">
<!-- Initial loading state -->
{{ if .SearchQuery }}
<div class="loading">Searching...</div>
{{ end }}
</div>
</div>
</main>
<!-- Modal container for HTMX -->
<div id="modal"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
{{ end }}
+6 -6
View File
@@ -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