@@ -0,0 +1,252 @@ |
| 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.Render(w, "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 | +} |