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