pkg-proxy/internal/server/browse.go

579 lines
17 KiB
Go
Raw Permalink Normal View History

2026-02-03 22:40:23 +00:00
package server
import (
"bytes"
2026-02-03 22:40:23 +00:00
"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"
2026-02-03 22:40:23 +00:00
"github.com/go-chi/chi/v5"
)
const contentTypePlainText = "text/plain; charset=utf-8"
// archiveFilename returns a filename suitable for archive format detection.
// Some ecosystems (e.g. composer) store artifacts with bare hash filenames
// that have no extension. This adds .zip when the original has no extension
// and the content is likely a zip archive.
func archiveFilename(filename string) string {
if path.Ext(filename) == "" {
return filename + ".zip"
}
return filename
}
// detectSingleRootDir returns the single top-level directory name if all files
// in the archive live under one common directory (e.g. GitHub zipballs use
// "repo-hash/"). Returns "" if there's no single root or the archive is flat.
func detectSingleRootDir(reader archives.Reader) string {
files, err := reader.List()
if err != nil || len(files) == 0 {
return ""
}
var root string
for _, f := range files {
parts := strings.SplitN(f.Path, "/", 2) //nolint:mnd // split into dir + rest
if len(parts) == 0 {
continue
}
dir := parts[0]
if root == "" {
root = dir
} else if dir != root {
return ""
}
}
if root == "" {
return ""
}
return root + "/"
}
// openArchive opens a cached artifact as an archive reader, auto-detecting
// and stripping a single top-level directory prefix (like GitHub zipballs).
// For npm, the hardcoded "package/" prefix takes precedence.
func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations
fname := archiveFilename(filename)
// npm always uses package/ prefix
if ecosystem == "npm" {
return archives.OpenWithPrefix(fname, content, "package/")
}
// Read content into memory so we can scan then wrap with prefix
data, err := io.ReadAll(content)
if err != nil {
return nil, fmt.Errorf("reading artifact: %w", err)
}
// Open once to detect root prefix
probe, err := archives.Open(fname, bytes.NewReader(data))
if err != nil {
return nil, err
}
prefix := detectSingleRootDir(probe)
_ = probe.Close()
return archives.OpenWithPrefix(fname, bytes.NewReader(data), prefix)
}
// BrowseListResponse contains the file listing for a directory in an archives.
2026-02-03 22:40:23 +00:00
type BrowseListResponse struct {
Path string `json:"path"`
Files []BrowseFileInfo `json:"files"`
}
// BrowseFileInfo contains metadata about a file in an archives.
2026-02-03 22:40:23 +00:00
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
2026-03-11 17:18:29 +00:00
// @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]
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
// handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler.
// It resolves namespaced package names by consulting the database.
//
// Supported paths:
//
// {name}/{version} -> browse list
// {name}/{version}/file/{path} -> browse file
func (s *Server) handleBrowsePath(w http.ResponseWriter, r *http.Request) {
2026-02-03 22:40:23 +00:00
ecosystem := chi.URLParam(r, "ecosystem")
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
wildcard := chi.URLParam(r, "*")
segments := splitWildcardPath(wildcard)
if ecosystem == "" || len(segments) < 2 {
http.Error(w, "ecosystem, name, and version required", http.StatusBadRequest)
return
}
// Check for /file/ in the path for browse file requests.
fileIdx := -1
for i, seg := range segments {
if seg == "file" && i > 0 {
fileIdx = i
break
}
}
if fileIdx >= 0 {
// Everything before "file" is name+version, everything after is the file path.
nameVersionSegments := segments[:fileIdx]
filePath := strings.Join(segments[fileIdx+1:], "/")
name, rest := resolvePackageName(s.db, ecosystem, nameVersionSegments)
if name == "" && len(nameVersionSegments) >= 2 {
name = strings.Join(nameVersionSegments[:len(nameVersionSegments)-1], "/")
rest = nameVersionSegments[len(nameVersionSegments)-1:]
}
if len(rest) != 1 {
http.Error(w, "not found", http.StatusNotFound)
return
}
s.browseFile(w, r, ecosystem, name, rest[0], filePath)
return
}
// No /file/ segment: this is a browse list.
name, rest := resolvePackageName(s.db, ecosystem, segments)
if name == "" && len(segments) >= 2 {
name = strings.Join(segments[:len(segments)-1], "/")
rest = segments[len(segments)-1:]
}
if len(rest) != 1 {
http.Error(w, "not found", http.StatusNotFound)
return
}
s.browseList(w, r, ecosystem, name, rest[0])
}
// handleComparePath dispatches /api/compare/{ecosystem}/* to the compare handler.
// Supported paths: {name}/{fromVersion}/{toVersion}
func (s *Server) handleComparePath(w http.ResponseWriter, r *http.Request) {
ecosystem := chi.URLParam(r, "ecosystem")
wildcard := chi.URLParam(r, "*")
segments := splitWildcardPath(wildcard)
if ecosystem == "" || len(segments) < 3 {
http.Error(w, "ecosystem, name, fromVersion, and toVersion required", http.StatusBadRequest)
return
}
// The last two segments are fromVersion and toVersion.
// Everything before that is the package name.
name := strings.Join(segments[:len(segments)-2], "/")
fromVersion := segments[len(segments)-2]
toVersion := segments[len(segments)-1]
s.compareDiff(w, r, ecosystem, name, fromVersion, toVersion)
}
func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, name, version string) {
2026-02-03 22:40:23 +00:00
dirPath := r.URL.Query().Get("path")
// Get the artifact for this version
versionPURL := purl.MakePURLString(ecosystem, name, version)
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
// Open archive with auto-detected prefix stripping
archiveReader, err := openArchive(cachedArtifact.Filename, artifactReader, ecosystem)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
// 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)
2026-02-03 22:40:23 +00:00
}
// handleBrowseFile returns the contents of a specific file within an archived package version.
// GET /api/browse/{ecosystem}/{name}/{version}/file/{filepath...}
2026-03-11 17:18:29 +00:00
// @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]
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) {
2026-02-03 22:40:23 +00:00
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)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
// Open archive with auto-detected prefix stripping
archiveReader, err := openArchive(cachedArtifact.Filename, artifactReader, ecosystem)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
// 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() }()
2026-02-03 22:40:23 +00:00
// 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)
2026-02-03 22:40:23 +00:00
}
// 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
2026-02-03 22:40:23 +00:00
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
2026-02-03 22:40:23 +00:00
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
2026-02-03 22:40:23 +00:00
}
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
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
// handleBrowseSource is now showBrowseSource in server.go, dispatched via handlePackagePath.
2026-02-03 22:40:23 +00:00
// handleCompareDiff compares two versions and returns a diff.
// GET /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}
2026-03-11 17:18:29 +00:00
// @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]
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
2026-02-03 22:40:23 +00:00
// Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)
toPURL := purl.MakePURLString(ecosystem, name, toVersion)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
fromArchive, err := openArchive(fromArtifact.Filename, fromReader, ecosystem)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
toArchive, err := openArchive(toArtifact.Filename, toReader, ecosystem)
2026-02-03 22:40:23 +00:00
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() }()
2026-02-03 22:40:23 +00:00
// 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)
2026-02-03 22:40:23 +00:00
}
// ComparePageData contains data for the version comparison page.
type ComparePageData struct {
Ecosystem string
PackageName string
FromVersion string
ToVersion string
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
// handleComparePage is now showComparePage in server.go, dispatched via handlePackagePath.