mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
187 lines
5.9 KiB
Go
187 lines
5.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
// StarRepositoryHandler handles starring a repository
|
|
type StarRepositoryHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
// starRequest is the JSON body for star/unstar requests
|
|
type starRequest struct {
|
|
Handle string `json:"handle"`
|
|
Repo string `json:"repo"`
|
|
}
|
|
|
|
func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Get authenticated user from middleware
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Parse JSON body
|
|
var req starRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
handle := req.Handle
|
|
repository := req.Repo
|
|
|
|
// Resolve owner's handle to DID
|
|
ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve handle for star", "handle", handle, "error", err)
|
|
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
|
|
slog.Debug("Creating PDS client for star", "user_did", user.DID)
|
|
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
|
|
// Create star record
|
|
starRecord := atproto.NewStarRecord(ownerDID, repository)
|
|
rkey := atproto.StarRecordKey(ownerDID, repository)
|
|
|
|
// Write star record to user's PDS
|
|
_, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord)
|
|
if err != nil {
|
|
// Check if OAuth error - if so, invalidate sessions and return 401
|
|
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
|
|
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
slog.Error("Failed to create star record", "error", err)
|
|
http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check if HTMX request - return HTML component
|
|
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
|
|
// Get current star count and do optimistic increment
|
|
stats, _ := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
|
|
starCount := 0
|
|
if stats != nil {
|
|
starCount = stats.StarCount
|
|
}
|
|
starCount++ // Optimistic increment
|
|
|
|
renderStarComponent(w, h.Templates, handle, repository, true, starCount)
|
|
return
|
|
}
|
|
|
|
// Return JSON for API clients
|
|
w.WriteHeader(http.StatusCreated)
|
|
render.JSON(w, r, map[string]bool{"starred": true})
|
|
}
|
|
|
|
// UnstarRepositoryHandler handles unstarring a repository
|
|
type UnstarRepositoryHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Get authenticated user from middleware
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Parse JSON body
|
|
var req starRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
handle := req.Handle
|
|
repository := req.Repo
|
|
|
|
// Resolve owner's handle to DID
|
|
ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
|
|
if err != nil {
|
|
slog.Warn("Failed to resolve handle for unstar", "handle", handle, "error", err)
|
|
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
|
|
slog.Debug("Creating PDS client for unstar", "user_did", user.DID)
|
|
pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
|
|
// Delete star record from user's PDS
|
|
rkey := atproto.StarRecordKey(ownerDID, repository)
|
|
slog.Debug("Deleting star record", "handle", handle, "repository", repository, "rkey", rkey)
|
|
err = pdsClient.DeleteRecord(r.Context(), atproto.StarCollection, rkey)
|
|
if err != nil {
|
|
// If record doesn't exist, still return success (idempotent)
|
|
if !errors.Is(err, atproto.ErrRecordNotFound) {
|
|
// Check if OAuth error - if so, invalidate sessions and return 401
|
|
if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
|
|
http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
slog.Error("Failed to delete star record", "error", err)
|
|
http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
slog.Debug("Star record not found, already unstarred")
|
|
}
|
|
|
|
// Check if HTMX request - return HTML component
|
|
if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
|
|
// Get current star count and do optimistic decrement
|
|
stats, _ := db.GetRepositoryStats(h.ReadOnlyDB, ownerDID, repository)
|
|
starCount := 0
|
|
if stats != nil {
|
|
starCount = stats.StarCount
|
|
}
|
|
if starCount > 0 {
|
|
starCount-- // Optimistic decrement
|
|
}
|
|
|
|
renderStarComponent(w, h.Templates, handle, repository, false, starCount)
|
|
return
|
|
}
|
|
|
|
// Return JSON for API clients
|
|
render.JSON(w, r, map[string]bool{"starred": false})
|
|
}
|
|
|
|
// renderStarComponent renders the star component HTML for HTMX responses
|
|
func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) {
|
|
data := map[string]any{
|
|
"Interactive": true,
|
|
"Handle": handle,
|
|
"Repository": repository,
|
|
"IsStarred": isStarred,
|
|
"StarCount": starCount,
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "star", data); err != nil {
|
|
slog.Error("Failed to render star component", "error", err)
|
|
http.Error(w, "Failed to render component", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(buf.Bytes())
|
|
}
|