| 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 |