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
+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