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