A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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 }