A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement stars UI

+292 -36
+7 -7
pkg/appview/db/models.go
··· 79 79 80 80 // RepositoryStats represents statistics for a repository 81 81 type RepositoryStats struct { 82 - DID string 83 - Repository string 84 - StarCount int 85 - PullCount int 86 - LastPull *time.Time 87 - PushCount int 88 - LastPush *time.Time 82 + DID string `json:"did"` 83 + Repository string `json:"repository"` 84 + StarCount int `json:"star_count"` 85 + PullCount int `json:"pull_count"` 86 + LastPull *time.Time `json:"last_pull,omitempty"` 87 + PushCount int `json:"push_count"` 88 + LastPush *time.Time `json:"last_push,omitempty"` 89 89 }
+19
pkg/appview/db/oauth_store.go
··· 92 92 return err 93 93 } 94 94 95 + // DeleteSessionsForDID removes all sessions for a given DID 96 + // This is useful for logout flows where we want to revoke all OAuth sessions 97 + func (s *OAuthStore) DeleteSessionsForDID(ctx context.Context, did string) error { 98 + result, err := s.db.ExecContext(ctx, ` 99 + DELETE FROM oauth_sessions WHERE account_did = ? 100 + `, did) 101 + 102 + if err != nil { 103 + return fmt.Errorf("failed to delete sessions for DID: %w", err) 104 + } 105 + 106 + deleted, _ := result.RowsAffected() 107 + if deleted > 0 { 108 + fmt.Printf("Deleted %d OAuth session(s) for DID %s\n", deleted, did) 109 + } 110 + 111 + return nil 112 + } 113 + 95 114 // GetAuthRequestInfo retrieves authentication request data by state 96 115 func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 97 116 var requestDataJSON string
-19
pkg/appview/db/schema.go
··· 174 174 return nil, err 175 175 } 176 176 177 - // Migration: Drop raw_manifest column if it exists 178 - // Check if column exists first 179 - var columnExists bool 180 - err = db.QueryRow(` 181 - SELECT COUNT(*) > 0 182 - FROM pragma_table_info('manifests') 183 - WHERE name = 'raw_manifest' 184 - `).Scan(&columnExists) 185 - if err != nil { 186 - return nil, err 187 - } 188 - 189 - if columnExists { 190 - // Drop the column (requires SQLite 3.35.0+) 191 - if _, err := db.Exec(`ALTER TABLE manifests DROP COLUMN raw_manifest`); err != nil { 192 - return nil, err 193 - } 194 - } 195 - 196 177 return db, nil 197 178 }
+34 -7
pkg/appview/handlers/api.go
··· 4 4 "context" 5 5 "database/sql" 6 6 "encoding/json" 7 + "fmt" 8 + "log" 7 9 "net/http" 8 10 9 11 "atcr.io/pkg/appview/db" ··· 38 40 // Resolve owner's handle to DID 39 41 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 40 42 if err != nil { 41 - http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 43 + log.Printf("StarRepository: Failed to resolve handle %s: %v", handle, err) 44 + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 42 45 return 43 46 } 47 + log.Printf("StarRepository: Resolved %s to DID %s", handle, ownerDID) 44 48 45 49 // Get OAuth session for the authenticated user 50 + log.Printf("StarRepository: Getting OAuth session for user DID %s", user.DID) 46 51 session, err := h.Refresher.GetSession(r.Context(), user.DID) 47 52 if err != nil { 48 - http.Error(w, "Failed to get OAuth session", http.StatusUnauthorized) 53 + log.Printf("StarRepository: Failed to get OAuth session for %s: %v", user.DID, err) 54 + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 49 55 return 50 56 } 57 + log.Printf("StarRepository: Got OAuth session for %s", user.DID) 51 58 52 59 // Get user's PDS client (use indigo's API client which handles DPoP automatically) 53 60 apiClient := session.APIClient() ··· 57 64 starRecord := atproto.NewStarRecord(ownerDID, repository) 58 65 rkey := atproto.StarRecordKey(ownerDID, repository) 59 66 67 + log.Printf("StarRepository: Creating star record for %s/%s (rkey: %s)", handle, repository, rkey) 68 + 60 69 // Write star record to user's PDS 61 70 _, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord) 62 71 if err != nil { 63 - http.Error(w, "Failed to create star", http.StatusInternalServerError) 72 + log.Printf("StarRepository: Failed to create star record: %v", err) 73 + http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError) 64 74 return 65 75 } 76 + 77 + log.Printf("StarRepository: Successfully starred %s/%s", handle, repository) 66 78 67 79 // Return success 68 80 w.Header().Set("Content-Type", "application/json") ··· 93 105 // Resolve owner's handle to DID 94 106 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 95 107 if err != nil { 96 - http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 108 + log.Printf("UnstarRepository: Failed to resolve handle %s: %v", handle, err) 109 + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 97 110 return 98 111 } 112 + log.Printf("UnstarRepository: Resolved %s to DID %s", handle, ownerDID) 99 113 100 114 // Get OAuth session for the authenticated user 115 + log.Printf("UnstarRepository: Getting OAuth session for user DID %s", user.DID) 101 116 session, err := h.Refresher.GetSession(r.Context(), user.DID) 102 117 if err != nil { 103 - http.Error(w, "Failed to get OAuth session", http.StatusUnauthorized) 118 + log.Printf("UnstarRepository: Failed to get OAuth session for %s: %v", user.DID, err) 119 + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 104 120 return 105 121 } 122 + log.Printf("UnstarRepository: Got OAuth session for %s", user.DID) 106 123 107 124 // Get user's PDS client (use indigo's API client which handles DPoP automatically) 108 125 apiClient := session.APIClient() ··· 110 127 111 128 // Delete star record from user's PDS 112 129 rkey := atproto.StarRecordKey(ownerDID, repository) 130 + log.Printf("UnstarRepository: Deleting star record for %s/%s (rkey: %s)", handle, repository, rkey) 113 131 err = pdsClient.DeleteRecord(r.Context(), atproto.StarCollection, rkey) 114 132 if err != nil { 115 133 // If record doesn't exist, still return success (idempotent) 116 134 if err.Error() != "record not found" { 117 - http.Error(w, "Failed to delete star", http.StatusInternalServerError) 135 + log.Printf("UnstarRepository: Failed to delete star record: %v", err) 136 + http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError) 118 137 return 119 138 } 139 + log.Printf("UnstarRepository: Star record not found (already unstarred)") 140 + } else { 141 + log.Printf("UnstarRepository: Successfully unstarred %s/%s", handle, repository) 120 142 } 121 143 122 144 // Return success ··· 149 171 // Resolve owner's handle to DID 150 172 ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 151 173 if err != nil { 152 - http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 174 + log.Printf("CheckStar: Failed to resolve handle %s: %v", handle, err) 175 + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 153 176 return 154 177 } 155 178 156 179 // Get OAuth session for the authenticated user 180 + log.Printf("CheckStar: Getting OAuth session for user DID %s", user.DID) 157 181 session, err := h.Refresher.GetSession(r.Context(), user.DID) 158 182 if err != nil { 183 + log.Printf("CheckStar: Failed to get OAuth session for %s: %v", user.DID, err) 159 184 // No OAuth session - return not starred 160 185 w.Header().Set("Content-Type", "application/json") 161 186 json.NewEncoder(w).Encode(map[string]bool{"starred": false}) ··· 168 193 169 194 // Check if star record exists 170 195 rkey := atproto.StarRecordKey(ownerDID, repository) 196 + log.Printf("CheckStar: Checking star record for %s/%s (rkey: %s)", handle, repository, rkey) 171 197 _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey) 172 198 173 199 starred := err == nil 200 + log.Printf("CheckStar: Star status for %s/%s: %v (err: %v)", handle, repository, starred, err) 174 201 175 202 // Return result 176 203 w.Header().Set("Content-Type", "application/json")
+28 -3
pkg/appview/handlers/auth.go
··· 76 76 did := ident.DID.String() 77 77 78 78 // Try to get existing OAuth session 79 - _, err := h.Refresher.GetSession(r.Context(), did) 79 + session, err := h.Refresher.GetSession(r.Context(), did) 80 80 if err == nil { 81 - // Found valid OAuth session! Create UI session silently 82 - fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) 81 + // Check if the session has all required scopes 82 + requiredScopes := oauth.GetDefaultScopes() 83 + sessionScopes := session.Data.Scopes 84 + 85 + if !hasAllScopes(sessionScopes, requiredScopes) { 86 + fmt.Printf("DEBUG [auth]: Session scopes mismatch for %s. Required: %v, Have: %v. Forcing re-auth.\n", 87 + handle, requiredScopes, sessionScopes) 88 + } else { 89 + // Found valid OAuth session with all required scopes! Create UI session silently 90 + fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) 83 91 84 92 // Get PDS endpoint from identity 85 93 pdsEndpoint := ident.PDSEndpoint() ··· 107 115 } 108 116 109 117 fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) 118 + } 110 119 } else { 111 120 fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err) 112 121 } ··· 135 144 // Redirect to OAuth authorize with handle 136 145 http.Redirect(w, r, "/auth/oauth/authorize?handle="+handle, http.StatusFound) 137 146 } 147 + 148 + // hasAllScopes checks if grantedScopes contains all requiredScopes 149 + func hasAllScopes(grantedScopes, requiredScopes []string) bool { 150 + grantedSet := make(map[string]bool) 151 + for _, scope := range grantedScopes { 152 + grantedSet[scope] = true 153 + } 154 + 155 + for _, required := range requiredScopes { 156 + if !grantedSet[required] { 157 + return false 158 + } 159 + } 160 + 161 + return true 162 + }
+53
pkg/appview/static/css/style.css
··· 806 806 margin: 0.5rem 0 0 0; 807 807 } 808 808 809 + .repo-actions { 810 + margin-top: 1.5rem; 811 + } 812 + 813 + .star-btn { 814 + display: inline-flex; 815 + align-items: center; 816 + gap: 0.5rem; 817 + padding: 0.5rem 1rem; 818 + background: var(--bg); 819 + border: 2px solid var(--border); 820 + border-radius: 6px; 821 + font-size: 1rem; 822 + cursor: pointer; 823 + transition: all 0.2s ease; 824 + color: var(--fg); 825 + } 826 + 827 + .star-btn:hover:not(:disabled) { 828 + border-color: var(--primary); 829 + background: var(--hover-bg); 830 + } 831 + 832 + .star-btn:disabled { 833 + opacity: 0.6; 834 + cursor: not-allowed; 835 + } 836 + 837 + .star-btn.starred { 838 + border-color: #fbbf24; 839 + background: #fffbeb; 840 + } 841 + 842 + .star-btn.starred:hover:not(:disabled) { 843 + background: #fef3c7; 844 + } 845 + 846 + .star-icon { 847 + font-size: 1.25rem; 848 + line-height: 1; 849 + transition: transform 0.2s ease; 850 + color: #fbbf24; 851 + } 852 + 853 + .star-btn:hover:not(:disabled) .star-icon { 854 + transform: scale(1.1); 855 + } 856 + 857 + .star-count { 858 + font-weight: 600; 859 + color: var(--fg); 860 + } 861 + 809 862 .repo-metadata { 810 863 display: flex; 811 864 gap: 1rem;
+130
pkg/appview/static/js/app.js
··· 115 115 dropdownMenu.setAttribute('hidden', ''); 116 116 } 117 117 } 118 + 119 + // Load star status on repository page 120 + loadStarStatus(); 118 121 }); 122 + 123 + // Toggle star on a repository 124 + async function toggleStar(handle, repository) { 125 + const starBtn = document.getElementById('star-btn'); 126 + const starIcon = document.getElementById('star-icon'); 127 + const starCountEl = document.getElementById('star-count'); 128 + 129 + if (!starBtn || !starIcon || !starCountEl) return; 130 + 131 + // Disable button during request 132 + starBtn.disabled = true; 133 + 134 + try { 135 + // Check current state 136 + const isStarred = starIcon.textContent === '★'; 137 + const method = isStarred ? 'DELETE' : 'POST'; 138 + const url = `/api/stars/${handle}/${repository}`; 139 + 140 + const response = await fetch(url, { 141 + method: method, 142 + credentials: 'include', 143 + }); 144 + 145 + if (response.status === 401) { 146 + console.log('Not authenticated, redirecting to login'); 147 + // Not authenticated, redirect to login 148 + window.location.href = '/auth/oauth/login'; 149 + return; 150 + } 151 + 152 + if (!response.ok) { 153 + const errorText = await response.text(); 154 + console.error(`Toggle star failed: ${response.status} ${response.statusText}`, errorText); 155 + throw new Error(`Failed to toggle star: ${errorText}`); 156 + } 157 + 158 + const data = await response.json(); 159 + 160 + // Update UI optimistically 161 + if (data.starred) { 162 + starIcon.textContent = '★'; 163 + starBtn.classList.add('starred'); 164 + // Optimistically increment count 165 + const currentCount = parseInt(starCountEl.textContent) || 0; 166 + starCountEl.textContent = currentCount + 1; 167 + } else { 168 + starIcon.textContent = '☆'; 169 + starBtn.classList.remove('starred'); 170 + // Optimistically decrement count 171 + const currentCount = parseInt(starCountEl.textContent) || 0; 172 + starCountEl.textContent = Math.max(0, currentCount - 1); 173 + } 174 + 175 + // Refresh actual count from server (will correct if optimistic update was wrong) 176 + await loadStarCount(handle, repository); 177 + 178 + } catch (err) { 179 + console.error('Error toggling star:', err); 180 + alert(`Failed to toggle star: ${err.message}`); 181 + } finally { 182 + starBtn.disabled = false; 183 + } 184 + } 185 + 186 + // Load star status and count for current repository 187 + async function loadStarStatus() { 188 + const starBtn = document.getElementById('star-btn'); 189 + const starIcon = document.getElementById('star-icon'); 190 + 191 + if (!starBtn || !starIcon) return; // Not on repository page 192 + 193 + // Extract handle and repository from button onclick attribute 194 + const onclick = starBtn.getAttribute('onclick'); 195 + const match = onclick.match(/toggleStar\('([^']+)',\s*'([^']+)'\)/); 196 + if (!match) return; 197 + 198 + const handle = match[1]; 199 + const repository = match[2]; 200 + 201 + try { 202 + // Check if user has starred this repo 203 + const starResponse = await fetch(`/api/stars/${handle}/${repository}`, { 204 + credentials: 'include', 205 + }); 206 + 207 + if (starResponse.ok) { 208 + const starData = await starResponse.json(); 209 + console.log('Star status data:', starData); 210 + if (starData.starred) { 211 + starIcon.textContent = '★'; 212 + starBtn.classList.add('starred'); 213 + } 214 + } else { 215 + const errorText = await starResponse.text(); 216 + console.error('Failed to load star status:', errorText); 217 + } 218 + 219 + // Load star count 220 + await loadStarCount(handle, repository); 221 + 222 + } catch (err) { 223 + console.error('Error loading star status:', err); 224 + } 225 + } 226 + 227 + // Load star count for a repository 228 + async function loadStarCount(handle, repository) { 229 + const starCountEl = document.getElementById('star-count'); 230 + if (!starCountEl) return; 231 + 232 + try { 233 + const statsResponse = await fetch(`/api/stats/${handle}/${repository}`, { 234 + credentials: 'include', 235 + }); 236 + 237 + if (statsResponse.ok) { 238 + const stats = await statsResponse.json(); 239 + console.log('Stats data:', stats); 240 + starCountEl.textContent = stats.star_count || 0; 241 + } else { 242 + const errorText = await statsResponse.text(); 243 + console.error('Failed to load stats:', errorText); 244 + } 245 + } catch (err) { 246 + console.error('Error loading star count:', err); 247 + } 248 + }
+8
pkg/appview/templates/pages/repository.html
··· 34 34 </div> 35 35 </div> 36 36 37 + <!-- Star Button --> 38 + <div class="repo-actions"> 39 + <button class="star-btn" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')"> 40 + <span class="star-icon" id="star-icon">☆</span> 41 + <span class="star-count" id="star-count">0</span> 42 + </button> 43 + </div> 44 + 37 45 <!-- Metadata Section --> 38 46 {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} 39 47 <div class="repo-metadata">
+11
pkg/atproto/client.go
··· 167 167 "rkey": rkey, 168 168 } 169 169 170 + // Use indigo API client (OAuth with DPoP) 171 + if c.useIndigoClient && c.indigoClient != nil { 172 + var result map[string]any // deleteRecord returns empty object on success 173 + err := c.indigoClient.Post(ctx, "com.atproto.repo.deleteRecord", payload, &result) 174 + if err != nil { 175 + return fmt.Errorf("deleteRecord failed: %w", err) 176 + } 177 + return nil 178 + } 179 + 180 + // Basic Auth (app passwords) 170 181 body, err := json.Marshal(payload) 171 182 if err != nil { 172 183 return fmt.Errorf("failed to marshal delete request: %w", err)
+2
pkg/auth/oauth/client.go
··· 123 123 "blob:application/vnd.docker.distribution.manifest.v2+json", 124 124 fmt.Sprintf("repo:%s", atproto.ManifestCollection), 125 125 fmt.Sprintf("repo:%s", atproto.TagCollection), 126 + fmt.Sprintf("repo:%s", atproto.StarCollection), 127 + fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 126 128 } 127 129 }