forked from mirrors/pkg-proxy
* 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.
508 lines
15 KiB
Go
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)
|
|
}
|
|
}
|