Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,14 @@ Registered when `hosted.enabled` is set. Pushing and deleting require `Authoriza
| `GET /api/hosted/{ecosystem}/{name}` | List the archives pushed for a package |
| `DELETE /api/hosted/{ecosystem}/{name}/{version}` | Delete a pushed version |

### Cache API

Registered when `cache_delete` is set. It has no authentication of its own.

| Endpoint | Description |
|----------|-------------|
| `DELETE /api/cache/{ecosystem}/{name}/{version}` | Delete a version's cached files, or only `?filename=` |

### Enrichment API

The proxy provides REST endpoints for package metadata enrichment, vulnerability scanning, and outdated detection.
Expand Down Expand Up @@ -1616,6 +1624,12 @@ cache/artifacts/
└── nginx-1.24.0-1.fc39.x86_64.rpm
```

To delete one version's cached files, so the next request fetches them again, enable `cache_delete` and use the Delete buttons on the version page in the web UI, or the [cache API](docs/configuration.md#cache-delete-api):

```bash
curl -X DELETE 'http://localhost:8080/api/cache/maven/com.google.guava:guava/32.1.3-jre?filename=guava-32.1.3-jre.jar'
```

Cache metadata is stored in SQLite (default) or PostgreSQL. To clear a local cache:

```bash
Expand Down
5 changes: 5 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ base_url: "http://localhost:8080"
# Set to "0" to disable the timeout. Default: "30s".
# http_timeout: "30s"

# Register DELETE /api/cache and show Delete buttons on the UI's version page,
# for removing cached artifacts so they are fetched again. Has no
# authentication: restrict /api/cache and /ui at the ingress first.
# cache_delete: false

# Largest upstream artifact to fetch (e.g. "2GB"). Larger ones are refused
# and not cached. Default: unlimited.
# artifact_max_size: ""
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1099,6 +1099,29 @@ curl -X POST http://localhost:8080/api/mirror \
-d '{"purls":["pkg:docker/library/alpine@3.20"],"platforms":["linux/amd64"]}'
```

## Cache Delete API

Deleting cached artifacts is disabled by default. Enable it to register `DELETE /api/cache` and show Delete buttons on the version page of the web UI:

```yaml
cache_delete: true
```

Or via environment variable: `PROXY_CACHE_DELETE=true`.

The endpoint has no authentication. Before enabling it, restrict `/api/cache` and `/ui` to trusted users at your ingress or reverse proxy. The UI sends its deletes to `/ui/api/cache`, so whatever protects `/ui` protects those too.

Delete every cached file of a version, or only the one named by `filename`:

```bash
curl -X DELETE http://localhost:8080/api/cache/maven/com.google.guava:guava/32.1.3-jre
curl -X DELETE 'http://localhost:8080/api/cache/maven/com.google.guava:guava/32.1.3-jre?filename=guava-32.1.3-jre.jar'
```

The name is the package name the UI shows, for example `group:artifact` for Maven. The response lists the deleted files. The next request for a deleted file fetches it from the upstream again. The stored bytes are removed after the same delay as a replaced artifact, so downloads already in progress can finish.

Pushed archives are not touched: a whole-version delete skips them, and naming one answers 409. Delete those through the [hosted API](#hosted-packages).

## Mirror Command

The `proxy mirror` command pre-populates the cache from various sources. It accepts the same storage and database flags as `serve`.
Expand Down
81 changes: 81 additions & 0 deletions docs/swagger/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,73 @@ const docTemplate = `{
}
}
},
"/api/cache/{ecosystem}/{name}/{version}": {
"delete": {
"description": "Removes the cached files of name@version, or only the one named by filename, so the next request fetches them from the upstream again. Pushed archives are not touched; delete those through /api/hosted.",
"produces": [
"application/json"
],
"tags": [
"cache"
],
"summary": "Delete cached artifacts",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name as shown in the UI, e.g. com.google.guava:guava for Maven",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Delete only this file",
"name": "filename",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.CacheDeleteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/hosted/{ecosystem}/{name}": {
"get": {
"produces": [
Expand Down Expand Up @@ -753,6 +820,20 @@ const docTemplate = `{
}
}
},
"server.CacheDeleteResponse": {
"type": "object",
"properties": {
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"purl": {
"type": "string"
}
}
},
"server.EcosystemStatsEntry": {
"type": "object",
"properties": {
Expand Down
81 changes: 81 additions & 0 deletions docs/swagger/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,73 @@
}
}
},
"/api/cache/{ecosystem}/{name}/{version}": {
"delete": {
"description": "Removes the cached files of name@version, or only the one named by filename, so the next request fetches them from the upstream again. Pushed archives are not touched; delete those through /api/hosted.",
"produces": [
"application/json"
],
"tags": [
"cache"
],
"summary": "Delete cached artifacts",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name as shown in the UI, e.g. com.google.guava:guava for Maven",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Delete only this file",
"name": "filename",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.CacheDeleteResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/hosted/{ecosystem}/{name}": {
"get": {
"produces": [
Expand Down Expand Up @@ -746,6 +813,20 @@
}
}
},
"server.CacheDeleteResponse": {
"type": "object",
"properties": {
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"purl": {
"type": "string"
}
}
},
"server.EcosystemStatsEntry": {
"type": "object",
"properties": {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ type Config struct {
// Disabled by default to prevent unauthenticated users from triggering downloads.
MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"`

// CacheDelete enables DELETE /api/cache for removing cached artifacts, and
// the delete buttons on the UI's version page. It has no authentication of
// its own, so restrict /api/cache and /ui at the ingress when enabling it.
CacheDelete bool `json:"cache_delete" yaml:"cache_delete"`

// Hosted configures pushing packages to the proxy (/api/hosted).
Hosted HostedConfig `json:"hosted" yaml:"hosted"`

Expand Down Expand Up @@ -1212,6 +1217,7 @@ func (c *Config) LoadFromEnv() {
setEnvString(&c.Scanning.FetchBaseURL, "PROXY_SCANNING_FETCH_BASE_URL")
setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA")
setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API")
setEnvBool(&c.CacheDelete, "PROXY_CACHE_DELETE")
setEnvBool(&c.Hosted.Enabled, "PROXY_HOSTED_ENABLED")
setEnvString(&c.Hosted.Token, "PROXY_HOSTED_TOKEN")
setEnvString(&c.Hosted.MaxUploadSize, "PROXY_HOSTED_MAX_UPLOAD_SIZE")
Expand Down
86 changes: 86 additions & 0 deletions internal/server/cache_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package server

import (
"net/http"
"strings"

"github.com/git-pkgs/proxy/internal/database"
"github.com/go-chi/chi/v5"
)

// CacheDeleteResponse lists the cached files removed for a version.
type CacheDeleteResponse struct {
PURL string `json:"purl"`
Deleted []string `json:"deleted"`
}

// handleCacheDelete handles DELETE /api/cache/{ecosystem}/{name}/{version}
// @Summary Delete cached artifacts
// @Description Removes the cached files of name@version, or only the one named by filename, so the next request fetches them from the upstream again. Pushed archives are not touched; delete those through /api/hosted.
// @Tags cache
// @Produce json
// @Param ecosystem path string true "Ecosystem"
// @Param name path string true "Package name as shown in the UI, e.g. com.google.guava:guava for Maven"
// @Param version path string true "Version"
// @Param filename query string false "Delete only this file"
// @Success 200 {object} CacheDeleteResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Router /api/cache/{ecosystem}/{name}/{version} [delete]
func (s *Server) handleCacheDelete(w http.ResponseWriter, r *http.Request) {
segments, err := packagePathSegments(r)
if err != nil {
badRequest(w, err.Error())
return
}
if len(segments) < minVersionPathSegments {
badRequest(w, "path must be /api/cache/{ecosystem}/{name}/{version}")
return
}
last := len(segments) - 1
ecosystem, name, version := chi.URLParam(r, "ecosystem"), strings.Join(segments[:last], "/"), segments[last]
filename := r.URL.Query().Get("filename")

versionPURL := s.cachedVersionPURL(ecosystem, name, version)
if versionPURL == "" {
notFound(w, "package not found")
return
}
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
if err != nil {
s.logger.Error("failed to list artifacts", "purl", versionPURL, "error", err)
internalError(w, "failed to list artifacts")
return
}

deleted := []string{}
for _, art := range artifacts {
if filename != "" && art.Filename != filename {
continue
}
if !art.StoragePath.Valid {
continue
}
if art.Source == database.SourceLocal {
if filename != "" {
writeError(w, http.StatusConflict, ErrCodeConflict, "pushed archives are deleted through /api/hosted")
return
}
continue
}
if err := s.db.DiscardArtifact(versionPURL, art.Filename, art.StoragePath.String); err != nil {
s.logger.Error("failed to delete cached artifact", "purl", versionPURL, "filename", art.Filename, "error", err)
internalError(w, "failed to delete cached artifact")
return
}
deleted = append(deleted, art.Filename)
}
if len(deleted) == 0 {
notFound(w, "no cached artifact matched")
return
}

s.logger.Info("deleted cached artifacts", "purl", versionPURL, "files", deleted)
writeJSON(w, CacheDeleteResponse{PURL: versionPURL, Deleted: deleted})
}
Loading
Loading