A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package handlers
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "strings"
11
12 "atcr.io/pkg/appview/db"
13 "atcr.io/pkg/appview/middleware"
14 "atcr.io/pkg/atproto"
15 "atcr.io/pkg/auth/oauth"
16 "github.com/bluesky-social/indigo/atproto/identity"
17 "github.com/go-chi/chi/v5"
18)
19
20// StarRepositoryHandler handles starring a repository
21type StarRepositoryHandler struct {
22 DB *sql.DB
23 Directory identity.Directory
24 Refresher *oauth.Refresher
25}
26
27func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
28 // Get authenticated user from middleware
29 user := middleware.GetUser(r)
30 if user == nil {
31 http.Error(w, "Unauthorized", http.StatusUnauthorized)
32 return
33 }
34
35 // Extract parameters
36 handle := chi.URLParam(r, "handle")
37 repository := chi.URLParam(r, "repository")
38
39 // Resolve owner's handle to DID
40 ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
41 if err != nil {
42 slog.Warn("Failed to resolve handle for star", "handle", handle, "error", err)
43 http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
44 return
45 }
46
47 // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
48 slog.Debug("Creating PDS client for star", "user_did", user.DID)
49 pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
50
51 // Create star record
52 starRecord := atproto.NewStarRecord(ownerDID, repository)
53 rkey := atproto.StarRecordKey(ownerDID, repository)
54
55 // Write star record to user's PDS
56 _, err = pdsClient.PutRecord(r.Context(), atproto.StarCollection, rkey, starRecord)
57 if err != nil {
58 // Check if OAuth error - if so, invalidate sessions and return 401
59 if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
60 http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
61 return
62 }
63 slog.Error("Failed to create star record", "error", err)
64 http.Error(w, fmt.Sprintf("Failed to create star: %v", err), http.StatusInternalServerError)
65 return
66 }
67
68 // Return success
69 w.Header().Set("Content-Type", "application/json")
70 w.WriteHeader(http.StatusCreated)
71 json.NewEncoder(w).Encode(map[string]bool{"starred": true})
72}
73
74// UnstarRepositoryHandler handles unstarring a repository
75type UnstarRepositoryHandler struct {
76 DB *sql.DB
77 Directory identity.Directory
78 Refresher *oauth.Refresher
79}
80
81func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
82 // Get authenticated user from middleware
83 user := middleware.GetUser(r)
84 if user == nil {
85 http.Error(w, "Unauthorized", http.StatusUnauthorized)
86 return
87 }
88
89 // Extract parameters
90 handle := chi.URLParam(r, "handle")
91 repository := chi.URLParam(r, "repository")
92
93 // Resolve owner's handle to DID
94 ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
95 if err != nil {
96 slog.Warn("Failed to resolve handle for unstar", "handle", handle, "error", err)
97 http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
98 return
99 }
100
101 // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
102 slog.Debug("Creating PDS client for unstar", "user_did", user.DID)
103 pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
104
105 // Delete star record from user's PDS
106 rkey := atproto.StarRecordKey(ownerDID, repository)
107 slog.Debug("Deleting star record", "handle", handle, "repository", repository, "rkey", rkey)
108 err = pdsClient.DeleteRecord(r.Context(), atproto.StarCollection, rkey)
109 if err != nil {
110 // If record doesn't exist, still return success (idempotent)
111 if !errors.Is(err, atproto.ErrRecordNotFound) {
112 // Check if OAuth error - if so, invalidate sessions and return 401
113 if handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
114 http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized)
115 return
116 }
117 slog.Error("Failed to delete star record", "error", err)
118 http.Error(w, fmt.Sprintf("Failed to delete star: %v", err), http.StatusInternalServerError)
119 return
120 }
121 slog.Debug("Star record not found, already unstarred")
122 }
123
124 // Return success
125 w.Header().Set("Content-Type", "application/json")
126 json.NewEncoder(w).Encode(map[string]bool{"starred": false})
127}
128
129// CheckStarHandler checks if current user has starred a repository
130type CheckStarHandler struct {
131 DB *sql.DB
132 Directory identity.Directory
133 Refresher *oauth.Refresher
134}
135
136func (h *CheckStarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
137 // Get authenticated user from middleware
138 user := middleware.GetUser(r)
139 if user == nil {
140 // Not authenticated - return not starred
141 w.Header().Set("Content-Type", "application/json")
142 json.NewEncoder(w).Encode(map[string]bool{"starred": false})
143 return
144 }
145
146 // Extract parameters
147 handle := chi.URLParam(r, "handle")
148 repository := chi.URLParam(r, "repository")
149
150 // Resolve owner's handle to DID
151 ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
152 if err != nil {
153 slog.Warn("Failed to resolve handle for check star", "handle", handle, "error", err)
154 http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
155 return
156 }
157
158 // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
159 // Note: Error handling moves to the PDS call - if session doesn't exist, GetRecord will fail
160 pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
161
162 // Check if star record exists
163 rkey := atproto.StarRecordKey(ownerDID, repository)
164 _, err = pdsClient.GetRecord(r.Context(), atproto.StarCollection, rkey)
165
166 // Check if OAuth error - if so, invalidate sessions
167 if err != nil && handleOAuthError(r.Context(), h.Refresher, user.DID, err) {
168 // For a read operation, just return not starred instead of error
169 w.Header().Set("Content-Type", "application/json")
170 json.NewEncoder(w).Encode(map[string]bool{"starred": false})
171 return
172 }
173
174 starred := err == nil
175
176 // Return result
177 w.Header().Set("Content-Type", "application/json")
178 json.NewEncoder(w).Encode(map[string]bool{"starred": starred})
179}
180
181// GetStatsHandler returns repository statistics
182type GetStatsHandler struct {
183 DB *sql.DB
184 Directory identity.Directory
185}
186
187func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
188 // Extract parameters
189 handle := chi.URLParam(r, "handle")
190 repository := chi.URLParam(r, "repository")
191
192 // Resolve owner's handle to DID
193 ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
194 if err != nil {
195 http.Error(w, "Failed to resolve handle", http.StatusBadRequest)
196 return
197 }
198
199 // Get repository stats from database
200 stats, err := db.GetRepositoryStats(h.DB, ownerDID, repository)
201 if err != nil {
202 http.Error(w, "Failed to fetch stats", http.StatusInternalServerError)
203 return
204 }
205
206 // Return stats as JSON
207 w.Header().Set("Content-Type", "application/json")
208 json.NewEncoder(w).Encode(stats)
209}
210
211// ManifestDetailHandler returns detailed manifest information including platforms
212type ManifestDetailHandler struct {
213 DB *sql.DB
214 Directory identity.Directory
215}
216
217func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
218 // Extract parameters
219 handle := chi.URLParam(r, "handle")
220 repository := chi.URLParam(r, "repository")
221 digest := chi.URLParam(r, "digest")
222
223 // Resolve owner's handle to DID
224 ownerDID, err := atproto.ResolveHandleToDID(r.Context(), handle)
225 if err != nil {
226 http.Error(w, "Failed to resolve handle", http.StatusBadRequest)
227 return
228 }
229
230 // Get manifest detail from database
231 manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest)
232 if err != nil {
233 if err.Error() == "manifest not found" {
234 http.Error(w, "Manifest not found", http.StatusNotFound)
235 return
236 }
237 slog.Error("Failed to get manifest detail", "error", err)
238 http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError)
239 return
240 }
241
242 // Return manifest as JSON
243 w.Header().Set("Content-Type", "application/json")
244 json.NewEncoder(w).Encode(manifest)
245}
246
247// CredentialHelperVersionResponse is the response for the credential helper version API
248type CredentialHelperVersionResponse struct {
249 Latest string `json:"latest"`
250 DownloadURLs map[string]string `json:"download_urls"`
251 Checksums map[string]string `json:"checksums"`
252 ReleaseNotes string `json:"release_notes,omitempty"`
253}
254
255// CredentialHelperVersionHandler returns the latest credential helper version info
256type CredentialHelperVersionHandler struct {
257 Version string
258 TangledRepo string
259 Checksums map[string]string
260}
261
262// Supported platforms for download URLs
263var credentialHelperPlatforms = []struct {
264 key string // API key (e.g., "linux_amd64")
265 os string // OS name in archive (e.g., "Linux")
266 arch string // Arch name in archive (e.g., "x86_64")
267 ext string // Archive extension (e.g., "tar.gz" or "zip")
268}{
269 {"linux_amd64", "Linux", "x86_64", "tar.gz"},
270 {"linux_arm64", "Linux", "arm64", "tar.gz"},
271 {"darwin_amd64", "Darwin", "x86_64", "tar.gz"},
272 {"darwin_arm64", "Darwin", "arm64", "tar.gz"},
273 {"windows_amd64", "Windows", "x86_64", "zip"},
274 {"windows_arm64", "Windows", "arm64", "zip"},
275}
276
277func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
278 // Check if version is configured
279 if h.Version == "" {
280 http.Error(w, "Credential helper version not configured", http.StatusServiceUnavailable)
281 return
282 }
283
284 // Build download URLs for all platforms
285 // URL format: {TangledRepo}/tags/{version}/download/docker-credential-atcr_{version_without_v}_{OS}_{Arch}.{ext}
286 downloadURLs := make(map[string]string)
287 versionWithoutV := strings.TrimPrefix(h.Version, "v")
288
289 for _, p := range credentialHelperPlatforms {
290 filename := fmt.Sprintf("docker-credential-atcr_%s_%s_%s.%s", versionWithoutV, p.os, p.arch, p.ext)
291 downloadURLs[p.key] = fmt.Sprintf("%s/tags/%s/download/%s", h.TangledRepo, h.Version, filename)
292 }
293
294 response := CredentialHelperVersionResponse{
295 Latest: h.Version,
296 DownloadURLs: downloadURLs,
297 Checksums: h.Checksums,
298 }
299
300 w.Header().Set("Content-Type", "application/json")
301 w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
302 json.NewEncoder(w).Encode(response)
303}