package handlers import ( "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" "github.com/bluesky-social/indigo/atproto/identity" "github.com/go-chi/chi/v5" ) // StarRepositoryHandler handles starring a repository type StarRepositoryHandler struct { DB *sql.DB Directory identity.Directory Refresher *oauth.Refresher } 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 } // Extract parameters handle := chi.URLParam(r, "handle") repository := chi.URLParam(r, "repository") // 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 } // Return success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]bool{"starred": true}) } // UnstarRepositoryHandler handles unstarring a repository type UnstarRepositoryHandler struct { DB *sql.DB Directory identity.Directory Refresher *oauth.Refresher } 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 } // Extract parameters handle := chi.URLParam(r, "handle") repository := chi.URLParam(r, "repository") // 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") } // Return success w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"starred": false}) } // CheckStarHandler checks if current user has starred a repository type CheckStarHandler struct { DB *sql.DB Directory identity.Directory Refresher *oauth.Refresher } func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Get authenticated user from middleware user := middleware.GetUser(r) if user == nil { // Not authenticated - return not starred w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"starred": false}) return } // Extract parameters handle := chi.URLParam(r, "handle") repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle) if err != nil { slog.Warn("Failed to resolve handle for check 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) // Note: Error handling moves to the PDS call - if session doesn't exist, GetRecord will fail pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) // Check if star record exists rkey := atproto.StarRecordKey(ownerDID, repository) _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) // Check if OAuth error - if so, invalidate sessions if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) { // For a read operation, just return not starred instead of error w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"starred": false}) return } starred := err == nil // Return result w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"starred": starred}) } // GetStatsHandler returns repository statistics type GetStatsHandler struct { DB *sql.DB Directory identity.Directory } func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Extract parameters handle := chi.URLParam(r, "handle") repository := chi.URLParam(r, "repository") // Resolve owner's handle to DID ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle) if err != nil { http.Error(w, "Failed to resolve handle", http.StatusBadRequest) return } // Get repository stats from database stats, err := db.GetRepositoryStats(h.DB, ownerDID, repository) if err != nil { http.Error(w, "Failed to fetch stats", http.StatusInternalServerError) return } // Return stats as JSON w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } // ManifestDetailHandler returns detailed manifest information including platforms type ManifestDetailHandler struct { DB *sql.DB Directory identity.Directory } func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Extract parameters handle := chi.URLParam(r, "handle") repository := chi.URLParam(r, "repository") digest := chi.URLParam(r, "digest") // Resolve owner's handle to DID ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle) if err != nil { http.Error(w, "Failed to resolve handle", http.StatusBadRequest) return } // Get manifest detail from database manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest) if err != nil { if err.Error() == "manifest not found" { http.Error(w, "Manifest not found", http.StatusNotFound) return } slog.Error("Failed to get manifest detail", "error", err) http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError) return } // Return manifest as JSON w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(manifest) } // 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 type CredentialHelperVersionHandler struct { Version string TangledRepo string Checksums map[string]string } // Supported platforms for download URLs var credentialHelperPlatforms = []struct { key string // API key (e.g., "linux_amd64") os string // OS name in archive (e.g., "Linux") arch string // Arch name in archive (e.g., "x86_64") ext string // Archive extension (e.g., "tar.gz" or "zip") }{ {"linux_amd64", "Linux", "x86_64", "tar.gz"}, {"linux_arm64", "Linux", "arm64", "tar.gz"}, {"darwin_amd64", "Darwin", "x86_64", "tar.gz"}, {"darwin_arm64", "Darwin", "arm64", "tar.gz"}, {"windows_amd64", "Windows", "x86_64", "zip"}, {"windows_arm64", "Windows", "arm64", "zip"}, } func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Check if version is configured if h.Version == "" { http.Error(w, "Credential helper version not configured", http.StatusServiceUnavailable) return } // Build download URLs for all platforms // URL format: {TangledRepo}/tags/{version}/download/docker-credential-atcr_{version_without_v}_{OS}_{Arch}.{ext} downloadURLs := make(map[string]string) versionWithoutV := strings.TrimPrefix(h.Version, "v") for _, p := range credentialHelperPlatforms { filename := fmt.Sprintf("docker-credential-atcr_%s_%s_%s.%s", versionWithoutV, p.os, p.arch, p.ext) downloadURLs[p.key] = fmt.Sprintf("%s/tags/%s/download/%s", h.TangledRepo, h.Version, filename) } response := CredentialHelperVersionResponse{ Latest: h.Version, DownloadURLs: downloadURLs, Checksums: h.Checksums, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes json.NewEncoder(w).Encode(response) }