···624 `)
625 return err
626}
627+628+// GetRepository fetches a specific repository for a user
629+func GetRepository(db *sql.DB, did, repository string) (*Repository, error) {
630+ // Get repository summary
631+ var r Repository
632+ r.Name = repository
633+634+ var tagCount, manifestCount int
635+ var lastPushStr string
636+637+ err := db.QueryRow(`
638+ SELECT
639+ COUNT(DISTINCT tag) as tag_count,
640+ COUNT(DISTINCT digest) as manifest_count,
641+ MAX(created_at) as last_push
642+ FROM (
643+ SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ?
644+ UNION
645+ SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ?
646+ )
647+ `, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr)
648+649+ if err != nil {
650+ return nil, err
651+ }
652+653+ r.TagCount = tagCount
654+ r.ManifestCount = manifestCount
655+656+ // Parse the timestamp string into time.Time
657+ if lastPushStr != "" {
658+ formats := []string{
659+ time.RFC3339Nano,
660+ "2006-01-02 15:04:05.999999999-07:00",
661+ "2006-01-02 15:04:05.999999999",
662+ time.RFC3339,
663+ "2006-01-02 15:04:05",
664+ }
665+666+ for _, format := range formats {
667+ if t, err := time.Parse(format, lastPushStr); err == nil {
668+ r.LastPush = t
669+ break
670+ }
671+ }
672+ }
673+674+ // Get tags for this repo
675+ tagRows, err := db.Query(`
676+ SELECT id, tag, digest, created_at
677+ FROM tags
678+ WHERE did = ? AND repository = ?
679+ ORDER BY created_at DESC
680+ `, did, repository)
681+682+ if err != nil {
683+ return nil, err
684+ }
685+686+ for tagRows.Next() {
687+ var t Tag
688+ t.DID = did
689+ t.Repository = repository
690+ if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
691+ tagRows.Close()
692+ return nil, err
693+ }
694+ r.Tags = append(r.Tags, t)
695+ }
696+ tagRows.Close()
697+698+ // Get manifests for this repo
699+ manifestRows, err := db.Query(`
700+ SELECT id, digest, hold_endpoint, schema_version, media_type,
701+ config_digest, config_size, raw_manifest, created_at,
702+ title, description, source_url, documentation_url, licenses, icon_url
703+ FROM manifests
704+ WHERE did = ? AND repository = ?
705+ ORDER BY created_at DESC
706+ `, did, repository)
707+708+ if err != nil {
709+ return nil, err
710+ }
711+712+ for manifestRows.Next() {
713+ var m Manifest
714+ m.DID = did
715+ m.Repository = repository
716+717+ // Use sql.NullString for nullable annotation fields
718+ var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString
719+720+ if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
721+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt,
722+ &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil {
723+ manifestRows.Close()
724+ return nil, err
725+ }
726+727+ // Convert NullString to string
728+ if title.Valid {
729+ m.Title = title.String
730+ }
731+ if description.Valid {
732+ m.Description = description.String
733+ }
734+ if sourceURL.Valid {
735+ m.SourceURL = sourceURL.String
736+ }
737+ if documentationURL.Valid {
738+ m.DocumentationURL = documentationURL.String
739+ }
740+ if licenses.Valid {
741+ m.Licenses = licenses.String
742+ }
743+ if iconURL.Valid {
744+ m.IconURL = iconURL.String
745+ }
746+747+ r.Manifests = append(r.Manifests, m)
748+ }
749+ manifestRows.Close()
750+751+ // Aggregate repository-level annotations from most recent manifest
752+ if len(r.Manifests) > 0 {
753+ latest := r.Manifests[0]
754+ r.Title = latest.Title
755+ r.Description = latest.Description
756+ r.SourceURL = latest.SourceURL
757+ r.DocumentationURL = latest.DocumentationURL
758+ r.Licenses = latest.Licenses
759+ r.IconURL = latest.IconURL
760+ }
761+762+ return &r, nil
763+}
+64-71
pkg/appview/db/schema.go
···79 completed BOOLEAN NOT NULL DEFAULT 0,
80 updated_at TIMESTAMP NOT NULL
81);
00000000000000000000000000000000000000000000000000000000000000082`
8384// InitDB initializes the SQLite database with the schema
···105 // Log but don't fail - column might already exist
106 }
107108- // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue
109- if err := migrateCDNURLs(db); err != nil {
110- // Log but don't fail - not critical
111- println("Warning: Failed to migrate CDN URLs:", err.Error())
112- }
113-114 // Migration: Add OCI annotation columns to manifests table
115 annotationColumns := []string{
116 "title TEXT",
···130 }
131132 return db, nil
133-}
134-135-// migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format
136-// Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
137-// New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73...
138-func migrateCDNURLs(db *sql.DB) error {
139- // Find all users with cdn.bsky.app avatars
140- rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`)
141- if err != nil {
142- return err
143- }
144- defer rows.Close()
145-146- updates := []struct {
147- did string
148- newURL string
149- }{}
150-151- for rows.Next() {
152- var did, oldURL string
153- if err := rows.Scan(&did, &oldURL); err != nil {
154- continue
155- }
156-157- // Extract CID from old URL
158- // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
159- parts := strings.Split(oldURL, "/")
160- if len(parts) < 7 {
161- continue
162- }
163-164- // Get the last part which contains CID@format
165- cidPart := parts[len(parts)-1]
166- // Strip off @jpeg or @png suffix
167- cid := strings.Split(cidPart, "@")[0]
168-169- // Construct new imgs.blue URL
170- newURL := "https://imgs.blue/" + did + "/" + cid
171-172- updates = append(updates, struct {
173- did string
174- newURL string
175- }{did, newURL})
176- }
177-178- // Update all users
179- stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`)
180- if err != nil {
181- return err
182- }
183- defer stmt.Close()
184-185- for _, update := range updates {
186- if _, err := stmt.Exec(update.newURL, update.did); err != nil {
187- // Log but continue
188- println("Warning: Failed to update avatar for", update.did, ":", err.Error())
189- }
190- }
191-192- if len(updates) > 0 {
193- println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue")
194- }
195-196- return nil
197-}
···79 completed BOOLEAN NOT NULL DEFAULT 0,
80 updated_at TIMESTAMP NOT NULL
81);
82+83+CREATE TABLE IF NOT EXISTS oauth_sessions (
84+ session_key TEXT PRIMARY KEY,
85+ account_did TEXT NOT NULL,
86+ session_id TEXT NOT NULL,
87+ session_data TEXT NOT NULL,
88+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
89+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
90+ UNIQUE(account_did, session_id)
91+);
92+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
93+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
94+95+CREATE TABLE IF NOT EXISTS oauth_auth_requests (
96+ state TEXT PRIMARY KEY,
97+ request_data TEXT NOT NULL,
98+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
99+);
100+CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
101+102+CREATE TABLE IF NOT EXISTS ui_sessions (
103+ id TEXT PRIMARY KEY,
104+ did TEXT NOT NULL,
105+ handle TEXT NOT NULL,
106+ pds_endpoint TEXT NOT NULL,
107+ oauth_session_id TEXT,
108+ expires_at TIMESTAMP NOT NULL,
109+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
110+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
111+);
112+CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
113+CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
114+115+CREATE TABLE IF NOT EXISTS devices (
116+ id TEXT PRIMARY KEY,
117+ did TEXT NOT NULL,
118+ handle TEXT NOT NULL,
119+ name TEXT NOT NULL,
120+ secret_hash TEXT NOT NULL UNIQUE,
121+ ip_address TEXT,
122+ location TEXT,
123+ user_agent TEXT,
124+ created_at TIMESTAMP NOT NULL,
125+ last_used TIMESTAMP,
126+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
127+);
128+CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
129+CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
130+131+CREATE TABLE IF NOT EXISTS pending_device_auth (
132+ device_code TEXT PRIMARY KEY,
133+ user_code TEXT NOT NULL UNIQUE,
134+ device_name TEXT NOT NULL,
135+ ip_address TEXT,
136+ user_agent TEXT,
137+ expires_at TIMESTAMP NOT NULL,
138+ approved_did TEXT,
139+ approved_at TIMESTAMP,
140+ device_secret TEXT,
141+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
142+);
143+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
144+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
145`
146147// InitDB initializes the SQLite database with the schema
···168 // Log but don't fail - column might already exist
169 }
170000000171 // Migration: Add OCI annotation columns to manifests table
172 annotationColumns := []string{
173 "title TEXT",
···187 }
188189 return db, nil
190+}0000000000000000000000000000000000000000000000000000000000000000
···81 return nil, fmt.Errorf("failed to parse DID: %w", err)
82 }
8384- // Get all sessions for this DID from store
85- fileStore, ok := r.app.clientApp.Store.(*FileStore)
86- if !ok {
87- return nil, fmt.Errorf("store is not a FileStore")
88 }
8990- // Find a session for this DID
91- sessions := fileStore.ListSessions()
92- var sessionID string
93- for _, sessionData := range sessions {
94- if sessionData.AccountDID.String() == did {
95- sessionID = sessionData.SessionID
96- break
97- }
98 }
99100- if sessionID == "" {
0101 return nil, fmt.Errorf("no session found for DID: %s", did)
102 }
103
···81 return nil, fmt.Errorf("failed to parse DID: %w", err)
82 }
8384+ // Get the latest session for this DID from SQLite store
85+ // The store must implement GetLatestSessionForDID (returns newest by updated_at)
86+ type sessionGetter interface {
87+ GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
88 }
8990+ getter, ok := r.app.clientApp.Store.(sessionGetter)
91+ if !ok {
92+ return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
0000093 }
9495+ _, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
96+ if err != nil {
97 return nil, fmt.Errorf("no session found for DID: %s", did)
98 }
99