mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
last_seen means "this user did something recently". The backfill walks every historical record in the network, so stamping it there recorded when the backfill ran, not when the user was active — for every user at once, on every run. That destroys the only signal the column carries, and it is the one column in users that nothing upstream can rebuild. It is now written on the two paths that represent real activity: an interactive login, and a live commit event on the firehose, which does mean the user just wrote a record. The backfill still corrects handle, PDS endpoint and avatar, which is why it re-resolves rather than trusting a cache; it just no longer claims the user was present. UpsertUser grows an options form rather than a fourth named variant, since the avatar and last_seen decisions are independent and all four combinations occur. Anyone computing MAU from this column should know it was unreliable for every backfill run before this change. Also corrects docs/HORIZONTAL_SCALING.md, which claimed oci_client and registry_domain were local-only preferences. They are fields on io.atcr.sailor.profile: settings writes them to the user's PDS and ProcessSailorProfile refreshes the local cache. users is fully derived apart from last_seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3232 lines
107 KiB
Go
3232 lines
107 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, manifest_key AS latest_key FROM (
|
|
SELECT did, repository, manifest_key,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY did, repository
|
|
ORDER BY created_at DESC, manifest_key DESC
|
|
) AS rn
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
) WHERE rn = 1
|
|
),
|
|
matching_repos AS (
|
|
SELECT DISTINCT lm.did, lm.repository, lm.latest_key
|
|
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_key = m.manifest_key
|
|
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, manifest_key AS latest_key FROM (
|
|
SELECT did, repository, manifest_key,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY did, repository
|
|
ORDER BY created_at DESC, manifest_key DESC
|
|
) AS rn
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
) WHERE rn = 1
|
|
)
|
|
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 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.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 manifest_key, 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.Key, &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, ®istryDomain, &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, ®istryDomain, &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
|
|
}
|
|
|
|
// UserUpsertOptions controls which columns an upsert overwrites on an existing
|
|
// row. The insert path always writes every column.
|
|
type UserUpsertOptions struct {
|
|
// UpdateAvatar overwrites a stored avatar. Leave false when the avatar
|
|
// fetch failed, so an empty string does not replace a good value.
|
|
UpdateAvatar bool
|
|
|
|
// UpdateLastSeen stamps last_seen. Set it only when the user actually did
|
|
// something: an interactive login, or a live record write observed on the
|
|
// firehose.
|
|
//
|
|
// It is deliberately NOT set by the backfill. The backfill walks every
|
|
// historical record in the network, so stamping there records when the
|
|
// backfill ran, not when the user was last active, and it does so for every
|
|
// user at once. That makes last_seen useless as an activity signal, which is
|
|
// exactly what it is for.
|
|
UpdateLastSeen bool
|
|
}
|
|
|
|
// UpsertUser inserts or updates a user record, stamping last_seen. Use it on
|
|
// paths that represent real user activity; see UpsertUserObserved for indexing.
|
|
func UpsertUser(db DBTX, user *User) error {
|
|
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: true, UpdateLastSeen: true})
|
|
}
|
|
|
|
// UpsertUserObserved records identity seen while indexing, without claiming the
|
|
// user did anything. Handle, PDS endpoint and avatar are still corrected, which
|
|
// is why the backfill re-resolves rather than trusting a cache.
|
|
func UpsertUserObserved(db DBTX, user *User, updateAvatar bool) error {
|
|
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: updateAvatar, UpdateLastSeen: false})
|
|
}
|
|
|
|
// UpsertUserWithOptions inserts or updates a user record.
|
|
func UpsertUserWithOptions(db DBTX, user *User, opts UserUpsertOptions) 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)
|
|
|
|
updates := []string{
|
|
"handle = excluded.handle",
|
|
"pds_endpoint = excluded.pds_endpoint",
|
|
}
|
|
if opts.UpdateAvatar {
|
|
updates = append(updates, "avatar = excluded.avatar")
|
|
}
|
|
if opts.UpdateLastSeen {
|
|
updates = append(updates, "last_seen = excluded.last_seen")
|
|
}
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(did) DO UPDATE SET
|
|
`+strings.Join(updates, ",\n ")+`
|
|
`, 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 {
|
|
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: false, UpdateLastSeen: true})
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Tags are identified by (did, repository, tag), which is their natural key and
|
|
// the table's primary key. This used to select surrogate ids, filter them in Go
|
|
// with a nested loop, and then issue one DELETE per row.
|
|
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
|
|
}
|
|
|
|
// Work out what to remove in Go rather than with a NOT IN over the keep
|
|
// list. A single NOT IN would need two placeholders per kept tag and break
|
|
// past the driver's parameter ceiling for a user with enough tags, and it
|
|
// cannot be split into chunks: each chunk would delete the tags every other
|
|
// chunk meant to keep.
|
|
keep := make(map[struct{ Repository, Tag string }]struct{}, len(keepTags))
|
|
for _, k := range keepTags {
|
|
keep[k] = struct{}{}
|
|
}
|
|
|
|
rows, err := db.Query(`SELECT repository, tag FROM tags WHERE did = ?`, did)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var toDelete []struct{ Repository, Tag string }
|
|
for rows.Next() {
|
|
var t struct{ Repository, Tag string }
|
|
if err := rows.Scan(&t.Repository, &t.Tag); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if _, ok := keep[t]; !ok {
|
|
toDelete = append(toDelete, t)
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// An explicit delete list chunks safely, unlike the NOT IN above.
|
|
for i := 0; i*BatchSize < len(toDelete); i++ {
|
|
start, end := chunk(len(toDelete), i)
|
|
batch := toDelete[start:end]
|
|
|
|
args := make([]any, 0, 1+2*len(batch))
|
|
args = append(args, did)
|
|
pairs := make([]string, 0, len(batch))
|
|
for _, t := range batch {
|
|
pairs = append(pairs, "(?, ?)")
|
|
args = append(args, t.Repository, t.Tag)
|
|
}
|
|
|
|
query := `DELETE FROM tags WHERE did = ? AND (repository, tag) IN (` +
|
|
strings.Join(pairs, ", ") + `)`
|
|
if _, err := db.Exec(query, args...); 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 key, which the caller could equally compute itself.
|
|
// Note: Annotations are stored separately in repository_annotations table
|
|
func InsertManifest(db DBTX, manifest *Manifest) (string, error) {
|
|
key := ManifestKey(manifest.DID, manifest.Repository, manifest.Digest)
|
|
|
|
_, err := db.Exec(`
|
|
INSERT INTO manifests
|
|
(manifest_key, 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
|
|
`, key, 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 "", err
|
|
}
|
|
|
|
return key, 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_key, digest, size, media_type, layer_index, annotations)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(manifest_key, layer_index) DO NOTHING
|
|
`, layer.ManifestKey, 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.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.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_key = COALESCE(child_m.manifest_key, m.manifest_key)), 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.manifest_key = mr.manifest_key
|
|
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.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 manifest_key, did, repository, digest, hold_endpoint, schema_version,
|
|
media_type, config_digest, config_size, created_at
|
|
FROM manifests
|
|
WHERE digest = ?
|
|
`, digest).Scan(&m.Key, &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 manifest_key, 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.Key, &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, manifestKey string) ([]Layer, error) {
|
|
rows, err := db.Query(`
|
|
SELECT manifest_key, digest, size, media_type, layer_index, annotations
|
|
FROM layers
|
|
WHERE manifest_key = ?
|
|
ORDER BY layer_index
|
|
`, manifestKey)
|
|
|
|
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.ManifestKey, &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_key, digest, size, media_type,
|
|
platform_architecture, platform_os,
|
|
platform_variant, platform_os_version,
|
|
is_attestation, reference_index)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ref.ManifestKey, 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, manifestKey string) ([]ManifestReference, error) {
|
|
rows, err := db.Query(`
|
|
SELECT manifest_key, digest, size, media_type,
|
|
platform_architecture, platform_os, platform_variant, platform_os_version,
|
|
reference_index
|
|
FROM manifest_references
|
|
WHERE manifest_key = ?
|
|
ORDER BY reference_index
|
|
`, manifestKey)
|
|
|
|
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.ManifestKey, &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_key = m.manifest_key
|
|
WHERE m.did = ? AND m.repository = ?
|
|
)
|
|
SELECT
|
|
m.manifest_key, 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.manifest_key = mr.manifest_key
|
|
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.manifest_key
|
|
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.Key, &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_key = child_m.manifest_key), 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_key = ?
|
|
ORDER BY mr.reference_index
|
|
`, manifests[i].DID, manifests[i].Repository, manifests[i].Key)
|
|
|
|
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.manifest_key, 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.manifest_key
|
|
`, did, repository, digest).Scan(
|
|
&m.Key, &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_key = child_m.manifest_key), 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_key = ?
|
|
ORDER BY mr.reference_index
|
|
`, m.DID, m.Repository, m.Key)
|
|
|
|
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_key = m.manifest_key
|
|
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_key = m.manifest_key
|
|
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_key = m.manifest_key
|
|
WHERE m.did = ? AND m.repository = ?
|
|
),
|
|
untagged_top_level AS (
|
|
SELECT m.manifest_key, 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.manifest_key
|
|
HAVING COUNT(t.tag) = 0
|
|
),
|
|
untagged_children AS (
|
|
SELECT DISTINCT mr.digest
|
|
FROM untagged_top_level ul
|
|
JOIN manifest_references mr ON ul.manifest_key = mr.manifest_key
|
|
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_key = m2.manifest_key
|
|
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 key and hold endpoint
|
|
var manifestListKey string
|
|
var parentHoldEndpoint string
|
|
err := db.QueryRow(`
|
|
SELECT manifest_key, hold_endpoint FROM manifests
|
|
WHERE did = ? AND repository = ? AND digest = ?
|
|
`, did, repository, manifestListDigest).Scan(&manifestListKey, &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.manifest_key
|
|
FROM manifest_references mr
|
|
LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ?
|
|
WHERE mr.manifest_key = ? AND mr.is_attestation = 1
|
|
ORDER BY mr.reference_index
|
|
`, did, repository, manifestListKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type refRow struct {
|
|
digest string
|
|
mediaType string
|
|
size int64
|
|
manifestKey string // empty if the attestation manifest is not indexed yet
|
|
}
|
|
var refs []refRow
|
|
for rows.Next() {
|
|
var r refRow
|
|
var key sql.NullString
|
|
if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &key); err != nil {
|
|
return nil, err
|
|
}
|
|
r.manifestKey = key.String
|
|
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.manifestKey != "" {
|
|
layers, err := GetLayersForManifest(db, ref.manifestKey)
|
|
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 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.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 manifest_key, 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.Key, &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, manifest_key AS latest_key FROM (
|
|
SELECT did, repository, manifest_key,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY did, repository
|
|
ORDER BY created_at DESC, manifest_key DESC
|
|
) AS rn
|
|
FROM manifests
|
|
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
) WHERE rn = 1
|
|
)
|
|
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_key = m.manifest_key
|
|
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, manifest_key AS latest_key FROM (
|
|
SELECT did, repository, manifest_key,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY did, repository
|
|
ORDER BY created_at DESC, manifest_key DESC
|
|
) AS rn
|
|
FROM manifests
|
|
WHERE did = ?
|
|
AND hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
) WHERE rn = 1
|
|
)
|
|
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_key = m.manifest_key
|
|
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 did, repository, manifest_key AS latest_key FROM (
|
|
SELECT m.did, m.repository, m.manifest_key,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY m.did, m.repository
|
|
ORDER BY m.created_at DESC, m.manifest_key DESC
|
|
) AS rn
|
|
FROM manifests m
|
|
JOIN starred st ON m.did = st.did AND m.repository = st.repository
|
|
WHERE m.hold_endpoint IN ` + accessibleHoldsSubquery + `
|
|
) WHERE rn = 1
|
|
)
|
|
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_key = m.manifest_key
|
|
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_key = m.manifest_key
|
|
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()
|
|
}
|