2026-02-03 22:40:23 +00:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-06 17:14:15 +01:00
|
|
|
"bytes"
|
2026-02-03 22:40:23 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"path"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-02-16 10:53:21 +00:00
|
|
|
"github.com/git-pkgs/archives"
|
2026-02-27 10:55:10 +00:00
|
|
|
"github.com/git-pkgs/archives/diff"
|
2026-03-04 09:20:16 +00:00
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const contentTypePlainText = "text/plain; charset=utf-8"
|
|
|
|
|
|
2026-04-06 16:43:20 +01:00
|
|
|
// 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 {
|
2026-04-06 19:06:48 +01:00
|
|
|
if path.Ext(filename) == "" {
|
2026-04-06 16:43:20 +01:00
|
|
|
return filename + ".zip"
|
|
|
|
|
}
|
|
|
|
|
return filename
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:14:15 +01:00
|
|
|
// 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 {
|
2026-04-06 19:06:48 +01:00
|
|
|
parts := strings.SplitN(f.Path, "/", 2) //nolint:mnd // split into dir + rest
|
2026-04-06 17:14:15 +01:00
|
|
|
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.
|
2026-04-06 19:06:48 +01:00
|
|
|
func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations
|
2026-04-06 17:14:15 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 10:53:21 +00:00
|
|
|
// 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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 10:53:21 +00:00
|
|
|
// 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
|
2026-03-04 09:20:16 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
defer func() { _ = artifactReader.Close() }()
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 17:14:15 +01: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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
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")
|
2026-02-16 10:53:21 +00:00
|
|
|
_ = 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
|
2026-03-04 09:20:16 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
defer func() { _ = artifactReader.Close() }()
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 17:14:15 +01: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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
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
|
2026-02-16 10:53:21 +00:00
|
|
|
_, _ = 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":
|
2026-03-18 10:59:29 +00:00
|
|
|
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":
|
2026-03-18 10:59:29 +00:00
|
|
|
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) {
|
2026-03-18 10:59:29 +00:00
|
|
|
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
|
2026-03-04 09:20:16 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
defer func() { _ = toReader.Close() }()
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 17:14:15 +01: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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
defer func() { _ = fromArchive.Close() }()
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 17:14:15 +01: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
|
|
|
|
|
}
|
2026-02-16 10:53:21 +00:00
|
|
|
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")
|
2026-02-16 10:53:21 +00:00
|
|
|
_ = 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.
|