Go · 20278 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package repo wires the HTTP handlers for repository creation and the
4 // (placeholder) repo home page. The actual orchestration lives in
5 // internal/repos; this package is the thin web layer that calls into it.
6 package repo
7
8 import (
9 "context"
10 "errors"
11 "log/slog"
12 "net/http"
13 "strconv"
14 "strings"
15
16 "github.com/go-chi/chi/v5"
17 "github.com/jackc/pgx/v5"
18 "github.com/jackc/pgx/v5/pgtype"
19 "github.com/jackc/pgx/v5/pgxpool"
20 "golang.org/x/sync/singleflight"
21
22 "github.com/tenseleyFlow/shithub/internal/auth/audit"
23 "github.com/tenseleyFlow/shithub/internal/auth/policy"
24 "github.com/tenseleyFlow/shithub/internal/auth/secretbox"
25 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
26 checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
27 "github.com/tenseleyFlow/shithub/internal/infra/storage"
28 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
29 "github.com/tenseleyFlow/shithub/internal/orgs"
30 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
31 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
32 "github.com/tenseleyFlow/shithub/internal/repos"
33 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
34 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
35 "github.com/tenseleyFlow/shithub/internal/repos/templates"
36 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
37 "github.com/tenseleyFlow/shithub/internal/web/middleware"
38 "github.com/tenseleyFlow/shithub/internal/web/render"
39 )
40
41 // CloneURLs configures the operator-visible HTTPS / SSH endpoints we
42 // surface on the empty-repo placeholder. SSHEnabled flips whether the
43 // "Clone over SSH" snippet renders at all (S12 wires the protocol).
44 type CloneURLs struct {
45 BaseURL string // e.g. "https://shithub.example"
46 SSHEnabled bool
47 SSHHost string // e.g. "git@shithub.example" — only used when SSHEnabled
48 }
49
50 // cloneHTTPS / cloneSSH compose the per-repo URL strings that every
51 // "Code" view drops into the clone dropdown. Centralized so the
52 // per-template plumbing in code views and the repo home stay
53 // consistent (and switching to a `git://` later is a one-line edit).
54 func (h *Handlers) cloneHTTPS(owner, name string) string {
55 return h.d.CloneURLs.BaseURL + "/" + owner + "/" + name + ".git"
56 }
57
58 func (h *Handlers) cloneSSH(owner, name string) string {
59 return h.d.CloneURLs.SSHHost + ":" + owner + "/" + name + ".git"
60 }
61
62 // Deps wires the handler set.
63 type Deps struct {
64 Logger *slog.Logger
65 Render *render.Renderer
66 Pool *pgxpool.Pool
67 RepoFS *storage.RepoFS
68 // ObjectStore serves archived Actions logs and other repo-scoped blobs.
69 // nil keeps pages renderable in dev/test but archived logs show an
70 // unavailable message instead of exposing storage details.
71 ObjectStore storage.ObjectStore
72 Audit *audit.Recorder
73 Limiter *throttle.Limiter
74 CloneURLs CloneURLs
75 // SecretBox AEAD-wraps webhook secrets at rest (S33). nil disables
76 // the webhook surface (the handler renders a placeholder page).
77 SecretBox *secretbox.Box
78 // ShithubdPath is forwarded to repos.Create so newly-init'd repos
79 // have hook shims pointing at the right binary. Empty in test fixtures
80 // that don't exercise hooks.
81 ShithubdPath string
82 }
83
84 // Handlers is the registered handler set. Construct via New.
85 type Handlers struct {
86 d Deps
87 rq *reposdb.Queries
88 uq *usersdb.Queries
89 iq *issuesdb.Queries
90 pq *pullsdb.Queries
91 cq *checksdb.Queries
92 submoduleBackfills singleflight.Group
93 }
94
95 // New constructs the handler set, validating Deps.
96 func New(d Deps) (*Handlers, error) {
97 if d.Render == nil {
98 return nil, errors.New("repo: nil Render")
99 }
100 if d.Pool == nil {
101 return nil, errors.New("repo: nil Pool")
102 }
103 if d.RepoFS == nil {
104 return nil, errors.New("repo: nil RepoFS")
105 }
106 if d.Audit == nil {
107 d.Audit = audit.NewRecorder()
108 }
109 if d.Limiter == nil {
110 d.Limiter = throttle.NewLimiter()
111 }
112 return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New(), pq: pullsdb.New(), cq: checksdb.New()}, nil
113 }
114
115 // MountNew registers /new (auth-required). Caller wraps with
116 // middleware.RequireUser before invoking.
117 func (h *Handlers) MountNew(r chi.Router) {
118 r.Get("/new", h.newRepoForm)
119 r.Post("/new", h.newRepoSubmit)
120 }
121
122 // MountRepoActionsAPI registers POST/state-changing routes under
123 // /{owner}/{repo}/actions/. Caller wraps with RequireUser. Currently
124 // just the workflow_dispatch endpoint (S41b); S41f will add re-run +
125 // cancel.
126 func (h *Handlers) MountRepoActionsAPI(r chi.Router) {
127 r.Post("/{owner}/{repo}/actions/workflows/{file}/dispatches", h.repoActionsDispatch)
128 }
129
130 // MountRepoHome registers the root repository route plus product-tab shells
131 // that are intentionally public and read-gated like the Code tab. The
132 // two-segment route doesn't collide with the /{username} catch-all from S09;
133 // caller is responsible for ordering this BEFORE /{username}.
134 func (h *Handlers) MountRepoHome(r chi.Router) {
135 r.Get("/{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/steps/{stepIndex}", h.repoActionStepLog)
136 r.Get("/{owner}/{repo}/actions/runs/{runIndex}/status", h.repoActionRunStatus)
137 r.Get("/{owner}/{repo}/actions/runs/{runIndex}", h.repoActionRun)
138 r.Get("/{owner}/{repo}/actions", h.repoTabActions)
139 r.Get("/{owner}/{repo}/projects", h.repoTabProjects)
140 r.Get("/{owner}/{repo}/wiki", h.repoTabWiki)
141 r.Get("/{owner}/{repo}/security", h.repoTabSecurity)
142 r.Get("/{owner}/{repo}/pulse", h.repoTabInsights)
143 r.Get("/{owner}/{repo}/packages", h.repoTabPackages)
144 r.Get("/{owner}/{repo}/releases", h.repoTabReleases)
145 r.Get("/{owner}/{repo}", h.repoHome)
146 }
147
148 // newRepoForm renders GET /new.
149 func (h *Handlers) newRepoForm(w http.ResponseWriter, r *http.Request) {
150 h.renderNewForm(w, r, formState{
151 Owner: strings.TrimSpace(r.URL.Query().Get("owner")),
152 Visibility: "public",
153 }, "")
154 }
155
156 // formState mirrors the new-repo form so a re-render after a validation
157 // error can repopulate the user's input.
158 type formState struct {
159 Owner string // "user:<id>" or "org:<id>"
160 Name string
161 Description string
162 Visibility string
163 SourceRemote string
164 InitReadme bool
165 License string
166 Gitignore string
167 }
168
169 // ownerOption is one entry the new-repo owner picker shows.
170 type ownerOption struct {
171 Kind string // "user" | "org"
172 ID int64
173 Slug string
174 Display string
175 // Token is the form value: "user:<id>" or "org:<id>".
176 Token string
177 }
178
179 // newRepoSubmit handles POST /new.
180 func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) {
181 if err := r.ParseForm(); err != nil {
182 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
183 return
184 }
185 user := middleware.CurrentUserFromContext(r.Context())
186 form := formState{
187 Owner: strings.TrimSpace(r.PostFormValue("owner")),
188 Name: repos.NormalizeName(r.PostFormValue("name")),
189 Description: strings.TrimSpace(r.PostFormValue("description")),
190 Visibility: strings.TrimSpace(r.PostFormValue("visibility")),
191 SourceRemote: strings.TrimSpace(r.PostFormValue("source_remote_url")),
192 InitReadme: r.PostFormValue("init_readme") == "on",
193 License: strings.TrimSpace(r.PostFormValue("license")),
194 Gitignore: strings.TrimSpace(r.PostFormValue("gitignore")),
195 }
196 if form.Visibility == "" {
197 form.Visibility = "public"
198 }
199 var sourceRemoteURL string
200 if form.SourceRemote != "" {
201 if form.InitReadme || form.License != "" || form.Gitignore != "" {
202 h.renderNewForm(w, r, form, "Source imports can't be combined with initial README, license, or .gitignore files.")
203 return
204 }
205 var sourceErr error
206 sourceRemoteURL, sourceErr = repos.ValidateSourceRemoteURL(r.Context(), form.SourceRemote)
207 if sourceErr != nil {
208 h.renderNewForm(w, r, form, "Source remote URL must be a public http(s) git remote without credentials.")
209 return
210 }
211 form.SourceRemote = sourceRemoteURL
212 }
213
214 params := repos.Params{
215 ActorUserID: user.ID,
216 ActorIsSiteAdmin: user.IsSiteAdmin,
217 Name: form.Name,
218 Description: form.Description,
219 Visibility: form.Visibility,
220 InitReadme: form.InitReadme,
221 LicenseKey: form.License,
222 GitignoreKey: form.Gitignore,
223 }
224 // Owner picker: "org:N" routes through the org-owner branch with
225 // the per-org allow_member_repo_create gate; anything else
226 // defaults to the viewer's personal namespace.
227 if kind, id, ok := parseOwnerToken(form.Owner); ok && kind == "org" {
228 odeps := orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
229 org, oerr := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, id)
230 if oerr != nil || org.DeletedAt.Valid {
231 h.renderNewForm(w, r, form, "Selected organization not found.")
232 return
233 }
234 isMem, _ := orgs.IsMember(r.Context(), odeps, org.ID, user.ID)
235 if !isMem {
236 h.renderNewForm(w, r, form, "You're not a member of that organization.")
237 return
238 }
239 isOwner, _ := orgs.IsOwner(r.Context(), odeps, org.ID, user.ID)
240 if !isOwner && !org.AllowMemberRepoCreate {
241 h.renderNewForm(w, r, form, "This organization restricts repo creation to owners.")
242 return
243 }
244 params.OwnerOrgID = org.ID
245 params.OwnerSlug = string(org.Slug)
246 } else {
247 params.OwnerUserID = user.ID
248 params.OwnerUsername = user.Username
249 }
250
251 res, err := repos.Create(r.Context(), repos.Deps{
252 Pool: h.d.Pool,
253 RepoFS: h.d.RepoFS,
254 Audit: h.d.Audit,
255 Limiter: h.d.Limiter,
256 Logger: h.d.Logger,
257 ShithubdPath: h.d.ShithubdPath,
258 }, params)
259 if err != nil {
260 // Surface the real error in the journal — friendlyCreateError
261 // collapses every non-typed cause into the bland "Could not
262 // create the repository" string the user sees, so without
263 // this log line the operator has no signal to triage a failed
264 // create.
265 h.d.Logger.WarnContext(
266 r.Context(), "repos: create failed",
267 "error", err,
268 "actor_user_id", params.ActorUserID,
269 "owner_user_id", params.OwnerUserID,
270 "owner_org_id", params.OwnerOrgID,
271 "name", params.Name,
272 "visibility", params.Visibility,
273 )
274 h.renderNewForm(w, r, form, friendlyCreateError(err))
275 return
276 }
277 ownerSlug := params.OwnerUsername
278 if ownerSlug == "" {
279 ownerSlug = params.OwnerSlug
280 }
281 if sourceRemoteURL != "" {
282 if _, err := h.rq.UpsertRepoSourceRemote(r.Context(), h.d.Pool, reposdb.UpsertRepoSourceRemoteParams{
283 RepoID: res.Repo.ID,
284 RemoteUrl: sourceRemoteURL,
285 }); err != nil {
286 h.d.Logger.WarnContext(r.Context(), "repos: source remote save after create", "error", err, "repo_id", res.Repo.ID)
287 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-save-failed", http.StatusSeeOther)
288 return
289 }
290 if err := h.fetchRepoSourceRemote(r.Context(), res.Repo, ownerSlug, sourceRemoteURL); err != nil {
291 h.d.Logger.WarnContext(r.Context(), "repos: source remote fetch after create", "error", err, "repo_id", res.Repo.ID, "remote", sourceRemoteURL)
292 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name+"/settings/general?notice=source-remote-fetch-failed", http.StatusSeeOther)
293 return
294 }
295 }
296 http.Redirect(w, r, "/"+ownerSlug+"/"+res.Repo.Name, http.StatusSeeOther)
297 }
298
299 // parseOwnerToken splits a value like "org:42" into ("org", 42, true).
300 // Returns ok=false on missing or unparseable input.
301 func parseOwnerToken(s string) (kind string, id int64, ok bool) {
302 if s == "" {
303 return "", 0, false
304 }
305 colon := strings.IndexByte(s, ':')
306 if colon <= 0 || colon == len(s)-1 {
307 return "", 0, false
308 }
309 kind = s[:colon]
310 if kind != "user" && kind != "org" {
311 return "", 0, false
312 }
313 n, err := strconv.ParseInt(s[colon+1:], 10, 64)
314 if err != nil || n <= 0 {
315 return "", 0, false
316 }
317 return kind, n, true
318 }
319
320 func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form formState, errMsg string) {
321 w.Header().Set("Content-Type", "text/html; charset=utf-8")
322 owners := h.ownerOptions(r)
323 // Default the form's owner pick to the viewer when the field is
324 // empty or invalid. GET /new?owner=<slug> can preselect an org,
325 // but only after matching that slug against the viewer's allowed
326 // owner choices.
327 form.Owner = selectedOwnerToken(form.Owner, owners)
328 if err := h.d.Render.RenderPage(w, r, "repo/new", map[string]any{
329 "Title": "New repository",
330 "CSRFToken": middleware.CSRFTokenForRequest(r),
331 "Form": form,
332 "Error": errMsg,
333 "Licenses": templates.Licenses(),
334 "Gitignores": templates.Gitignores(),
335 "Owners": owners,
336 }); err != nil {
337 h.d.Logger.ErrorContext(r.Context(), "repo: render new", "error", err)
338 }
339 }
340
341 func selectedOwnerToken(raw string, owners []ownerOption) string {
342 if len(owners) == 0 {
343 return ""
344 }
345 raw = strings.TrimSpace(raw)
346 if raw != "" {
347 for _, owner := range owners {
348 if raw == owner.Token {
349 return owner.Token
350 }
351 }
352 for _, owner := range owners {
353 if strings.EqualFold(raw, owner.Slug) {
354 return owner.Token
355 }
356 }
357 }
358 return owners[0].Token
359 }
360
361 // ownerOptions returns the entries the form's owner picker shows:
362 // the viewer themselves plus every org they're a member of where
363 // they're allowed to create (owner role OR allow_member_repo_create).
364 func (h *Handlers) ownerOptions(r *http.Request) []ownerOption {
365 user := middleware.CurrentUserFromContext(r.Context())
366 if user.IsAnonymous() {
367 return nil
368 }
369 out := []ownerOption{{
370 Kind: "user", ID: user.ID, Slug: user.Username,
371 Display: user.Username,
372 Token: "user:" + strconv.FormatInt(user.ID, 10),
373 }}
374 memberships, err := orgsdb.New().ListOrgsForUser(r.Context(), h.d.Pool, user.ID)
375 if err != nil {
376 return out
377 }
378 for _, m := range memberships {
379 isOwner := m.Role == orgsdb.OrgRoleOwner
380 canCreate := isOwner
381 if !isOwner {
382 full, ferr := orgsdb.New().GetOrgByID(r.Context(), h.d.Pool, m.OrgID)
383 if ferr == nil {
384 canCreate = full.AllowMemberRepoCreate
385 }
386 }
387 if !canCreate {
388 continue
389 }
390 display := m.DisplayName
391 if display == "" {
392 display = string(m.Slug)
393 }
394 out = append(out, ownerOption{
395 Kind: "org",
396 ID: m.OrgID,
397 Slug: string(m.Slug),
398 Display: display,
399 Token: "org:" + strconv.FormatInt(m.OrgID, 10),
400 })
401 }
402 return out
403 }
404
405 // repoHome serves GET /{owner}/{repo}. Empty repos render the quick setup
406 // placeholder; populated repos render the Code tab at the repository home URL.
407 func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
408 owner := chi.URLParam(r, "owner")
409 name := chi.URLParam(r, "repo")
410
411 row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context()))
412 if err != nil {
413 // Maybe the (owner, name) is a stale name; look up the redirect
414 // table and 301 to the canonical URL so old bookmarks keep
415 // working. Authoritative miss after the redirect check 404s.
416 if newURL := h.tryRedirect(r, owner, name); newURL != "" {
417 http.Redirect(w, r, newURL, http.StatusMovedPermanently)
418 return
419 }
420 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
421 return
422 }
423
424 // Keep the populated repository home at /owner/name, matching GitHub's
425 // Code tab instead of forcing users through the /tree/default-branch URL.
426 diskPath, fsErr := h.d.RepoFS.RepoPath(owner, row.Name)
427 hasBranch := false
428 if fsErr == nil {
429 if ok, herr := repogit.HasAnyBranch(r.Context(), diskPath); herr == nil {
430 hasBranch = ok
431 } else {
432 h.d.Logger.WarnContext(r.Context(), "repo: HasAnyBranch", "error", herr)
433 }
434 }
435 if hasBranch {
436 refs, refsErr := repogit.ListRefs(r.Context(), diskPath)
437 if refsErr != nil {
438 h.d.Logger.WarnContext(r.Context(), "repo: ListRefs", "error", refsErr)
439 }
440 h.renderRepoTree(w, r, &codeContext{
441 owner: owner,
442 row: row,
443 gitDir: diskPath,
444 refs: refs,
445 allRefs: refNames(refs),
446 ref: row.DefaultBranch,
447 subpath: "",
448 })
449 return
450 }
451
452 common := map[string]any{
453 "Title": row.Name + " · " + owner,
454 "CSRFToken": middleware.CSRFTokenForRequest(r),
455 "Owner": owner,
456 "Repo": row,
457 "DefaultBranch": row.DefaultBranch,
458 "HTTPSCloneURL": h.cloneHTTPS(owner, row.Name),
459 "SSHEnabled": h.d.CloneURLs.SSHEnabled,
460 "SSHCloneURL": h.cloneSSH(owner, row.Name),
461 "RepoActions": h.repoActions(r, row.ID),
462 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
463 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
464 "ActiveSubnav": "code",
465 }
466
467 w.Header().Set("Content-Type", "text/html; charset=utf-8")
468 // Empty path only — populated repos already redirected above.
469 if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil {
470 h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
471 }
472 }
473
474 // lookupRepoForViewer returns the repo row when:
475 // - it exists,
476 // - it is not soft-deleted,
477 // - AND the viewer is allowed to see it (public OR viewer is owner).
478 //
479 // Anything else returns ErrNoRows so the caller can 404 uniformly.
480 //
481 // Owner kind is dispatched via principals: ownerName resolves to
482 // either a user_id or an org_id via the same single-source-of-truth
483 // table that drives /{slug} routing. Both kinds resolve through the
484 // same indexed lookup so the cost is identical.
485 func (h *Handlers) lookupRepoForViewer(ctx context.Context, ownerName, repoName string, viewer middleware.CurrentUser) (reposdb.Repo, error) {
486 principal, err := orgs.Resolve(ctx, h.d.Pool, ownerName)
487 if err != nil {
488 return reposdb.Repo{}, pgx.ErrNoRows
489 }
490 var row reposdb.Repo
491 switch principal.Kind {
492 case orgs.PrincipalUser:
493 row, err = h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
494 OwnerUserID: pgtype.Int8{Int64: principal.ID, Valid: true},
495 Name: repoName,
496 })
497 case orgs.PrincipalOrg:
498 row, err = h.rq.GetRepoByOwnerOrgAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerOrgAndNameParams{
499 OwnerOrgID: pgtype.Int8{Int64: principal.ID, Valid: true},
500 Name: repoName,
501 })
502 default:
503 return reposdb.Repo{}, pgx.ErrNoRows
504 }
505 if err != nil {
506 return reposdb.Repo{}, err
507 }
508 // Visibility decision delegated to policy.Can. We use ActionRepoRead;
509 // when the decision denies, return ErrNoRows so the caller can 404.
510 repoRef := policy.NewRepoRefFromRepo(row)
511 var actor policy.Actor
512 if viewer.IsAnonymous() {
513 actor = policy.AnonymousActor()
514 } else {
515 actor = viewer.PolicyActor()
516 }
517 // ActionRepoRead deny on a private repo with a non-collab viewer is
518 // indistinguishable from "doesn't exist" — Maybe404 returns 404 in
519 // that shape, so the caller's pgx.ErrNoRows fallthrough is the
520 // right shape regardless of whether the row was missing or just
521 // invisible. Honest 403s (e.g. ActionRepoWrite on archived) are
522 // gated through `loadRepoAndAuthorize`, not this read-only helper.
523 if !policy.Can(ctx, policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoRead, repoRef).Allow {
524 return reposdb.Repo{}, pgx.ErrNoRows
525 }
526 return row, nil
527 }
528
529 // friendlyCreateError maps the typed errors from internal/repos to the
530 // strings the form surfaces to the user.
531 func friendlyCreateError(err error) string {
532 switch {
533 case errors.Is(err, repos.ErrInvalidName):
534 return "Name must be 1–100 chars: lowercase letters, digits, dots, hyphens, underscores."
535 case errors.Is(err, repos.ErrReservedName):
536 return "That name is reserved."
537 case errors.Is(err, repos.ErrTaken):
538 return "You already own a repository with that name."
539 case errors.Is(err, repos.ErrNoVerifiedEmail):
540 return "Verify a primary email before creating a repository — we use it for the initial commit author."
541 case errors.Is(err, repos.ErrDescriptionTooLong):
542 return "Description is too long (max 350 characters)."
543 case errors.Is(err, repos.ErrUnknownLicense):
544 return "Unknown license selection."
545 case errors.Is(err, repos.ErrUnknownGitignore):
546 return "Unknown .gitignore selection."
547 }
548 if t, ok := isThrottled(err); ok {
549 return "You're creating repositories too quickly. Try again in " + t + "."
550 }
551 return "Could not create the repository. Try again."
552 }
553
554 // isThrottled extracts the user-friendly retry-after string from the
555 // throttle package's typed error, if that's what we caught.
556 func isThrottled(err error) (string, bool) {
557 var t *throttle.ErrThrottled
558 if errors.As(err, &t) {
559 return t.RetryAfter.String(), true
560 }
561 return "", false
562 }
563