Go · 13871 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package profile
4
5 import (
6 "context"
7 "errors"
8 "html/template"
9 "net/http"
10 "net/url"
11 "sort"
12 "strconv"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16 "github.com/jackc/pgx/v5"
17 "github.com/jackc/pgx/v5/pgtype"
18
19 authpkg "github.com/tenseleyFlow/shithub/internal/auth"
20 "github.com/tenseleyFlow/shithub/internal/auth/policy"
21 "github.com/tenseleyFlow/shithub/internal/orgs"
22 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
23 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24 usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25 "github.com/tenseleyFlow/shithub/internal/web/middleware"
26 )
27
28 const profilePinLimit = 6
29
30 var (
31 errInvalidPinnedRepo = errors.New("invalid pinned repository")
32 errTooManyPins = errors.New("too many pinned repositories")
33 )
34
35 type profilePinCandidate struct {
36 ID int64
37 OwnerSlug string
38 Name string
39 Description string
40 Visibility string
41 PrimaryLanguage string
42 PrimaryLanguageColor template.CSS
43 StarCount int64
44 ForkCount int64
45 UpdatedAt pgtype.Timestamptz
46 IsPinned bool
47 PinPosition int
48 }
49
50 func (h *Handlers) pinsUpdate(w http.ResponseWriter, r *http.Request) {
51 ctx := r.Context()
52 rawName := chi.URLParam(r, "username")
53 lower := strings.ToLower(rawName)
54 if authpkg.IsReserved(lower) {
55 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
56 return
57 }
58 viewer := middleware.CurrentUserFromContext(ctx)
59 if viewer.IsAnonymous() {
60 h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
61 return
62 }
63 if err := r.ParseForm(); err != nil {
64 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
65 return
66 }
67
68 if p, err := orgs.Resolve(ctx, h.d.Pool, lower); err == nil && p.Kind == orgs.PrincipalOrg {
69 h.updateOrgPins(w, r, p.ID, viewer)
70 return
71 }
72 h.updateUserPins(w, r, rawName, viewer)
73 }
74
75 func (h *Handlers) updateOrgPins(w http.ResponseWriter, r *http.Request, orgID int64, viewer middleware.CurrentUser) {
76 ctx := r.Context()
77 org, err := orgsdb.New().GetOrgByID(ctx, h.d.Pool, orgID)
78 if err != nil || org.DeletedAt.Valid {
79 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
80 return
81 }
82 owner, err := orgs.IsOwner(ctx, orgs.Deps{Pool: h.d.Pool, Logger: h.d.Logger}, org.ID, viewer.ID)
83 if err != nil {
84 h.d.Logger.ErrorContext(ctx, "profile pins: org owner check", "org_id", org.ID, "error", err)
85 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
86 return
87 }
88 if !owner {
89 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
90 return
91 }
92
93 candidates := h.publicOrgPinCandidates(ctx, org.ID, string(org.Slug))
94 repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
95 if err != nil {
96 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
97 return
98 }
99 if err := h.saveOrgPins(ctx, org.ID, repoIDs); err != nil {
100 h.d.Logger.ErrorContext(ctx, "profile pins: save org pins", "org_id", org.ID, "error", err)
101 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
102 return
103 }
104 http.Redirect(w, r, "/"+url.PathEscape(string(org.Slug))+"#pinned", http.StatusSeeOther)
105 }
106
107 func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawName string, viewer middleware.CurrentUser) {
108 ctx := r.Context()
109 user, err := h.q.GetUserByUsername(ctx, h.d.Pool, rawName)
110 if err != nil {
111 if errors.Is(err, pgx.ErrNoRows) {
112 h.d.Render.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
113 return
114 }
115 h.d.Logger.ErrorContext(ctx, "profile pins: user lookup", "error", err)
116 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
117 return
118 }
119 if user.SuspendedAt.Valid || user.DeletedAt.Valid {
120 h.d.Render.HTTPError(w, r, http.StatusGone, "")
121 return
122 }
123 if viewer.ID != user.ID {
124 h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
125 return
126 }
127
128 candidates := h.publicUserPinCandidates(ctx, user.ID)
129 repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
130 if err != nil {
131 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
132 return
133 }
134 if err := h.saveUserPins(ctx, user.ID, repoIDs); err != nil {
135 h.d.Logger.ErrorContext(ctx, "profile pins: save user pins", "user_id", user.ID, "error", err)
136 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
137 return
138 }
139 http.Redirect(w, r, "/"+url.PathEscape(user.Username)+"#pinned", http.StatusSeeOther)
140 }
141
142 func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string, repos []orgProfileRepo) ([]orgProfileRepo, []profilePinCandidate) {
143 publicRepos := publicOrgProfileRepos(repos)
144 pinned := pinnedOrgRepos(publicRepos)
145
146 setID, explicit, err := h.lookupOrgPinSet(ctx, orgID)
147 if err != nil {
148 h.d.Logger.WarnContext(ctx, "profile pins: lookup org pin set", "org_id", orgID, "error", err)
149 } else if explicit {
150 pinned = h.savedOrgPins(ctx, setID, publicRepos)
151 }
152
153 candidates := profilePinCandidatesFromOrgRepos(orgSlug, publicRepos)
154 markPinnedCandidates(candidates, orgProfileRepoIDs(pinned))
155 return pinned, candidates
156 }
157
158 func (h *Handlers) userPinData(ctx context.Context, user usersdb.User) ([]profilePinCandidate, []profilePinCandidate) {
159 candidates := h.publicUserPinCandidates(ctx, user.ID)
160 var selectedIDs []int64
161
162 setID, explicit, err := h.lookupUserPinSet(ctx, user.ID)
163 if err != nil {
164 h.d.Logger.WarnContext(ctx, "profile pins: lookup user pin set", "user_id", user.ID, "error", err)
165 } else if explicit {
166 selectedIDs = h.savedPinIDs(ctx, setID)
167 }
168
169 pinned := selectedPinCandidates(candidates, selectedIDs)
170 markPinnedCandidates(candidates, selectedIDs)
171 return pinned, candidates
172 }
173
174 func (h *Handlers) lookupOrgPinSet(ctx context.Context, orgID int64) (int64, bool, error) {
175 setID, err := reposdb.New().GetProfilePinSetForOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true})
176 if err != nil {
177 if errors.Is(err, pgx.ErrNoRows) {
178 return 0, false, nil
179 }
180 return 0, false, err
181 }
182 return setID, true, nil
183 }
184
185 func (h *Handlers) lookupUserPinSet(ctx context.Context, userID int64) (int64, bool, error) {
186 setID, err := reposdb.New().GetProfilePinSetForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
187 if err != nil {
188 if errors.Is(err, pgx.ErrNoRows) {
189 return 0, false, nil
190 }
191 return 0, false, err
192 }
193 return setID, true, nil
194 }
195
196 func (h *Handlers) savedPinIDs(ctx context.Context, setID int64) []int64 {
197 rows, err := reposdb.New().ListProfilePinsForSet(ctx, h.d.Pool, setID)
198 if err != nil {
199 h.d.Logger.WarnContext(ctx, "profile pins: list pins", "set_id", setID, "error", err)
200 return nil
201 }
202 out := make([]int64, 0, len(rows))
203 for _, row := range rows {
204 out = append(out, row.RepoID)
205 }
206 return out
207 }
208
209 func (h *Handlers) savedOrgPins(ctx context.Context, setID int64, repos []orgProfileRepo) []orgProfileRepo {
210 return selectedOrgProfileRepos(repos, h.savedPinIDs(ctx, setID))
211 }
212
213 func (h *Handlers) publicUserPinCandidates(ctx context.Context, userID int64) []profilePinCandidate {
214 rows, err := reposdb.New().ListProfilePinCandidateReposForUser(ctx, h.d.Pool, pgtype.Int8{Int64: userID, Valid: true})
215 if err != nil {
216 h.d.Logger.WarnContext(ctx, "profile pins: list user repos", "user_id", userID, "error", err)
217 return nil
218 }
219 out := make([]profilePinCandidate, 0, len(rows))
220 for _, row := range rows {
221 if !policy.NewRepoRefFromRepo(row.Repo).IsPublic() {
222 continue
223 }
224 out = append(out, profilePinCandidateFromRepo(row.OwnerSlug, row.Repo))
225 }
226 sortPinCandidates(out)
227 return out
228 }
229
230 func (h *Handlers) publicOrgPinCandidates(ctx context.Context, orgID int64, orgSlug string) []profilePinCandidate {
231 rows, err := reposdb.New().ListReposForOwnerOrg(ctx, h.d.Pool, pgtype.Int8{Int64: orgID, Valid: true})
232 if err != nil {
233 h.d.Logger.WarnContext(ctx, "profile pins: list org repos", "org_id", orgID, "error", err)
234 return nil
235 }
236 out := make([]profilePinCandidate, 0, len(rows))
237 for _, row := range rows {
238 if !policy.NewRepoRefFromRepo(row).IsPublic() {
239 continue
240 }
241 out = append(out, profilePinCandidateFromRepo(orgSlug, row))
242 }
243 sortPinCandidates(out)
244 return out
245 }
246
247 func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([]int64, error) {
248 allowed := make(map[int64]struct{}, len(candidates))
249 for _, candidate := range candidates {
250 allowed[candidate.ID] = struct{}{}
251 }
252 seen := make(map[int64]struct{}, len(values))
253 out := make([]int64, 0, len(values))
254 for _, value := range values {
255 repoID, err := strconv.ParseInt(value, 10, 64)
256 if err != nil || repoID <= 0 {
257 return nil, errInvalidPinnedRepo
258 }
259 if _, ok := allowed[repoID]; !ok {
260 return nil, errInvalidPinnedRepo
261 }
262 if _, ok := seen[repoID]; ok {
263 continue
264 }
265 seen[repoID] = struct{}{}
266 out = append(out, repoID)
267 }
268 if len(out) > profilePinLimit {
269 return nil, errTooManyPins
270 }
271 return out, nil
272 }
273
274 func (h *Handlers) saveUserPins(ctx context.Context, userID int64, repoIDs []int64) error {
275 tx, err := h.d.Pool.Begin(ctx)
276 if err != nil {
277 return err
278 }
279 defer func() { _ = tx.Rollback(ctx) }()
280
281 q := reposdb.New()
282 setID, err := q.UpsertProfilePinSetForUser(ctx, tx, pgtype.Int8{Int64: userID, Valid: true})
283 if err != nil {
284 return err
285 }
286 if err := replaceProfilePins(ctx, q, tx, setID, repoIDs); err != nil {
287 return err
288 }
289 return tx.Commit(ctx)
290 }
291
292 func (h *Handlers) saveOrgPins(ctx context.Context, orgID int64, repoIDs []int64) error {
293 tx, err := h.d.Pool.Begin(ctx)
294 if err != nil {
295 return err
296 }
297 defer func() { _ = tx.Rollback(ctx) }()
298
299 q := reposdb.New()
300 setID, err := q.UpsertProfilePinSetForOrg(ctx, tx, pgtype.Int8{Int64: orgID, Valid: true})
301 if err != nil {
302 return err
303 }
304 if err := replaceProfilePins(ctx, q, tx, setID, repoIDs); err != nil {
305 return err
306 }
307 return tx.Commit(ctx)
308 }
309
310 func replaceProfilePins(ctx context.Context, q *reposdb.Queries, tx pgx.Tx, setID int64, repoIDs []int64) error {
311 if err := q.DeleteProfilePinsForSet(ctx, tx, setID); err != nil {
312 return err
313 }
314 for i, repoID := range repoIDs {
315 if err := q.InsertProfilePin(ctx, tx, reposdb.InsertProfilePinParams{
316 SetID: setID,
317 RepoID: repoID,
318 Position: int32(i + 1),
319 }); err != nil {
320 return err
321 }
322 }
323 return nil
324 }
325
326 func profilePinCandidatesFromOrgRepos(ownerSlug string, repos []orgProfileRepo) []profilePinCandidate {
327 out := make([]profilePinCandidate, 0, len(repos))
328 for _, repo := range repos {
329 out = append(out, profilePinCandidate{
330 ID: repo.ID,
331 OwnerSlug: ownerSlug,
332 Name: repo.Name,
333 Description: repo.Description,
334 Visibility: repo.Visibility,
335 PrimaryLanguage: repo.PrimaryLanguage,
336 PrimaryLanguageColor: repo.PrimaryLanguageColor,
337 StarCount: repo.StarCount,
338 ForkCount: repo.ForkCount,
339 })
340 }
341 sortPinCandidates(out)
342 return out
343 }
344
345 func profilePinCandidateFromRepo(ownerSlug string, repo reposdb.Repo) profilePinCandidate {
346 language := pgTextStringOrEmpty(repo.PrimaryLanguage)
347 return profilePinCandidate{
348 ID: repo.ID,
349 OwnerSlug: ownerSlug,
350 Name: repo.Name,
351 Description: repo.Description,
352 Visibility: string(repo.Visibility),
353 PrimaryLanguage: language,
354 PrimaryLanguageColor: template.CSS(orgLanguageColor(language)), //nolint:gosec // CSS value comes from server-side constants.
355 StarCount: repo.StarCount,
356 ForkCount: repo.ForkCount,
357 UpdatedAt: repo.UpdatedAt,
358 }
359 }
360
361 func publicOrgProfileRepos(repos []orgProfileRepo) []orgProfileRepo {
362 out := make([]orgProfileRepo, 0, len(repos))
363 for _, repo := range repos {
364 if orgProfileRepoRef(repo).IsPublic() {
365 out = append(out, repo)
366 }
367 }
368 return out
369 }
370
371 func orgProfileRepoRef(repo orgProfileRepo) policy.RepoRef {
372 return policy.RepoRef{
373 ID: repo.ID,
374 Visibility: repo.Visibility,
375 IsArchived: repo.IsArchived,
376 }
377 }
378
379 func orgProfileRepoIDs(repos []orgProfileRepo) []int64 {
380 out := make([]int64, 0, len(repos))
381 for _, repo := range repos {
382 out = append(out, repo.ID)
383 }
384 return out
385 }
386
387 func selectedOrgProfileRepos(repos []orgProfileRepo, repoIDs []int64) []orgProfileRepo {
388 byID := make(map[int64]orgProfileRepo, len(repos))
389 for _, repo := range repos {
390 byID[repo.ID] = repo
391 }
392 out := make([]orgProfileRepo, 0, len(repoIDs))
393 for _, repoID := range repoIDs {
394 if repo, ok := byID[repoID]; ok {
395 out = append(out, repo)
396 }
397 }
398 return out
399 }
400
401 func selectedPinCandidates(candidates []profilePinCandidate, repoIDs []int64) []profilePinCandidate {
402 byID := make(map[int64]profilePinCandidate, len(candidates))
403 for _, candidate := range candidates {
404 byID[candidate.ID] = candidate
405 }
406 out := make([]profilePinCandidate, 0, len(repoIDs))
407 for _, repoID := range repoIDs {
408 if candidate, ok := byID[repoID]; ok {
409 out = append(out, candidate)
410 }
411 }
412 return out
413 }
414
415 func markPinnedCandidates(candidates []profilePinCandidate, repoIDs []int64) {
416 positions := make(map[int64]int, len(repoIDs))
417 for i, repoID := range repoIDs {
418 positions[repoID] = i + 1
419 }
420 for i := range candidates {
421 if pos, ok := positions[candidates[i].ID]; ok {
422 candidates[i].IsPinned = true
423 candidates[i].PinPosition = pos
424 }
425 }
426 sortPinCandidates(candidates)
427 }
428
429 func sortPinCandidates(candidates []profilePinCandidate) {
430 sort.SliceStable(candidates, func(i, j int) bool {
431 left := candidates[i]
432 right := candidates[j]
433 if left.IsPinned != right.IsPinned {
434 return left.IsPinned
435 }
436 if left.IsPinned && left.PinPosition != right.PinPosition {
437 return left.PinPosition < right.PinPosition
438 }
439 return strings.ToLower(left.Name) < strings.ToLower(right.Name)
440 })
441 }
442
443 func profilePinsRemaining(candidates []profilePinCandidate) int {
444 count := 0
445 for _, candidate := range candidates {
446 if candidate.IsPinned {
447 count++
448 }
449 }
450 if count >= profilePinLimit {
451 return 0
452 }
453 return profilePinLimit - count
454 }
455