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}) } // CredentialHelperVersionResponse is the response for the credential helper version API type CredentialHelperVersionResponse struct { Latest string `json:"latest"` DownloadURLs map[string]string `json:"download_urls"` Checksums map[string]string `json:"checksums"` ReleaseNotes string `json:"release_notes,omitempty"` } // CredentialHelperVersionHandler returns the latest credential helper version info // Note: Version info is fetched dynamically from TangledRepo's releases type CredentialHelperVersionHandler struct { TangledRepo string } func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // This endpoint directs users to the Tangled repository for downloads // Version info should be fetched from the repository's releases page response := CredentialHelperVersionResponse{ Latest: "", DownloadURLs: map[string]string{"tangled_repo": h.TangledRepo}, Checksums: nil, ReleaseNotes: "Visit the Tangled repository for the latest releases: " + h.TangledRepo, } render.SetContentType(render.ContentTypeJSON) w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes render.JSON(w, r, response) } // 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()) }