Generate file attachment transactions for explicit Remarkup attachments on common...
[phabricator.git] / src / applications / system / engine / PhabricatorSystemActionEngine.php
blob6d6f9eacfd890a07e84f5a34a67569d5432b257b
1 <?php
3 final class PhabricatorSystemActionEngine extends Phobject {
5 /**
6 * Prepare to take an action, throwing an exception if the user has exceeded
7 * the rate limit.
9 * The `$actors` are a list of strings. Normally this will be a list of
10 * user PHIDs, but some systems use other identifiers (like email
11 * addresses). Each actor's score threshold is tracked independently. If
12 * any actor exceeds the rate limit for the action, this method throws.
14 * The `$action` defines the actual thing being rate limited, and sets the
15 * limit.
17 * You can pass either a positive, zero, or negative `$score` to this method:
19 * - If the score is positive, the user is given that many points toward
20 * the rate limit after the limit is checked. Over time, this will cause
21 * them to hit the rate limit and be prevented from taking further
22 * actions.
23 * - If the score is zero, the rate limit is checked but no score changes
24 * are made. This allows you to check for a rate limit before beginning
25 * a workflow, so the user doesn't fill in a form only to get rate limited
26 * at the end.
27 * - If the score is negative, the user is credited points, allowing them
28 * to take more actions than the limit normally permits. By awarding
29 * points for failed actions and credits for successful actions, a
30 * system can be sensitive to failure without overly restricting
31 * legitimate uses.
33 * If any actor is exceeding their rate limit, this method throws a
34 * @{class:PhabricatorSystemActionRateLimitException}.
36 * @param list<string> List of actors.
37 * @param PhabricatorSystemAction Action being taken.
38 * @param float Score or credit, see above.
39 * @return void
41 public static function willTakeAction(
42 array $actors,
43 PhabricatorSystemAction $action,
44 $score) {
46 // If the score for this action is negative, we're giving the user a credit,
47 // so don't bother checking if they're blocked or not.
48 if ($score >= 0) {
49 $blocked = self::loadBlockedActors($actors, $action, $score);
50 if ($blocked) {
51 foreach ($blocked as $actor => $actor_score) {
52 throw new PhabricatorSystemActionRateLimitException(
53 $action,
54 $actor_score);
59 if ($score != 0) {
60 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
61 self::recordAction($actors, $action, $score);
62 unset($unguarded);
66 public static function loadBlockedActors(
67 array $actors,
68 PhabricatorSystemAction $action,
69 $score) {
71 $scores = self::loadScores($actors, $action);
72 $window = self::getWindow();
74 $blocked = array();
75 foreach ($scores as $actor => $actor_score) {
76 // For the purposes of checking for a block, we just use the raw
77 // persistent score and do not include the score for this action. This
78 // allows callers to test for a block without adding any points and get
79 // the same result they would if they were adding points: we only
80 // trigger a rate limit when the persistent score exceeds the threshold.
81 if ($action->shouldBlockActor($actor, $actor_score)) {
82 // When reporting the results, we do include the points for this
83 // action. This makes the error messages more clear, since they
84 // more accurately report the number of actions the user has really
85 // tried to take.
86 $blocked[$actor] = $actor_score + ($score / $window);
90 return $blocked;
93 public static function loadScores(
94 array $actors,
95 PhabricatorSystemAction $action) {
97 if (!$actors) {
98 return array();
101 $actor_hashes = array();
102 foreach ($actors as $actor) {
103 $digest = PhabricatorHash::digestForIndex($actor);
104 $actor_hashes[$digest] = $actor;
107 $log = new PhabricatorSystemActionLog();
109 $window = self::getWindow();
111 $conn = $log->establishConnection('r');
113 $rows = queryfx_all(
114 $conn,
115 'SELECT actorHash, SUM(score) totalScore FROM %T
116 WHERE action = %s AND actorHash IN (%Ls)
117 AND epoch >= %d GROUP BY actorHash',
118 $log->getTableName(),
119 $action->getActionConstant(),
120 array_keys($actor_hashes),
121 (PhabricatorTime::getNow() - $window));
123 $rows = ipull($rows, 'totalScore', 'actorHash');
125 $scores = array();
126 foreach ($actor_hashes as $digest => $actor) {
127 $score = idx($rows, $digest, 0);
128 $scores[$actor] = ($score / $window);
131 return $scores;
134 private static function recordAction(
135 array $actors,
136 PhabricatorSystemAction $action,
137 $score) {
139 $log = new PhabricatorSystemActionLog();
140 $conn_w = $log->establishConnection('w');
142 $sql = array();
143 foreach ($actors as $actor) {
144 $sql[] = qsprintf(
145 $conn_w,
146 '(%s, %s, %s, %f, %d)',
147 PhabricatorHash::digestForIndex($actor),
148 $actor,
149 $action->getActionConstant(),
150 $score,
151 time());
154 foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) {
155 queryfx(
156 $conn_w,
157 'INSERT INTO %T (actorHash, actorIdentity, action, score, epoch)
158 VALUES %LQ',
159 $log->getTableName(),
160 $chunk);
164 private static function getWindow() {
165 // Limit queries to the last hour of data so we don't need to look at as
166 // many rows. We can use an arbitrarily larger window instead (we normalize
167 // scores to actions per second) but all the actions we care about limiting
168 // have a limit much higher than one action per hour.
169 return phutil_units('1 hour in seconds');
174 * Reset all action counts for actions taken by some set of actors in the
175 * previous action window.
177 * @param list<string> Actors to reset counts for.
178 * @return int Number of actions cleared.
180 public static function resetActions(array $actors) {
181 $log = new PhabricatorSystemActionLog();
182 $conn_w = $log->establishConnection('w');
184 $now = PhabricatorTime::getNow();
186 $hashes = array();
187 foreach ($actors as $actor) {
188 $hashes[] = PhabricatorHash::digestForIndex($actor);
191 queryfx(
192 $conn_w,
193 'DELETE FROM %T
194 WHERE actorHash IN (%Ls) AND epoch BETWEEN %d AND %d',
195 $log->getTableName(),
196 $hashes,
197 $now - self::getWindow(),
198 $now);
200 return $conn_w->getAffectedRows();
203 public static function newActorFromRequest(AphrontRequest $request) {
204 return $request->getRemoteAddress();