@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 207 lines 6.6 kB view raw
1<?php 2 3final class PhabricatorSystemActionEngine extends Phobject { 4 5 /** 6 * Prepare to take an action, throwing an exception if the user has exceeded 7 * the rate limit. 8 * 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. 13 * 14 * The `$action` defines the actual thing being rate limited, and sets the 15 * limit. 16 * 17 * You can pass either a positive, zero, or negative `$score` to this method: 18 * 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. 32 * 33 * If any actor is exceeding their rate limit, this method throws a 34 * @{class:PhabricatorSystemActionRateLimitException}. 35 * 36 * @param list<string> $actors List of actors. 37 * @param PhabricatorSystemAction $action Action being taken. 38 * @param float $score Score or credit, see above. 39 * @return void 40 */ 41 public static function willTakeAction( 42 array $actors, 43 PhabricatorSystemAction $action, 44 $score) { 45 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); 55 } 56 } 57 } 58 59 if ($score != 0) { 60 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 61 self::recordAction($actors, $action, $score); 62 unset($unguarded); 63 } 64 } 65 66 public static function loadBlockedActors( 67 array $actors, 68 PhabricatorSystemAction $action, 69 $score) { 70 71 $scores = self::loadScores($actors, $action); 72 $window = self::getWindow(); 73 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); 87 } 88 } 89 90 return $blocked; 91 } 92 93 public static function loadScores( 94 array $actors, 95 PhabricatorSystemAction $action) { 96 97 if (!$actors) { 98 return array(); 99 } 100 101 $actor_hashes = array(); 102 foreach ($actors as $actor) { 103 $digest = PhabricatorHash::digestForIndex($actor); 104 $actor_hashes[$digest] = $actor; 105 } 106 107 $log = new PhabricatorSystemActionLog(); 108 109 $window = self::getWindow(); 110 111 $conn = $log->establishConnection('r'); 112 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)); 122 123 $rows = ipull($rows, 'totalScore', 'actorHash'); 124 125 $scores = array(); 126 foreach ($actor_hashes as $digest => $actor) { 127 $score = idx($rows, $digest, 0); 128 $scores[$actor] = ($score / $window); 129 } 130 131 return $scores; 132 } 133 134 private static function recordAction( 135 array $actors, 136 PhabricatorSystemAction $action, 137 $score) { 138 139 $log = new PhabricatorSystemActionLog(); 140 $conn_w = $log->establishConnection('w'); 141 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()); 152 } 153 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); 161 } 162 } 163 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'); 170 } 171 172 173 /** 174 * Reset all action counts for actions taken by some set of actors in the 175 * previous action window. 176 * 177 * @param list<string> $actors Actors to reset counts for. 178 * @return int Number of actions cleared. 179 */ 180 public static function resetActions(array $actors) { 181 $log = new PhabricatorSystemActionLog(); 182 $conn_w = $log->establishConnection('w'); 183 184 $now = PhabricatorTime::getNow(); 185 186 $hashes = array(); 187 foreach ($actors as $actor) { 188 $hashes[] = PhabricatorHash::digestForIndex($actor); 189 } 190 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); 199 200 return $conn_w->getAffectedRows(); 201 } 202 203 public static function newActorFromRequest(AphrontRequest $request) { 204 return $request->getRemoteAddress(); 205 } 206 207}