Go · 25313 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package api
4
5 import (
6 "encoding/json"
7 "errors"
8 "net/http"
9 "strings"
10 "time"
11
12 "github.com/go-chi/chi/v5"
13 "github.com/jackc/pgx/v5"
14 "github.com/jackc/pgx/v5/pgtype"
15
16 "github.com/tenseleyFlow/shithub/internal/auth/pat"
17 "github.com/tenseleyFlow/shithub/internal/auth/policy"
18 "github.com/tenseleyFlow/shithub/internal/entitlements"
19 "github.com/tenseleyFlow/shithub/internal/orgs"
20 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/repos"
22 "github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
23 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
26 "github.com/tenseleyFlow/shithub/internal/web/middleware"
27 )
28
29 // mountRepos registers the S50 §2 REST surface for repositories.
30 //
31 // GET /api/v1/user/repos list authenticated user's repos
32 // GET /api/v1/users/{username}/repos list a user's public repos
33 // GET /api/v1/orgs/{org}/repos list an org's repos (visibility-aware)
34 // GET /api/v1/repos/{owner}/{repo} fetch a single repo
35 // POST /api/v1/user/repos create personal repo
36 // POST /api/v1/orgs/{org}/repos create org-owned repo
37 // PATCH /api/v1/repos/{owner}/{repo} update mutable repo settings
38 // DELETE /api/v1/repos/{owner}/{repo} soft-delete a repo
39 //
40 // Scopes: repo:read for GETs, repo:write for POST/PATCH/DELETE. Existence
41 // leaks are smothered behind policy gates that 404 instead of 403 when
42 // the caller can't see the resource.
43 func (h *Handlers) mountRepos(r chi.Router) {
44 r.Group(func(r chi.Router) {
45 r.Use(middleware.RequireScope(pat.ScopeRepoRead))
46 r.Get("/api/v1/user/repos", h.userReposList)
47 r.Get("/api/v1/users/{username}/repos", h.userPublicReposList)
48 r.Get("/api/v1/orgs/{org}/repos", h.orgReposList)
49 r.Get("/api/v1/repos/{owner}/{repo}", h.repoGet)
50 })
51 r.Group(func(r chi.Router) {
52 r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
53 r.Post("/api/v1/user/repos", h.userRepoCreate)
54 r.Post("/api/v1/orgs/{org}/repos", h.orgRepoCreate)
55 r.Patch("/api/v1/repos/{owner}/{repo}", h.repoPatch)
56 r.Delete("/api/v1/repos/{owner}/{repo}", h.repoDelete)
57 })
58 }
59
60 // repoResponse mirrors GitHub's repo shape. Field selection is the
61 // minimum the CLI's `gh repo view` / clone logic needs to operate.
62 type repoResponse struct {
63 ID int64 `json:"id"`
64 Name string `json:"name"`
65 FullName string `json:"full_name"`
66 OwnerLogin string `json:"owner_login"`
67 OwnerType string `json:"owner_type"` // "user" | "org"
68 Description string `json:"description"`
69 Visibility string `json:"visibility"`
70 Private bool `json:"private"`
71 DefaultBranch string `json:"default_branch"`
72 Fork bool `json:"fork"`
73 Archived bool `json:"archived"`
74 HasIssues bool `json:"has_issues"`
75 HasPulls bool `json:"has_pulls"`
76 StarCount int64 `json:"star_count"`
77 WatcherCount int64 `json:"watcher_count"`
78 ForkCount int64 `json:"fork_count"`
79 CreatedAt string `json:"created_at"`
80 UpdatedAt string `json:"updated_at"`
81 }
82
83 func presentRepo(r reposdb.Repo, ownerLogin string) repoResponse {
84 ownerType := "user"
85 if r.OwnerOrgID.Valid {
86 ownerType = "org"
87 }
88 repoRef := policy.NewRepoRefFromRepo(r)
89 return repoResponse{
90 ID: r.ID,
91 Name: r.Name,
92 FullName: ownerLogin + "/" + r.Name,
93 OwnerLogin: ownerLogin,
94 OwnerType: ownerType,
95 Description: r.Description,
96 Visibility: string(r.Visibility),
97 Private: repoRef.IsPrivate(),
98 DefaultBranch: r.DefaultBranch,
99 Fork: r.ForkOfRepoID.Valid,
100 Archived: r.IsArchived,
101 HasIssues: r.HasIssues,
102 HasPulls: r.HasPulls,
103 StarCount: r.StarCount,
104 WatcherCount: r.WatcherCount,
105 ForkCount: r.ForkCount,
106 CreatedAt: r.CreatedAt.Time.UTC().Format(time.RFC3339),
107 UpdatedAt: r.UpdatedAt.Time.UTC().Format(time.RFC3339),
108 }
109 }
110
111 // ─── list endpoints ─────────────────────────────────────────────────
112
113 func (h *Handlers) userReposList(w http.ResponseWriter, r *http.Request) {
114 auth := middleware.PATAuthFromContext(r.Context())
115 if auth.UserID == 0 {
116 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
117 return
118 }
119 page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
120 q := reposdb.New()
121 total, err := q.CountReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: auth.UserID, Valid: true})
122 if err != nil {
123 h.d.Logger.ErrorContext(r.Context(), "api: count user repos", "error", err)
124 writeAPIError(w, http.StatusInternalServerError, "list failed")
125 return
126 }
127 rows, err := q.ListReposForOwnerUserPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerUserPagedParams{
128 OwnerUserID: pgtype.Int8{Int64: auth.UserID, Valid: true},
129 Limit: int32(perPage),
130 Offset: int32((page - 1) * perPage),
131 })
132 if err != nil {
133 h.d.Logger.ErrorContext(r.Context(), "api: list user repos", "error", err)
134 writeAPIError(w, http.StatusInternalServerError, "list failed")
135 return
136 }
137 h.writeRepoListPage(w, r, page, perPage, int(total), rows, auth.Username)
138 }
139
140 func (h *Handlers) userPublicReposList(w http.ResponseWriter, r *http.Request) {
141 owner, ok := h.resolveAPIUserOwner(w, r, chi.URLParam(r, "username"))
142 if !ok {
143 return
144 }
145 auth := middleware.PATAuthFromContext(r.Context())
146 q := reposdb.New()
147 // Self-view of /users/{me}/repos shows everything (private included),
148 // matching GitHub's behavior.
149 if auth.UserID == owner.ID {
150 page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
151 total, err := q.CountReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: owner.ID, Valid: true})
152 if err != nil {
153 h.d.Logger.ErrorContext(r.Context(), "api: count user repos", "error", err)
154 writeAPIError(w, http.StatusInternalServerError, "list failed")
155 return
156 }
157 rows, err := q.ListReposForOwnerUserPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerUserPagedParams{
158 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
159 Limit: int32(perPage),
160 Offset: int32((page - 1) * perPage),
161 })
162 if err != nil {
163 h.d.Logger.ErrorContext(r.Context(), "api: list user repos", "error", err)
164 writeAPIError(w, http.StatusInternalServerError, "list failed")
165 return
166 }
167 h.writeRepoListPage(w, r, page, perPage, int(total), rows, owner.Username)
168 return
169 }
170 page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
171 total, err := q.CountPublicReposForOwnerUser(r.Context(), h.d.Pool, pgtype.Int8{Int64: owner.ID, Valid: true})
172 if err != nil {
173 h.d.Logger.ErrorContext(r.Context(), "api: count public repos", "error", err)
174 writeAPIError(w, http.StatusInternalServerError, "list failed")
175 return
176 }
177 rows, err := q.ListPublicReposForOwnerUser(r.Context(), h.d.Pool, reposdb.ListPublicReposForOwnerUserParams{
178 OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
179 Limit: int32(perPage),
180 Offset: int32((page - 1) * perPage),
181 })
182 if err != nil {
183 h.d.Logger.ErrorContext(r.Context(), "api: list public repos", "error", err)
184 writeAPIError(w, http.StatusInternalServerError, "list failed")
185 return
186 }
187 h.writeRepoListPage(w, r, page, perPage, int(total), rows, owner.Username)
188 }
189
190 func (h *Handlers) orgReposList(w http.ResponseWriter, r *http.Request) {
191 org, ok := h.resolveAPIOrgOwner(w, r, chi.URLParam(r, "org"))
192 if !ok {
193 return
194 }
195 auth := middleware.PATAuthFromContext(r.Context())
196 q := reposdb.New()
197
198 memberView := false
199 if auth.UserID != 0 {
200 isMem, err := orgs.IsMember(r.Context(), orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, auth.UserID)
201 if err != nil {
202 h.d.Logger.ErrorContext(r.Context(), "api: org member check", "error", err)
203 writeAPIError(w, http.StatusInternalServerError, "list failed")
204 return
205 }
206 memberView = isMem || auth.IsSiteAdmin
207 }
208
209 page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
210 if memberView {
211 total, err := q.CountReposForOwnerOrg(r.Context(), h.d.Pool, pgtype.Int8{Int64: org.ID, Valid: true})
212 if err != nil {
213 h.d.Logger.ErrorContext(r.Context(), "api: count org repos", "error", err)
214 writeAPIError(w, http.StatusInternalServerError, "list failed")
215 return
216 }
217 rows, err := q.ListReposForOwnerOrgPaged(r.Context(), h.d.Pool, reposdb.ListReposForOwnerOrgPagedParams{
218 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
219 Limit: int32(perPage),
220 Offset: int32((page - 1) * perPage),
221 })
222 if err != nil {
223 h.d.Logger.ErrorContext(r.Context(), "api: list org repos", "error", err)
224 writeAPIError(w, http.StatusInternalServerError, "list failed")
225 return
226 }
227 h.writeRepoListPage(w, r, page, perPage, int(total), rows, string(org.Slug))
228 return
229 }
230 total, err := q.CountPublicReposForOwnerOrg(r.Context(), h.d.Pool, pgtype.Int8{Int64: org.ID, Valid: true})
231 if err != nil {
232 h.d.Logger.ErrorContext(r.Context(), "api: count public org repos", "error", err)
233 writeAPIError(w, http.StatusInternalServerError, "list failed")
234 return
235 }
236 rows, err := q.ListPublicReposForOwnerOrg(r.Context(), h.d.Pool, reposdb.ListPublicReposForOwnerOrgParams{
237 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
238 Limit: int32(perPage),
239 Offset: int32((page - 1) * perPage),
240 })
241 if err != nil {
242 h.d.Logger.ErrorContext(r.Context(), "api: list public org repos", "error", err)
243 writeAPIError(w, http.StatusInternalServerError, "list failed")
244 return
245 }
246 h.writeRepoListPage(w, r, page, perPage, int(total), rows, string(org.Slug))
247 }
248
249 func (h *Handlers) writeRepoListPage(w http.ResponseWriter, r *http.Request, page, perPage, total int, rows []reposdb.Repo, ownerLogin string) {
250 link := apipage.Page{
251 Current: page, PerPage: perPage, Total: total,
252 }.LinkHeader(h.d.BaseURL, sanitizedURL(r))
253 if link != "" {
254 w.Header().Set("Link", link)
255 }
256 out := make([]repoResponse, 0, len(rows))
257 for _, row := range rows {
258 out = append(out, presentRepo(row, ownerLogin))
259 }
260 writeJSON(w, http.StatusOK, out)
261 }
262
263 // ─── single-repo GET ────────────────────────────────────────────────
264
265 func (h *Handlers) repoGet(w http.ResponseWriter, r *http.Request) {
266 repo, ownerLogin, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoRead)
267 if !ok {
268 return
269 }
270 writeJSON(w, http.StatusOK, presentRepo(repo, ownerLogin))
271 }
272
273 // ─── create endpoints ───────────────────────────────────────────────
274
275 type repoCreateRequest struct {
276 Name string `json:"name"`
277 Description string `json:"description"`
278 Visibility string `json:"visibility"`
279 Private *bool `json:"private,omitempty"`
280 AutoInit bool `json:"auto_init"`
281 License string `json:"license_template"`
282 Gitignore string `json:"gitignore_template"`
283 }
284
285 // resolvedVisibility picks "public" or "private" from a request, honoring
286 // either `visibility` (preferred, matches our internal vocab) or the
287 // gh-compatible `private` boolean. Defaults to "private" — safer than
288 // public.
289 func (req repoCreateRequest) resolvedVisibility() (string, error) {
290 if req.Visibility != "" {
291 switch strings.ToLower(req.Visibility) {
292 case "public", "private":
293 return strings.ToLower(req.Visibility), nil
294 default:
295 return "", errors.New("visibility must be public or private")
296 }
297 }
298 if req.Private != nil {
299 if *req.Private {
300 return "private", nil
301 }
302 return "public", nil
303 }
304 return "private", nil
305 }
306
307 func (h *Handlers) userRepoCreate(w http.ResponseWriter, r *http.Request) {
308 auth := middleware.PATAuthFromContext(r.Context())
309 if auth.UserID == 0 {
310 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
311 return
312 }
313 var body repoCreateRequest
314 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
315 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
316 return
317 }
318 visibility, err := body.resolvedVisibility()
319 if err != nil {
320 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
321 return
322 }
323 params := repos.Params{
324 ActorUserID: auth.UserID,
325 ActorIsSiteAdmin: auth.IsSiteAdmin,
326 OwnerUserID: auth.UserID,
327 OwnerUsername: auth.Username,
328 Name: repos.NormalizeName(body.Name),
329 Description: body.Description,
330 Visibility: visibility,
331 InitReadme: body.AutoInit,
332 LicenseKey: body.License,
333 GitignoreKey: body.Gitignore,
334 }
335 h.runRepoCreate(w, r, params, auth.Username)
336 }
337
338 func (h *Handlers) orgRepoCreate(w http.ResponseWriter, r *http.Request) {
339 auth := middleware.PATAuthFromContext(r.Context())
340 if auth.UserID == 0 {
341 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
342 return
343 }
344 org, ok := h.resolveAPIOrgOwner(w, r, chi.URLParam(r, "org"))
345 if !ok {
346 return
347 }
348 odeps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
349 isMember, err := orgs.IsMember(r.Context(), odeps, org.ID, auth.UserID)
350 if err != nil {
351 h.d.Logger.ErrorContext(r.Context(), "api: org member check", "error", err)
352 writeAPIError(w, http.StatusInternalServerError, "create failed")
353 return
354 }
355 if !isMember && !auth.IsSiteAdmin {
356 // Existence-leak parity with the rest of the surface.
357 writeAPIError(w, http.StatusNotFound, "org not found")
358 return
359 }
360 isOwner, err := orgs.IsOwner(r.Context(), odeps, org.ID, auth.UserID)
361 if err != nil {
362 h.d.Logger.ErrorContext(r.Context(), "api: org owner check", "error", err)
363 writeAPIError(w, http.StatusInternalServerError, "create failed")
364 return
365 }
366 if !isOwner && !org.AllowMemberRepoCreate && !auth.IsSiteAdmin {
367 writeAPIError(w, http.StatusForbidden, "organization restricts repo creation to owners")
368 return
369 }
370 var body repoCreateRequest
371 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
372 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
373 return
374 }
375 visibility, err := body.resolvedVisibility()
376 if err != nil {
377 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
378 return
379 }
380 params := repos.Params{
381 ActorUserID: auth.UserID,
382 ActorIsSiteAdmin: auth.IsSiteAdmin,
383 OwnerOrgID: org.ID,
384 OwnerSlug: string(org.Slug),
385 Name: repos.NormalizeName(body.Name),
386 Description: body.Description,
387 Visibility: visibility,
388 InitReadme: body.AutoInit,
389 LicenseKey: body.License,
390 GitignoreKey: body.Gitignore,
391 }
392 h.runRepoCreate(w, r, params, string(org.Slug))
393 }
394
395 func (h *Handlers) runRepoCreate(w http.ResponseWriter, r *http.Request, params repos.Params, ownerLogin string) {
396 if h.d.Audit == nil || h.d.Throttle == nil || h.d.RepoFS == nil {
397 writeAPIError(w, http.StatusServiceUnavailable, "repo create is not configured")
398 return
399 }
400 res, err := repos.Create(r.Context(), repos.Deps{
401 Pool: h.d.Pool,
402 RepoFS: h.d.RepoFS,
403 Audit: h.d.Audit,
404 Limiter: h.d.Throttle,
405 Logger: h.d.Logger,
406 ShithubdPath: h.d.ShithubdPath,
407 }, params)
408 if err != nil {
409 writeRepoCreateError(w, err)
410 return
411 }
412 writeJSON(w, http.StatusCreated, presentRepo(res.Repo, ownerLogin))
413 }
414
415 func writeRepoCreateError(w http.ResponseWriter, err error) {
416 switch {
417 case errors.Is(err, repos.ErrInvalidName),
418 errors.Is(err, repos.ErrReservedName),
419 errors.Is(err, repos.ErrDescriptionTooLong),
420 errors.Is(err, repos.ErrUnknownLicense),
421 errors.Is(err, repos.ErrUnknownGitignore):
422 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
423 case errors.Is(err, repos.ErrTaken):
424 writeAPIError(w, http.StatusConflict, "name taken for owner")
425 case errors.Is(err, repos.ErrNoVerifiedEmail):
426 writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email")
427 case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
428 writeAPIError(w, http.StatusPaymentRequired, err.Error())
429 default:
430 writeAPIError(w, http.StatusInternalServerError, "create failed")
431 }
432 }
433
434 // ─── update / delete ────────────────────────────────────────────────
435
436 type repoPatchRequest struct {
437 Description *string `json:"description,omitempty"`
438 HasIssues *bool `json:"has_issues,omitempty"`
439 HasPulls *bool `json:"has_pulls,omitempty"`
440 Archived *bool `json:"archived,omitempty"`
441 Visibility *string `json:"visibility,omitempty"`
442 }
443
444 func (h *Handlers) repoPatch(w http.ResponseWriter, r *http.Request) {
445 repo, ownerLogin, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoSettingsGeneral)
446 if !ok {
447 return
448 }
449 auth := middleware.PATAuthFromContext(r.Context())
450 var body repoPatchRequest
451 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
452 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
453 return
454 }
455 // General settings (description, has_issues, has_pulls) go through
456 // the single UpdateRepoGeneralSettings query so the form-driven HTML
457 // surface and this REST path observe the same row updates.
458 if body.Description != nil || body.HasIssues != nil || body.HasPulls != nil {
459 desc := repo.Description
460 if body.Description != nil {
461 if err := repos.ValidateDescription(*body.Description); err != nil {
462 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
463 return
464 }
465 desc = *body.Description
466 }
467 hasIssues := repo.HasIssues
468 if body.HasIssues != nil {
469 hasIssues = *body.HasIssues
470 }
471 hasPulls := repo.HasPulls
472 if body.HasPulls != nil {
473 hasPulls = *body.HasPulls
474 }
475 if err := reposdb.New().UpdateRepoGeneralSettings(r.Context(), h.d.Pool, reposdb.UpdateRepoGeneralSettingsParams{
476 ID: repo.ID,
477 Description: desc,
478 HasIssues: hasIssues,
479 HasPulls: hasPulls,
480 }); err != nil {
481 h.d.Logger.ErrorContext(r.Context(), "api: repo patch general", "error", err)
482 writeAPIError(w, http.StatusInternalServerError, "update failed")
483 return
484 }
485 }
486 if body.Archived != nil {
487 ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
488 wantArchived := *body.Archived
489 currentlyArchived := repo.IsArchived
490 switch {
491 case wantArchived && !currentlyArchived:
492 if err := lifecycle.Archive(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
493 h.d.Logger.ErrorContext(r.Context(), "api: archive", "error", err)
494 writeAPIError(w, http.StatusInternalServerError, "archive failed")
495 return
496 }
497 case !wantArchived && currentlyArchived:
498 if err := lifecycle.Unarchive(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
499 h.d.Logger.ErrorContext(r.Context(), "api: unarchive", "error", err)
500 writeAPIError(w, http.StatusInternalServerError, "unarchive failed")
501 return
502 }
503 }
504 }
505 if body.Visibility != nil {
506 newVis := strings.ToLower(*body.Visibility)
507 if newVis != "public" && newVis != "private" {
508 writeAPIError(w, http.StatusUnprocessableEntity, "visibility must be public or private")
509 return
510 }
511 if newVis != string(repo.Visibility) {
512 ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
513 if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil {
514 h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err)
515 if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
516 writeAPIError(w, http.StatusPaymentRequired, err.Error())
517 return
518 }
519 writeAPIError(w, http.StatusInternalServerError, "visibility update failed")
520 return
521 }
522 }
523 }
524 // Re-load the freshest copy so the response reflects all four
525 // possible updates in a single payload.
526 fresh, err := reposdb.New().GetRepoByID(r.Context(), h.d.Pool, repo.ID)
527 if err != nil {
528 h.d.Logger.ErrorContext(r.Context(), "api: refetch after patch", "error", err)
529 writeAPIError(w, http.StatusInternalServerError, "reload failed")
530 return
531 }
532 writeJSON(w, http.StatusOK, presentRepo(fresh, ownerLogin))
533 }
534
535 func (h *Handlers) repoDelete(w http.ResponseWriter, r *http.Request) {
536 repo, _, ok := h.resolveAPIRepoWithLogin(w, r, policy.ActionRepoDelete)
537 if !ok {
538 return
539 }
540 auth := middleware.PATAuthFromContext(r.Context())
541 ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
542 if err := lifecycle.SoftDelete(r.Context(), ldeps, auth.UserID, repo.ID); err != nil {
543 if errors.Is(err, lifecycle.ErrAlreadyDeleted) {
544 writeAPIError(w, http.StatusNotFound, "repo not found")
545 return
546 }
547 h.d.Logger.ErrorContext(r.Context(), "api: soft delete", "error", err)
548 writeAPIError(w, http.StatusInternalServerError, "delete failed")
549 return
550 }
551 w.WriteHeader(http.StatusNoContent)
552 }
553
554 // ─── resolvers ──────────────────────────────────────────────────────
555
556 func (h *Handlers) resolveAPIUserOwner(w http.ResponseWriter, r *http.Request, username string) (usersdb.User, bool) {
557 user, err := usersdb.New().GetUserByUsername(r.Context(), h.d.Pool, username)
558 if err != nil {
559 if errors.Is(err, pgx.ErrNoRows) {
560 writeAPIError(w, http.StatusNotFound, "user not found")
561 return usersdb.User{}, false
562 }
563 h.d.Logger.ErrorContext(r.Context(), "api: lookup user", "error", err)
564 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
565 return usersdb.User{}, false
566 }
567 return user, true
568 }
569
570 func (h *Handlers) resolveAPIOrgOwner(w http.ResponseWriter, r *http.Request, slug string) (orgsdb.Org, bool) {
571 org, err := orgsdb.New().GetOrgBySlug(r.Context(), h.d.Pool, slug)
572 if err != nil {
573 if errors.Is(err, pgx.ErrNoRows) {
574 writeAPIError(w, http.StatusNotFound, "org not found")
575 return orgsdb.Org{}, false
576 }
577 h.d.Logger.ErrorContext(r.Context(), "api: lookup org", "error", err)
578 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
579 return orgsdb.Org{}, false
580 }
581 if org.DeletedAt.Valid {
582 writeAPIError(w, http.StatusNotFound, "org not found")
583 return orgsdb.Org{}, false
584 }
585 return org, true
586 }
587
588 // resolveAPIRepoWithLogin loads {owner}/{repo}, runs the policy gate
589 // (404-on-deny), and additionally returns the owner's login string for
590 // rendering `full_name`. The login lookup is one extra DB round-trip per
591 // request — fine for a non-hot path. We compose on top of the existing
592 // resolveAPIRepo so the existence-leak treatment stays identical.
593 func (h *Handlers) resolveAPIRepoWithLogin(w http.ResponseWriter, r *http.Request, action policy.Action) (reposdb.Repo, string, bool) {
594 auth := middleware.PATAuthFromContext(r.Context())
595 if auth.UserID == 0 && actionRequiresAuth(action) {
596 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
597 return reposdb.Repo{}, "", false
598 }
599 ownerLogin := chi.URLParam(r, "owner")
600 repoName := chi.URLParam(r, "repo")
601 repo, login, err := lookupRepoByLogin(r, h.d.Pool, ownerLogin, repoName)
602 if err != nil {
603 writeAPIError(w, http.StatusNotFound, "repo not found")
604 return reposdb.Repo{}, "", false
605 }
606 if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), action, policy.NewRepoRefFromRepo(repo)).Allow {
607 writeAPIError(w, http.StatusNotFound, "repo not found")
608 return reposdb.Repo{}, "", false
609 }
610 return repo, login, true
611 }
612
613 // actionRequiresAuth returns true for actions that always require a
614 // logged-in caller. Read-shaped actions pass through anonymously so
615 // the visibility gate inside policy.Can does the talking.
616 func actionRequiresAuth(a policy.Action) bool {
617 switch a {
618 case policy.ActionRepoRead, policy.ActionIssueRead, policy.ActionPullRead:
619 return false
620 default:
621 return true
622 }
623 }
624
625 // lookupRepoByLogin tries the user-owner path first, then the org-owner
626 // path. The login string returned is whichever resolved successfully so
627 // the caller can plug it into the full_name field.
628 func lookupRepoByLogin(r *http.Request, pool reposdbPool, ownerLogin, repoName string) (reposdb.Repo, string, error) {
629 rq := reposdb.New()
630 if user, err := usersdb.New().GetUserByUsername(r.Context(), pool, ownerLogin); err == nil {
631 repo, err := rq.GetRepoByOwnerUserAndName(r.Context(), pool, reposdb.GetRepoByOwnerUserAndNameParams{
632 OwnerUserID: pgtype.Int8{Int64: user.ID, Valid: true},
633 Name: repoName,
634 })
635 if err == nil {
636 return repo, user.Username, nil
637 }
638 if !errors.Is(err, pgx.ErrNoRows) {
639 return reposdb.Repo{}, "", err
640 }
641 }
642 if org, err := orgsdb.New().GetOrgBySlug(r.Context(), pool, ownerLogin); err == nil {
643 repo, err := rq.GetRepoByOwnerOrgAndName(r.Context(), pool, reposdb.GetRepoByOwnerOrgAndNameParams{
644 OwnerOrgID: pgtype.Int8{Int64: org.ID, Valid: true},
645 Name: repoName,
646 })
647 if err == nil {
648 return repo, string(org.Slug), nil
649 }
650 }
651 return reposdb.Repo{}, "", pgx.ErrNoRows
652 }
653
654 // reposdbPool aliases the pgx DBTX interface that all sqlc-generated
655 // methods accept; declaring it here keeps this file from importing
656 // pgxpool directly for what is effectively a typed parameter.
657 type reposdbPool = reposdb.DBTX
658