Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/server/hosted_api.go
Simon Chrzanowski 48916bcb09 Never let a cache fill replace a pushed artifact
- Record a fetched artifact with its package and version in one
  transaction that refuses to replace a pushed artifact, so a fetch that
  finishes after a push keeps both the pushed file and its version's
  integrity. Pushes and cache fills share this write, including the
  compare-and-swap on the storage path that queues every replaced object.
- Don't open the stored file for ecosystems without a manifest.
- Derive multi-file versions from the file being named in the path.
- Validate DELETE paths like PUT paths, answering 400 for malformed ones.
- Parse ?sha256= with integrity.ParseHex.
2026-10-21 12:24:04 +02:00

256 lines
9.5 KiB
Go

package server
import (
"bufio"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"github.com/git-pkgs/integrity"
"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}, and
// PUT /api/hosted/generic/{name}/{version}/{file} for generic files.
// @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. Generic files are pushed to /api/hosted/generic/{name}/{version}/{file} and served at /generic/hosted/{name}/{version}/{file}. A file that was already pushed is refused with 409; delete it first to push it again.
// @Tags hosted
// @Accept octet-stream
// @Produce json
// @Security BearerAuth
// @Param ecosystem path string true "Ecosystem (npm, composer or generic)"
// @Param name path string true "Package name, including an npm scope or the Composer vendor"
// @Param version path string true "Version"
// @Param sha256 query string false "Expected sha256 of the body, hex-encoded; a mismatch is refused"
// @Success 201 {object} HostedPushResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 413 {object} ErrorResponse
// @Router /api/hosted/{ecosystem}/{name}/{version} [put]
func (h *HostedAPIHandler) HandlePush(w http.ResponseWriter, r *http.Request) {
path, ok := hostedPath(w, r, true)
if !ok {
return
}
target, err := handler.HostedTargetFor(path.ecosystem, path.name, path.version, path.filename)
if err != nil {
badRequest(w, err.Error())
return
}
wantSHA256 := r.URL.Query().Get("sha256")
if wantSHA256 != "" && !validSHA256(wantSHA256) {
badRequest(w, "sha256 must be 64 hex characters")
return
}
ecosystem := path.ecosystem
// Refuse before reading the body, so a client sending
// "Expect: 100-continue" isn't made to upload a file that is refused.
if err := h.proxy.CheckPush(ecosystem, target); err != nil {
h.pushError(w, err)
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"), wantSHA256)
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")
case errors.Is(err, handler.ErrHostedInvalidArchive), errors.Is(err, handler.ErrHostedManifestMismatch),
errors.Is(err, handler.ErrHostedChecksumMismatch):
badRequest(w, err.Error())
case errors.Is(err, handler.ErrHostedExists):
writeError(w, http.StatusConflict, ErrCodeConflict, err.Error())
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}, and
// DELETE /api/hosted/generic/{name}/{version}/{file} for one generic file.
// @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. For generic files, /api/hosted/generic/{name}/{version}/{file} removes only that file.
// @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) {
path, ok := hostedPath(w, r, false)
if !ok {
return
}
if err := handler.ValidateHostedPath(path.ecosystem, path.name, path.version, path.filename); err != nil {
badRequest(w, err.Error())
return
}
var err error
if path.filename != "" {
err = h.proxy.DeleteLocalFile(path.ecosystem, path.name, path.version, path.filename)
} else {
err = h.proxy.DeleteLocalVersion(path.ecosystem, path.name, path.version)
}
switch {
case errors.Is(err, handler.ErrHostedVersionNotFound), errors.Is(err, handler.ErrHostedFileNotFound):
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", path.ecosystem, "name", path.name,
"version", path.version, "filename", path.filename)
w.WriteHeader(http.StatusNoContent)
}
}
// minVersionPathSegments is one name segment plus the version.
const minVersionPathSegments = 2
// hostedPushPath is a parsed /api/hosted/{ecosystem}/... path.
type hostedPushPath struct {
ecosystem string
name string
version string
filename string
}
// hostedPath splits the wildcard into name, version and, for ecosystems
// whose pushes name the file, the filename. The version is the last segment
// otherwise, and the name may span several (npm scopes, Composer vendors).
// Ecosystems that name the file have single-segment names, so their path is
// {name}/{version}/{file}; requireFile is false for deletes, where
// {name}/{version} removes the whole version.
func hostedPath(w http.ResponseWriter, r *http.Request, requireFile bool) (hostedPushPath, bool) {
segments, err := packagePathSegments(r)
if err != nil {
badRequest(w, err.Error())
return hostedPushPath{}, false
}
path := hostedPushPath{ecosystem: chi.URLParam(r, "ecosystem")}
if handler.HostedFileInPath(path.ecosystem) {
switch {
case len(segments) == minVersionPathSegments+1:
path.name, path.version, path.filename = segments[0], segments[1], segments[2]
case len(segments) == minVersionPathSegments && !requireFile:
path.name, path.version = segments[0], segments[1]
default:
badRequest(w, "path must be /api/hosted/"+path.ecosystem+"/{name}/{version}/{file}")
return hostedPushPath{}, false
}
return path, true
}
if len(segments) < minVersionPathSegments {
badRequest(w, "path must be /api/hosted/{ecosystem}/{name}/{version}")
return hostedPushPath{}, false
}
last := len(segments) - 1
path.name, path.version = strings.Join(segments[:last], "/"), segments[last]
return path, true
}
// validSHA256 reports whether s is a hex-encoded sha256 digest.
func validSHA256(s string) bool {
_, err := integrity.ParseHex(integrity.SHA256, s)
return err == nil
}