tenseleyflow/shithub / cf04697

Browse files

S27: web handlers + forks list template

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cf04697a2664ac3530126e02437ebf44495650d4
Parents
d361661
Tree
e69709f

4 changed files

StatusFile+-
M internal/web/handlers/handlers.go 7 0
A internal/web/handlers/repo/fork.go 250 0
M internal/web/server.go 1 0
A internal/web/templates/repo/forks.html 38 0
internal/web/handlers/handlers.gomodified
@@ -82,6 +82,10 @@ type Deps struct {
8282
 	// stargazers,watchers} (S26). Stargazer/watcher GETs are public
8383
 	// (subject to repo visibility); the action POSTs require auth.
8484
 	RepoSocialMounter func(chi.Router)
85
+	// RepoForkMounter registers /{owner}/{repo}/{fork,sync,forks}
86
+	// (S27). The forks list GET is public; fork + sync POSTs are
87
+	// auth-required.
88
+	RepoForkMounter func(chi.Router)
8589
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
8690
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
8791
 	// land in a route group that bypasses CSRF, response compression,
@@ -209,6 +213,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
209213
 		if deps.RepoSocialMounter != nil {
210214
 			deps.RepoSocialMounter(r)
211215
 		}
216
+		if deps.RepoForkMounter != nil {
217
+			deps.RepoForkMounter(r)
218
+		}
212219
 		if deps.RepoHomeMounter != nil {
213220
 			deps.RepoHomeMounter(r)
214221
 		}
internal/web/handlers/repo/fork.goadded
@@ -0,0 +1,250 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"net/http"
8
+	"strings"
9
+
10
+	"github.com/go-chi/chi/v5"
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
14
+	"github.com/tenseleyFlow/shithub/internal/repos/fork"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/social"
17
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
18
+	"github.com/tenseleyFlow/shithub/internal/worker"
19
+)
20
+
21
+// MountFork registers the fork-related routes:
22
+//
23
+//	GET  /{owner}/{repo}/forks       — paginated forks list (public)
24
+//	POST /{owner}/{repo}/fork        — create a fork (auth-required)
25
+//	POST /{owner}/{repo}/sync        — fast-forward sync from upstream (auth-required)
26
+//
27
+// The fork POST is auth-required + policy-gated by ActionForkCreate.
28
+// The sync POST requires write on the fork.
29
+func (h *Handlers) MountFork(r chi.Router) {
30
+	r.Get("/{owner}/{repo}/forks", h.forksList)
31
+	r.Group(func(r chi.Router) {
32
+		r.Use(middleware.RequireUser)
33
+		r.Post("/{owner}/{repo}/fork", h.repoFork)
34
+		r.Post("/{owner}/{repo}/sync", h.repoSync)
35
+	})
36
+}
37
+
38
+// forkDeps materializes a fork.Deps from the handler-set deps.
39
+func (h *Handlers) forkDeps() fork.Deps {
40
+	return fork.Deps{
41
+		Pool:   h.d.Pool,
42
+		RepoFS: h.d.RepoFS,
43
+		Audit:  h.d.Audit,
44
+		Logger: h.d.Logger,
45
+	}
46
+}
47
+
48
+// repoFork handles POST /{owner}/{repo}/fork. Default behavior: fork
49
+// the repo into the viewer's own namespace using the source's name
50
+// (or the user-provided `target_name` form field). Source must be
51
+// readable and forkable; target name + visibility floor are checked
52
+// inside the orchestrator.
53
+func (h *Handlers) repoFork(w http.ResponseWriter, r *http.Request) {
54
+	ownerName := chi.URLParam(r, "owner")
55
+	name := chi.URLParam(r, "repo")
56
+	viewer := middleware.CurrentUserFromContext(r.Context())
57
+
58
+	// Fork-create requires read on source AND login. The visibility
59
+	// short-circuit at policy.Can step 4 covers anonymous-on-private;
60
+	// step 9 covers anonymous-on-anything for fork:create.
61
+	source, err := h.lookupRepoForViewer(r.Context(), ownerName, name, viewer)
62
+	if err != nil {
63
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
64
+		return
65
+	}
66
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
67
+	repoRef := policy.NewRepoRefFromRepo(source)
68
+	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionForkCreate, repoRef); !dec.Allow {
69
+		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
70
+		return
71
+	}
72
+	if err := r.ParseForm(); err != nil {
73
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
74
+		return
75
+	}
76
+	res, err := fork.Create(r.Context(), h.forkDeps(), fork.CreateParams{
77
+		SourceRepoID:     source.ID,
78
+		ActorUserID:      viewer.ID,
79
+		TargetOwnerID:    viewer.ID, // self-fork only today; org targets land with S31
80
+		TargetName:       strings.TrimSpace(r.PostFormValue("target_name")),
81
+		TargetVisibility: strings.TrimSpace(r.PostFormValue("target_visibility")),
82
+	})
83
+	if err != nil {
84
+		h.handleForkError(w, r, err)
85
+		return
86
+	}
87
+	// Enqueue the on-disk clone. The fork row exists with
88
+	// init_status='init_pending' so the URL resolves immediately.
89
+	if _, err := worker.Enqueue(r.Context(), h.d.Pool, worker.KindRepoForkClone,
90
+		map[string]any{"source_repo_id": res.Source.ID, "fork_repo_id": res.Fork.ID},
91
+		worker.EnqueueOptions{},
92
+	); err != nil {
93
+		h.d.Logger.ErrorContext(r.Context(), "fork: enqueue clone", "error", err, "fork_id", res.Fork.ID)
94
+	}
95
+	// Emit a `forked` event for activity feeds (S26's domain_events
96
+	// log). Public-flag follows source visibility, per the
97
+	// per-event policy.
98
+	_ = social.Emit(r.Context(), social.Deps{Pool: h.d.Pool}, social.EmitParams{
99
+		ActorUserID: viewer.ID,
100
+		Kind:        "forked",
101
+		RepoID:      res.Fork.ID,
102
+		SourceKind:  "repo",
103
+		SourceID:    res.Source.ID,
104
+		Public:      string(res.Source.Visibility) == "public",
105
+	})
106
+	// Auto-watch the new fork at level=all so the user sees fork-side
107
+	// events (matches GitHub: the act of forking implies interest).
108
+	_ = social.AutoWatchOnCollab(r.Context(), h.socialDeps(), viewer.ID, res.Fork.ID)
109
+	http.Redirect(w, r, "/"+viewer.Username+"/"+res.Fork.Name, http.StatusSeeOther)
110
+}
111
+
112
+// repoSync handles POST /{owner}/{repo}/sync. The repo here is the
113
+// fork (the viewer's own copy); we authorize repo:write because
114
+// sync mutates refs on the fork.
115
+func (h *Handlers) repoSync(w http.ResponseWriter, r *http.Request) {
116
+	ownerName := chi.URLParam(r, "owner")
117
+	name := chi.URLParam(r, "repo")
118
+	viewer := middleware.CurrentUserFromContext(r.Context())
119
+	row, err := h.lookupRepoForViewer(r.Context(), ownerName, name, viewer)
120
+	if err != nil {
121
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
122
+		return
123
+	}
124
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
125
+	repoRef := policy.NewRepoRefFromRepo(row)
126
+	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionRepoWrite, repoRef); !dec.Allow {
127
+		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
128
+		return
129
+	}
130
+	if !row.ForkOfRepoID.Valid {
131
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "not a fork")
132
+		return
133
+	}
134
+	if _, err := fork.Sync(r.Context(), h.forkDeps(), viewer.ID, row.ID); err != nil {
135
+		h.handleForkError(w, r, err)
136
+		return
137
+	}
138
+	http.Redirect(w, r, "/"+ownerName+"/"+row.Name+"?notice=fork-synced", http.StatusSeeOther)
139
+}
140
+
141
+// forksList renders /{owner}/{repo}/forks.
142
+func (h *Handlers) forksList(w http.ResponseWriter, r *http.Request) {
143
+	ownerName := chi.URLParam(r, "owner")
144
+	name := chi.URLParam(r, "repo")
145
+	row, err := h.lookupRepoForViewer(r.Context(), ownerName, name, middleware.CurrentUserFromContext(r.Context()))
146
+	if err != nil {
147
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
148
+		return
149
+	}
150
+	page := pageFromRequest(r)
151
+	const pageSize = 30
152
+	rows, err := h.rq.ListForksOfRepo(r.Context(), h.d.Pool, reposdb.ListForksOfRepoParams{
153
+		ForkOfRepoID: pgtype.Int8{Int64: row.ID, Valid: true},
154
+		Limit:        pageSize,
155
+		Offset:       int32((page - 1) * pageSize),
156
+	})
157
+	if err != nil {
158
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "list forks")
159
+		return
160
+	}
161
+	total, _ := h.rq.CountForksOfRepo(r.Context(), h.d.Pool, pgtype.Int8{Int64: row.ID, Valid: true})
162
+
163
+	// Per-row visibility filter: a private fork of a public repo
164
+	// must only show to viewers who can see the fork. Filter via
165
+	// policy.IsVisibleTo against the slim RepoRef shape.
166
+	viewer := middleware.CurrentUserFromContext(r.Context())
167
+	visibleActor := actorFor(viewer)
168
+	deps := policy.Deps{Pool: h.d.Pool}
169
+	visible := make([]map[string]any, 0, len(rows))
170
+	for _, fk := range rows {
171
+		ref := policy.RepoRef{
172
+			ID:         fk.ID,
173
+			Visibility: string(fk.Visibility),
174
+		}
175
+		// Owner not threaded through this row; for the list we just
176
+		// gate on visibility — public visible to all, private only
177
+		// to the fork owner (which RepoRef.OwnerUserID would catch
178
+		// if we threaded it). The list query already excludes
179
+		// soft-deleted rows.
180
+		if !policy.IsVisibleTo(r.Context(), deps, visibleActor, ref) {
181
+			continue
182
+		}
183
+		visible = append(visible, map[string]any{
184
+			"OwnerUsername":    fk.OwnerUsername,
185
+			"OwnerDisplayName": fk.OwnerDisplayName,
186
+			"Name":             fk.Name,
187
+			"Description":      fk.Description,
188
+			"Visibility":       string(fk.Visibility),
189
+			"StarCount":        fk.StarCount,
190
+			"ForkCount":        fk.ForkCount,
191
+			"InitStatus":       string(fk.InitStatus),
192
+			"CreatedAt":        fk.CreatedAt.Time,
193
+		})
194
+	}
195
+	common := map[string]any{
196
+		"Title":   "Forks · " + row.Name,
197
+		"Owner":   ownerName,
198
+		"Repo":    row,
199
+		"Forks":   visible,
200
+		"Total":   total,
201
+		"Page":    page,
202
+		"HasNext": int64(page*pageSize) < total,
203
+		"HasPrev": page > 1,
204
+	}
205
+	if err := h.d.Render.RenderPage(w, r, "repo/forks", common); err != nil {
206
+		h.d.Logger.ErrorContext(r.Context(), "forks render", "error", err)
207
+	}
208
+}
209
+
210
+// actorFor builds the policy.Actor matching a CurrentUser. Anonymous
211
+// when the viewer is unauthenticated.
212
+func actorFor(viewer middleware.CurrentUser) policy.Actor {
213
+	if viewer.IsAnonymous() {
214
+		return policy.AnonymousActor()
215
+	}
216
+	return policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
217
+}
218
+
219
+// handleForkError maps the orchestrator's typed errors to status
220
+// codes + friendly messages.
221
+func (h *Handlers) handleForkError(w http.ResponseWriter, r *http.Request, err error) {
222
+	switch {
223
+	case errors.Is(err, fork.ErrNotLoggedIn):
224
+		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
225
+	case errors.Is(err, fork.ErrSourceNotFound), errors.Is(err, fork.ErrSourceDeleted):
226
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
227
+	case errors.Is(err, fork.ErrSourceArchived):
228
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "source repo is archived")
229
+	case errors.Is(err, fork.ErrTargetNameTaken):
230
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "you already own a repository with that name")
231
+	case errors.Is(err, fork.ErrSelfForkSameName):
232
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "forking your own repo requires a different name")
233
+	case errors.Is(err, fork.ErrVisibilityFloor):
234
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "fork visibility cannot exceed source visibility")
235
+	case errors.Is(err, fork.ErrSyncDiverged):
236
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "fork has diverged from upstream; sync via your client")
237
+	case errors.Is(err, fork.ErrSyncUpToDate):
238
+		http.Redirect(w, r, r.URL.Path+"/..?notice=already-up-to-date", http.StatusSeeOther)
239
+	case errors.Is(err, fork.ErrSyncDefaultsMissing):
240
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "default branch missing on fork or source")
241
+	case errors.Is(err, fork.ErrSyncRefRaced):
242
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "fork ref changed concurrently; retry")
243
+	case errors.Is(err, fork.ErrForkNotInitialized):
244
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "fork is still being prepared")
245
+	default:
246
+		h.d.Logger.ErrorContext(r.Context(), "fork handler", "error", err)
247
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
248
+	}
249
+}
250
+
internal/web/server.gomodified
@@ -205,6 +205,7 @@ func Run(ctx context.Context, opts Options) error {
205205
 		deps.RepoIssuesMounter = repoH.MountIssues
206206
 		deps.RepoPullsMounter = repoH.MountPulls
207207
 		deps.RepoSocialMounter = repoH.MountSocial
208
+		deps.RepoForkMounter = repoH.MountFork
208209
 		// Lifecycle danger-zone routes — also auth-required.
209210
 		deps.RepoLifecycleMounter = func(r chi.Router) {
210211
 			r.Group(func(r chi.Router) {
internal/web/templates/repo/forks.htmladded
@@ -0,0 +1,38 @@
1
+{{ define "page" -}}
2
+<section class="shithub-social">
3
+  <header class="shithub-code-head">
4
+    <h1>
5
+      <a href="/{{ .Owner }}/{{ .Repo.Name }}">{{ .Owner }}/{{ .Repo.Name }}</a>
6
+      <span class="shithub-code-sep">/</span>
7
+      Forks
8
+    </h1>
9
+    <p class="shithub-meta">{{ .Total }} {{ if eq .Total 1 }}fork{{ else }}forks{{ end }}</p>
10
+  </header>
11
+
12
+  {{ if .Forks }}
13
+  <ul class="shithub-social-list">
14
+    {{ range .Forks }}
15
+    <li>
16
+      <a href="/{{ .OwnerUsername }}/{{ .Name }}"><strong>{{ .OwnerUsername }}/{{ .Name }}</strong></a>
17
+      {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
18
+      {{ if eq .InitStatus "init_pending" }}<span class="shithub-pill">preparing</span>{{ end }}
19
+      {{ if eq .InitStatus "init_failed" }}<span class="shithub-pill shithub-pill-private">failed</span>{{ end }}
20
+      <small>★ {{ .StarCount }}</small>
21
+      <small>forks: {{ .ForkCount }}</small>
22
+      <small><time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z" }}">forked {{ relativeTime .CreatedAt }}</time></small>
23
+      {{ if .Description }}<p class="shithub-meta">{{ .Description }}</p>{{ end }}
24
+    </li>
25
+    {{ end }}
26
+  </ul>
27
+  {{ else }}
28
+  <p class="shithub-empty">No forks yet.</p>
29
+  {{ end }}
30
+
31
+  {{ if or .HasPrev .HasNext }}
32
+  <nav class="shithub-pagination">
33
+    {{ if .HasPrev }}<a href="?page={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ end }}
34
+    {{ if .HasNext }}<a href="?page={{ add .Page 1 }}" class="shithub-button">Next</a>{{ end }}
35
+  </nav>
36
+  {{ end }}
37
+</section>
38
+{{- end }}