Go · 4249 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package social
4
5 import (
6 "context"
7 "fmt"
8 "time"
9
10 "github.com/jackc/pgx/v5/pgtype"
11
12 "github.com/tenseleyFlow/shithub/internal/auth/audit"
13 "github.com/tenseleyFlow/shithub/internal/auth/throttle"
14 socialdb "github.com/tenseleyFlow/shithub/internal/social/sqlc"
15 )
16
17 // starRateMax is the spec's day-1 rate cap: 100 star/unstar actions
18 // per hour per user. Defends against trending-manipulation abuse
19 // (S35 owns the abuse-heuristics layer; this is the floor).
20 const (
21 starRateMax = 100
22 starRateWindow = time.Hour
23 )
24
25 // Star idempotently records a star. Re-starring an already-starred
26 // repo is a no-op (the underlying INSERT … ON CONFLICT DO NOTHING +
27 // AFTER INSERT trigger combination keeps star_count consistent).
28 //
29 // The handler MUST authorize via policy.Can(ActionStarCreate, repo)
30 // before calling — this orchestrator trusts that the actor has read
31 // access to the repo and is logged in.
32 //
33 // Emits a `star` event into domain_events with `public = repoIsPublic`.
34 func Star(ctx context.Context, deps Deps, actorUserID, repoID int64, repoIsPublic bool) error {
35 if actorUserID == 0 {
36 return ErrNotLoggedIn
37 }
38 if err := hitStarLimit(ctx, deps, actorUserID); err != nil {
39 return err
40 }
41
42 tx, err := deps.Pool.Begin(ctx)
43 if err != nil {
44 return err
45 }
46 committed := false
47 defer func() {
48 if !committed {
49 _ = tx.Rollback(ctx)
50 }
51 }()
52
53 q := socialdb.New()
54 if err := q.InsertStar(ctx, tx, socialdb.InsertStarParams{
55 UserID: actorUserID, RepoID: repoID,
56 }); err != nil {
57 return fmt.Errorf("insert star: %w", err)
58 }
59 if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
60 ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: true},
61 Kind: "star",
62 RepoID: pgtype.Int8{Int64: repoID, Valid: true},
63 SourceKind: "repo",
64 SourceID: repoID,
65 Public: repoIsPublic,
66 Payload: []byte("{}"),
67 }); err != nil {
68 return fmt.Errorf("insert event: %w", err)
69 }
70 if err := tx.Commit(ctx); err != nil {
71 return err
72 }
73 committed = true
74 if deps.Audit != nil {
75 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
76 audit.ActionStarCreated, audit.TargetRepo, repoID, nil)
77 }
78 return nil
79 }
80
81 // Unstar removes the star idempotently. Unstarring a non-starred
82 // repo is a no-op. Emits an `unstar` event for symmetry.
83 func Unstar(ctx context.Context, deps Deps, actorUserID, repoID int64, repoIsPublic bool) error {
84 if actorUserID == 0 {
85 return ErrNotLoggedIn
86 }
87 if err := hitStarLimit(ctx, deps, actorUserID); err != nil {
88 return err
89 }
90
91 tx, err := deps.Pool.Begin(ctx)
92 if err != nil {
93 return err
94 }
95 committed := false
96 defer func() {
97 if !committed {
98 _ = tx.Rollback(ctx)
99 }
100 }()
101
102 q := socialdb.New()
103 if err := q.DeleteStar(ctx, tx, socialdb.DeleteStarParams{
104 UserID: actorUserID, RepoID: repoID,
105 }); err != nil {
106 return fmt.Errorf("delete star: %w", err)
107 }
108 if _, err := q.InsertDomainEvent(ctx, tx, socialdb.InsertDomainEventParams{
109 ActorUserID: pgtype.Int8{Int64: actorUserID, Valid: true},
110 Kind: "unstar",
111 RepoID: pgtype.Int8{Int64: repoID, Valid: true},
112 SourceKind: "repo",
113 SourceID: repoID,
114 Public: repoIsPublic,
115 Payload: []byte("{}"),
116 }); err != nil {
117 return fmt.Errorf("insert event: %w", err)
118 }
119 if err := tx.Commit(ctx); err != nil {
120 return err
121 }
122 committed = true
123 if deps.Audit != nil {
124 _ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
125 audit.ActionStarDeleted, audit.TargetRepo, repoID, nil)
126 }
127 return nil
128 }
129
130 // HasStar reports whether the user has starred the repo. Used by the
131 // star button on the repo page to render the toggled state.
132 func HasStar(ctx context.Context, deps Deps, userID, repoID int64) (bool, error) {
133 if userID == 0 {
134 return false, nil
135 }
136 return socialdb.New().HasStar(ctx, deps.Pool, socialdb.HasStarParams{
137 UserID: userID, RepoID: repoID,
138 })
139 }
140
141 func hitStarLimit(ctx context.Context, deps Deps, userID int64) error {
142 if deps.Limiter == nil {
143 return nil
144 }
145 if err := deps.Limiter.Hit(ctx, deps.Pool, throttle.Limit{
146 Scope: "star",
147 Identifier: fmt.Sprintf("user:%d", userID),
148 Max: starRateMax,
149 Window: starRateWindow,
150 }); err != nil {
151 return ErrStarRateLimit
152 }
153 return nil
154 }
155