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 } // UpdateUserAutoRemoveUntagged caches the sailor profile's autoRemoveUntagged // preference. Callers here always know the answer, so this writes 0 or 1 and // never NULL; NULL is reserved for "not learned yet". See UserHoldPrefs. func UpdateUserAutoRemoveUntagged(db DBTX, did string, autoRemove bool) error { _, err := db.Exec(` UPDATE users SET auto_remove_untagged = ? WHERE did = ? `, autoRemove, did) return err } // UserHoldPrefs is the slice of a sailor profile the registry hot path reads on // every /v2/ request: which hold the owner's blobs go to, and whether a tag // overwrite deletes the manifest that lost its last tag. type UserHoldPrefs struct { // Found is false when the user has no row at all. Found bool // DefaultHoldDID is the user's chosen hold, already normalized to a DID by // whoever cached it. Empty means "use the appview's default hold", the same // meaning it carries for the auth gate's resolveHoldDID. DefaultHoldDID string // AutoRemoveUntagged is invalid when the column is NULL, meaning no profile // has been processed for this user yet and the value is simply unknown. A // valid false is a known false. Callers must not collapse the two: unknown // is what triggers the one-shot live fetch. AutoRemoveUntagged sql.NullBool } // GetUserHoldPrefs reads the cached sailor profile preferences for a user. // A missing row is not an error; it comes back with Found false. func GetUserHoldPrefs(db DBTX, did string) (UserHoldPrefs, error) { var ( prefs UserHoldPrefs holdDID sql.NullString autoRemove sql.NullBool ) err := db.QueryRow(` SELECT default_hold_did, auto_remove_untagged FROM users WHERE did = ? `, did).Scan(&holdDID, &autoRemove) if err == sql.ErrNoRows { return prefs, nil } if err != nil { return prefs, err } prefs.Found = true if holdDID.Valid { prefs.DefaultHoldDID = holdDID.String } prefs.AutoRemoveUntagged = autoRemove return prefs, nil } // CacheUserHoldPrefs writes both cached profile preferences for a user, // creating the row if this DID has never been seen before. // // It exists for the registry's one-shot fallback: when the Jetstream-fed cache // has nothing to say about a user, the middleware fetches the profile once and // records the answer here so no later request has to. autoRemove is written as // 0 or 1 even when the user has no profile record at all, which is what stops // the fallback from firing again for that user. // // last_seen is stamped only on insert, and only because the column is NOT NULL. // It is deliberately left alone on update: serving somebody else's pull of this // user's repository is not this user's activity, and last_seen is read as an // activity signal. func CacheUserHoldPrefs(db DBTX, did, handle, pdsEndpoint, holdDID string, autoRemove bool) error { // A handle belongs to exactly one DID at a time; release it from any stale // owner before claiming it, exactly as the other user upserts do. _, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`, handle, did) _, err := db.Exec(` INSERT INTO users (did, handle, pds_endpoint, default_hold_did, auto_remove_untagged, last_seen) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET default_hold_did = excluded.default_hold_did, auto_remove_untagged = excluded.auto_remove_untagged `, did, handle, pdsEndpoint, holdDID, autoRemove, time.Now()) 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) } // GetUserHoldPrefs reads the cached sailor profile preferences for a user. // Implements the registry middleware's UserPrefsCache. func (h *HoldDIDDB) GetUserHoldPrefs(did string) (UserHoldPrefs, error) { return GetUserHoldPrefs(h.db, did) } // CacheUserHoldPrefs records both cached profile preferences for a user. // Implements the registry middleware's UserPrefsCache. func (h *HoldDIDDB) CacheUserHoldPrefs(did, handle, pdsEndpoint, holdDID string, autoRemove bool) error { return CacheUserHoldPrefs(h.db, did, handle, pdsEndpoint, holdDID, autoRemove) } // 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() }