···11+description: Add manifest_references table for multi-arch manifest support
22+query: |
33+ CREATE TABLE IF NOT EXISTS manifest_references (
44+ manifest_id INTEGER NOT NULL,
55+ digest TEXT NOT NULL,
66+ media_type TEXT NOT NULL,
77+ size INTEGER NOT NULL,
88+ platform_architecture TEXT,
99+ platform_os TEXT,
1010+ platform_variant TEXT,
1111+ platform_os_version TEXT,
1212+ reference_index INTEGER NOT NULL,
1313+ PRIMARY KEY(manifest_id, reference_index),
1414+ FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
1515+ );
1616+ CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
+37
pkg/appview/db/models.go
···4040 LayerIndex int
4141}
42424343+// ManifestReference represents a reference to a manifest in a manifest list/index
4444+type ManifestReference struct {
4545+ ManifestID int64
4646+ Digest string
4747+ Size int64
4848+ MediaType string
4949+ PlatformArchitecture string
5050+ PlatformOS string
5151+ PlatformVariant string
5252+ PlatformOSVersion string
5353+ ReferenceIndex int
5454+}
5555+4356// Tag represents a tag pointing to a manifest
4457type Tag struct {
4558 ID int64
···120133 StarCount int
121134 PullCount int
122135}
136136+137137+// PlatformInfo represents platform information (OS/Architecture)
138138+type PlatformInfo struct {
139139+ OS string
140140+ Architecture string
141141+ Variant string
142142+ OSVersion string
143143+}
144144+145145+// TagWithPlatforms extends Tag with platform information
146146+type TagWithPlatforms struct {
147147+ Tag
148148+ Platforms []PlatformInfo
149149+ IsMultiArch bool
150150+}
151151+152152+// ManifestWithMetadata extends Manifest with tags and platform information
153153+type ManifestWithMetadata struct {
154154+ Manifest
155155+ Tags []string
156156+ Platforms []PlatformInfo
157157+ PlatformCount int
158158+ IsManifestList bool
159159+}
+361-2
pkg/appview/db/queries.go
···484484 return tx.Commit()
485485}
486486487487-// InsertManifest inserts a new manifest record
487487+// InsertManifest inserts or updates a manifest record
488488+// Uses UPSERT to update labels/annotations if manifest already exists
488489func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) {
489490 result, err := db.Exec(`
490490- INSERT OR IGNORE INTO manifests
491491+ INSERT INTO manifests
491492 (did, repository, digest, hold_endpoint, schema_version, media_type,
492493 config_digest, config_size, created_at,
493494 title, description, source_url, documentation_url, licenses, icon_url)
494495 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
496496+ ON CONFLICT(did, repository, digest) DO UPDATE SET
497497+ hold_endpoint = excluded.hold_endpoint,
498498+ schema_version = excluded.schema_version,
499499+ media_type = excluded.media_type,
500500+ config_digest = excluded.config_digest,
501501+ config_size = excluded.config_size,
502502+ title = excluded.title,
503503+ description = excluded.description,
504504+ source_url = excluded.source_url,
505505+ documentation_url = excluded.documentation_url,
506506+ licenses = excluded.licenses,
507507+ icon_url = excluded.icon_url
495508 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
496509 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
497510 manifest.ConfigSize, manifest.CreatedAt,
···532545 DELETE FROM tags WHERE did = ? AND repository = ? AND tag = ?
533546 `, did, repository, tag)
534547 return err
548548+}
549549+550550+// GetTagsWithPlatforms returns all tags for a repository with platform information
551551+// For multi-arch tags, includes all platforms from manifest_references
552552+// For single-arch tags, includes the platform info
553553+func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) {
554554+ rows, err := db.Query(`
555555+ SELECT
556556+ t.id,
557557+ t.did,
558558+ t.repository,
559559+ t.tag,
560560+ t.digest,
561561+ t.created_at,
562562+ m.media_type,
563563+ COALESCE(mr.platform_os, '') as platform_os,
564564+ COALESCE(mr.platform_architecture, '') as platform_architecture,
565565+ COALESCE(mr.platform_variant, '') as platform_variant,
566566+ COALESCE(mr.platform_os_version, '') as platform_os_version
567567+ FROM tags t
568568+ JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
569569+ LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
570570+ WHERE t.did = ? AND t.repository = ?
571571+ ORDER BY t.created_at DESC, mr.reference_index
572572+ `, did, repository)
573573+574574+ if err != nil {
575575+ return nil, err
576576+ }
577577+ defer rows.Close()
578578+579579+ // Group platforms by tag
580580+ tagMap := make(map[string]*TagWithPlatforms)
581581+ var tagOrder []string // Preserve order
582582+583583+ for rows.Next() {
584584+ var t Tag
585585+ var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string
586586+587587+ if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
588588+ &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil {
589589+ return nil, err
590590+ }
591591+592592+ // Get or create TagWithPlatforms
593593+ tagKey := t.Tag
594594+ if _, exists := tagMap[tagKey]; !exists {
595595+ tagMap[tagKey] = &TagWithPlatforms{
596596+ Tag: t,
597597+ Platforms: []PlatformInfo{},
598598+ }
599599+ tagOrder = append(tagOrder, tagKey)
600600+ }
601601+602602+ // Add platform info if present
603603+ if platformOS != "" || platformArch != "" {
604604+ tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
605605+ OS: platformOS,
606606+ Architecture: platformArch,
607607+ Variant: platformVariant,
608608+ OSVersion: platformOSVersion,
609609+ })
610610+ }
611611+ }
612612+613613+ // Convert map to slice, preserving order and setting IsMultiArch
614614+ result := make([]TagWithPlatforms, 0, len(tagMap))
615615+ for _, tagKey := range tagOrder {
616616+ tag := tagMap[tagKey]
617617+ tag.IsMultiArch = len(tag.Platforms) > 1
618618+ result = append(result, *tag)
619619+ }
620620+621621+ return result, nil
535622}
536623537624// DeleteManifest deletes a manifest and its associated layers
···617704 }
618705619706 return layers, nil
707707+}
708708+709709+// InsertManifestReference inserts a new manifest reference record (for manifest lists/indexes)
710710+func InsertManifestReference(db *sql.DB, ref *ManifestReference) error {
711711+ _, err := db.Exec(`
712712+ INSERT INTO manifest_references (manifest_id, digest, size, media_type,
713713+ platform_architecture, platform_os,
714714+ platform_variant, platform_os_version,
715715+ reference_index)
716716+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
717717+ `, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType,
718718+ ref.PlatformArchitecture, ref.PlatformOS,
719719+ ref.PlatformVariant, ref.PlatformOSVersion,
720720+ ref.ReferenceIndex)
721721+ return err
722722+}
723723+724724+// GetManifestReferencesForManifest fetches all manifest references for a manifest list/index
725725+func GetManifestReferencesForManifest(db *sql.DB, manifestID int64) ([]ManifestReference, error) {
726726+ rows, err := db.Query(`
727727+ SELECT manifest_id, digest, size, media_type,
728728+ platform_architecture, platform_os, platform_variant, platform_os_version,
729729+ reference_index
730730+ FROM manifest_references
731731+ WHERE manifest_id = ?
732732+ ORDER BY reference_index
733733+ `, manifestID)
734734+735735+ if err != nil {
736736+ return nil, err
737737+ }
738738+ defer rows.Close()
739739+740740+ var refs []ManifestReference
741741+ for rows.Next() {
742742+ var r ManifestReference
743743+ var arch, os, variant, osVersion sql.NullString
744744+ if err := rows.Scan(&r.ManifestID, &r.Digest, &r.Size, &r.MediaType,
745745+ &arch, &os, &variant, &osVersion,
746746+ &r.ReferenceIndex); err != nil {
747747+ return nil, err
748748+ }
749749+750750+ // Convert nullable strings
751751+ if arch.Valid {
752752+ r.PlatformArchitecture = arch.String
753753+ }
754754+ if os.Valid {
755755+ r.PlatformOS = os.String
756756+ }
757757+ if variant.Valid {
758758+ r.PlatformVariant = variant.String
759759+ }
760760+ if osVersion.Valid {
761761+ r.PlatformOSVersion = osVersion.String
762762+ }
763763+764764+ refs = append(refs, r)
765765+ }
766766+767767+ return refs, nil
768768+}
769769+770770+// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests
771771+// Filters out platform-specific manifests that are referenced by manifest lists
772772+func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) {
773773+ rows, err := db.Query(`
774774+ WITH manifest_list_children AS (
775775+ -- Get all digests that are children of manifest lists
776776+ SELECT DISTINCT mr.digest
777777+ FROM manifest_references mr
778778+ JOIN manifests m ON mr.manifest_id = m.id
779779+ WHERE m.did = ? AND m.repository = ?
780780+ )
781781+ SELECT
782782+ m.id, m.did, m.repository, m.digest, m.media_type,
783783+ m.schema_version, m.created_at, m.title, m.description,
784784+ m.source_url, m.documentation_url, m.licenses, m.icon_url,
785785+ m.config_digest, m.config_size, m.hold_endpoint,
786786+ GROUP_CONCAT(DISTINCT t.tag) as tags,
787787+ COUNT(DISTINCT mr.digest) as platform_count
788788+ FROM manifests m
789789+ LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
790790+ LEFT JOIN manifest_references mr ON m.id = mr.manifest_id
791791+ WHERE m.did = ? AND m.repository = ?
792792+ AND (
793793+ -- Include manifest lists
794794+ m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
795795+ OR
796796+ -- Include single-arch NOT referenced by any list
797797+ m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
798798+ )
799799+ GROUP BY m.id
800800+ ORDER BY m.created_at DESC
801801+ LIMIT ? OFFSET ?
802802+ `, did, repository, did, repository, limit, offset)
803803+804804+ if err != nil {
805805+ return nil, err
806806+ }
807807+ defer rows.Close()
808808+809809+ var manifests []ManifestWithMetadata
810810+ for rows.Next() {
811811+ var m ManifestWithMetadata
812812+ var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString
813813+ var configSize sql.NullInt64
814814+815815+ if err := rows.Scan(
816816+ &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
817817+ &m.SchemaVersion, &m.CreatedAt, &title, &description,
818818+ &sourceURL, &documentationURL, &licenses, &iconURL,
819819+ &configDigest, &configSize, &m.HoldEndpoint,
820820+ &tags, &m.PlatformCount,
821821+ ); err != nil {
822822+ return nil, err
823823+ }
824824+825825+ // Set nullable fields
826826+ if title.Valid {
827827+ m.Title = title.String
828828+ }
829829+ if description.Valid {
830830+ m.Description = description.String
831831+ }
832832+ if sourceURL.Valid {
833833+ m.SourceURL = sourceURL.String
834834+ }
835835+ if documentationURL.Valid {
836836+ m.DocumentationURL = documentationURL.String
837837+ }
838838+ if licenses.Valid {
839839+ m.Licenses = licenses.String
840840+ }
841841+ if iconURL.Valid {
842842+ m.IconURL = iconURL.String
843843+ }
844844+ if configDigest.Valid {
845845+ m.ConfigDigest = configDigest.String
846846+ }
847847+ if configSize.Valid {
848848+ m.ConfigSize = configSize.Int64
849849+ }
850850+851851+ // Parse tags
852852+ if tags.Valid && tags.String != "" {
853853+ m.Tags = strings.Split(tags.String, ",")
854854+ }
855855+856856+ // Determine if manifest list
857857+ m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
858858+859859+ manifests = append(manifests, m)
860860+ }
861861+862862+ return manifests, nil
863863+}
864864+865865+// GetManifestDetail returns a manifest with full platform details and tags
866866+func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) {
867867+ // First, get the manifest and its tags
868868+ var m ManifestWithMetadata
869869+ var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString
870870+ var configSize sql.NullInt64
871871+872872+ err := db.QueryRow(`
873873+ SELECT
874874+ m.id, m.did, m.repository, m.digest, m.media_type,
875875+ m.schema_version, m.created_at, m.title, m.description,
876876+ m.source_url, m.documentation_url, m.licenses, m.icon_url,
877877+ m.config_digest, m.config_size, m.hold_endpoint,
878878+ GROUP_CONCAT(DISTINCT t.tag) as tags
879879+ FROM manifests m
880880+ LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
881881+ WHERE m.did = ? AND m.repository = ? AND m.digest = ?
882882+ GROUP BY m.id
883883+ `, did, repository, digest).Scan(
884884+ &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
885885+ &m.SchemaVersion, &m.CreatedAt, &title, &description,
886886+ &sourceURL, &documentationURL, &licenses, &iconURL,
887887+ &configDigest, &configSize, &m.HoldEndpoint,
888888+ &tags,
889889+ )
890890+891891+ if err != nil {
892892+ if err == sql.ErrNoRows {
893893+ return nil, fmt.Errorf("manifest not found")
894894+ }
895895+ return nil, err
896896+ }
897897+898898+ // Set nullable fields
899899+ if title.Valid {
900900+ m.Title = title.String
901901+ }
902902+ if description.Valid {
903903+ m.Description = description.String
904904+ }
905905+ if sourceURL.Valid {
906906+ m.SourceURL = sourceURL.String
907907+ }
908908+ if documentationURL.Valid {
909909+ m.DocumentationURL = documentationURL.String
910910+ }
911911+ if licenses.Valid {
912912+ m.Licenses = licenses.String
913913+ }
914914+ if iconURL.Valid {
915915+ m.IconURL = iconURL.String
916916+ }
917917+ if configDigest.Valid {
918918+ m.ConfigDigest = configDigest.String
919919+ }
920920+ if configSize.Valid {
921921+ m.ConfigSize = configSize.Int64
922922+ }
923923+924924+ // Parse tags
925925+ if tags.Valid && tags.String != "" {
926926+ m.Tags = strings.Split(tags.String, ",")
927927+ }
928928+929929+ // Determine if manifest list
930930+ m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
931931+932932+ // If this is a manifest list, get platform details
933933+ if m.IsManifestList {
934934+ platforms, err := db.Query(`
935935+ SELECT
936936+ mr.platform_os,
937937+ mr.platform_architecture,
938938+ mr.platform_variant,
939939+ mr.platform_os_version
940940+ FROM manifest_references mr
941941+ WHERE mr.manifest_id = ?
942942+ ORDER BY mr.reference_index
943943+ `, m.ID)
944944+945945+ if err != nil {
946946+ return nil, err
947947+ }
948948+ defer platforms.Close()
949949+950950+ m.Platforms = []PlatformInfo{}
951951+ for platforms.Next() {
952952+ var p PlatformInfo
953953+ var os, arch, variant, osVersion sql.NullString
954954+955955+ if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil {
956956+ return nil, err
957957+ }
958958+959959+ if os.Valid {
960960+ p.OS = os.String
961961+ }
962962+ if arch.Valid {
963963+ p.Architecture = arch.String
964964+ }
965965+ if variant.Valid {
966966+ p.Variant = variant.String
967967+ }
968968+ if osVersion.Valid {
969969+ p.OSVersion = osVersion.String
970970+ }
971971+972972+ m.Platforms = append(m.Platforms, p)
973973+ }
974974+975975+ m.PlatformCount = len(m.Platforms)
976976+ }
977977+978978+ return &m, nil
620979}
621980622981// GetFirehoseCursor retrieves the current firehose cursor
+15
pkg/appview/db/schema.go
···6868);
6969CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest);
70707171+CREATE TABLE IF NOT EXISTS manifest_references (
7272+ manifest_id INTEGER NOT NULL,
7373+ digest TEXT NOT NULL,
7474+ media_type TEXT NOT NULL,
7575+ size INTEGER NOT NULL,
7676+ platform_architecture TEXT,
7777+ platform_os TEXT,
7878+ platform_variant TEXT,
7979+ platform_os_version TEXT,
8080+ reference_index INTEGER NOT NULL,
8181+ PRIMARY KEY(manifest_id, reference_index),
8282+ FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE
8383+);
8484+CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
8585+7186CREATE TABLE IF NOT EXISTS tags (
7287 id INTEGER PRIMARY KEY AUTOINCREMENT,
7388 did TEXT NOT NULL,
+37
pkg/appview/handlers/api.go
···223223 json.NewEncoder(w).Encode(stats)
224224}
225225226226+// ManifestDetailHandler returns detailed manifest information including platforms
227227+type ManifestDetailHandler struct {
228228+ DB *sql.DB
229229+ Directory identity.Directory
230230+}
231231+232232+func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
233233+ // Extract parameters
234234+ vars := mux.Vars(r)
235235+ handle := vars["handle"]
236236+ repository := vars["repository"]
237237+ digest := vars["digest"]
238238+239239+ // Resolve owner's handle to DID
240240+ ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle)
241241+ if err != nil {
242242+ http.Error(w, "Failed to resolve handle", http.StatusBadRequest)
243243+ return
244244+ }
245245+246246+ // Get manifest detail from database
247247+ manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest)
248248+ if err != nil {
249249+ if err.Error() == "manifest not found" {
250250+ http.Error(w, "Manifest not found", http.StatusNotFound)
251251+ return
252252+ }
253253+ log.Printf("GetManifestDetail error: %v", err)
254254+ http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError)
255255+ return
256256+ }
257257+258258+ // Return manifest as JSON
259259+ w.Header().Set("Content-Type", "application/json")
260260+ json.NewEncoder(w).Encode(manifest)
261261+}
262262+226263// resolveIdentityToDID is a helper function that resolves a handle or DID to a DID
227264func resolveIdentityToDID(ctx context.Context, directory identity.Directory, identityStr string) (string, error) {
228265 // Parse as AT identifier (handle or DID)
+47-6
pkg/appview/handlers/images.go
···2233import (
44 "database/sql"
55+ "fmt"
56 "net/http"
77+ "strings"
6879 "atcr.io/pkg/appview/db"
810 "atcr.io/pkg/appview/middleware"
1111+ "atcr.io/pkg/atproto"
1212+ "atcr.io/pkg/auth/oauth"
913 "github.com/gorilla/mux"
1014)
11151216// DeleteTagHandler handles deleting a tag
1317type DeleteTagHandler struct {
1414- DB *sql.DB
1515- // TODO: Add ATProto client for deleting from PDS
1818+ DB *sql.DB
1919+ Refresher *oauth.Refresher
1620}
17211822func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···2630 repo := vars["repository"]
2731 tag := vars["tag"]
28322929- // TODO: Delete from PDS via ATProto client
3333+ // Get OAuth session for the authenticated user
3434+ session, err := h.Refresher.GetSession(r.Context(), user.DID)
3535+ if err != nil {
3636+ http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
3737+ return
3838+ }
3939+4040+ // Create ATProto client with OAuth credentials
4141+ apiClient := session.APIClient()
4242+ pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
4343+4444+ // Compute rkey for tag record (repository_tag with slashes replaced)
4545+ rkey := fmt.Sprintf("%s_%s", repo, tag)
4646+ rkey = strings.ReplaceAll(rkey, "/", "-")
4747+4848+ // Delete from PDS first
4949+ if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, rkey); err != nil {
5050+ http.Error(w, fmt.Sprintf("Failed to delete tag from PDS: %v", err), http.StatusInternalServerError)
5151+ return
5252+ }
30533154 // Delete from cache
3255 if err := db.DeleteTag(h.DB, user.DID, repo, tag); err != nil {
···40634164// DeleteManifestHandler handles deleting a manifest
4265type DeleteManifestHandler struct {
4343- DB *sql.DB
4444- // TODO: Add ATProto client for deleting from PDS
6666+ DB *sql.DB
6767+ Refresher *oauth.Refresher
4568}
46694770func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···6790 return
6891 }
69927070- // TODO: Delete from PDS via ATProto client
9393+ // Get OAuth session for the authenticated user
9494+ session, err := h.Refresher.GetSession(r.Context(), user.DID)
9595+ if err != nil {
9696+ http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized)
9797+ return
9898+ }
9999+100100+ // Create ATProto client with OAuth credentials
101101+ apiClient := session.APIClient()
102102+ pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient)
103103+104104+ // Compute rkey for manifest record (digest without "sha256:" prefix)
105105+ rkey := strings.TrimPrefix(digest, "sha256:")
106106+107107+ // Delete from PDS first
108108+ if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil {
109109+ http.Error(w, fmt.Sprintf("Failed to delete manifest from PDS: %v", err), http.StatusInternalServerError)
110110+ return
111111+ }
7111272113 // Delete from cache
73114 if err := db.DeleteManifest(h.DB, user.DID, repo, digest); err != nil {