diff --git a/VKAPI/Handlers/Friends.php b/VKAPI/Handlers/Friends.php index 77de7d9d..4c109e13 100644 --- a/VKAPI/Handlers/Friends.php +++ b/VKAPI/Handlers/Friends.php @@ -98,6 +98,10 @@ final class Friends extends VKAPIRequestHandler switch ($user->getSubscriptionStatus($this->getUser())) { case 0: + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "friends.outgoing_sub")) { + $this->failTooOften(); + } + $user->toggleSubscription($this->getUser()); return 1; diff --git a/VKAPI/Handlers/Gifts.php b/VKAPI/Handlers/Gifts.php index 9ee5d222..460f11df 100644 --- a/VKAPI/Handlers/Gifts.php +++ b/VKAPI/Handlers/Gifts.php @@ -61,6 +61,10 @@ final class Gifts extends VKAPIRequestHandler $this->fail(-105, "Commerce is disabled on this instance"); } + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "gifts.send", false)) { + $this->failTooOften(); + } + $user = (new UsersRepo())->get((int) $user_ids); # FAKE прогноз погоды (в данном случае user_ids) if (!$user || $user->isDeleted()) { diff --git a/VKAPI/Handlers/Groups.php b/VKAPI/Handlers/Groups.php index 8933ca5a..707dc0f4 100644 --- a/VKAPI/Handlers/Groups.php +++ b/VKAPI/Handlers/Groups.php @@ -312,6 +312,10 @@ final class Groups extends VKAPIRequestHandler $isMember = !is_null($this->getUser()) ? (int) $club->getSubscriptionStatus($this->getUser()) : 0; if ($isMember == 0) { + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "groups.sub")) { + $this->failTooOften(); + } + $club->toggleSubscription($this->getUser()); } diff --git a/VKAPI/Handlers/VKAPIRequestHandler.php b/VKAPI/Handlers/VKAPIRequestHandler.php index 4804a8dd..1f40fceb 100644 --- a/VKAPI/Handlers/VKAPIRequestHandler.php +++ b/VKAPI/Handlers/VKAPIRequestHandler.php @@ -25,6 +25,11 @@ abstract class VKAPIRequestHandler throw new APIErrorException($message, $code); } + protected function failTooOften(): never + { + $this->fail(9, "Rate limited"); + } + protected function getUser(): ?User { return $this->user; diff --git a/VKAPI/Handlers/Wall.php b/VKAPI/Handlers/Wall.php index 9113eb6e..5309f025 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -620,6 +620,10 @@ final class Wall extends VKAPIRequestHandler return (object) ["post_id" => $post->getVirtualId()]; } + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "wall.post", false)) { + $this->failTooOften(); + } + $anon = OPENVK_ROOT_CONF["openvk"]["preferences"]["wall"]["anonymousPosting"]["enable"]; if ($wallOwner instanceof Club && $from_group == 1 && $signed != 1 && $anon) { $manager = $wallOwner->getManager($this->getUser()); @@ -723,9 +727,11 @@ final class Wall extends VKAPIRequestHandler } if ($owner_id > 0 && $owner_id !== $this->getUser()->getId()) { - (new WallPostNotification($wallOwner, $post, $this->user->identity))->emit(); + (new WallPostNotification($wallOwner, $post, $this->getUser()))->emit(); } + \openvk\Web\Util\EventRateLimiter::i()->writeEvent("wall.post", $this->getUser(), $wallOwner); + return (object) ["post_id" => $post->getVirtualId()]; } diff --git a/Web/Models/Entities/Traits/TSubscribable.php b/Web/Models/Entities/Traits/TSubscribable.php index 898f33a8..712b2366 100644 --- a/Web/Models/Entities/Traits/TSubscribable.php +++ b/Web/Models/Entities/Traits/TSubscribable.php @@ -37,6 +37,7 @@ trait TSubscribable if (!($sub->fetch())) { $ctx->table("subscriptions")->insert($data); + return true; } diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index df4da4d0..80a787fa 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -114,6 +114,7 @@ class User extends RowModel public function getChandlerUser(): ChandlerUser { + # TODO cache this function return new ChandlerUser($this->getRecord()->ref("ChandlerUsers", "user")); } @@ -1738,4 +1739,42 @@ class User extends RowModel { return DatabaseConnection::i()->getContext()->table("blacklist_relations")->where("author", $this->getId())->count(); } + + public function recieveEventsData(array $list): array + { + $ev_str = $this->getRecord()->events_counters; + $values = []; + + if (!$ev_str) { + for ($i = 0; $i < sizeof(array_keys($list)); $i++) { + $values[] = 0; + } + } else { + $keys = array_keys($list); + $values = unpack("S*", base64_decode($ev_str)); + } + + return [ + 'counters' => $values, + 'refresh_time' => $this->getRecord()->events_refresh_time, + ]; + } + + public function stateEvents(array $list): void + { + $this->stateChanges("events_counters", base64_encode(pack("S*", array_values($list)))); + } + + public function resetEvents(array $list, int $restriction_length) + { + $values = []; + + for ($i = 0; $i < sizeof(array_keys($list)); $i++) { + $values[] = 0; + } + + $this->stateEvents($values); + $this->stateChanges("events_refresh_time", $restriction_length + time()); + $this->save(); + } } diff --git a/Web/Presenters/GiftsPresenter.php b/Web/Presenters/GiftsPresenter.php index 007be976..f945c342 100644 --- a/Web/Presenters/GiftsPresenter.php +++ b/Web/Presenters/GiftsPresenter.php @@ -106,6 +106,10 @@ final class GiftsPresenter extends OpenVKPresenter return; } + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "gifts.send", false)) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + $comment = empty($c = $this->postParam("comment")) ? null : $c; $notification = new GiftNotification($user, $this->user->identity, $gift, $comment); $notification->emit(); diff --git a/Web/Presenters/GroupPresenter.php b/Web/Presenters/GroupPresenter.php index a38dacaa..96f69083 100644 --- a/Web/Presenters/GroupPresenter.php +++ b/Web/Presenters/GroupPresenter.php @@ -63,6 +63,10 @@ final class GroupPresenter extends OpenVKPresenter if ($_SERVER["REQUEST_METHOD"] === "POST") { if (!empty($this->postParam("name")) && mb_strlen(trim($this->postParam("name"))) > 0) { + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "groups.create")) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + $club = new Club(); $club->setName($this->postParam("name")); $club->setAbout(empty($this->postParam("about")) ? null : $this->postParam("about")); @@ -79,6 +83,7 @@ final class GroupPresenter extends OpenVKPresenter } $club->toggleSubscription($this->user->identity); + $this->redirect("/club" . $club->getId()); } else { $this->flashFail("err", tr("error"), tr("error_no_group_name")); diff --git a/Web/Util/EventRateLimiter.php b/Web/Util/EventRateLimiter.php new file mode 100644 index 00000000..7d3b0862 --- /dev/null +++ b/Web/Util/EventRateLimiter.php @@ -0,0 +1,123 @@ +config = OPENVK_ROOT_CONF["openvk"]["preferences"]["security"]["rateLimits"]["eventsLimit"]; + + $this->availableFields = array_keys($this->config['list']); + } + + /* + Checks count of actions for last hours + + Uses config path OPENVK_ROOT_CONF["openvk"]["preferences"]["security"]["rateLimits"]["eventsLimit"] + + Returns: + + true — limit has exceed and the action must be restricted + + false — the action can be performed + */ + public function tryToLimit(?User $user, string $event_type, bool $distinct = true): bool + { + $isEnabled = $this->config['enable']; + $isIgnoreForAdmins = $this->config['ignoreForAdmins']; + $restrictionTime = $this->config['restrictionTime']; + $eventsList = $this->config['list']; + + if (!$isEnabled) { + return false; + } + + if ($isIgnoreForAdmins && $user->isAdmin()) { + return false; + } + + $limitForThatEvent = $eventsList[$event_type]; + $stat = $this->getEvent($event_type, $user); + bdump($stat); + + $is_restrict_over = $stat["refresh_time"] < time() - $restrictionTime; + + if ($is_restrict_over) { + $user->resetEvents($eventsList, $restrictionTime); + + return false; + } + + $is = $stat["compared"] > $limitForThatEvent; + + if ($is === false) { + $this->incrementEvent($event_type, $user); + } + + return $is; + } + + public function getEvent(string $event_type, User $by_user): array + { + $ev_data = $by_user->recieveEventsData($this->config['list']); + $values = $ev_data['counters']; + $i = 0; + + $compared = []; + bdump($values); + + foreach ($this->config['list'] as $name => $value) { + bdump($value); + $compared[$name] = $values[$i]; + $i += 1; + } + + return [ + "compared" => $compared, + "refresh_time" => $ev_data["refresh_time"] + ]; + } + + /* + Updates counter for user + */ + public function incrementEvent(string $event_type, User $initiator): bool + { + $isEnabled = $this->config['enable']; + $eventsList = OPENVK_ROOT_CONF["openvk"]["preferences"]["security"]["rateLimits"]["eventsLimit"]; + + if (!$isEnabled) { + return false; + } + + $ev_data = $initiator->recieveEventsData($eventsList); + $values = $ev_data['counters']; + $i = 0; + + $compared = []; + + foreach ($eventsList as $name => $value) { + $compared[$name] = $values[$i]; + $i += 1; + } + + $compared[$event_type] += 1; + + bdump($compared); + $initiator->stateEvents($compared); + + return true; + } +} diff --git a/install/sqls/00057-event-limiting.sql b/install/sqls/00057-event-limiting.sql new file mode 100644 index 00000000..bf34f5be --- /dev/null +++ b/install/sqls/00057-event-limiting.sql @@ -0,0 +1,3 @@ +ALTER TABLE `profiles` +ADD `events_counters` VARCHAR(299) NULL DEFAULT NULL AFTER `audio_broadcast_enabled`, +ADD `events_refresh_time` BIGINT(20) UNSIGNED NULL DEFAULT NULL AFTER `events_counters`; diff --git a/locales/en.strings b/locales/en.strings index 1632b165..48576550 100644 --- a/locales/en.strings +++ b/locales/en.strings @@ -1657,6 +1657,8 @@ "error_geolocation" = "Error while trying to pin geolocation"; "error_no_geotag" = "There is no geo-tag pinned in this post"; +"limit_exceed_exception" = "You're doing this action too often. Try again later."; + /* Admin actions */ "login_as" = "Login as $1"; diff --git a/locales/ru.strings b/locales/ru.strings index 6366b747..9d116554 100644 --- a/locales/ru.strings +++ b/locales/ru.strings @@ -1561,6 +1561,8 @@ "error_geolocation" = "Ошибка при прикреплении геометки"; "error_no_geotag" = "У поста не указана гео-метка"; +"limit_exceed_exception" = "Вы совершаете это действие слишком часто. Повторите позже."; + /* Admin actions */ "login_as" = "Войти как $1"; diff --git a/openvk-example.yml b/openvk-example.yml index 525d7cf8..19fb16bc 100644 --- a/openvk-example.yml +++ b/openvk-example.yml @@ -41,6 +41,16 @@ openvk: maxViolations: 50 maxViolationsAge: 120 autoban: true + eventsLimit: + enable: true + ignoreForAdmins: true + restrictionTime: 86400 + list: + groups.create: 5 + groups.sub: 50 + friends.outgoing_sub: 25 + wall.post: 5000 + gifts.send: 20 blacklists: limit: 100 applyToAdmins: true