mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
implement searching. provide read only connection and authorizercallback
This commit is contained in:
@@ -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