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