pkg-proxy/internal/server/browse.go
Andrew Nesbitt 599fe9e254
Fix all golangci-lint issues across the codebase (#32)
* Fix all golangci-lint issues across the codebase

Resolve 77 lint issues reported by golangci-lint with gocritic, gocognit,
gocyclo, maintidx, dupl, mnd, unparam, ireturn, goconst, and errcheck
enabled. Net reduction of ~175 lines through shared helpers and
deduplication.

* Suppress staticcheck SA1019 for intentional deprecated field usage

The Storage.Path field is deprecated but still read for backwards
compatibility with existing configs that haven't migrated to the URL field.
2026-03-18 10:59:29 +00:00

508 lines
15 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"github.com/git-pkgs/archives"
"github.com/git-pkgs/archives/diff"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/purl"
"github.com/go-chi/chi/v5"
)
const contentTypePlainText = "text/plain; charset=utf-8"
// getStripPrefix returns the path prefix to strip for a given ecosystem.
// npm packages wrap content in a "package/" directory.
func getStripPrefix(ecosystem string) string {
switch ecosystem {
case "npm":
return "package/"
default:
return ""
}
}
// BrowseListResponse contains the file listing for a directory in an archives.
type BrowseListResponse struct {
Path string `json:"path"`
Files []BrowseFileInfo `json:"files"`
}
// BrowseFileInfo contains metadata about a file in an archives.
type BrowseFileInfo struct {
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"is_dir"`
ModTime string `json:"mod_time,omitempty"`
}
// handleBrowseList returns a list of files in a directory within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}?path=/some/dir
// @Summary List files inside a cached artifact
// @Description Lists files from the first cached artifact for a package version.
// @Tags browse
// @Produce json
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name"
// @Param version path string true "Version"
// @Param path query string false "Directory path inside the archive"
// @Success 200 {object} BrowseListResponse
// @Failure 404 {string} string
// @Failure 500 {string} string
// @Router /api/browse/{ecosystem}/{name}/{version} [get]
func (s *Server) handleBrowseList(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
name := chi.URLParam(r, "name")
version := chi.URLParam(r, "version")
dirPath := r.URL.Query().Get("path")
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
// Find the first cached artifact
var cachedArtifact *database.Artifact
for i := range artifacts {
if artifacts[i].StoragePath.Valid {
cachedArtifact = &artifacts[i]
break
}
}
if cachedArtifact == nil {
http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
// Open the artifact from storage
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
// Open archive with appropriate prefix stripping
stripPrefix := getStripPrefix(ecosystem)
archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
defer func() { _ = archiveReader.Close() }()
// List files in the directory
files, err := archiveReader.ListDir(dirPath)
if err != nil {
s.logger.Error("failed to list directory", "error", err, "path", dirPath)
http.Error(w, "failed to list directory", http.StatusInternalServerError)
return
}
// Convert to response format
response := BrowseListResponse{
Path: dirPath,
Files: make([]BrowseFileInfo, len(files)),
}
for i, f := range files {
response.Files[i] = BrowseFileInfo{
Path: f.Path,
Name: f.Name,
Size: f.Size,
IsDir: f.IsDir,
ModTime: f.ModTime.Format("2006-01-02 15:04:05"),
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
// handleBrowseFile returns the contents of a specific file within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}/file/{filepath...}
// @Summary Fetch a file inside a cached artifact
// @Description Streams a single file from the cached artifact. The file path may contain slashes.
// @Tags browse
// @Produce application/octet-stream
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name"
// @Param version path string true "Version"
// @Param filepath path string true "File path inside the archive"
// @Success 200 {file} file
// @Failure 400 {string} string
// @Failure 404 {string} string
// @Failure 500 {string} string
// @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get]
func (s *Server) handleBrowseFile(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
name := chi.URLParam(r, "name")
version := chi.URLParam(r, "version")
// Get the wildcard path
filePath := chi.URLParam(r, "*")
if filePath == "" {
http.Error(w, "file path required", http.StatusBadRequest)
return
}
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
http.Error(w, "version not found", http.StatusNotFound)
return
}
if len(artifacts) == 0 {
http.Error(w, "no artifacts cached", http.StatusNotFound)
return
}
// Find the first cached artifact
var cachedArtifact *database.Artifact
for i := range artifacts {
if artifacts[i].StoragePath.Valid {
cachedArtifact = &artifacts[i]
break
}
}
if cachedArtifact == nil {
http.Error(w, "artifact not cached", http.StatusNotFound)
return
}
// Open the artifact from storage
artifactReader, err := s.storage.Open(r.Context(), cachedArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to read artifact from storage", "error", err)
http.Error(w, "failed to read artifact", http.StatusInternalServerError)
return
}
defer func() { _ = artifactReader.Close() }()
// Open archive with appropriate prefix stripping
stripPrefix := getStripPrefix(ecosystem)
archiveReader, err := archives.OpenWithPrefix(cachedArtifact.Filename, artifactReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open archive", "error", err, "filename", cachedArtifact.Filename)
http.Error(w, "failed to open archive", http.StatusInternalServerError)
return
}
defer func() { _ = archiveReader.Close() }()
// Extract the file
fileReader, err := archiveReader.Extract(filePath)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "file not found", http.StatusNotFound)
return
}
s.logger.Error("failed to extract file", "error", err, "path", filePath)
http.Error(w, "failed to extract file", http.StatusInternalServerError)
return
}
defer func() { _ = fileReader.Close() }()
// Set content type based on file extension
contentType := detectContentType(filePath)
w.Header().Set("Content-Type", contentType)
// Set filename for download
_, filename := path.Split(filePath)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))
// Stream the file
_, _ = io.Copy(w, fileReader)
}
// detectContentType returns an appropriate content type based on file extension.
func detectContentType(filename string) string {
ext := strings.ToLower(path.Ext(filename))
switch ext {
// Text formats
case ".txt", ".md", ".markdown":
return contentTypePlainText
case ".html", ".htm":
return "text/html; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".xml":
return "application/xml; charset=utf-8"
case ".yaml", ".yml":
return "text/yaml; charset=utf-8"
case ".toml":
return "text/toml; charset=utf-8"
// Programming languages
case ".go":
return "text/x-go; charset=utf-8"
case ".rs":
return "text/x-rust; charset=utf-8"
case ".py":
return "text/x-python; charset=utf-8"
case ".rb":
return "text/x-ruby; charset=utf-8"
case ".java":
return "text/x-java; charset=utf-8"
case ".c", ".h":
return "text/x-c; charset=utf-8"
case ".cpp", ".cc", ".cxx", ".hpp":
return "text/x-c++; charset=utf-8"
case ".ts":
return "text/typescript; charset=utf-8"
case ".tsx":
return "text/tsx; charset=utf-8"
case ".jsx":
return "text/jsx; charset=utf-8"
case ".php":
return "text/x-php; charset=utf-8"
// Config files
case ".conf", ".config", ".ini":
return contentTypePlainText
case ".sh", ".bash":
return "text/x-shellscript; charset=utf-8"
case ".dockerfile":
return "text/x-dockerfile; charset=utf-8"
// Images
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/x-icon"
// Archives
case ".zip", ".tar", ".gz", ".bz2", ".xz":
return "application/octet-stream"
default:
// Try to detect if it looks like text
if isLikelyText(filename) {
return contentTypePlainText
}
return "application/octet-stream"
}
}
// isLikelyText checks if a filename suggests it's a text file.
func isLikelyText(filename string) bool {
base := path.Base(filename)
// Common text files without extensions
textFiles := []string{
"readme", "license", "authors", "contributors",
"changelog", "changes", "news", "history",
"install", "makefile", "dockerfile",
"gemfile", "rakefile", "procfile",
".gitignore", ".dockerignore", ".npmignore",
}
baseLower := strings.ToLower(base)
for _, tf := range textFiles {
if baseLower == tf || strings.HasPrefix(baseLower, tf+".") {
return true
}
}
return false
}
// BrowseSourceData contains data for the browse source page.
type BrowseSourceData struct {
Ecosystem string
PackageName string
Version string
}
// handleBrowseSource renders the source code browser UI.
// GET /package/{ecosystem}/{name}/{version}/browse
func (s *Server) handleBrowseSource(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
name := chi.URLParam(r, "name")
version := chi.URLParam(r, "version")
data := BrowseSourceData{
Ecosystem: ecosystem,
PackageName: name,
Version: version,
}
if err := s.templates.Render(w, "browse_source", data); err != nil {
s.logger.Error("failed to render browse source page", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
// handleCompareDiff compares two versions and returns a diff.
// GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}
// @Summary Compare two cached versions
// @Description Returns a structured diff for two cached versions.
// @Tags browse
// @Produce json
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name"
// @Param fromVersion path string true "From version"
// @Param toVersion path string true "To version"
// @Success 200 {object} map[string]any
// @Failure 404 {string} string
// @Failure 500 {string} string
// @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get]
func (s *Server) handleCompareDiff(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
name := chi.URLParam(r, "name")
fromVersion := chi.URLParam(r, "fromVersion")
toVersion := chi.URLParam(r, "toVersion")
// Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
toPURL := purl.MakePURLString(ecosystem, name, toVersion)
fromArtifacts, err := s.db.GetArtifactsByVersionPURL(fromPURL)
if err != nil || len(fromArtifacts) == 0 {
http.Error(w, "from version not found or not cached", http.StatusNotFound)
return
}
toArtifacts, err := s.db.GetArtifactsByVersionPURL(toPURL)
if err != nil || len(toArtifacts) == 0 {
http.Error(w, "to version not found or not cached", http.StatusNotFound)
return
}
// Find cached artifacts
var fromArtifact, toArtifact *database.Artifact
for i := range fromArtifacts {
if fromArtifacts[i].StoragePath.Valid {
fromArtifact = &fromArtifacts[i]
break
}
}
for i := range toArtifacts {
if toArtifacts[i].StoragePath.Valid {
toArtifact = &toArtifacts[i]
break
}
}
if fromArtifact == nil || toArtifact == nil {
http.Error(w, "one or both versions not cached", http.StatusNotFound)
return
}
// Open both archives
fromReader, err := s.storage.Open(r.Context(), fromArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open from artifact", "error", err)
http.Error(w, "failed to read from version", http.StatusInternalServerError)
return
}
defer func() { _ = fromReader.Close() }()
toReader, err := s.storage.Open(r.Context(), toArtifact.StoragePath.String)
if err != nil {
s.logger.Error("failed to open to artifact", "error", err)
http.Error(w, "failed to read to version", http.StatusInternalServerError)
return
}
defer func() { _ = toReader.Close() }()
stripPrefix := getStripPrefix(ecosystem)
fromArchive, err := archives.OpenWithPrefix(fromArtifact.Filename, fromReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open from archive", "error", err)
http.Error(w, "failed to open from archive", http.StatusInternalServerError)
return
}
defer func() { _ = fromArchive.Close() }()
toArchive, err := archives.OpenWithPrefix(toArtifact.Filename, toReader, stripPrefix)
if err != nil {
s.logger.Error("failed to open to archive", "error", err)
http.Error(w, "failed to open to archive", http.StatusInternalServerError)
return
}
defer func() { _ = toArchive.Close() }()
// Generate diff
result, err := diff.Compare(fromArchive, toArchive)
if err != nil {
s.logger.Error("failed to generate diff", "error", err)
http.Error(w, "failed to generate diff", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
// ComparePageData contains data for the version comparison page.
type ComparePageData struct {
Ecosystem string
PackageName string
FromVersion string
ToVersion string
}
// handleComparePage renders the version comparison UI.
// GET /package/{ecosystem}/{name}/compare/{versions}
// where {versions} is in format "fromVersion...toVersion"
func (s *Server) handleComparePage(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
name := chi.URLParam(r, "name")
versions := chi.URLParam(r, "versions")
// Parse versions (format: "1.0.0...2.0.0")
const compareVersionParts = 2
parts := strings.Split(versions, "...")
if len(parts) != compareVersionParts {
http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest)
return
}
fromVersion := parts[0]
toVersion := parts[1]
data := ComparePageData{
Ecosystem: ecosystem,
PackageName: name,
FromVersion: fromVersion,
ToVersion: toVersion,
}
if err := s.templates.Render(w, "compare_versions", data); err != nil {
s.logger.Error("failed to render compare page", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}