mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
implement searching. provide read only connection and authorizercallback
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -295,4 +295,4 @@ func loadMigrations() ([]Migration, error) {
|
||||
}
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user