| 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 | "strings" |
| 14 | |
| 15 | "github.com/go-chi/chi/v5" |
| 16 | "github.com/jackc/pgx/v5" |
| 17 | "github.com/jackc/pgx/v5/pgtype" |
| 18 | "github.com/jackc/pgx/v5/pgxpool" |
| 19 | |
| 20 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 21 | "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 22 | "github.com/tenseleyFlow/shithub/internal/auth/throttle" |
| 23 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 24 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 25 | issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 26 | pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" |
| 27 | "github.com/tenseleyFlow/shithub/internal/repos" |
| 28 | repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 29 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 30 | "github.com/tenseleyFlow/shithub/internal/repos/templates" |
| 31 | usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" |
| 32 | "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 33 | "github.com/tenseleyFlow/shithub/internal/web/render" |
| 34 | ) |
| 35 | |
| 36 | // CloneURLs configures the operator-visible HTTPS / SSH endpoints we |
| 37 | // surface on the empty-repo placeholder. SSHEnabled flips whether the |
| 38 | // "Clone over SSH" snippet renders at all (S12 wires the protocol). |
| 39 | type CloneURLs struct { |
| 40 | BaseURL string // e.g. "https://shithub.example" |
| 41 | SSHEnabled bool |
| 42 | SSHHost string // e.g. "git@shithub.example" — only used when SSHEnabled |
| 43 | } |
| 44 | |
| 45 | // cloneHTTPS / cloneSSH compose the per-repo URL strings that every |
| 46 | // "Code" view drops into the clone dropdown. Centralized so the |
| 47 | // per-template plumbing in code views and the repo home stay |
| 48 | // consistent (and switching to a `git://` later is a one-line edit). |
| 49 | func (h *Handlers) cloneHTTPS(owner, name string) string { |
| 50 | return h.d.CloneURLs.BaseURL + "/" + owner + "/" + name + ".git" |
| 51 | } |
| 52 | func (h *Handlers) cloneSSH(owner, name string) string { |
| 53 | return h.d.CloneURLs.SSHHost + ":" + owner + "/" + name + ".git" |
| 54 | } |
| 55 | |
| 56 | // Deps wires the handler set. |
| 57 | type Deps struct { |
| 58 | Logger *slog.Logger |
| 59 | Render *render.Renderer |
| 60 | Pool *pgxpool.Pool |
| 61 | RepoFS *storage.RepoFS |
| 62 | Audit *audit.Recorder |
| 63 | Limiter *throttle.Limiter |
| 64 | CloneURLs CloneURLs |
| 65 | // ShithubdPath is forwarded to repos.Create so newly-init'd repos |
| 66 | // have hook shims pointing at the right binary. Empty in test fixtures |
| 67 | // that don't exercise hooks. |
| 68 | ShithubdPath string |
| 69 | } |
| 70 | |
| 71 | // Handlers is the registered handler set. Construct via New. |
| 72 | type Handlers struct { |
| 73 | d Deps |
| 74 | rq *reposdb.Queries |
| 75 | uq *usersdb.Queries |
| 76 | iq *issuesdb.Queries |
| 77 | pq *pullsdb.Queries |
| 78 | cq *checksdb.Queries |
| 79 | } |
| 80 | |
| 81 | // New constructs the handler set, validating Deps. |
| 82 | func New(d Deps) (*Handlers, error) { |
| 83 | if d.Render == nil { |
| 84 | return nil, errors.New("repo: nil Render") |
| 85 | } |
| 86 | if d.Pool == nil { |
| 87 | return nil, errors.New("repo: nil Pool") |
| 88 | } |
| 89 | if d.RepoFS == nil { |
| 90 | return nil, errors.New("repo: nil RepoFS") |
| 91 | } |
| 92 | if d.Audit == nil { |
| 93 | d.Audit = audit.NewRecorder() |
| 94 | } |
| 95 | if d.Limiter == nil { |
| 96 | d.Limiter = throttle.NewLimiter() |
| 97 | } |
| 98 | return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New(), pq: pullsdb.New(), cq: checksdb.New()}, nil |
| 99 | } |
| 100 | |
| 101 | // MountNew registers /new (auth-required). Caller wraps with |
| 102 | // middleware.RequireUser before invoking. |
| 103 | func (h *Handlers) MountNew(r chi.Router) { |
| 104 | r.Get("/new", h.newRepoForm) |
| 105 | r.Post("/new", h.newRepoSubmit) |
| 106 | } |
| 107 | |
| 108 | // MountRepoHome registers /{owner}/{repo}. This is a 2-segment route so |
| 109 | // it doesn't collide with the /{username} catch-all from S09. Caller is |
| 110 | // responsible for ordering: register this BEFORE /{username}. |
| 111 | func (h *Handlers) MountRepoHome(r chi.Router) { |
| 112 | r.Get("/{owner}/{repo}", h.repoHome) |
| 113 | } |
| 114 | |
| 115 | // newRepoForm renders GET /new. |
| 116 | func (h *Handlers) newRepoForm(w http.ResponseWriter, r *http.Request) { |
| 117 | h.renderNewForm(w, r, formState{ |
| 118 | Visibility: "public", |
| 119 | }, "") |
| 120 | } |
| 121 | |
| 122 | // formState mirrors the new-repo form so a re-render after a validation |
| 123 | // error can repopulate the user's input. |
| 124 | type formState struct { |
| 125 | Name string |
| 126 | Description string |
| 127 | Visibility string |
| 128 | InitReadme bool |
| 129 | License string |
| 130 | Gitignore string |
| 131 | } |
| 132 | |
| 133 | // newRepoSubmit handles POST /new. |
| 134 | func (h *Handlers) newRepoSubmit(w http.ResponseWriter, r *http.Request) { |
| 135 | if err := r.ParseForm(); err != nil { |
| 136 | h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 137 | return |
| 138 | } |
| 139 | user := middleware.CurrentUserFromContext(r.Context()) |
| 140 | form := formState{ |
| 141 | Name: repos.NormalizeName(r.PostFormValue("name")), |
| 142 | Description: strings.TrimSpace(r.PostFormValue("description")), |
| 143 | Visibility: strings.TrimSpace(r.PostFormValue("visibility")), |
| 144 | InitReadme: r.PostFormValue("init_readme") == "on", |
| 145 | License: strings.TrimSpace(r.PostFormValue("license")), |
| 146 | Gitignore: strings.TrimSpace(r.PostFormValue("gitignore")), |
| 147 | } |
| 148 | if form.Visibility == "" { |
| 149 | form.Visibility = "public" |
| 150 | } |
| 151 | |
| 152 | res, err := repos.Create(r.Context(), repos.Deps{ |
| 153 | Pool: h.d.Pool, |
| 154 | RepoFS: h.d.RepoFS, |
| 155 | Audit: h.d.Audit, |
| 156 | Limiter: h.d.Limiter, |
| 157 | Logger: h.d.Logger, |
| 158 | ShithubdPath: h.d.ShithubdPath, |
| 159 | }, repos.Params{ |
| 160 | OwnerUserID: user.ID, |
| 161 | OwnerUsername: user.Username, |
| 162 | Name: form.Name, |
| 163 | Description: form.Description, |
| 164 | Visibility: form.Visibility, |
| 165 | InitReadme: form.InitReadme, |
| 166 | LicenseKey: form.License, |
| 167 | GitignoreKey: form.Gitignore, |
| 168 | }) |
| 169 | if err != nil { |
| 170 | h.renderNewForm(w, r, form, friendlyCreateError(err)) |
| 171 | return |
| 172 | } |
| 173 | |
| 174 | http.Redirect(w, r, "/"+user.Username+"/"+res.Repo.Name, http.StatusSeeOther) |
| 175 | } |
| 176 | |
| 177 | func (h *Handlers) renderNewForm(w http.ResponseWriter, r *http.Request, form formState, errMsg string) { |
| 178 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 179 | if err := h.d.Render.RenderPage(w, r, "repo/new", map[string]any{ |
| 180 | "Title": "New repository", |
| 181 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 182 | "Form": form, |
| 183 | "Error": errMsg, |
| 184 | "Licenses": templates.Licenses(), |
| 185 | "Gitignores": templates.Gitignores(), |
| 186 | }); err != nil { |
| 187 | h.d.Logger.ErrorContext(r.Context(), "repo: render new", "error", err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // repoHome serves GET /{owner}/{repo}. Forks on whether the bare repo |
| 192 | // has any branches: empty → quick-setup placeholder; populated → a slim |
| 193 | // "post-push" view with the head commit on the default branch. The full |
| 194 | // tree/file listing is S17. |
| 195 | func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) { |
| 196 | owner := chi.URLParam(r, "owner") |
| 197 | name := chi.URLParam(r, "repo") |
| 198 | |
| 199 | row, err := h.lookupRepoForViewer(r.Context(), owner, name, middleware.CurrentUserFromContext(r.Context())) |
| 200 | if err != nil { |
| 201 | // Maybe the (owner, name) is a stale name; look up the redirect |
| 202 | // table and 301 to the canonical URL so old bookmarks keep |
| 203 | // working. Authoritative miss after the redirect check 404s. |
| 204 | if newURL := h.tryRedirect(r, owner, name); newURL != "" { |
| 205 | http.Redirect(w, r, newURL, http.StatusMovedPermanently) |
| 206 | return |
| 207 | } |
| 208 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 209 | return |
| 210 | } |
| 211 | |
| 212 | // S17: when the repo has any branch, the canonical view is the |
| 213 | // tree at default_branch. The S11 quick-setup placeholder still |
| 214 | // covers the empty case. |
| 215 | diskPath, fsErr := h.d.RepoFS.RepoPath(owner, row.Name) |
| 216 | hasBranch := false |
| 217 | if fsErr == nil { |
| 218 | if ok, herr := repogit.HasAnyBranch(r.Context(), diskPath); herr == nil { |
| 219 | hasBranch = ok |
| 220 | } else { |
| 221 | h.d.Logger.WarnContext(r.Context(), "repo: HasAnyBranch", "error", herr) |
| 222 | } |
| 223 | } |
| 224 | if hasBranch { |
| 225 | http.Redirect(w, r, "/"+owner+"/"+row.Name+"/tree/"+row.DefaultBranch, http.StatusSeeOther) |
| 226 | return |
| 227 | } |
| 228 | |
| 229 | common := map[string]any{ |
| 230 | "Title": row.Name + " · " + owner, |
| 231 | "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 232 | "Owner": owner, |
| 233 | "Repo": row, |
| 234 | "DefaultBranch": row.DefaultBranch, |
| 235 | "HTTPSCloneURL": h.cloneHTTPS(owner, row.Name), |
| 236 | "SSHEnabled": h.d.CloneURLs.SSHEnabled, |
| 237 | "SSHCloneURL": h.cloneSSH(owner, row.Name), |
| 238 | } |
| 239 | |
| 240 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 241 | // Empty path only — populated repos already redirected above. |
| 242 | if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil { |
| 243 | h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err) |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // lookupRepoForViewer returns the repo row when: |
| 248 | // - it exists, |
| 249 | // - it is not soft-deleted, |
| 250 | // - AND the viewer is allowed to see it (public OR viewer is owner). |
| 251 | // |
| 252 | // Anything else returns ErrNoRows so the caller can 404 uniformly. |
| 253 | func (h *Handlers) lookupRepoForViewer(ctx context.Context, ownerName, repoName string, viewer middleware.CurrentUser) (reposdb.Repo, error) { |
| 254 | owner, err := h.uq.GetUserByUsername(ctx, h.d.Pool, ownerName) |
| 255 | if err != nil { |
| 256 | return reposdb.Repo{}, err |
| 257 | } |
| 258 | row, err := h.rq.GetRepoByOwnerUserAndName(ctx, h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{ |
| 259 | OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true}, |
| 260 | Name: repoName, |
| 261 | }) |
| 262 | if err != nil { |
| 263 | return reposdb.Repo{}, err |
| 264 | } |
| 265 | // Visibility decision delegated to policy.Can. We use ActionRepoRead; |
| 266 | // when the decision denies, return ErrNoRows so the caller can 404. |
| 267 | repoRef := policy.NewRepoRefFromRepo(row) |
| 268 | var actor policy.Actor |
| 269 | if viewer.IsAnonymous() { |
| 270 | actor = policy.AnonymousActor() |
| 271 | } else { |
| 272 | actor = policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 273 | } |
| 274 | // ActionRepoRead deny on a private repo with a non-collab viewer is |
| 275 | // indistinguishable from "doesn't exist" — Maybe404 returns 404 in |
| 276 | // that shape, so the caller's pgx.ErrNoRows fallthrough is the |
| 277 | // right shape regardless of whether the row was missing or just |
| 278 | // invisible. Honest 403s (e.g. ActionRepoWrite on archived) are |
| 279 | // gated through `loadRepoAndAuthorize`, not this read-only helper. |
| 280 | if !policy.Can(ctx, policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoRead, repoRef).Allow { |
| 281 | return reposdb.Repo{}, pgx.ErrNoRows |
| 282 | } |
| 283 | return row, nil |
| 284 | } |
| 285 | |
| 286 | // friendlyCreateError maps the typed errors from internal/repos to the |
| 287 | // strings the form surfaces to the user. |
| 288 | func friendlyCreateError(err error) string { |
| 289 | switch { |
| 290 | case errors.Is(err, repos.ErrInvalidName): |
| 291 | return "Name must be 1–100 chars: lowercase letters, digits, dots, hyphens, underscores." |
| 292 | case errors.Is(err, repos.ErrReservedName): |
| 293 | return "That name is reserved." |
| 294 | case errors.Is(err, repos.ErrTaken): |
| 295 | return "You already own a repository with that name." |
| 296 | case errors.Is(err, repos.ErrNoVerifiedEmail): |
| 297 | return "Verify a primary email before creating a repository — we use it for the initial commit author." |
| 298 | case errors.Is(err, repos.ErrDescriptionTooLong): |
| 299 | return "Description is too long (max 350 characters)." |
| 300 | case errors.Is(err, repos.ErrUnknownLicense): |
| 301 | return "Unknown license selection." |
| 302 | case errors.Is(err, repos.ErrUnknownGitignore): |
| 303 | return "Unknown .gitignore selection." |
| 304 | } |
| 305 | if t, ok := isThrottled(err); ok { |
| 306 | return "You're creating repositories too quickly. Try again in " + t + "." |
| 307 | } |
| 308 | return "Could not create the repository. Try again." |
| 309 | } |
| 310 | |
| 311 | // isThrottled extracts the user-friendly retry-after string from the |
| 312 | // throttle package's typed error, if that's what we caught. |
| 313 | func isThrottled(err error) (string, bool) { |
| 314 | var t *throttle.ErrThrottled |
| 315 | if errors.As(err, &t) { |
| 316 | return t.RetryAfter.String(), true |
| 317 | } |
| 318 | return "", false |
| 319 | } |
| 320 |