Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
pkg-proxy/internal/server/hosted_api.go

194 lines
6.9 KiB
Go
Raw Permalink Normal View History

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")
case errors.Is(err, handler.ErrHostedInvalidArchive), errors.Is(err, handler.ErrHostedManifestMismatch):
badRequest(w, 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}
// @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
}