tenseleyflow/shithub / 6341769

Browse files

S11: web handlers for /new + /{owner}/{repo} empty home; wire into chi router

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
634176993883582991b1326bbefa807a7568c3cc
Parents
969e627
Tree
24e71cc

4 changed files

StatusFile+-
M internal/web/handlers/handlers.go 13 0
A internal/web/handlers/repo/repo.go 252 0
A internal/web/repo_wiring.go 58 0
M internal/web/server.go 13 0
internal/web/handlers/handlers.gomodified
@@ -46,6 +46,13 @@ type Deps struct {
4646
 	// AvatarMounter, when non-nil, registers /avatars/{username} on the
4747
 	// CSRF-exempt group (avatar GETs are safe and benefit from caching).
4848
 	AvatarMounter func(chi.Router)
49
+	// RepoNewMounter, when non-nil, registers /new on the CSRF-protected
50
+	// group. The handler enforces auth itself.
51
+	RepoNewMounter func(chi.Router)
52
+	// RepoHomeMounter, when non-nil, registers /{owner}/{repo} on the
53
+	// CSRF-protected group. Two-segment match doesn't collide with the
54
+	// /{username} catch-all.
55
+	RepoHomeMounter func(chi.Router)
4956
 	// ProfileMounter, when non-nil, registers the /{username} catch-all
5057
 	// route. MUST run last in its group — chi matches in registration
5158
 	// order, and {username} swallows everything else.
@@ -119,6 +126,12 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
119126
 		if deps.AuthMounter != nil {
120127
 			deps.AuthMounter(r)
121128
 		}
129
+		if deps.RepoNewMounter != nil {
130
+			deps.RepoNewMounter(r)
131
+		}
132
+		if deps.RepoHomeMounter != nil {
133
+			deps.RepoHomeMounter(r)
134
+		}
122135
 		// Profile is registered LAST so /{username} doesn't shadow any
123136
 		// static top-level route.
124137
 		if deps.ProfileMounter != nil {
internal/web/handlers/repo/repo.goadded
@@ -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
+}
internal/web/repo_wiring.goadded
@@ -0,0 +1,58 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package web
4
+
5
+import (
6
+	"errors"
7
+	"fmt"
8
+	"io/fs"
9
+	"log/slog"
10
+	"path/filepath"
11
+
12
+	"github.com/jackc/pgx/v5/pgxpool"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
16
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
17
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
+	repoh "github.com/tenseleyFlow/shithub/internal/web/handlers/repo"
19
+	"github.com/tenseleyFlow/shithub/internal/web/render"
20
+)
21
+
22
+// buildRepoHandlers wires the repo-create + empty-home handlers. The
23
+// bare repos live at cfg.Storage.ReposRoot (must be set; we refuse to
24
+// boot the repo surface without it).
25
+func buildRepoHandlers(
26
+	cfg config.Config,
27
+	pool *pgxpool.Pool,
28
+	tmplFS fs.FS,
29
+	logger *slog.Logger,
30
+) (*repoh.Handlers, error) {
31
+	if cfg.Storage.ReposRoot == "" {
32
+		return nil, errors.New("repo: cfg.Storage.ReposRoot is empty")
33
+	}
34
+	root, err := filepath.Abs(cfg.Storage.ReposRoot)
35
+	if err != nil {
36
+		return nil, fmt.Errorf("repo: resolve repos_root: %w", err)
37
+	}
38
+	rfs, err := storage.NewRepoFS(root)
39
+	if err != nil {
40
+		return nil, fmt.Errorf("repo: NewRepoFS: %w", err)
41
+	}
42
+	rr, err := render.New(tmplFS, render.Options{Octicons: render.BuiltinOcticons()})
43
+	if err != nil {
44
+		return nil, fmt.Errorf("repo: render.New: %w", err)
45
+	}
46
+	return repoh.New(repoh.Deps{
47
+		Logger:  logger,
48
+		Render:  rr,
49
+		Pool:    pool,
50
+		RepoFS:  rfs,
51
+		Audit:   audit.NewRecorder(),
52
+		Limiter: throttle.NewLimiter(),
53
+		CloneURLs: repoh.CloneURLs{
54
+			BaseURL:    cfg.Auth.BaseURL,
55
+			SSHEnabled: false, // S12/S13 will flip this when SSH service ships.
56
+		},
57
+	})
58
+}
internal/web/server.gomodified
@@ -170,6 +170,19 @@ func Run(ctx context.Context, opts Options) error {
170170
 		}
171171
 		deps.AvatarMounter = profile.MountAvatars
172172
 		deps.ProfileMounter = profile.MountProfile
173
+
174
+		repoH, err := buildRepoHandlers(cfg, pool, deps.TemplatesFS, logger)
175
+		if err != nil {
176
+			return fmt.Errorf("repo handlers: %w", err)
177
+		}
178
+		// /new is wrapped in RequireUser — it requires a logged-in caller.
179
+		deps.RepoNewMounter = func(r chi.Router) {
180
+			r.Group(func(r chi.Router) {
181
+				r.Use(middleware.RequireUser)
182
+				repoH.MountNew(r)
183
+			})
184
+		}
185
+		deps.RepoHomeMounter = repoH.MountRepoHome
173186
 	} else {
174187
 		logger.Warn("auth: no DB pool — signup/login routes not mounted")
175188
 	}