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
|
|
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// DeleteLocalArtifacts removes the pushed artifacts of a version, or only the
|
|
|
|
|
// one named filename when it is set, 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, filename string) (int, 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
|
|
|
tx, err := db.Beginx()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
where := "version_purl = ? AND source = 'local'"
|
|
|
|
|
args := []any{versionPURL}
|
|
|
|
|
if filename != "" {
|
|
|
|
|
where += " AND filename = ?"
|
|
|
|
|
args = append(args, filename)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
var paths []string
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
selectQuery := db.Rebind(`SELECT storage_path FROM artifacts WHERE ` + where + ` AND storage_path IS NOT NULL`)
|
|
|
|
|
if err := tx.Select(&paths, selectQuery, args...); err != nil {
|
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
|
|
|
return 0, fmt.Errorf("reading local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
res, err := tx.Exec(db.Rebind(`DELETE FROM artifacts WHERE `+where), args...)
|
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
|
|
|
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()
|
|
|
|
|
}
|
2026-10-20 20:03:06 +02:00
|
|
|
|
|
|
|
|
// RecordLocalArtifact records a pushed artifact in one transaction: the
|
|
|
|
|
// package if it is missing, the version, and the artifact. An artifact cached
|
|
|
|
|
// from an upstream under the same name is replaced and its stored object
|
|
|
|
|
// queued for deletion; a pushed one is never replaced, and the whole record
|
|
|
|
|
// is refused with ErrLocalArtifactExists, so of two concurrent pushes of the
|
|
|
|
|
// same file exactly one wins.
|
|
|
|
|
//
|
2026-10-21 12:33:43 +02:00
|
|
|
// When oneFilePerVersion is false, an existing version record is left as it
|
|
|
|
|
// is. Otherwise its integrity is set to v.Integrity, and the push is refused
|
|
|
|
|
// if the version already holds a pushed artifact under any filename, since
|
|
|
|
|
// aliases of one name (Composer's are case-insensitive) share the version
|
|
|
|
|
// record and its integrity. The version upsert locks that record first, so
|
|
|
|
|
// concurrent pushes of aliases see each other.
|
|
|
|
|
func (db *DB) RecordLocalArtifact(pkg *Package, v *Version, oneFilePerVersion bool, a *Artifact) error {
|
2026-10-20 20:21:01 +02:00
|
|
|
local := *a
|
|
|
|
|
local.Source = SourceLocal
|
|
|
|
|
return db.recordArtifact(&local, func(tx execer) error {
|
|
|
|
|
now := time.Now()
|
|
|
|
|
if _, err := tx.Exec(db.Rebind(`
|
|
|
|
|
INSERT INTO packages (purl, ecosystem, name, created_at, updated_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT(purl) DO NOTHING
|
|
|
|
|
`), pkg.PURL, pkg.Ecosystem, pkg.Name, now, now); err != nil {
|
|
|
|
|
return fmt.Errorf("inserting package: %w", err)
|
2026-10-20 20:03:06 +02:00
|
|
|
}
|
2026-10-20 20:21:01 +02:00
|
|
|
versionConflict := `DO NOTHING`
|
2026-10-21 12:33:43 +02:00
|
|
|
if oneFilePerVersion {
|
2026-10-20 20:21:01 +02:00
|
|
|
versionConflict = `DO UPDATE SET integrity = excluded.integrity, updated_at = excluded.updated_at`
|
|
|
|
|
}
|
|
|
|
|
if _, err := tx.Exec(db.Rebind(`
|
|
|
|
|
INSERT INTO versions (purl, package_purl, integrity, created_at, updated_at)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
|
|
|
|
ON CONFLICT(purl) `+versionConflict,
|
|
|
|
|
), v.PURL, v.PackagePURL, v.Integrity, now, now); err != nil {
|
|
|
|
|
return fmt.Errorf("recording version: %w", err)
|
|
|
|
|
}
|
2026-10-21 12:33:43 +02:00
|
|
|
if oneFilePerVersion {
|
|
|
|
|
var pushed int
|
|
|
|
|
if err := tx.Get(&pushed, db.Rebind(`
|
|
|
|
|
SELECT COUNT(*) FROM artifacts WHERE version_purl = ? AND source = ?
|
|
|
|
|
`), v.PURL, SourceLocal); err != nil {
|
|
|
|
|
return fmt.Errorf("checking pushed artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if pushed > 0 {
|
|
|
|
|
return ErrLocalArtifactExists
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-20 20:21:01 +02:00
|
|
|
return nil
|
|
|
|
|
}, ErrLocalArtifactExists)
|
2026-10-20 20:03:06 +02:00
|
|
|
}
|