Files

304 lines
10 KiB
Go

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)
}