3 final class PhabricatorSystemActionEngine
extends Phobject
{
6 * Prepare to take an action, throwing an exception if the user has exceeded
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
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
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
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
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.
41 public static function willTakeAction(
43 PhabricatorSystemAction
$action,
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.
49 $blocked = self
::loadBlockedActors($actors, $action, $score);
51 foreach ($blocked as $actor => $actor_score) {
52 throw new PhabricatorSystemActionRateLimitException(
60 $unguarded = AphrontWriteGuard
::beginScopedUnguardedWrites();
61 self
::recordAction($actors, $action, $score);
66 public static function loadBlockedActors(
68 PhabricatorSystemAction
$action,
71 $scores = self
::loadScores($actors, $action);
72 $window = self
::getWindow();
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
86 $blocked[$actor] = $actor_score +
($score / $window);
93 public static function loadScores(
95 PhabricatorSystemAction
$action) {
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');
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');
126 foreach ($actor_hashes as $digest => $actor) {
127 $score = idx($rows, $digest, 0);
128 $scores[$actor] = ($score / $window);
134 private static function recordAction(
136 PhabricatorSystemAction
$action,
139 $log = new PhabricatorSystemActionLog();
140 $conn_w = $log->establishConnection('w');
143 foreach ($actors as $actor) {
146 '(%s, %s, %s, %f, %d)',
147 PhabricatorHash
::digestForIndex($actor),
149 $action->getActionConstant(),
154 foreach (PhabricatorLiskDAO
::chunkSQL($sql) as $chunk) {
157 'INSERT INTO %T (actorHash, actorIdentity, action, score, epoch)
159 $log->getTableName(),
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();
187 foreach ($actors as $actor) {
188 $hashes[] = PhabricatorHash
::digestForIndex($actor);
194 WHERE actorHash IN (%Ls) AND epoch BETWEEN %d AND %d',
195 $log->getTableName(),
197 $now - self
::getWindow(),
200 return $conn_w->getAffectedRows();
203 public static function newActorFromRequest(AphrontRequest
$request) {
204 return $request->getRemoteAddress();