Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
package server
import (
"bufio"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/go-chi/chi/v5"
)
// HostedAPIHandler serves /api/hosted, where operators push archives that the
// proxy serves in place of, or in addition to, what the upstream has.
type HostedAPIHandler struct {
proxy * handler . Proxy
maxUploadBytes int64
logger * slog . Logger
}
// NewHostedAPIHandler creates the /api/hosted handler.
func NewHostedAPIHandler ( proxy * handler . Proxy , maxUploadBytes int64 , logger * slog . Logger ) * HostedAPIHandler {
return & HostedAPIHandler { proxy : proxy , maxUploadBytes : maxUploadBytes , logger : logger }
}
// HostedPushResponse describes a pushed archive.
type HostedPushResponse struct {
Ecosystem string ` json:"ecosystem" `
Name string ` json:"name" `
handler . HostedArtifact
}
// HostedListResponse lists the archives pushed for a package.
type HostedListResponse struct {
Ecosystem string ` json:"ecosystem" `
Name string ` json:"name" `
Artifacts [ ] handler . HostedArtifact ` json:"artifacts" `
}
// registerHostedRoutes mounts /api/hosted. Listing is open like the rest of
// the API; pushing and deleting require the bearer token.
func registerHostedRoutes ( r chi . Router , h * HostedAPIHandler , token string ) {
r . Get ( "/api/hosted/{ecosystem}/*" , h . HandleList )
r . Group ( func ( w chi . Router ) {
w . Use ( requireBearerToken ( token ) )
w . Put ( "/api/hosted/{ecosystem}/*" , h . HandlePush )
w . Delete ( "/api/hosted/{ecosystem}/*" , h . HandleDelete )
} )
}
// HandlePush handles PUT /api/hosted/{ecosystem}/{name}/{version}
// @Summary Push a package archive
// @Description Stores the request body as the archive for name@version. It is served in place of the upstream archive with the same version and is never evicted.
// @Tags hosted
// @Accept octet-stream
// @Produce json
// @Security BearerAuth
// @Param ecosystem path string true "Ecosystem (npm or composer)"
// @Param name path string true "Package name, including an npm scope or the Composer vendor"
// @Param version path string true "Version"
// @Success 201 {object} HostedPushResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 413 {object} ErrorResponse
// @Router /api/hosted/{ecosystem}/{name}/{version} [put]
func ( h * HostedAPIHandler ) HandlePush ( w http . ResponseWriter , r * http . Request ) {
ecosystem , name , version , ok := hostedVersionPath ( w , r )
if ! ok {
return
}
target , err := handler . HostedTargetFor ( ecosystem , name , version )
if err != nil {
badRequest ( w , err . Error ( ) )
return
}
body := bufio . NewReader ( http . MaxBytesReader ( w , r . Body , h . maxUploadBytes ) )
if _ , err := body . Peek ( 1 ) ; errors . Is ( err , io . EOF ) {
badRequest ( w , "request body is empty" )
return
}
pushed , err := h . proxy . PushLocalArtifact ( r . Context ( ) , ecosystem , target , body , r . Header . Get ( "Content-Type" ) )
if err != nil {
h . pushError ( w , err )
return
}
h . logger . Info ( "pushed package archive" ,
"ecosystem" , ecosystem , "name" , target . Name , "version" , target . Version ,
"filename" , target . Filename , "sha256" , pushed . SHA256 )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusCreated )
writeJSON ( w , HostedPushResponse { Ecosystem : ecosystem , Name : target . Name , HostedArtifact : pushed } )
}
func ( h * HostedAPIHandler ) pushError ( w http . ResponseWriter , err error ) {
var tooLarge * http . MaxBytesError
switch {
case errors . As ( err , & tooLarge ) :
writeError ( w , http . StatusRequestEntityTooLarge , ErrCodeBadRequest , "archive exceeds hosted.max_upload_size" )
2026-10-13 13:19:58 +02:00
case errors . Is ( err , handler . ErrHostedInvalidArchive ) , errors . Is ( err , handler . ErrHostedManifestMismatch ) :
badRequest ( w , err . Error ( ) )
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
case errors . Is ( err , handler . ErrVersionDenied ) , errors . Is ( err , handler . ErrArtifactBlocked ) :
writeError ( w , http . StatusForbidden , ErrCodeBadRequest , err . Error ( ) )
default :
h . logger . Error ( "failed to push package archive" , "error" , err )
internalError ( w , "failed to store archive" )
}
}
// HandleList handles GET /api/hosted/{ecosystem}/{name}
// @Summary List pushed package archives
// @Tags hosted
// @Produce json
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name"
// @Success 200 {object} HostedListResponse
// @Failure 400 {object} ErrorResponse
// @Router /api/hosted/{ecosystem}/{name} [get]
func ( h * HostedAPIHandler ) HandleList ( w http . ResponseWriter , r * http . Request ) {
ecosystem := chi . URLParam ( r , "ecosystem" )
segments , err := packagePathSegments ( r )
if err != nil {
badRequest ( w , err . Error ( ) )
return
}
name := strings . Join ( segments , "/" )
if ! handler . HostedSupported ( ecosystem ) {
badRequest ( w , handler . ErrHostedUnsupported . Error ( ) )
return
}
listed , err := h . proxy . LocalArtifacts ( ecosystem , name )
if err != nil {
h . logger . Error ( "failed to list pushed archives" , "error" , err )
internalError ( w , "failed to list pushed archives" )
return
}
writeJSON ( w , HostedListResponse { Ecosystem : ecosystem , Name : name , Artifacts : listed } )
}
// HandleDelete handles DELETE /api/hosted/{ecosystem}/{name}/{version}
// @Summary Delete a pushed version
// @Description Removes the archives pushed for name@version. If the upstream has the version, clients get the upstream archive again.
// @Tags hosted
// @Security BearerAuth
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name"
// @Param version path string true "Version"
// @Success 204
// @Failure 401 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Router /api/hosted/{ecosystem}/{name}/{version} [delete]
func ( h * HostedAPIHandler ) HandleDelete ( w http . ResponseWriter , r * http . Request ) {
ecosystem , name , version , ok := hostedVersionPath ( w , r )
if ! ok {
return
}
err := h . proxy . DeleteLocalVersion ( ecosystem , name , version )
switch {
case errors . Is ( err , handler . ErrHostedVersionNotFound ) :
notFound ( w , err . Error ( ) )
case err != nil :
h . logger . Error ( "failed to delete pushed version" , "error" , err )
internalError ( w , "failed to delete pushed version" )
default :
h . logger . Info ( "deleted pushed version" , "ecosystem" , ecosystem , "name" , name , "version" , version )
w . WriteHeader ( http . StatusNoContent )
}
}
// minVersionPathSegments is one name segment plus the version.
const minVersionPathSegments = 2
// hostedVersionPath splits {name}/{version} from the wildcard. The version is
// always the last segment; the name may span several (npm scopes, Composer
// vendors).
func hostedVersionPath ( w http . ResponseWriter , r * http . Request ) ( ecosystem , name , version string , ok bool ) {
segments , err := packagePathSegments ( r )
if err != nil {
badRequest ( w , err . Error ( ) )
return "" , "" , "" , false
}
if len ( segments ) < minVersionPathSegments {
badRequest ( w , "path must be /api/hosted/{ecosystem}/{name}/{version}" )
return "" , "" , "" , false
}
last := len ( segments ) - 1
return chi . URLParam ( r , "ecosystem" ) , strings . Join ( segments [ : last ] , "/" ) , segments [ last ] , true
}