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