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 database
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-13 13:19:58 +02:00
|
|
|
"database/sql"
|
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
|
|
|
"fmt"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
// LocalArtifact is a pushed artifact with the integrity of its version.
|
|
|
|
|
type LocalArtifact struct {
|
|
|
|
|
Artifact
|
|
|
|
|
Integrity sql.NullString `db:"integrity"`
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// GetLocalArtifacts returns the artifacts pushed to the proxy for a package,
|
|
|
|
|
// ordered by version and filename.
|
2026-10-13 13:19:58 +02:00
|
|
|
func (db *DB) GetLocalArtifacts(packagePURL string) ([]LocalArtifact, error) {
|
|
|
|
|
var artifacts []LocalArtifact
|
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
|
|
|
query := db.Rebind(`
|
|
|
|
|
SELECT a.id, a.version_purl, a.filename, a.upstream_url, a.storage_path, a.content_hash,
|
|
|
|
|
a.size, a.content_type, a.fetched_at, a.hit_count, a.last_accessed_at,
|
2026-10-13 13:19:58 +02:00
|
|
|
a.created_at, a.updated_at, a.source, a.manifest, v.integrity
|
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
|
|
|
FROM artifacts a
|
|
|
|
|
JOIN versions v ON v.purl = a.version_purl
|
|
|
|
|
WHERE v.package_purl = ? AND a.source = 'local'
|
|
|
|
|
ORDER BY a.version_purl, a.filename
|
|
|
|
|
`)
|
|
|
|
|
if err := db.Select(&artifacts, query, packagePURL); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return artifacts, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteLocalArtifacts removes the pushed artifacts of a version and queues
|
|
|
|
|
// their stored objects for deletion in the same transaction, so a failed
|
|
|
|
|
// queue keeps the records instead of orphaning the objects. Artifacts cached
|
|
|
|
|
// from an upstream are left alone. It returns how many records it removed.
|
|
|
|
|
func (db *DB) DeleteLocalArtifacts(versionPURL string) (int, error) {
|
|
|
|
|
tx, err := db.Beginx()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
|
|
|
|
var paths []string
|
|
|
|
|
selectQuery := db.Rebind(`
|
|
|
|
|
SELECT storage_path FROM artifacts
|
|
|
|
|
WHERE version_purl = ? AND source = 'local' AND storage_path IS NOT NULL
|
|
|
|
|
`)
|
|
|
|
|
if err := tx.Select(&paths, selectQuery, versionPURL); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("reading local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := tx.Exec(db.Rebind(`DELETE FROM artifacts WHERE version_purl = ? AND source = 'local'`), versionPURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("deleting local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
deleted, err := res.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queuedAt := time.Now().UTC()
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
if _, err := tx.Exec(db.Rebind(queuePendingDeleteQuery), path, queuedAt); err != nil {
|
|
|
|
|
return 0, fmt.Errorf("queueing %s for deletion: %w", path, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return int(deleted), tx.Commit()
|
|
|
|
|
}
|