Files
at-container-registry/pkg/appview/db/queries.go
T
Evan JarrettandClaude Opus 5 2580dcdb0f appview: stop a tag delete cascading into another repo's live image
io.atcr.manifest rkeys are the digest alone (digestToRKey), so a single
record backs every repository of a user holding identical content. Both
paths that cascade-delete that record checked for remaining tags scoped to
one repository, which asks the wrong question: a tag in another repo keeps
the shared record alive just as much as a tag in this one.

With me/a:v1 and me/b:v1 at the same digest, deleting me/a:v1 saw no
remaining tags in repo a, deleted the shared PDS record, and purged the
layers on the hold. me/b:v1 was left pointing at content that no longer
exists, and the firehose delete handler then cleared the rows for every
repo (DeleteManifest with an empty repository argument).

The collision predates this, but it was reachable only behind the opt-in
AutoRemoveUntagged profile flag. 1b91768 enabled OCI manifest DELETE and
made TagStore.Untag cascade unconditionally, which turned a latent metadata
collision into blob-level data loss on an ordinary skopeo/crane delete.

  - cleanupUntaggedManifest no longer filters candidate tags to
    rctx.Repository. The surrounding comment already noted that the tag
    collection is account-wide; the filter contradicted it.
  - ShouldCascadeDeleteManifest takes the tag question DID-wide via a new
    IsManifestTaggedAnyRepo, and drops its now-meaningless repository
    parameter.
  - IsManifestTagged stays repository-scoped and keeps its caller: the
    delete-manifest confirmation prompt is genuinely asking about the one
    repo whose tags the user is about to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:22:08 -05:00

3175 lines
104 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"errors"
"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)
}
// activeTakedownClause returns a SQL fragment ready to drop into a `WHERE NOT
// EXISTS (...)` filter for excluding rows whose `(did, repository)` pair is currently
// taken down. The `alias` argument is the outer table alias (e.g. "m" for manifests,
// "lm" for latest_manifests) and must already be in scope at the use site. Mirrors
// `IsTakenDown` so listings stay consistent with the per-repo page check.
func activeTakedownClause(alias string) string {
return `NOT EXISTS (
SELECT 1 FROM taken_down_subjects t
WHERE t.did = ` + alias + `.did
AND (t.repo = ` + alias + `.repository OR t.repo = '')
)`
}
// 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 = ?)
)`
// Artifact type values stored in manifests.artifact_type and returned by
// GetArtifactType. Container-image is the default for OCI/Docker images and
// manifest lists; helm-chart is for OCI helm chart artifacts.
const (
ArtifactTypeContainerImage = "container-image"
ArtifactTypeHelmChart = "helm-chart"
ArtifactTypeUnknown = "unknown"
)
// 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 ArtifactTypeHelmChart
case strings.Contains(configMediaType, "oci.image.config") ||
strings.Contains(configMediaType, "docker.container.image"):
return ArtifactTypeContainerImage
case configMediaType == "":
// Manifest lists don't have a config - treat as container-image
return ArtifactTypeContainerImage
default:
return ArtifactTypeUnknown
}
}
// 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 '\'
))
AND ` + activeTakedownClause("lm") + `
),
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(COALESCE(rs.last_push, m.created_at), 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(COALESCE(rs.last_push, m.created_at), 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).
//
// Implementation: one summary query for the accessible repository set, then
// four bulk queries (tags, manifests, annotations, repo_pages) all keyed by
// did. Results are grouped in Go and assembled per repo. Total: 5 queries
// regardless of how many repos the user owns.
func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
// Step 1: summary query. 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
}
type repoSummary struct {
Name string
TagCount int
ManifestCount int
LastPushStr string
}
var summaries []repoSummary
for rows.Next() {
var s repoSummary
if err := rows.Scan(&s.Name, &s.TagCount, &s.ManifestCount, &s.LastPushStr); err != nil {
rows.Close()
return nil, err
}
summaries = append(summaries, s)
}
rows.Close()
if len(summaries) == 0 {
return nil, nil
}
// Build the set of accessible repo names for filtering bulk-fetched rows
// against repos that the viewer can't see (rows for repos owned by `did`
// but stored on inaccessible holds).
accessible := make(map[string]bool, len(summaries))
for _, s := range summaries {
accessible[s.Name] = true
}
// Step 2: bulk-fetch tags for all repos owned by did, grouped by repo.
tagsByRepo, err := bulkTagsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 3: bulk-fetch manifests, grouped by repo.
manifestsByRepo, err := bulkManifestsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 4: bulk-fetch annotations, grouped by repo.
annotationsByRepo, err := GetRepositoryAnnotationsByDID(db, did)
if err != nil {
return nil, err
}
// Step 5: bulk-fetch repo pages (existing helper), keyed by repo.
pages, err := GetRepoPagesByDID(db, did)
if err != nil {
return nil, err
}
pagesByRepo := make(map[string]*RepoPage, len(pages))
for i := range pages {
pagesByRepo[pages[i].Repository] = &pages[i]
}
// Assemble results in summary order (preserves last_push DESC).
repos := make([]Repository, 0, len(summaries))
for _, s := range summaries {
r := Repository{
Name: s.Name,
TagCount: s.TagCount,
ManifestCount: s.ManifestCount,
LastPush: parseRepoTimestamp(s.LastPushStr),
Tags: tagsByRepo[s.Name],
Manifests: manifestsByRepo[s.Name],
}
annotations := annotationsByRepo[s.Name]
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"]
// Repo page avatar overrides annotation icon when present.
if page, ok := pagesByRepo[s.Name]; ok && page.AvatarCID != "" {
r.IconURL = BlobCDNURL(did, page.AvatarCID)
}
repos = append(repos, r)
}
return repos, nil
}
// bulkTagsByRepo fetches every tag owned by did and groups by repository,
// dropping repos not in the accessible set. Result preserves created_at DESC
// ordering within each repo.
func bulkTagsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Tag, error) {
rows, err := db.Query(`
SELECT id, repository, tag, digest, created_at
FROM tags
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Tag)
for rows.Next() {
var t Tag
t.DID = did
if err := rows.Scan(&t.ID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
return nil, err
}
if !accessible[t.Repository] {
continue
}
out[t.Repository] = append(out[t.Repository], t)
}
return out, rows.Err()
}
// bulkManifestsByRepo fetches every manifest owned by did and groups by
// repository, dropping repos not in the accessible set. Result preserves
// created_at DESC ordering within each repo.
func bulkManifestsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Manifest, error) {
rows, err := db.Query(`
SELECT id, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at
FROM manifests
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Manifest)
for rows.Next() {
var m Manifest
m.DID = did
if err := rows.Scan(&m.ID, &m.Repository, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
return nil, err
}
if !accessible[m.Repository] {
continue
}
out[m.Repository] = append(out[m.Repository], m)
}
return out, rows.Err()
}
// parseRepoTimestamp tolerates the several timestamp formats SQLite/libsql
// can return for MAX(created_at) depending on driver and schema history.
func parseRepoTimestamp(s string) time.Time {
if s == "" {
return time.Time{}
}
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, s); err == nil {
return t
}
}
return time.Time{}
}
// 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, registryDomain sql.NullString
err := db.QueryRow(`
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, registry_domain, last_seen
FROM users
WHERE did = ?
`, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &registryDomain, &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
}
if registryDomain.Valid {
user.RegistryDomain = registryDomain.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, registryDomain sql.NullString
err := db.QueryRow(`
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, registry_domain, last_seen
FROM users
WHERE handle = ?
`, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &registryDomain, &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
}
if registryDomain.Valid {
user.RegistryDomain = registryDomain.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 {
// Never blank a cached handle. handle is NOT NULL UNIQUE, so writing "" both
// discards good data and collides the moment a second user does the same.
// Callers that see an empty/unresolved handle should leave the cache untouched.
if newHandle == "" {
return nil
}
_, 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
}
// UpdateUserRegistryDomain updates a user's cached preferred registry domain.
// An empty string means "use the primary (first configured) domain".
func UpdateUserRegistryDomain(db DBTX, did string, registryDomain string) error {
_, err := db.Exec(`
UPDATE users SET registry_domain = ? WHERE did = ?
`, registryDomain, did)
return err
}
// GetUserDefaultHoldDID returns a user's explicitly-set default hold DID from
// the cached profile, or "" if unset. Unlike GetUserHoldDID it does NOT fall
// back to a manifest's hold_endpoint (which may be a URL, not a DID): callers
// gating on managed-hold membership need the clean default-hold signal where
// "" means the user falls back to the operator's primary managed hold.
func GetUserDefaultHoldDID(db DBTX, did string) string {
var holdDID sql.NullString
_ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
if holdDID.Valid {
return holdDID.String
}
return ""
}
// 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
}
// StripeEventSeen reports whether a Stripe event has already been successfully
// processed (idempotency guard for webhook redelivery).
func StripeEventSeen(db DBTX, eventID string) (bool, error) {
var one int
err := db.QueryRow("SELECT 1 FROM stripe_processed_events WHERE event_id = ?", eventID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// LatestStripeEventCreatedForCustomer returns the highest event_created
// timestamp recorded for a customer's successfully processed events, or 0 if
// none. Used to drop stale out-of-order deliveries.
func LatestStripeEventCreatedForCustomer(db DBTX, customerID string) (int64, error) {
if customerID == "" {
return 0, nil
}
var created sql.NullInt64
err := db.QueryRow(
"SELECT MAX(event_created) FROM stripe_processed_events WHERE customer_id = ?",
customerID,
).Scan(&created)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return created.Int64, nil
}
// RecordStripeEvent records a successfully processed Stripe event. Idempotent:
// a duplicate event_id is ignored rather than erroring.
func RecordStripeEvent(db DBTX, eventID, customerID string, created int64) error {
_, err := db.Exec(`
INSERT INTO stripe_processed_events (event_id, customer_id, event_created)
VALUES (?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
`, eventID, customerID, created)
return err
}
// GetChildManifestPlatform returns the platform info for a manifest that is
// referenced as a child of a manifest list. Returns nil if the digest is not
// a child of any manifest list (i.e., it's a top-level single-arch manifest).
// Used by the diff handler to match architectures when comparing a platform
// child digest against a manifest list (or another child).
func GetChildManifestPlatform(db DBTX, did, repository, digest string) (*PlatformInfo, error) {
var p PlatformInfo
var arch, os, variant, osVersion sql.NullString
err := db.QueryRow(`
SELECT
COALESCE(mr.platform_os, ''),
COALESCE(mr.platform_architecture, ''),
COALESCE(mr.platform_variant, ''),
COALESCE(mr.platform_os_version, '')
FROM manifest_references mr
JOIN manifests m ON mr.manifest_id = m.id
WHERE m.did = ? AND m.repository = ? AND mr.digest = ?
LIMIT 1
`, did, repository, digest).Scan(&os, &arch, &variant, &osVersion)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
p.OS = os.String
p.Architecture = arch.String
p.Variant = variant.String
p.OSVersion = osVersion.String
p.Digest = digest
return &p, nil
}
// 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
}
// GetTagDigest returns the manifest digest that a tag points to.
// Returns sql.ErrNoRows if the tag does not exist.
func GetTagDigest(db DBTX, did, repository, tag string) (string, error) {
var digest string
err := db.QueryRow(`
SELECT digest FROM tags
WHERE did = ? AND repository = ? AND tag = ?
`, did, repository, tag).Scan(&digest)
return digest, err
}
// ShouldCascadeDeleteManifest returns true iff a manifest can be safely
// deleted after a tag is removed: it has no remaining tags in ANY of the
// user's repositories AND is not referenced by any manifest list (multi-arch
// parent). Manifest-list children must be preserved even when untagged, since
// their parent index still depends on them.
//
// The tag check is deliberately DID-wide rather than repository-scoped. The
// io.atcr.manifest record this gates is keyed by digest alone, so a single
// record backs every repository holding identical content; a repo-scoped check
// would cascade a delete in one repo into another repo's live image and purge
// the shared layers on the hold.
func ShouldCascadeDeleteManifest(db DBTX, did, digest string) (bool, error) {
tagged, err := IsManifestTaggedAnyRepo(db, did, digest)
if err != nil {
return false, err
}
if tagged {
return false, nil
}
referenced, err := IsManifestReferenced(db, did, digest)
if err != nil {
return false, err
}
return !referenced, nil
}
// IsManifestTaggedAnyRepo reports whether a digest still carries a tag in any
// of the user's repositories. Use this to gate deletion of per-digest state
// (the io.atcr.manifest record, hold layer records); use IsManifestTagged when
// the question is genuinely about one repository, such as a confirmation
// prompt listing the tags a user is about to remove.
func IsManifestTaggedAnyRepo(db DBTX, did, digest string) (bool, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM tags
WHERE did = ? AND digest = ?
`, did, digest).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// IsManifestTagged checks if a manifest has any tags in the given repository
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(COALESCE(rs.last_push, m.created_at), 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(COALESCE(rs.last_push, m.created_at), 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
WHERE ` + activeTakedownClause("m") + `
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(COALESCE(rs.last_push, m.created_at), 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
WHERE ` + activeTakedownClause("m") + `
ORDER BY MAX(COALESCE(rs.last_push, m.created_at), 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
}
// GetStarredRepoCards fetches repository cards for repositories starred by
// starrerDID. Stars whose target repo no longer has a manifest, lives on a
// hold the viewer can't access, or is currently taken down are silently
// dropped via the joins and filters. Ordered by star creation time DESC.
func GetStarredRepoCards(db DBTX, starrerDID string, currentUserDID string) ([]RepoCardData, error) {
query := `
WITH starred AS (
SELECT owner_did AS did, repository, created_at AS starred_at
FROM stars
WHERE starrer_did = ?
),
latest_manifests AS (
SELECT m.did, m.repository, MAX(m.id) as latest_id
FROM manifests m
JOIN starred st ON m.did = st.did AND m.repository = st.repository
WHERE m.hold_endpoint IN ` + accessibleHoldsSubquery + `
GROUP BY m.did, m.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(COALESCE(rs.last_push, m.created_at), m.created_at),
COALESCE(rp.avatar_cid, ''),
st.starred_at
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
JOIN starred st ON st.did = m.did AND st.repository = m.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
WHERE ` + activeTakedownClause("m") + `
ORDER BY st.starred_at DESC
`
rows, err := db.Query(query, starrerDID, 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
var starredAtStr 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, &starredAtStr); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
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.
//
// Triggers is a packed integer: bits 0-7 are trigger flags (see
// pkg/appview/webhooks.Trigger* constants) and bits 8-15 are the quota
// threshold percent (only meaningful when TriggerQuota is set). Use the
// webhooks.TriggerFlags() and webhooks.ThresholdPct() helpers to decode.
//
// LastFiredAt is generic edge-trigger state — currently used by quota webhooks
// to dedupe (NULL = armed, set = already fired and still above threshold).
// Future trigger types can adopt it for rate-limiting without schema changes.
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"`
LastFiredAt *time.Time `json:"lastFiredAt,omitempty"`
}
// 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, last_fired_at
FROM webhooks WHERE user_did = ? ORDER BY created_at ASC, id ASC
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var secret string
var lastFired sql.NullTime
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt, &lastFired); err != nil {
continue
}
w.HasSecret = secret != ""
w.URL = maskWebhookURL(w.URL)
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
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
var lastFired sql.NullTime
err := db.QueryRow(`
SELECT id, user_did, url, secret, triggers, created_at, last_fired_at
FROM webhooks WHERE id = ?
`, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt, &lastFired)
if err != nil {
return nil, err
}
w.HasSecret = w.Secret != ""
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
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, last_fired_at
FROM webhooks WHERE user_did = ?
ORDER BY created_at ASC, id ASC
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var lastFired sql.NullTime
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt, &lastFired); err != nil {
continue
}
w.HasSecret = w.Secret != ""
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
webhooks = append(webhooks, w)
}
return webhooks, rows.Err()
}
// UpdateWebhookLastFiredAt records when a webhook fired. Used by edge-triggered
// dispatch (currently just quota webhooks) to suppress re-fires while a
// condition remains true.
func UpdateWebhookLastFiredAt(db DBTX, id string, t time.Time) error {
_, err := db.Exec(`UPDATE webhooks SET last_fired_at = ? WHERE id = ?`, t, id)
return err
}
// ClearWebhookLastFiredAt re-arms an edge-triggered webhook. For quota
// webhooks this is called when usage drops back below the threshold.
func ClearWebhookLastFiredAt(db DBTX, id string) error {
_, err := db.Exec(`UPDATE webhooks SET last_fired_at = NULL WHERE id = ?`, id)
return 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()
}