mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 16:40:29 +00:00
2780 lines
89 KiB
Go
2780 lines
89 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// BlobCDNURL returns the CDN URL for an ATProto blob
|
|
// This is a local copy to avoid importing atproto (prevents circular dependencies)
|
|
func BlobCDNURL(did, cid string) string {
|
|
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
|
|
}
|
|
|
|
// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs
|
|
// the viewer is allowed to see in listings. Requires the viewerDID to be
|
|
// passed twice as query arguments (once for the owner_did check and once
|
|
// for the crew membership check). Empty viewerDID (anonymous) naturally
|
|
// matches no owner or crew rows, so only public + self-service holds
|
|
// (allow_all_crew=1) are returned.
|
|
const accessibleHoldsSubquery = `(
|
|
SELECT hold_did FROM hold_captain_records
|
|
WHERE public = 1
|
|
OR allow_all_crew = 1
|
|
OR owner_did = ?
|
|
OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?)
|
|
)`
|
|
|
|
// GetArtifactType determines the artifact type based on config media type
|
|
// Returns: "helm-chart", "container-image", or "unknown"
|
|
func GetArtifactType(configMediaType string) string {
|
|
switch {
|
|
case strings.Contains(configMediaType, "helm.config"):
|
|
return "helm-chart"
|
|
case strings.Contains(configMediaType, "oci.image.config") ||
|
|
strings.Contains(configMediaType, "docker.container.image"):
|
|
return "container-image"
|
|
case configMediaType == "":
|
|
// Manifest lists don't have a config - treat as container-image
|
|
return "container-image"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching.
|
|
// It also sanitizes the input to prevent injection attacks via special characters.
|
|
func escapeLikePattern(s string) string {
|
|
// Remove NULL bytes (could truncate query in C-based databases like SQLite)
|
|
s = strings.ReplaceAll(s, "\x00", "")
|
|
|
|
// Remove other control characters that could cause issues
|
|
s = strings.Map(func(r rune) rune {
|
|
// Keep printable characters, spaces, and common punctuation
|
|
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
|
|
return -1 // Remove control characters
|
|
}
|
|
return r
|
|
}, s)
|
|
|
|
// Escape LIKE wildcards - order matters! Backslash must be first
|
|
s = strings.ReplaceAll(s, "\\", "\\\\") // Escape backslash first
|
|
s = strings.ReplaceAll(s, "%", "\\%") // Escape % wildcard
|
|
s = strings.ReplaceAll(s, "_", "\\_") // Escape _ wildcard
|
|
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// SearchRepositories searches for repositories matching the query across handles, DIDs, repositories, and annotations
|
|
// Returns RepoCardData (one per repository) instead of individual pushes/tags
|
|
func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID string) ([]RepoCardData, int, error) {
|
|
// Escape LIKE wildcards so they're treated literally
|
|
query = escapeLikePattern(query)
|
|
|
|
// Prepare search pattern for LIKE queries (case-insensitive)
|
|
searchPattern := "%" + query + "%"
|
|
|
|
sqlQuery := `
|
|
WITH latest_manifests AS (
|
|
SELECT did, repository, MAX(id) as latest_id
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
GROUP BY did, repository
|
|
),
|
|
matching_repos AS (
|
|
SELECT DISTINCT lm.did, lm.repository, lm.latest_id
|
|
FROM latest_manifests lm
|
|
JOIN users u ON lm.did = u.did
|
|
WHERE u.handle LIKE ? ESCAPE '\'
|
|
OR u.did = ?
|
|
OR lm.repository LIKE ? ESCAPE '\'
|
|
OR EXISTS (
|
|
SELECT 1 FROM repository_annotations ra
|
|
WHERE ra.did = lm.did AND ra.repository = lm.repository
|
|
AND ra.value LIKE ? ESCAPE '\'
|
|
)
|
|
),
|
|
repo_stats AS (
|
|
SELECT
|
|
mr.did,
|
|
mr.repository,
|
|
COALESCE(rs.pull_count, 0) as pull_count,
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = mr.did AND repository = mr.repository), 0) as star_count
|
|
FROM matching_repos mr
|
|
LEFT JOIN repository_stats rs ON mr.did = rs.did AND mr.repository = rs.repository
|
|
)
|
|
SELECT
|
|
m.did,
|
|
u.handle,
|
|
COALESCE(u.avatar, ''),
|
|
m.repository,
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
|
|
repo_stats.star_count,
|
|
repo_stats.pull_count,
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
|
|
COALESCE(m.artifact_type, 'container-image'),
|
|
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
|
|
COALESCE(m.digest, ''),
|
|
MAX(rs.last_push, m.created_at),
|
|
COALESCE(rp.avatar_cid, '')
|
|
FROM matching_repos mr
|
|
JOIN manifests m ON mr.latest_id = m.id
|
|
JOIN users u ON m.did = u.did
|
|
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
|
|
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
|
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
|
ORDER BY MAX(rs.last_push, m.created_at) DESC
|
|
LIMIT ? OFFSET ?
|
|
`
|
|
|
|
rows, err := db.Query(sqlQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cards []RepoCardData
|
|
for rows.Next() {
|
|
var c RepoCardData
|
|
var ownerDID string
|
|
var isStarredInt int
|
|
var avatarCID string
|
|
var lastUpdatedStr sql.NullString
|
|
|
|
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
|
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
c.IsStarred = isStarredInt > 0
|
|
if lastUpdatedStr.Valid {
|
|
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
|
|
c.LastUpdated = t
|
|
}
|
|
}
|
|
// Prefer repo page avatar over annotation icon
|
|
if avatarCID != "" {
|
|
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
|
|
}
|
|
|
|
cards = append(cards, c)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Get total count of matching repositories
|
|
countQuery := `
|
|
WITH latest_manifests AS (
|
|
SELECT did, repository, MAX(id) as latest_id
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
GROUP BY did, repository
|
|
)
|
|
SELECT COUNT(DISTINCT lm.did || '/' || lm.repository)
|
|
FROM latest_manifests lm
|
|
JOIN users u ON lm.did = u.did
|
|
WHERE u.handle LIKE ? ESCAPE '\'
|
|
OR u.did = ?
|
|
OR lm.repository LIKE ? ESCAPE '\'
|
|
OR EXISTS (
|
|
SELECT 1 FROM repository_annotations ra
|
|
WHERE ra.did = lm.did AND ra.repository = lm.repository
|
|
AND ra.value LIKE ? ESCAPE '\'
|
|
)
|
|
`
|
|
|
|
var total int
|
|
if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
if err := PopulateRepoCardTags(db, cards); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return cards, total, nil
|
|
}
|
|
|
|
// GetUserRepositories fetches all repositories for a user.
|
|
// viewerDID scopes results to repositories whose manifests live on holds the
|
|
// viewer can access (empty viewerDID = anonymous → public + self-service only).
|
|
func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
|
|
// Get repository summary.
|
|
// Both tags and manifests are filtered via join onto manifests.hold_endpoint
|
|
// so repositories where every row lives on an inaccessible hold drop out.
|
|
rows, err := db.Query(`
|
|
SELECT
|
|
repository,
|
|
COUNT(DISTINCT tag) as tag_count,
|
|
COUNT(DISTINCT digest) as manifest_count,
|
|
MAX(created_at) as last_push
|
|
FROM (
|
|
SELECT t.repository, t.tag, t.digest, t.created_at
|
|
FROM tags t
|
|
JOIN manifests tm ON t.did = tm.did AND t.repository = tm.repository AND t.digest = tm.digest
|
|
WHERE t.did = ? AND tm.hold_endpoint IN `+accessibleHoldsSubquery+`
|
|
UNION
|
|
SELECT m.repository, NULL, m.digest, m.created_at
|
|
FROM manifests m
|
|
WHERE m.did = ? AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
|
|
)
|
|
GROUP BY repository
|
|
ORDER BY last_push DESC
|
|
`, did, viewerDID, viewerDID, did, viewerDID, viewerDID)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var repos []Repository
|
|
for rows.Next() {
|
|
var r Repository
|
|
var lastPushStr string
|
|
if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &lastPushStr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse the timestamp string into time.Time
|
|
if lastPushStr != "" {
|
|
// Try multiple timestamp formats
|
|
formats := []string{
|
|
time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
|
|
"2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
|
|
"2006-01-02 15:04:05.999999999", // SQLite with microseconds
|
|
time.RFC3339, // 2006-01-02T15:04:05Z07:00
|
|
"2006-01-02 15:04:05", // SQLite default
|
|
}
|
|
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, lastPushStr); err == nil {
|
|
r.LastPush = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get tags for this repo
|
|
tagRows, err := db.Query(`
|
|
SELECT id, tag, digest, created_at
|
|
FROM tags
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
`, did, r.Name)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for tagRows.Next() {
|
|
var t Tag
|
|
t.DID = did
|
|
t.Repository = r.Name
|
|
if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
|
|
tagRows.Close()
|
|
return nil, err
|
|
}
|
|
r.Tags = append(r.Tags, t)
|
|
}
|
|
tagRows.Close()
|
|
|
|
// Get manifests for this repo
|
|
manifestRows, err := db.Query(`
|
|
SELECT id, digest, hold_endpoint, schema_version, media_type,
|
|
config_digest, config_size, artifact_type, created_at
|
|
FROM manifests
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
`, did, r.Name)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for manifestRows.Next() {
|
|
var m Manifest
|
|
m.DID = did
|
|
m.Repository = r.Name
|
|
|
|
if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
|
|
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
|
|
manifestRows.Close()
|
|
return nil, err
|
|
}
|
|
|
|
r.Manifests = append(r.Manifests, m)
|
|
}
|
|
manifestRows.Close()
|
|
|
|
// Fetch repository-level annotations from annotations table
|
|
annotations, err := GetRepositoryAnnotations(db, did, r.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.Title = annotations["org.opencontainers.image.title"]
|
|
r.Description = annotations["org.opencontainers.image.description"]
|
|
r.SourceURL = annotations["org.opencontainers.image.source"]
|
|
r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
|
|
r.Licenses = annotations["org.opencontainers.image.licenses"]
|
|
r.IconURL = annotations["io.atcr.icon"]
|
|
r.ReadmeURL = annotations["io.atcr.readme"]
|
|
|
|
// Check for repo page avatar (overrides annotation icon)
|
|
repoPage, err := GetRepoPage(db, did, r.Name)
|
|
if err == nil && repoPage != nil && repoPage.AvatarCID != "" {
|
|
r.IconURL = BlobCDNURL(did, repoPage.AvatarCID)
|
|
}
|
|
|
|
repos = append(repos, r)
|
|
}
|
|
|
|
return repos, nil
|
|
}
|
|
|
|
// GetRepositoryMetadata retrieves metadata for a repository from annotations table
|
|
// Returns a map of annotation key -> value for easy access in templates and handlers
|
|
func GetRepositoryMetadata(db DBTX, did string, repository string) (map[string]string, error) {
|
|
return GetRepositoryAnnotations(db, did, repository)
|
|
}
|
|
|
|
// GetUserByDID retrieves a user by DID
|
|
func GetUserByDID(db DBTX, did string) (*User, error) {
|
|
var user User
|
|
var avatar, defaultHoldDID, ociClient sql.NullString
|
|
err := db.QueryRow(`
|
|
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen
|
|
FROM users
|
|
WHERE did = ?
|
|
`, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if avatar.Valid {
|
|
user.Avatar = avatar.String
|
|
}
|
|
if defaultHoldDID.Valid {
|
|
user.DefaultHoldDID = defaultHoldDID.String
|
|
}
|
|
if ociClient.Valid {
|
|
user.OciClient = ociClient.String
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// GetUserByHandle retrieves a user by handle
|
|
func GetUserByHandle(db DBTX, handle string) (*User, error) {
|
|
var user User
|
|
var avatar, defaultHoldDID, ociClient sql.NullString
|
|
err := db.QueryRow(`
|
|
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen
|
|
FROM users
|
|
WHERE handle = ?
|
|
`, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if avatar.Valid {
|
|
user.Avatar = avatar.String
|
|
}
|
|
if defaultHoldDID.Valid {
|
|
user.DefaultHoldDID = defaultHoldDID.String
|
|
}
|
|
if ociClient.Valid {
|
|
user.OciClient = ociClient.String
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
// InsertUserIfNotExists inserts a user record only if it doesn't already exist.
|
|
// Used by non-profile collections to avoid unnecessary writes during backfill.
|
|
func InsertUserIfNotExists(db DBTX, user *User) error {
|
|
// Clear handle from any other DID that currently holds it.
|
|
// In ATProto, a handle belongs to exactly one DID at a time —
|
|
// if a new DID claims this handle, the old association is stale.
|
|
_, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`,
|
|
user.Handle, user.DID)
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did) DO NOTHING
|
|
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
|
|
return err
|
|
}
|
|
|
|
// UpsertUser inserts or updates a user record
|
|
func UpsertUser(db DBTX, user *User) error {
|
|
// Clear handle from any other DID that currently holds it.
|
|
// In ATProto, a handle belongs to exactly one DID at a time —
|
|
// if a new DID claims this handle, the old association is stale.
|
|
_, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`,
|
|
user.Handle, user.DID)
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did) DO UPDATE SET
|
|
handle = excluded.handle,
|
|
pds_endpoint = excluded.pds_endpoint,
|
|
avatar = excluded.avatar,
|
|
last_seen = excluded.last_seen
|
|
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
|
|
return err
|
|
}
|
|
|
|
// UpsertUserIgnoreAvatar inserts or updates a user record, but preserves existing avatar on update
|
|
// This is useful when avatar fetch fails, and we don't want to overwrite an existing avatar with empty string
|
|
func UpsertUserIgnoreAvatar(db DBTX, user *User) error {
|
|
// Clear handle from any other DID that currently holds it.
|
|
// In ATProto, a handle belongs to exactly one DID at a time —
|
|
// if a new DID claims this handle, the old association is stale.
|
|
_, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`,
|
|
user.Handle, user.DID)
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did) DO UPDATE SET
|
|
handle = excluded.handle,
|
|
pds_endpoint = excluded.pds_endpoint,
|
|
last_seen = excluded.last_seen
|
|
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
|
|
return err
|
|
}
|
|
|
|
// UpdateUserLastSeen updates only the last_seen timestamp for a user
|
|
// This is more efficient than UpsertUser when only updating activity timestamp
|
|
func UpdateUserLastSeen(db DBTX, did string) error {
|
|
_, err := db.Exec(`
|
|
UPDATE users SET last_seen = ? WHERE did = ?
|
|
`, time.Now(), did)
|
|
return err
|
|
}
|
|
|
|
// UpdateUserHandle updates a user's handle when an identity change event is received
|
|
// This is called when Jetstream receives an identity event indicating a handle change
|
|
func UpdateUserHandle(db DBTX, did string, newHandle string) error {
|
|
_, err := db.Exec(`
|
|
UPDATE users SET handle = ?, last_seen = ? WHERE did = ?
|
|
`, newHandle, time.Now(), did)
|
|
return err
|
|
}
|
|
|
|
// UpdateUserDefaultHold updates a user's cached default hold DID
|
|
// This is called when Jetstream receives a sailor profile update
|
|
func UpdateUserDefaultHold(db DBTX, did string, holdDID string) error {
|
|
_, err := db.Exec(`
|
|
UPDATE users SET default_hold_did = ? WHERE did = ?
|
|
`, holdDID, did)
|
|
return err
|
|
}
|
|
|
|
// UpdateUserOciClient updates a user's cached OCI client preference
|
|
func UpdateUserOciClient(db DBTX, did string, ociClient string) error {
|
|
_, err := db.Exec(`
|
|
UPDATE users SET oci_client = ? WHERE did = ?
|
|
`, ociClient, did)
|
|
return err
|
|
}
|
|
|
|
// GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did
|
|
// if available, otherwise falls back to the most recent manifest's hold_endpoint.
|
|
func GetUserHoldDID(db DBTX, did string) string {
|
|
// Try cached default hold first
|
|
var holdDID sql.NullString
|
|
_ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
|
|
if holdDID.Valid && holdDID.String != "" {
|
|
return holdDID.String
|
|
}
|
|
|
|
// Fallback: most recent manifest's hold
|
|
var manifestHold string
|
|
err := db.QueryRow(`
|
|
SELECT hold_endpoint FROM manifests
|
|
WHERE did = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, did).Scan(&manifestHold)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return manifestHold
|
|
}
|
|
|
|
// UpdateUserAvatar updates a user's avatar URL when a profile change is detected
|
|
// This is called when Jetstream receives an app.bsky.actor.profile update
|
|
func UpdateUserAvatar(db DBTX, did string, avatarURL string) error {
|
|
_, err := db.Exec(`
|
|
UPDATE users SET avatar = ?, last_seen = ? WHERE did = ?
|
|
`, avatarURL, time.Now(), did)
|
|
return err
|
|
}
|
|
|
|
// GetManifestDigestsForDID returns all manifest digests for a DID
|
|
func GetManifestDigestsForDID(db DBTX, did string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
SELECT digest FROM manifests WHERE did = ?
|
|
`, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var digests []string
|
|
for rows.Next() {
|
|
var digest string
|
|
if err := rows.Scan(&digest); err != nil {
|
|
return nil, err
|
|
}
|
|
digests = append(digests, digest)
|
|
}
|
|
|
|
return digests, rows.Err()
|
|
}
|
|
|
|
// DeleteManifestsNotInList deletes all manifests for a DID that are not in the provided list
|
|
func DeleteManifestsNotInList(db DBTX, did string, keepDigests []string) error {
|
|
if len(keepDigests) == 0 {
|
|
// No manifests to keep - delete all for this DID
|
|
_, err := db.Exec(`DELETE FROM manifests WHERE did = ?`, did)
|
|
return err
|
|
}
|
|
|
|
// Build placeholders for IN clause
|
|
placeholders := make([]string, len(keepDigests))
|
|
args := []any{did}
|
|
for i, digest := range keepDigests {
|
|
placeholders[i] = "?"
|
|
args = append(args, digest)
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
DELETE FROM manifests
|
|
WHERE did = ? AND digest NOT IN (%s)
|
|
`, strings.Join(placeholders, ","))
|
|
|
|
_, err := db.Exec(query, args...)
|
|
return err
|
|
}
|
|
|
|
// GetTagsForDID returns all (repository, tag) pairs for a DID
|
|
func GetTagsForDID(db DBTX, did string) ([]struct{ Repository, Tag string }, error) {
|
|
rows, err := db.Query(`
|
|
SELECT repository, tag FROM tags WHERE did = ?
|
|
`, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tags []struct{ Repository, Tag string }
|
|
for rows.Next() {
|
|
var t struct{ Repository, Tag string }
|
|
if err := rows.Scan(&t.Repository, &t.Tag); err != nil {
|
|
return nil, err
|
|
}
|
|
tags = append(tags, t)
|
|
}
|
|
|
|
return tags, rows.Err()
|
|
}
|
|
|
|
// DeleteTagsNotInList deletes all tags for a DID that are not in the provided list.
|
|
// Atomicity is provided by the caller's transaction when used during backfill.
|
|
func DeleteTagsNotInList(db DBTX, did string, keepTags []struct{ Repository, Tag string }) error {
|
|
if len(keepTags) == 0 {
|
|
// No tags to keep - delete all for this DID
|
|
_, err := db.Exec(`DELETE FROM tags WHERE did = ?`, did)
|
|
return err
|
|
}
|
|
|
|
// First, get all current tags
|
|
rows, err := db.Query(`SELECT id, repository, tag FROM tags WHERE did = ?`, did)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var toDelete []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
var repo, tag string
|
|
if err := rows.Scan(&id, &repo, &tag); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
|
|
// Check if this tag should be kept
|
|
found := false
|
|
for _, keep := range keepTags {
|
|
if keep.Repository == repo && keep.Tag == tag {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
toDelete = append(toDelete, id)
|
|
}
|
|
}
|
|
rows.Close()
|
|
|
|
// Delete tags not in keep list
|
|
for _, id := range toDelete {
|
|
if _, err := db.Exec(`DELETE FROM tags WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InsertManifest inserts or updates a manifest record
|
|
// Uses UPSERT to update core metadata if manifest already exists
|
|
// Returns the manifest ID (works correctly for both insert and update)
|
|
// Note: Annotations are stored separately in repository_annotations table
|
|
func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
|
|
_, err := db.Exec(`
|
|
INSERT INTO manifests
|
|
(did, repository, digest, hold_endpoint, schema_version, media_type,
|
|
config_digest, config_size, artifact_type, subject_digest, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(did, repository, digest) DO UPDATE SET
|
|
hold_endpoint = excluded.hold_endpoint,
|
|
schema_version = excluded.schema_version,
|
|
media_type = excluded.media_type,
|
|
config_digest = excluded.config_digest,
|
|
config_size = excluded.config_size,
|
|
artifact_type = excluded.artifact_type,
|
|
subject_digest = excluded.subject_digest
|
|
WHERE excluded.hold_endpoint != manifests.hold_endpoint
|
|
OR excluded.schema_version != manifests.schema_version
|
|
OR excluded.media_type != manifests.media_type
|
|
OR excluded.config_digest IS NOT manifests.config_digest
|
|
OR excluded.config_size IS NOT manifests.config_size
|
|
OR excluded.artifact_type != manifests.artifact_type
|
|
OR excluded.subject_digest IS NOT manifests.subject_digest
|
|
`, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
|
|
manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
|
|
manifest.ConfigSize, manifest.ArtifactType,
|
|
sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""},
|
|
manifest.CreatedAt)
|
|
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Query for the ID (works for both insert and update)
|
|
var id int64
|
|
err = db.QueryRow(`
|
|
SELECT id FROM manifests
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
`, manifest.DID, manifest.Repository, manifest.Digest).Scan(&id)
|
|
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get manifest ID after upsert: %w", err)
|
|
}
|
|
|
|
return id, nil
|
|
}
|
|
|
|
// InsertLayer inserts a layer record, skipping if it already exists.
|
|
// Layers are immutable — once created, their digest/size/media_type never change.
|
|
func InsertLayer(db DBTX, layer *Layer) error {
|
|
var annotationsJSON *string
|
|
if len(layer.Annotations) > 0 {
|
|
b, err := json.Marshal(layer.Annotations)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal layer annotations: %w", err)
|
|
}
|
|
s := string(b)
|
|
annotationsJSON = &s
|
|
}
|
|
_, err := db.Exec(`
|
|
INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(manifest_id, layer_index) DO NOTHING
|
|
`, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON)
|
|
return err
|
|
}
|
|
|
|
// UpsertTag inserts or updates a tag record
|
|
func UpsertTag(db DBTX, tag *Tag) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO tags (did, repository, tag, digest, created_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did, repository, tag) DO UPDATE SET
|
|
digest = excluded.digest,
|
|
created_at = excluded.created_at
|
|
WHERE excluded.digest != tags.digest
|
|
OR excluded.created_at != tags.created_at
|
|
`, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt)
|
|
return err
|
|
}
|
|
|
|
// DeleteTag deletes a tag record
|
|
func DeleteTag(db DBTX, did, repository, tag string) error {
|
|
_, err := db.Exec(`
|
|
DELETE FROM tags WHERE did = ? AND repository = ? AND tag = ?
|
|
`, did, repository, tag)
|
|
return err
|
|
}
|
|
|
|
// LatestTagInfo holds the most recent tag name and its artifact type.
|
|
type LatestTagInfo struct {
|
|
Tag string
|
|
ArtifactType string
|
|
}
|
|
|
|
// MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint.
|
|
type MostRecentTagInfo struct {
|
|
Tag string
|
|
Digest string
|
|
HoldEndpoint string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// GetMostRecentTag returns the most recently created tag with its digest and hold endpoint.
|
|
// Returns nil, nil if no tags exist.
|
|
func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) {
|
|
var info MostRecentTagInfo
|
|
err := db.QueryRow(`
|
|
SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at
|
|
FROM tags t
|
|
LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
|
|
WHERE t.did = ? AND t.repository = ?
|
|
ORDER BY t.created_at DESC LIMIT 1
|
|
`, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt)
|
|
if err != nil {
|
|
return nil, nil // no tags is not an error
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// RepositoryExists checks if any manifests exist for a given repository.
|
|
func RepositoryExists(db DBTX, did, repository string) (bool, error) {
|
|
var count int
|
|
err := db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ? AND repository = ? LIMIT 1`, did, repository).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GetLatestTag returns the most recently created tag and its artifact type for a repository.
|
|
// Returns nil if no tags exist.
|
|
func GetLatestTag(db DBTX, did, repository string) (*LatestTagInfo, error) {
|
|
var info LatestTagInfo
|
|
err := db.QueryRow(`
|
|
SELECT t.tag, COALESCE(m.artifact_type, 'container-image')
|
|
FROM tags t
|
|
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
|
|
WHERE t.did = ? AND t.repository = ?
|
|
ORDER BY t.created_at DESC LIMIT 1
|
|
`, did, repository).Scan(&info.Tag, &info.ArtifactType)
|
|
if err != nil {
|
|
return nil, nil // no tags is not an error
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// CountTags returns the total number of tags for a repository.
|
|
func CountTags(db DBTX, did, repository string) (int, error) {
|
|
var count int
|
|
err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ?`, did, repository).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// GetTagsWithPlatforms returns tags for a repository with platform information
|
|
// Only multi-arch tags (manifest lists) have platform info in manifest_references
|
|
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
|
|
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
|
|
func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]TagWithPlatforms, error) {
|
|
return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset, viewerDID, true)
|
|
}
|
|
|
|
// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
|
|
// If tagName is non-empty, only that specific tag is returned.
|
|
// When applyHoldFilter is true, rows are filtered by hold access for viewerDID.
|
|
func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int, viewerDID string, applyHoldFilter bool) ([]TagWithPlatforms, error) {
|
|
var tagFilter string
|
|
var holdFilter string
|
|
var args []any
|
|
args = append(args, did, repository)
|
|
if tagName != "" {
|
|
tagFilter = "AND t.tag = ?"
|
|
args = append(args, tagName)
|
|
}
|
|
if applyHoldFilter {
|
|
holdFilter = "AND m.hold_endpoint IN " + accessibleHoldsSubquery
|
|
args = append(args, viewerDID, viewerDID)
|
|
}
|
|
args = append(args, limit, offset)
|
|
|
|
query := `
|
|
WITH paged_tags AS (
|
|
SELECT t.id, t.did, t.repository, t.tag, t.digest, t.created_at
|
|
FROM tags t
|
|
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
|
WHERE t.did = ? AND t.repository = ?
|
|
` + tagFilter + `
|
|
` + holdFilter + `
|
|
ORDER BY t.created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
)
|
|
SELECT
|
|
t.id,
|
|
t.did,
|
|
t.repository,
|
|
t.tag,
|
|
t.digest,
|
|
t.created_at,
|
|
m.media_type,
|
|
m.artifact_type,
|
|
m.hold_endpoint,
|
|
COALESCE(mr.platform_os, '') as platform_os,
|
|
COALESCE(mr.platform_architecture, '') as platform_architecture,
|
|
COALESCE(mr.platform_variant, '') as platform_variant,
|
|
COALESCE(mr.platform_os_version, '') as platform_os_version,
|
|
COALESCE(mr.is_attestation, 0) as is_attestation,
|
|
COALESCE(mr.digest, '') as child_digest,
|
|
COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint,
|
|
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = COALESCE(child_m.id, m.id)), 0) as compressed_size
|
|
FROM paged_tags t
|
|
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
|
|
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
|
|
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository
|
|
ORDER BY t.created_at DESC, mr.reference_index`
|
|
|
|
rows, err := db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Group platforms by tag
|
|
tagMap := make(map[string]*TagWithPlatforms)
|
|
var tagOrder []string // Preserve order
|
|
|
|
for rows.Next() {
|
|
var t Tag
|
|
var mediaType, artifactType, holdEndpoint string
|
|
var platformOS, platformArch, platformVariant, platformOSVersion string
|
|
var isAttestation bool
|
|
var childDigest, childHoldEndpoint string
|
|
var compressedSize int64
|
|
|
|
if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
|
|
&mediaType, &artifactType, &holdEndpoint,
|
|
&platformOS, &platformArch, &platformVariant, &platformOSVersion,
|
|
&isAttestation, &childDigest, &childHoldEndpoint, &compressedSize); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get or create TagWithPlatforms
|
|
tagKey := t.Tag
|
|
if _, exists := tagMap[tagKey]; !exists {
|
|
tagMap[tagKey] = &TagWithPlatforms{
|
|
Tag: t,
|
|
HoldEndpoint: holdEndpoint,
|
|
Platforms: []PlatformInfo{},
|
|
ArtifactType: artifactType,
|
|
CompressedSize: compressedSize, // for single-arch (no manifest_references row)
|
|
}
|
|
tagOrder = append(tagOrder, tagKey)
|
|
}
|
|
|
|
// Track if manifest list has attestations
|
|
if isAttestation {
|
|
tagMap[tagKey].HasAttestations = true
|
|
// Skip attestation references in platform display
|
|
continue
|
|
}
|
|
|
|
// Add platform info if present (only for multi-arch manifest lists)
|
|
if platformOS != "" || platformArch != "" {
|
|
tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
|
|
OS: platformOS,
|
|
Architecture: platformArch,
|
|
Variant: platformVariant,
|
|
OSVersion: platformOSVersion,
|
|
Digest: childDigest,
|
|
HoldEndpoint: childHoldEndpoint,
|
|
CompressedSize: compressedSize,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Convert map to slice, preserving order and setting IsMultiArch
|
|
result := make([]TagWithPlatforms, 0, len(tagMap))
|
|
for _, tagKey := range tagOrder {
|
|
tag := tagMap[tagKey]
|
|
tag.IsMultiArch = len(tag.Platforms) > 1
|
|
result = append(result, *tag)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DeleteManifest deletes a manifest and its associated layers.
|
|
// Also deletes any attestation manifests that reference this manifest via subject_digest.
|
|
// If repository is empty, deletes all manifests matching did and digest.
|
|
func DeleteManifest(db DBTX, did, repository, digest string) error {
|
|
var err error
|
|
if repository == "" {
|
|
// Delete attestation children first, then the manifest itself
|
|
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest)
|
|
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
|
|
} else {
|
|
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest)
|
|
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// DeleteUserData deletes all cached data for a user.
|
|
// This is used when an account is permanently deleted or when we discover
|
|
// the account no longer exists (e.g., RepoNotFound during backfill).
|
|
//
|
|
// Due to ON DELETE CASCADE in the schema, deleting from users will automatically
|
|
// cascade to: manifests, tags, layers, references, annotations, stars, repo_pages, etc.
|
|
func DeleteUserData(db DBTX, did string) (bool, error) {
|
|
result, err := db.Exec(`DELETE FROM users WHERE did = ?`, did)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to delete user: %w", err)
|
|
}
|
|
|
|
rowsAffected, _ := result.RowsAffected()
|
|
return rowsAffected > 0, nil
|
|
}
|
|
|
|
// GetManifest fetches a single manifest by digest
|
|
// Note: Annotations are stored separately in repository_annotations table
|
|
func GetManifest(db DBTX, digest string) (*Manifest, error) {
|
|
var m Manifest
|
|
|
|
err := db.QueryRow(`
|
|
SELECT id, did, repository, digest, hold_endpoint, schema_version,
|
|
media_type, config_digest, config_size, created_at
|
|
FROM manifests
|
|
WHERE digest = ?
|
|
`, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint,
|
|
&m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize,
|
|
&m.CreatedAt)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// GetNewestManifestForRepo returns the newest manifest for a specific repository
|
|
// Used by backfill to ensure annotations come from the most recent manifest
|
|
func GetNewestManifestForRepo(db DBTX, did, repository string) (*Manifest, error) {
|
|
var m Manifest
|
|
err := db.QueryRow(`
|
|
SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type,
|
|
config_digest, config_size, created_at
|
|
FROM manifests
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, did, repository).Scan(
|
|
&m.ID, &m.DID, &m.Repository, &m.Digest,
|
|
&m.HoldEndpoint, &m.SchemaVersion, &m.MediaType,
|
|
&m.ConfigDigest, &m.ConfigSize, &m.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
|
|
// Returns empty string if no manifests exist (e.g., first push)
|
|
// This is used instead of the in-memory cache to determine which hold to use for blob operations
|
|
func GetLatestHoldDIDForRepo(db DBTX, did, repository string) (string, error) {
|
|
var holdDID string
|
|
err := db.QueryRow(`
|
|
SELECT hold_endpoint
|
|
FROM manifests
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, did, repository).Scan(&holdDID)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// No manifests yet - return empty string (first push case)
|
|
return "", nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return holdDID, nil
|
|
}
|
|
|
|
// GetRepositoriesForDID returns all unique repository names for a DID
|
|
// Used by backfill to reconcile annotations for all repositories
|
|
func GetRepositoriesForDID(db DBTX, did string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
SELECT DISTINCT repository
|
|
FROM manifests
|
|
WHERE did = ?
|
|
`, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var repositories []string
|
|
for rows.Next() {
|
|
var repo string
|
|
if err := rows.Scan(&repo); err != nil {
|
|
return nil, err
|
|
}
|
|
repositories = append(repositories, repo)
|
|
}
|
|
return repositories, rows.Err()
|
|
}
|
|
|
|
// GetLayersForManifest fetches all layers for a manifest
|
|
func GetLayersForManifest(db DBTX, manifestID int64) ([]Layer, error) {
|
|
rows, err := db.Query(`
|
|
SELECT manifest_id, digest, size, media_type, layer_index, annotations
|
|
FROM layers
|
|
WHERE manifest_id = ?
|
|
ORDER BY layer_index
|
|
`, manifestID)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var layers []Layer
|
|
for rows.Next() {
|
|
var l Layer
|
|
var annotationsJSON sql.NullString
|
|
if err := rows.Scan(&l.ManifestID, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex, &annotationsJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
if annotationsJSON.Valid && annotationsJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(annotationsJSON.String), &l.Annotations); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal layer annotations: %w", err)
|
|
}
|
|
}
|
|
layers = append(layers, l)
|
|
}
|
|
|
|
return layers, nil
|
|
}
|
|
|
|
// InsertManifestReference inserts a new manifest reference record (for manifest lists/indexes)
|
|
func InsertManifestReference(db DBTX, ref *ManifestReference) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO manifest_references (manifest_id, digest, size, media_type,
|
|
platform_architecture, platform_os,
|
|
platform_variant, platform_os_version,
|
|
is_attestation, reference_index)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType,
|
|
ref.PlatformArchitecture, ref.PlatformOS,
|
|
ref.PlatformVariant, ref.PlatformOSVersion,
|
|
ref.IsAttestation, ref.ReferenceIndex)
|
|
return err
|
|
}
|
|
|
|
// GetManifestReferencesForManifest fetches all manifest references for a manifest list/index
|
|
func GetManifestReferencesForManifest(db DBTX, manifestID int64) ([]ManifestReference, error) {
|
|
rows, err := db.Query(`
|
|
SELECT manifest_id, digest, size, media_type,
|
|
platform_architecture, platform_os, platform_variant, platform_os_version,
|
|
reference_index
|
|
FROM manifest_references
|
|
WHERE manifest_id = ?
|
|
ORDER BY reference_index
|
|
`, manifestID)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var refs []ManifestReference
|
|
for rows.Next() {
|
|
var r ManifestReference
|
|
var arch, os, variant, osVersion sql.NullString
|
|
if err := rows.Scan(&r.ManifestID, &r.Digest, &r.Size, &r.MediaType,
|
|
&arch, &os, &variant, &osVersion,
|
|
&r.ReferenceIndex); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert nullable strings
|
|
if arch.Valid {
|
|
r.PlatformArchitecture = arch.String
|
|
}
|
|
if os.Valid {
|
|
r.PlatformOS = os.String
|
|
}
|
|
if variant.Valid {
|
|
r.PlatformVariant = variant.String
|
|
}
|
|
if osVersion.Valid {
|
|
r.PlatformOSVersion = osVersion.String
|
|
}
|
|
|
|
refs = append(refs, r)
|
|
}
|
|
|
|
return refs, nil
|
|
}
|
|
|
|
// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests
|
|
// Filters out platform-specific manifests that are referenced by manifest lists
|
|
// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
|
|
func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]ManifestWithMetadata, error) {
|
|
rows, err := db.Query(`
|
|
WITH manifest_list_children AS (
|
|
-- Get all digests that are children of manifest lists
|
|
SELECT DISTINCT mr.digest
|
|
FROM manifest_references mr
|
|
JOIN manifests m ON mr.manifest_id = m.id
|
|
WHERE m.did = ? AND m.repository = ?
|
|
)
|
|
SELECT
|
|
m.id, m.did, m.repository, m.digest, m.media_type,
|
|
m.schema_version, m.created_at,
|
|
m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type,
|
|
GROUP_CONCAT(DISTINCT t.tag) as tags,
|
|
COUNT(DISTINCT mr.digest) as platform_count
|
|
FROM manifests m
|
|
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
|
|
LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
|
|
WHERE m.did = ? AND m.repository = ?
|
|
AND m.subject_digest IS NULL
|
|
AND m.artifact_type != 'unknown'
|
|
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
|
|
AND (
|
|
-- Include manifest lists
|
|
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
|
|
OR
|
|
-- Include single-arch NOT referenced by any list
|
|
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
|
|
)
|
|
GROUP BY m.id
|
|
ORDER BY m.created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`, did, repository, did, repository, viewerDID, viewerDID, limit, offset)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var manifests []ManifestWithMetadata
|
|
for rows.Next() {
|
|
var m ManifestWithMetadata
|
|
var tags, configDigest sql.NullString
|
|
var configSize sql.NullInt64
|
|
|
|
if err := rows.Scan(
|
|
&m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
|
|
&m.SchemaVersion, &m.CreatedAt,
|
|
&configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType,
|
|
&tags, &m.PlatformCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set nullable fields
|
|
if configDigest.Valid {
|
|
m.ConfigDigest = configDigest.String
|
|
}
|
|
if configSize.Valid {
|
|
m.ConfigSize = configSize.Int64
|
|
}
|
|
|
|
// Parse tags
|
|
if tags.Valid && tags.String != "" {
|
|
m.Tags = strings.Split(tags.String, ",")
|
|
}
|
|
|
|
// Determine if manifest list
|
|
m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
|
|
|
|
manifests = append(manifests, m)
|
|
}
|
|
|
|
// Fetch platform details for multi-arch manifests AFTER closing the main query
|
|
for i := range manifests {
|
|
if manifests[i].IsManifestList {
|
|
platformRows, err := db.Query(`
|
|
SELECT
|
|
COALESCE(mr.platform_os, '') as platform_os,
|
|
COALESCE(mr.platform_architecture, '') as platform_architecture,
|
|
COALESCE(mr.platform_variant, '') as platform_variant,
|
|
COALESCE(mr.platform_os_version, '') as platform_os_version,
|
|
COALESCE(mr.is_attestation, 0) as is_attestation,
|
|
COALESCE(mr.digest, '') as child_digest,
|
|
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
|
|
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size
|
|
FROM manifest_references mr
|
|
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
|
|
WHERE mr.manifest_id = ?
|
|
ORDER BY mr.reference_index
|
|
`, manifests[i].DID, manifests[i].Repository, manifests[i].ID)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
manifests[i].Platforms = []PlatformInfo{}
|
|
for platformRows.Next() {
|
|
var p PlatformInfo
|
|
var isAttestation bool
|
|
|
|
if err := platformRows.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
|
|
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
|
|
platformRows.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// Track if manifest list has attestations
|
|
if isAttestation {
|
|
manifests[i].HasAttestations = true
|
|
continue
|
|
}
|
|
|
|
manifests[i].Platforms = append(manifests[i].Platforms, p)
|
|
}
|
|
platformRows.Close()
|
|
|
|
manifests[i].PlatformCount = len(manifests[i].Platforms)
|
|
}
|
|
}
|
|
|
|
return manifests, nil
|
|
}
|
|
|
|
// GetManifestDetail returns a manifest with full platform details and tags
|
|
// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
|
|
func GetManifestDetail(db DBTX, did, repository, digest string) (*ManifestWithMetadata, error) {
|
|
// First, get the manifest and its tags
|
|
var m ManifestWithMetadata
|
|
var tags, configDigest sql.NullString
|
|
var configSize sql.NullInt64
|
|
|
|
err := db.QueryRow(`
|
|
SELECT
|
|
m.id, m.did, m.repository, m.digest, m.media_type,
|
|
m.schema_version, m.created_at,
|
|
m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type,
|
|
GROUP_CONCAT(DISTINCT t.tag) as tags
|
|
FROM manifests m
|
|
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
|
|
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
|
|
GROUP BY m.id
|
|
`, did, repository, digest).Scan(
|
|
&m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
|
|
&m.SchemaVersion, &m.CreatedAt,
|
|
&configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType,
|
|
&tags,
|
|
)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("manifest not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Set nullable fields
|
|
if configDigest.Valid {
|
|
m.ConfigDigest = configDigest.String
|
|
}
|
|
if configSize.Valid {
|
|
m.ConfigSize = configSize.Int64
|
|
}
|
|
|
|
// Parse tags
|
|
if tags.Valid && tags.String != "" {
|
|
m.Tags = strings.Split(tags.String, ",")
|
|
}
|
|
|
|
// Determine if manifest list
|
|
m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
|
|
|
|
// If this is a manifest list, get platform details with child digests and sizes
|
|
if m.IsManifestList {
|
|
platforms, err := db.Query(`
|
|
SELECT
|
|
COALESCE(mr.platform_os, '') as platform_os,
|
|
COALESCE(mr.platform_architecture, '') as platform_architecture,
|
|
COALESCE(mr.platform_variant, '') as platform_variant,
|
|
COALESCE(mr.platform_os_version, '') as platform_os_version,
|
|
COALESCE(mr.is_attestation, 0) as is_attestation,
|
|
COALESCE(mr.digest, '') as child_digest,
|
|
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
|
|
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_id = child_m.id), 0) as compressed_size
|
|
FROM manifest_references mr
|
|
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
|
|
WHERE mr.manifest_id = ?
|
|
ORDER BY mr.reference_index
|
|
`, m.DID, m.Repository, m.ID)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer platforms.Close()
|
|
|
|
m.Platforms = []PlatformInfo{}
|
|
for platforms.Next() {
|
|
var p PlatformInfo
|
|
var isAttestation bool
|
|
|
|
if err := platforms.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
|
|
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if isAttestation {
|
|
m.HasAttestations = true
|
|
continue
|
|
}
|
|
|
|
m.Platforms = append(m.Platforms, p)
|
|
}
|
|
|
|
m.PlatformCount = len(m.Platforms)
|
|
}
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// GetFirehoseCursor retrieves the current firehose cursor
|
|
func GetFirehoseCursor(db DBTX) (int64, error) {
|
|
var cursor int64
|
|
err := db.QueryRow("SELECT cursor FROM firehose_cursor WHERE id = 1").Scan(&cursor)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
return cursor, err
|
|
}
|
|
|
|
// UpdateFirehoseCursor updates the firehose cursor
|
|
func UpdateFirehoseCursor(db DBTX, cursor int64) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO firehose_cursor (id, cursor, updated_at)
|
|
VALUES (1, ?, datetime('now'))
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
cursor = excluded.cursor,
|
|
updated_at = excluded.updated_at
|
|
`, cursor)
|
|
return err
|
|
}
|
|
|
|
// IsManifestReferenced checks if a manifest digest is referenced as a child of
|
|
// any manifest list for the given user. Used to protect manifest list children
|
|
// from auto-removal (they are untagged but still needed by their parent list).
|
|
func IsManifestReferenced(db DBTX, did, digest string) (bool, error) {
|
|
var count int
|
|
err := db.QueryRow(`
|
|
SELECT COUNT(*) FROM manifest_references mr
|
|
JOIN manifests m ON mr.manifest_id = m.id
|
|
WHERE mr.digest = ? AND m.did = ?
|
|
LIMIT 1
|
|
`, digest, did).Scan(&count)
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
// IsManifestTagged checks if a manifest has any tags
|
|
func IsManifestTagged(db DBTX, did, repository, digest string) (bool, error) {
|
|
var count int
|
|
err := db.QueryRow(`
|
|
SELECT COUNT(*) FROM tags
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
`, did, repository, digest).Scan(&count)
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GetManifestTags retrieves all tags for a manifest
|
|
func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
SELECT tag FROM tags
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
ORDER BY tag
|
|
`, did, repository, digest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tags []string
|
|
for rows.Next() {
|
|
var tag string
|
|
if err := rows.Scan(&tag); err != nil {
|
|
return nil, err
|
|
}
|
|
tags = append(tags, tag)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tags, nil
|
|
}
|
|
|
|
// GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion.
|
|
// Returns children of untagged manifest lists first (bottom-up) so the handler can delete
|
|
// children before parents, avoiding orphaned manifests from cascade-deleted references.
|
|
// Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch).
|
|
func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
WITH manifest_list_children AS (
|
|
SELECT DISTINCT mr.digest
|
|
FROM manifest_references mr
|
|
JOIN manifests m ON mr.manifest_id = m.id
|
|
WHERE m.did = ? AND m.repository = ?
|
|
),
|
|
untagged_top_level AS (
|
|
SELECT m.id, m.digest,
|
|
CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
|
|
THEN 1 ELSE 0 END as is_list
|
|
FROM manifests m
|
|
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
|
|
WHERE m.did = ? AND m.repository = ?
|
|
AND m.subject_digest IS NULL
|
|
AND (
|
|
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
|
|
OR
|
|
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
|
|
)
|
|
GROUP BY m.id
|
|
HAVING COUNT(t.tag) = 0
|
|
),
|
|
untagged_children AS (
|
|
SELECT DISTINCT mr.digest
|
|
FROM untagged_top_level ul
|
|
JOIN manifest_references mr ON ul.id = mr.manifest_id
|
|
JOIN manifests child_m ON mr.digest = child_m.digest
|
|
AND child_m.did = ? AND child_m.repository = ?
|
|
LEFT JOIN tags ct ON child_m.digest = ct.digest
|
|
AND child_m.did = ct.did AND child_m.repository = ct.repository
|
|
WHERE ul.is_list = 1 AND ct.tag IS NULL
|
|
AND mr.digest NOT IN (
|
|
SELECT mr2.digest FROM manifest_references mr2
|
|
JOIN manifests m2 ON mr2.manifest_id = m2.id
|
|
JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository
|
|
WHERE m2.did = ? AND m2.repository = ?
|
|
)
|
|
)
|
|
SELECT digest FROM untagged_children
|
|
UNION ALL
|
|
SELECT digest FROM untagged_top_level
|
|
`, did, repository, did, repository, did, repository, did, repository)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var digests []string
|
|
for rows.Next() {
|
|
var digest string
|
|
if err := rows.Scan(&digest); err != nil {
|
|
return nil, err
|
|
}
|
|
digests = append(digests, digest)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return digests, nil
|
|
}
|
|
|
|
// GetAttestationDetails returns attestation manifests and their layers for a manifest list.
|
|
// Joins manifest_references (is_attestation=true) → manifests → layers.
|
|
func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) {
|
|
// Step 1: Get the manifest list ID and hold endpoint
|
|
var manifestListID int64
|
|
var parentHoldEndpoint string
|
|
err := db.QueryRow(`
|
|
SELECT id, hold_endpoint FROM manifests
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
`, did, repository, manifestListDigest).Scan(&manifestListID, &parentHoldEndpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 2: Get attestation references and join to their manifest records
|
|
rows, err := db.Query(`
|
|
SELECT mr.digest, mr.media_type, mr.size, m.id
|
|
FROM manifest_references mr
|
|
LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ?
|
|
WHERE mr.manifest_id = ? AND mr.is_attestation = 1
|
|
ORDER BY mr.reference_index
|
|
`, did, repository, manifestListID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type refRow struct {
|
|
digest string
|
|
mediaType string
|
|
size int64
|
|
manifestID *int64 // may be NULL if attestation manifest not indexed yet
|
|
}
|
|
var refs []refRow
|
|
for rows.Next() {
|
|
var r refRow
|
|
var mid sql.NullInt64
|
|
if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &mid); err != nil {
|
|
return nil, err
|
|
}
|
|
if mid.Valid {
|
|
r.manifestID = &mid.Int64
|
|
}
|
|
refs = append(refs, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 3: For each attestation manifest, fetch its layers
|
|
// Use the parent manifest list's hold endpoint — attestation blobs are in the same hold
|
|
details := make([]AttestationDetail, 0, len(refs))
|
|
for _, ref := range refs {
|
|
detail := AttestationDetail{
|
|
Digest: ref.digest,
|
|
MediaType: ref.mediaType,
|
|
Size: ref.size,
|
|
HoldEndpoint: parentHoldEndpoint,
|
|
}
|
|
if ref.manifestID != nil {
|
|
layers, err := GetLayersForManifest(db, *ref.manifestID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
detail.Layers = layers
|
|
}
|
|
details = append(details, detail)
|
|
}
|
|
|
|
return details, nil
|
|
}
|
|
|
|
// BackfillState represents the backfill progress
|
|
type BackfillState struct {
|
|
StartCursor int64
|
|
CurrentCursor int64
|
|
Completed bool
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// GetBackfillState retrieves the backfill state
|
|
func GetBackfillState(db DBTX) (*BackfillState, error) {
|
|
var state BackfillState
|
|
var updatedAtStr string
|
|
|
|
err := db.QueryRow(`
|
|
SELECT start_cursor, current_cursor, completed, updated_at
|
|
FROM backfill_state
|
|
WHERE id = 1
|
|
`).Scan(&state.StartCursor, &state.CurrentCursor, &state.Completed, &updatedAtStr)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil // No backfill state exists
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse timestamp
|
|
if updatedAtStr != "" {
|
|
formats := []string{
|
|
time.RFC3339Nano,
|
|
"2006-01-02 15:04:05.999999999-07:00",
|
|
"2006-01-02 15:04:05.999999999",
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05",
|
|
}
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, updatedAtStr); err == nil {
|
|
state.UpdatedAt = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return &state, nil
|
|
}
|
|
|
|
// UpsertBackfillState updates or creates backfill state
|
|
func UpsertBackfillState(db DBTX, state *BackfillState) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO backfill_state (id, start_cursor, current_cursor, completed, updated_at)
|
|
VALUES (1, ?, ?, ?, datetime('now'))
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
start_cursor = excluded.start_cursor,
|
|
current_cursor = excluded.current_cursor,
|
|
completed = excluded.completed,
|
|
updated_at = excluded.updated_at
|
|
`, state.StartCursor, state.CurrentCursor, state.Completed)
|
|
return err
|
|
}
|
|
|
|
// UpdateBackfillCursor updates just the current cursor position
|
|
func UpdateBackfillCursor(db DBTX, cursor int64) error {
|
|
_, err := db.Exec(`
|
|
UPDATE backfill_state
|
|
SET current_cursor = ?, updated_at = datetime('now')
|
|
WHERE id = 1
|
|
`, cursor)
|
|
return err
|
|
}
|
|
|
|
// MarkBackfillCompleted marks the backfill as completed
|
|
func MarkBackfillCompleted(db DBTX) error {
|
|
_, err := db.Exec(`
|
|
UPDATE backfill_state
|
|
SET completed = 1, updated_at = datetime('now')
|
|
WHERE id = 1
|
|
`)
|
|
return err
|
|
}
|
|
|
|
// GetRepository fetches a specific repository for a user
|
|
func GetRepository(db DBTX, did, repository string) (*Repository, error) {
|
|
// Get repository summary
|
|
var r Repository
|
|
r.Name = repository
|
|
|
|
var tagCount, manifestCount int
|
|
var lastPushStr string
|
|
|
|
err := db.QueryRow(`
|
|
SELECT
|
|
COUNT(DISTINCT tag) as tag_count,
|
|
COUNT(DISTINCT digest) as manifest_count,
|
|
MAX(created_at) as last_push
|
|
FROM (
|
|
SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ?
|
|
UNION
|
|
SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ?
|
|
)
|
|
`, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.TagCount = tagCount
|
|
r.ManifestCount = manifestCount
|
|
|
|
// Parse the timestamp string into time.Time
|
|
if lastPushStr != "" {
|
|
formats := []string{
|
|
time.RFC3339Nano,
|
|
"2006-01-02 15:04:05.999999999-07:00",
|
|
"2006-01-02 15:04:05.999999999",
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05",
|
|
}
|
|
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, lastPushStr); err == nil {
|
|
r.LastPush = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get tags for this repo
|
|
tagRows, err := db.Query(`
|
|
SELECT id, tag, digest, created_at
|
|
FROM tags
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
`, did, repository)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for tagRows.Next() {
|
|
var t Tag
|
|
t.DID = did
|
|
t.Repository = repository
|
|
if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
|
|
tagRows.Close()
|
|
return nil, err
|
|
}
|
|
r.Tags = append(r.Tags, t)
|
|
}
|
|
tagRows.Close()
|
|
|
|
// Get manifests for this repo
|
|
manifestRows, err := db.Query(`
|
|
SELECT id, digest, hold_endpoint, schema_version, media_type,
|
|
config_digest, config_size, artifact_type, created_at
|
|
FROM manifests
|
|
WHERE did = ? AND repository = ?
|
|
ORDER BY created_at DESC
|
|
`, did, repository)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for manifestRows.Next() {
|
|
var m Manifest
|
|
m.DID = did
|
|
m.Repository = repository
|
|
|
|
if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
|
|
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
|
|
manifestRows.Close()
|
|
return nil, err
|
|
}
|
|
|
|
r.Manifests = append(r.Manifests, m)
|
|
}
|
|
manifestRows.Close()
|
|
|
|
// Fetch repository-level annotations from annotations table
|
|
annotations, err := GetRepositoryAnnotations(db, did, repository)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r.Title = annotations["org.opencontainers.image.title"]
|
|
r.Description = annotations["org.opencontainers.image.description"]
|
|
r.SourceURL = annotations["org.opencontainers.image.source"]
|
|
r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
|
|
r.Licenses = annotations["org.opencontainers.image.licenses"]
|
|
r.IconURL = annotations["io.atcr.icon"]
|
|
r.ReadmeURL = annotations["io.atcr.readme"]
|
|
|
|
return &r, nil
|
|
}
|
|
|
|
// GetRepositoryStats fetches stats for a repository
|
|
func GetRepositoryStats(db DBTX, did, repository string) (*RepositoryStats, error) {
|
|
var stats RepositoryStats
|
|
var lastPullStr, lastPushStr sql.NullString
|
|
|
|
// Get pull/push stats from repository_stats, and star count from stars table
|
|
err := db.QueryRow(`
|
|
SELECT
|
|
COALESCE(rs.did, ?) as did,
|
|
COALESCE(rs.repository, ?) as repository,
|
|
(SELECT COUNT(*) FROM stars WHERE owner_did = ? AND repository = ?) as star_count,
|
|
COALESCE(rs.pull_count, 0) as pull_count,
|
|
rs.last_pull,
|
|
COALESCE(rs.push_count, 0) as push_count,
|
|
rs.last_push
|
|
FROM (SELECT ? as did, ? as repository) AS placeholder
|
|
LEFT JOIN repository_stats rs ON rs.did = ? AND rs.repository = ?
|
|
`, did, repository, did, repository, did, repository, did, repository).Scan(&stats.DID, &stats.Repository, &stats.StarCount, &stats.PullCount, &lastPullStr, &stats.PushCount, &lastPushStr)
|
|
|
|
if err == sql.ErrNoRows {
|
|
// Return zero stats if no record exists yet
|
|
return &RepositoryStats{
|
|
DID: did,
|
|
Repository: repository,
|
|
StarCount: 0,
|
|
PullCount: 0,
|
|
PushCount: 0,
|
|
}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse timestamps
|
|
if lastPullStr.Valid {
|
|
t, err := parseTimestamp(lastPullStr.String)
|
|
if err == nil {
|
|
stats.LastPull = &t
|
|
}
|
|
}
|
|
if lastPushStr.Valid {
|
|
t, err := parseTimestamp(lastPushStr.String)
|
|
if err == nil {
|
|
stats.LastPush = &t
|
|
}
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// UpsertRepositoryStats inserts or updates repository stats
|
|
// Note: star_count is calculated dynamically from the stars table, not stored here
|
|
func UpsertRepositoryStats(db DBTX, stats *RepositoryStats) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO repository_stats (did, repository, pull_count, last_pull, push_count, last_push)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(did, repository) DO UPDATE SET
|
|
pull_count = excluded.pull_count,
|
|
last_pull = excluded.last_pull,
|
|
push_count = excluded.push_count,
|
|
last_push = excluded.last_push
|
|
WHERE excluded.pull_count != repository_stats.pull_count
|
|
OR excluded.last_pull IS NOT repository_stats.last_pull
|
|
OR excluded.push_count != repository_stats.push_count
|
|
OR excluded.last_push IS NOT repository_stats.last_push
|
|
`, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush)
|
|
return err
|
|
}
|
|
|
|
// UpsertStar inserts a star record, skipping if it already exists.
|
|
// Stars are immutable — once created, they don't change.
|
|
func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO stars (starrer_did, owner_did, repository, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING
|
|
`, starrerDID, ownerDID, repository, createdAt)
|
|
return err
|
|
}
|
|
|
|
// DeleteStar deletes a star record
|
|
func DeleteStar(db DBTX, starrerDID, ownerDID, repository string) error {
|
|
_, err := db.Exec(`
|
|
DELETE FROM stars
|
|
WHERE starrer_did = ? AND owner_did = ? AND repository = ?
|
|
`, starrerDID, ownerDID, repository)
|
|
return err
|
|
}
|
|
|
|
// RebuildStarCount rebuilds the star count for a specific repository from the stars table
|
|
func RebuildStarCount(db DBTX, ownerDID, repository string) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO repository_stats (did, repository, star_count)
|
|
VALUES (?, ?, (
|
|
SELECT COUNT(*) FROM stars
|
|
WHERE owner_did = ? AND repository = ?
|
|
))
|
|
ON CONFLICT(did, repository) DO UPDATE SET
|
|
star_count = (
|
|
SELECT COUNT(*) FROM stars
|
|
WHERE owner_did = ? AND repository = ?
|
|
)
|
|
`, ownerDID, repository, ownerDID, repository, ownerDID, repository)
|
|
return err
|
|
}
|
|
|
|
// GetStarsForDID returns all stars created by a specific DID (for backfill reconciliation)
|
|
// Returns a map of (ownerDID, repository) -> createdAt
|
|
func GetStarsForDID(db DBTX, starrerDID string) (map[string]time.Time, error) {
|
|
rows, err := db.Query(`
|
|
SELECT owner_did, repository, created_at
|
|
FROM stars
|
|
WHERE starrer_did = ?
|
|
`, starrerDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
stars := make(map[string]time.Time)
|
|
for rows.Next() {
|
|
var ownerDID, repository string
|
|
var createdAt time.Time
|
|
if err := rows.Scan(&ownerDID, &repository, &createdAt); err != nil {
|
|
return nil, err
|
|
}
|
|
key := fmt.Sprintf("%s/%s", ownerDID, repository)
|
|
stars[key] = createdAt
|
|
}
|
|
|
|
return stars, rows.Err()
|
|
}
|
|
|
|
// CleanupOrphanedTags removes tags whose manifest digest no longer exists
|
|
// This handles cases where manifests were deleted but tags pointing to them remain
|
|
func CleanupOrphanedTags(db DBTX, did string) error {
|
|
_, err := db.Exec(`
|
|
DELETE FROM tags
|
|
WHERE did = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM manifests
|
|
WHERE manifests.did = tags.did
|
|
AND manifests.digest = tags.digest
|
|
)
|
|
`, did)
|
|
return err
|
|
}
|
|
|
|
// DeleteStarsNotInList deletes stars from the database that are not in the provided list
|
|
// This is used during backfill reconciliation to remove stars that no longer exist on PDS
|
|
func DeleteStarsNotInList(db DBTX, starrerDID string, foundStars map[string]time.Time) error {
|
|
// Get current stars in DB
|
|
currentStars, err := GetStarsForDID(db, starrerDID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current stars: %w", err)
|
|
}
|
|
|
|
// Find stars to delete (in DB but not on PDS)
|
|
var toDelete []struct{ ownerDID, repository string }
|
|
for key := range currentStars {
|
|
if _, exists := foundStars[key]; !exists {
|
|
parts := strings.SplitN(key, "/", 2)
|
|
if len(parts) == 2 {
|
|
toDelete = append(toDelete, struct{ ownerDID, repository string }{
|
|
ownerDID: parts[0],
|
|
repository: parts[1],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete orphaned stars
|
|
for _, star := range toDelete {
|
|
if err := DeleteStar(db, starrerDID, star.ownerDID, star.repository); err != nil {
|
|
return fmt.Errorf("failed to delete star: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseTimestamp parses a timestamp string with multiple format attempts
|
|
func parseTimestamp(s string) (time.Time, error) {
|
|
formats := []string{
|
|
time.RFC3339Nano,
|
|
"2006-01-02 15:04:05.999999999-07:00",
|
|
"2006-01-02 15:04:05.999999999",
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05",
|
|
}
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, s); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
|
|
}
|
|
|
|
// UpdateManifestHoldDID rewrites the hold_endpoint column for all manifests
|
|
// belonging to a user that currently point to oldHoldDID, changing them to newHoldDID.
|
|
// Returns the number of rows affected.
|
|
func UpdateManifestHoldDID(db DBTX, did, oldHoldDID, newHoldDID string) (int64, error) {
|
|
result, err := db.Exec(`
|
|
UPDATE manifests SET hold_endpoint = ?
|
|
WHERE did = ? AND hold_endpoint = ?
|
|
`, newHoldDID, did, oldHoldDID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
// HoldDIDDB wraps a sql.DB and implements the HoldDIDLookup interface for middleware
|
|
// This is a minimal wrapper that only provides hold DID lookups for blob routing
|
|
type HoldDIDDB struct {
|
|
db DBTX
|
|
}
|
|
|
|
// NewHoldDIDDB creates a new hold DID database wrapper
|
|
func NewHoldDIDDB(db DBTX) *HoldDIDDB {
|
|
return &HoldDIDDB{db: db}
|
|
}
|
|
|
|
// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
|
|
func (h *HoldDIDDB) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
|
|
return GetLatestHoldDIDForRepo(h.db, did, repository)
|
|
}
|
|
|
|
// UpdateManifestHoldDID rewrites hold_endpoint for all manifests belonging to a user
|
|
func (h *HoldDIDDB) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) {
|
|
return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID)
|
|
}
|
|
|
|
// GetDistinctManifestHoldDIDs returns all distinct hold DIDs referenced by a user's manifests.
|
|
func GetDistinctManifestHoldDIDs(db DBTX, did string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
SELECT DISTINCT hold_endpoint FROM manifests
|
|
WHERE did = ? AND hold_endpoint != ''
|
|
`, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var holds []string
|
|
for rows.Next() {
|
|
var h string
|
|
if err := rows.Scan(&h); err != nil {
|
|
return nil, err
|
|
}
|
|
holds = append(holds, h)
|
|
}
|
|
return holds, rows.Err()
|
|
}
|
|
|
|
// GetDistinctManifestHoldDIDs wraps the package-level function.
|
|
func (h *HoldDIDDB) GetDistinctManifestHoldDIDs(did string) ([]string, error) {
|
|
return GetDistinctManifestHoldDIDs(h.db, did)
|
|
}
|
|
|
|
// IsManifestReferenced checks if a digest is a child of any manifest list for the user.
|
|
// Implements storage.ManifestReferenceChecker.
|
|
func (h *HoldDIDDB) IsManifestReferenced(did, digest string) (bool, error) {
|
|
return IsManifestReferenced(h.db, did, digest)
|
|
}
|
|
|
|
// RepoCardSortOrder specifies how repo cards should be sorted
|
|
type RepoCardSortOrder string
|
|
|
|
const (
|
|
// SortByScore sorts by combined stars and pulls (for Featured)
|
|
SortByScore RepoCardSortOrder = "score"
|
|
// SortByLastUpdate sorts by most recent push (for What's New)
|
|
SortByLastUpdate RepoCardSortOrder = "last_update"
|
|
)
|
|
|
|
// GetRepoCards fetches repository cards with full data including Tag, Digest, and LastUpdated
|
|
func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardSortOrder) ([]RepoCardData, error) {
|
|
// Build ORDER BY clause based on sort order
|
|
var orderBy string
|
|
switch sortOrder {
|
|
case SortByLastUpdate:
|
|
orderBy = "MAX(rs.last_push, m.created_at) DESC"
|
|
default: // SortByScore
|
|
orderBy = "(COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0) * 10) DESC, m.created_at DESC"
|
|
}
|
|
|
|
query := `
|
|
WITH latest_manifests AS (
|
|
SELECT did, repository, MAX(id) as latest_id
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
GROUP BY did, repository
|
|
)
|
|
SELECT
|
|
m.did,
|
|
u.handle,
|
|
COALESCE(u.avatar, ''),
|
|
m.repository,
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
|
|
COALESCE(rs.pull_count, 0),
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
|
|
COALESCE(m.artifact_type, 'container-image'),
|
|
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
|
|
COALESCE(m.digest, ''),
|
|
MAX(rs.last_push, m.created_at),
|
|
COALESCE(rp.avatar_cid, '')
|
|
FROM latest_manifests lm
|
|
JOIN manifests m ON lm.latest_id = m.id
|
|
JOIN users u ON m.did = u.did
|
|
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
|
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
|
ORDER BY ` + orderBy + `
|
|
LIMIT ?
|
|
`
|
|
|
|
rows, err := db.Query(query, currentUserDID, currentUserDID, currentUserDID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cards []RepoCardData
|
|
for rows.Next() {
|
|
var c RepoCardData
|
|
var ownerDID string
|
|
var isStarredInt int
|
|
var avatarCID string
|
|
var lastUpdatedStr sql.NullString
|
|
|
|
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
|
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
|
return nil, err
|
|
}
|
|
c.IsStarred = isStarredInt > 0
|
|
if lastUpdatedStr.Valid {
|
|
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
|
|
c.LastUpdated = t
|
|
}
|
|
}
|
|
// Prefer repo page avatar over annotation icon
|
|
if avatarCID != "" {
|
|
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
|
|
}
|
|
|
|
cards = append(cards, c)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := PopulateRepoCardTags(db, cards); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cards, nil
|
|
}
|
|
|
|
// GetUserRepoCards fetches repository cards for a specific user with full data
|
|
func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCardData, error) {
|
|
query := `
|
|
WITH latest_manifests AS (
|
|
SELECT did, repository, MAX(id) as latest_id
|
|
FROM manifests
|
|
WHERE did = ?
|
|
AND hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
GROUP BY did, repository
|
|
)
|
|
SELECT
|
|
m.did,
|
|
u.handle,
|
|
COALESCE(u.avatar, ''),
|
|
m.repository,
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
|
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
|
|
COALESCE(rs.pull_count, 0),
|
|
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
|
|
COALESCE(m.artifact_type, 'container-image'),
|
|
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
|
|
COALESCE(m.digest, ''),
|
|
MAX(rs.last_push, m.created_at),
|
|
COALESCE(rp.avatar_cid, '')
|
|
FROM latest_manifests lm
|
|
JOIN manifests m ON lm.latest_id = m.id
|
|
JOIN users u ON m.did = u.did
|
|
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
|
|
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
|
|
ORDER BY MAX(rs.last_push, m.created_at) DESC
|
|
`
|
|
|
|
rows, err := db.Query(query, userDID, currentUserDID, currentUserDID, currentUserDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cards []RepoCardData
|
|
for rows.Next() {
|
|
var c RepoCardData
|
|
var ownerDID string
|
|
var isStarredInt int
|
|
var avatarCID string
|
|
var lastUpdatedStr sql.NullString
|
|
|
|
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
|
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
|
return nil, err
|
|
}
|
|
c.IsStarred = isStarredInt > 0
|
|
if lastUpdatedStr.Valid {
|
|
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
|
|
c.LastUpdated = t
|
|
}
|
|
}
|
|
// Prefer repo page avatar over annotation icon
|
|
if avatarCID != "" {
|
|
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
|
|
}
|
|
|
|
cards = append(cards, c)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := PopulateRepoCardTags(db, cards); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cards, nil
|
|
}
|
|
|
|
// RepoPage represents a repository page record cached from PDS
|
|
type RepoPage struct {
|
|
DID string
|
|
Repository string
|
|
Description string
|
|
AvatarCID string
|
|
UserEdited bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// UpsertRepoPage inserts or updates a repo page record
|
|
func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(did, repository) DO UPDATE SET
|
|
description = excluded.description,
|
|
avatar_cid = excluded.avatar_cid,
|
|
user_edited = excluded.user_edited,
|
|
updated_at = excluded.updated_at
|
|
WHERE excluded.description IS NOT repo_pages.description
|
|
OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
|
|
OR excluded.user_edited IS NOT repo_pages.user_edited
|
|
`, did, repository, description, avatarCID, userEdited, createdAt, updatedAt)
|
|
return err
|
|
}
|
|
|
|
// GetRepoPage retrieves a repo page record
|
|
func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) {
|
|
var rp RepoPage
|
|
err := db.QueryRow(`
|
|
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
|
|
FROM repo_pages
|
|
WHERE did = ? AND repository = ?
|
|
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &rp, nil
|
|
}
|
|
|
|
// DeleteRepoPage deletes a repo page record
|
|
func DeleteRepoPage(db DBTX, did, repository string) error {
|
|
_, err := db.Exec(`
|
|
DELETE FROM repo_pages WHERE did = ? AND repository = ?
|
|
`, did, repository)
|
|
return err
|
|
}
|
|
|
|
// GetRepoPagesByDID returns all repo pages for a DID
|
|
func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
|
|
rows, err := db.Query(`
|
|
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
|
|
FROM repo_pages
|
|
WHERE did = ?
|
|
`, did)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var pages []RepoPage
|
|
for rows.Next() {
|
|
var rp RepoPage
|
|
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
pages = append(pages, rp)
|
|
}
|
|
return pages, rows.Err()
|
|
}
|
|
|
|
// --- Webhook types and queries ---
|
|
|
|
// Webhook represents a webhook configuration stored in the appview DB
|
|
type Webhook struct {
|
|
ID string `json:"id"`
|
|
UserDID string `json:"userDid"`
|
|
URL string `json:"url"`
|
|
Secret string `json:"-"`
|
|
Triggers int `json:"triggers"`
|
|
HasSecret bool `json:"hasSecret"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// CountWebhooks returns the number of webhooks configured for a user
|
|
func CountWebhooks(db DBTX, userDID string) (int, error) {
|
|
var count int
|
|
err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// ListWebhooks returns webhook configurations for display (masked URLs, no secrets)
|
|
func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) {
|
|
rows, err := db.Query(`
|
|
SELECT id, user_did, url, secret, triggers, created_at
|
|
FROM webhooks WHERE user_did = ? ORDER BY created_at ASC
|
|
`, userDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var webhooks []Webhook
|
|
for rows.Next() {
|
|
var w Webhook
|
|
var secret string
|
|
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
w.HasSecret = secret != ""
|
|
w.URL = maskWebhookURL(w.URL)
|
|
webhooks = append(webhooks, w)
|
|
}
|
|
if webhooks == nil {
|
|
webhooks = []Webhook{}
|
|
}
|
|
return webhooks, rows.Err()
|
|
}
|
|
|
|
// GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test)
|
|
func GetWebhookByID(db DBTX, id string) (*Webhook, error) {
|
|
var w Webhook
|
|
err := db.QueryRow(`
|
|
SELECT id, user_did, url, secret, triggers, created_at
|
|
FROM webhooks WHERE id = ?
|
|
`, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w.HasSecret = w.Secret != ""
|
|
return &w, nil
|
|
}
|
|
|
|
// InsertWebhook creates a new webhook record
|
|
func InsertWebhook(db DBTX, w *Webhook) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt)
|
|
return err
|
|
}
|
|
|
|
// DeleteWebhook deletes a webhook by ID, validating ownership
|
|
func DeleteWebhook(db DBTX, id, userDID string) error {
|
|
result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return fmt.Errorf("webhook not found or not owned by user")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetWebhooksForUser returns all webhooks with full URL+secret for dispatch
|
|
func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) {
|
|
rows, err := db.Query(`
|
|
SELECT id, user_did, url, secret, triggers, created_at
|
|
FROM webhooks WHERE user_did = ?
|
|
`, userDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var webhooks []Webhook
|
|
for rows.Next() {
|
|
var w Webhook
|
|
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
w.HasSecret = w.Secret != ""
|
|
webhooks = append(webhooks, w)
|
|
}
|
|
return webhooks, rows.Err()
|
|
}
|
|
|
|
// maskWebhookURL masks a URL for display (shows scheme + host, hides path/query)
|
|
func maskWebhookURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
if len(rawURL) > 30 {
|
|
return rawURL[:30] + "***"
|
|
}
|
|
return rawURL
|
|
}
|
|
masked := u.Scheme + "://" + u.Host
|
|
if u.Path != "" && u.Path != "/" {
|
|
masked += "/***"
|
|
}
|
|
return masked
|
|
}
|
|
|
|
// --- Scan types and queries ---
|
|
|
|
// Scan represents a cached scan record from Jetstream
|
|
type Scan struct {
|
|
HoldDID string
|
|
ManifestDigest string
|
|
UserDID string
|
|
Repository string
|
|
Critical int
|
|
High int
|
|
Medium int
|
|
Low int
|
|
Total int
|
|
ScannerVersion string
|
|
ScannedAt time.Time
|
|
}
|
|
|
|
// UpsertScan inserts or updates a scan record, returning the previous scan for change detection
|
|
func UpsertScan(db DBTX, scan *Scan) (*Scan, error) {
|
|
// Fetch previous scan (if any) before upserting
|
|
var prev *Scan
|
|
var p Scan
|
|
err := db.QueryRow(`
|
|
SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at
|
|
FROM scans WHERE hold_did = ? AND manifest_digest = ?
|
|
`, scan.HoldDID, scan.ManifestDigest).Scan(
|
|
&p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository,
|
|
&p.Critical, &p.High, &p.Medium, &p.Low, &p.Total,
|
|
&p.ScannerVersion, &p.ScannedAt,
|
|
)
|
|
if err == nil {
|
|
prev = &p
|
|
}
|
|
|
|
// Upsert the new scan
|
|
_, err = db.Exec(`
|
|
INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET
|
|
user_did = excluded.user_did,
|
|
repository = excluded.repository,
|
|
critical = excluded.critical,
|
|
high = excluded.high,
|
|
medium = excluded.medium,
|
|
low = excluded.low,
|
|
total = excluded.total,
|
|
scanner_version = excluded.scanner_version,
|
|
scanned_at = excluded.scanned_at
|
|
WHERE excluded.critical != scans.critical
|
|
OR excluded.high != scans.high
|
|
OR excluded.medium != scans.medium
|
|
OR excluded.low != scans.low
|
|
OR excluded.total != scans.total
|
|
OR excluded.scanner_version IS NOT scans.scanner_version
|
|
OR excluded.scanned_at != scans.scanned_at
|
|
`, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository,
|
|
scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total,
|
|
scan.ScannerVersion, scan.ScannedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to upsert scan: %w", err)
|
|
}
|
|
|
|
return prev, nil
|
|
}
|
|
|
|
// GetTagByDigest returns the most recent tag for a manifest digest in a user's repository
|
|
func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) {
|
|
var tag string
|
|
err := db.QueryRow(`
|
|
SELECT tag FROM tags
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, userDID, repository, digest).Scan(&tag)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tag, nil
|
|
}
|
|
|
|
// IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list.
|
|
func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) {
|
|
if userDID == "" || len(managedHolds) == 0 {
|
|
return false, nil
|
|
}
|
|
|
|
placeholders := make([]string, len(managedHolds))
|
|
args := make([]any, 0, len(managedHolds)+1)
|
|
args = append(args, userDID)
|
|
for i, did := range managedHolds {
|
|
placeholders[i] = "?"
|
|
args = append(args, did)
|
|
}
|
|
|
|
var exists int
|
|
err := db.QueryRow(
|
|
`SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`,
|
|
args...,
|
|
).Scan(&exists)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// GetTagByName returns a single tag with platform information by tag name.
|
|
// Returns nil, nil if the tag doesn't exist.
|
|
func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
|
|
tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0, "", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tags) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &tags[0], nil
|
|
}
|
|
|
|
// GetRepoHoldDIDs returns the distinct hold DIDs that host manifests for a
|
|
// given repository, restricted to holds the viewer can access.
|
|
func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]string, error) {
|
|
rows, err := db.Query(`
|
|
SELECT DISTINCT m.hold_endpoint
|
|
FROM manifests m
|
|
WHERE m.did = ? AND m.repository = ?
|
|
AND m.hold_endpoint != ''
|
|
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
|
|
`, did, repository, viewerDID, viewerDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var holds []string
|
|
for rows.Next() {
|
|
var h string
|
|
if err := rows.Scan(&h); err != nil {
|
|
return nil, err
|
|
}
|
|
holds = append(holds, h)
|
|
}
|
|
return holds, rows.Err()
|
|
}
|
|
|
|
// TagNameDigest is a lightweight (tag, digest) pair used for dropdown
|
|
// population and default-selection heuristics.
|
|
type TagNameDigest struct {
|
|
Name string
|
|
Digest string
|
|
}
|
|
|
|
// shaTagPattern matches CI-style git-sha tags like "sha-937fa4c".
|
|
var shaTagPattern = regexp.MustCompile(`^sha-[0-9a-f]{6,40}$`)
|
|
|
|
// PickDefaultTag chooses the best display tag from a list of (name, digest)
|
|
// pairs ordered most-recent first.
|
|
//
|
|
// 1. Start with the newest tag.
|
|
// 2. If that newest tag looks like a git-sha tag, look for a sibling with
|
|
// the same digest that doesn't — happyview-style repos push both
|
|
// "sha-937fa4c" and "2.0.0-dev.45" pointing at the same image; we'd
|
|
// rather show the semver name.
|
|
// 3. If "latest" exists AND points to the same digest as the chosen tag,
|
|
// prefer "latest" as the friendliest label. A stale "latest" pointing
|
|
// at an old digest is bypassed.
|
|
func PickDefaultTag(tags []TagNameDigest) string {
|
|
if len(tags) == 0 {
|
|
return ""
|
|
}
|
|
chosen := tags[0]
|
|
if shaTagPattern.MatchString(chosen.Name) {
|
|
for _, t := range tags[1:] {
|
|
if t.Digest == chosen.Digest && !shaTagPattern.MatchString(t.Name) {
|
|
chosen = t
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen.Name != "latest" {
|
|
for _, t := range tags {
|
|
if t.Name == "latest" && t.Digest == chosen.Digest {
|
|
return "latest"
|
|
}
|
|
}
|
|
}
|
|
return chosen.Name
|
|
}
|
|
|
|
// PopulateRepoCardTags overrides each card's Tag field with the best display
|
|
// tag chosen by PickDefaultTag. Issues one batch query for all (handle, repository)
|
|
// pairs in the slice. No-op for an empty slice.
|
|
//
|
|
// RepoCardData doesn't carry the owner DID, so we join through users.handle.
|
|
// This is fine because (handle, repository) is unique within the appview.
|
|
func PopulateRepoCardTags(db DBTX, cards []RepoCardData) error {
|
|
if len(cards) == 0 {
|
|
return nil
|
|
}
|
|
type key struct{ handle, repo string }
|
|
placeholders := make([]string, 0, len(cards))
|
|
args := make([]any, 0, len(cards)*2)
|
|
for _, c := range cards {
|
|
placeholders = append(placeholders, "(?, ?)")
|
|
args = append(args, c.OwnerHandle, c.Repository)
|
|
}
|
|
q := `
|
|
SELECT u.handle, t.repository, t.tag, t.digest
|
|
FROM tags t
|
|
JOIN users u ON t.did = u.did
|
|
WHERE (u.handle, t.repository) IN (VALUES ` + strings.Join(placeholders, ",") + `)
|
|
ORDER BY t.repository, t.created_at DESC
|
|
`
|
|
rows, err := db.Query(q, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
groups := make(map[key][]TagNameDigest)
|
|
for rows.Next() {
|
|
var handle, repo, tag, digest string
|
|
if err := rows.Scan(&handle, &repo, &tag, &digest); err != nil {
|
|
return err
|
|
}
|
|
k := key{handle, repo}
|
|
groups[k] = append(groups[k], TagNameDigest{Name: tag, Digest: digest})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
for i := range cards {
|
|
k := key{cards[i].OwnerHandle, cards[i].Repository}
|
|
if g, ok := groups[k]; ok && len(g) > 0 {
|
|
cards[i].Tag = PickDefaultTag(g)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
|
|
// Filters out tags whose manifests live on holds the viewer can't access.
|
|
func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) {
|
|
pairs, err := GetAllTagsWithDigests(db, did, repository, viewerDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, len(pairs))
|
|
for i, p := range pairs {
|
|
names[i] = p.Name
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// GetAllTagsWithDigests returns all tags for a repository with their manifest
|
|
// digests, ordered by most recent first. Filters out tags whose manifests live
|
|
// on holds the viewer can't access.
|
|
func GetAllTagsWithDigests(db DBTX, did, repository string, viewerDID string) ([]TagNameDigest, error) {
|
|
rows, err := db.Query(`
|
|
SELECT t.tag, t.digest FROM tags t
|
|
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
|
|
WHERE t.did = ? AND t.repository = ?
|
|
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
|
|
ORDER BY t.created_at DESC
|
|
`, did, repository, viewerDID, viewerDID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []TagNameDigest
|
|
for rows.Next() {
|
|
var p TagNameDigest
|
|
if err := rows.Scan(&p.Name, &p.Digest); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
|
|
func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) {
|
|
var count int
|
|
err := db.QueryRow(`
|
|
SELECT COUNT(*) FROM layers l
|
|
JOIN manifests m ON l.manifest_id = m.id
|
|
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
|
|
`, did, repository, digest).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest.
|
|
// Returns sql.ErrNoRows if no cached suggestions exist.
|
|
func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) {
|
|
err = db.QueryRow(
|
|
`SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`,
|
|
manifestDigest,
|
|
).Scan(&suggestionsJSON, &createdAt)
|
|
return
|
|
}
|
|
|
|
// UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest.
|
|
func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error {
|
|
_, err := db.Exec(
|
|
`INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
|
manifestDigest, suggestionsJSON,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// UpsertDailyStats inserts or updates daily repository stats
|
|
func UpsertDailyStats(db DBTX, stats *DailyStats) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did, repository, date) DO UPDATE SET
|
|
pull_count = excluded.pull_count,
|
|
push_count = excluded.push_count
|
|
WHERE excluded.pull_count != repository_stats_daily.pull_count
|
|
OR excluded.push_count != repository_stats_daily.push_count
|
|
`, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount)
|
|
return err
|
|
}
|
|
|
|
// GetDailyStats retrieves daily stats for a repository within a date range
|
|
// startDate and endDate should be in YYYY-MM-DD format
|
|
func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) {
|
|
rows, err := db.Query(`
|
|
SELECT did, repository, date, pull_count, push_count
|
|
FROM repository_stats_daily
|
|
WHERE did = ? AND repository = ? AND date >= ? AND date <= ?
|
|
ORDER BY date ASC
|
|
`, did, repository, startDate, endDate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stats []DailyStats
|
|
for rows.Next() {
|
|
var s DailyStats
|
|
if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil {
|
|
return nil, err
|
|
}
|
|
stats = append(stats, s)
|
|
}
|
|
return stats, rows.Err()
|
|
}
|