| 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 |