Go · 11444 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 "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