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