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