From 93b1202a133dd748f808c4c2aee8d4e088d117db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?mr=E2=9D=A4=EF=B8=8F=F0=9F=A4=A2?= <99399973+mrilyew@users.noreply.github.com> Date: Sun, 29 Jun 2025 17:11:33 +0300 Subject: [PATCH] feat(rate_limits) (#1353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет возможность ограничить такие действия как отправка подарка, заявка в друзья, джойн в группу, создание группы на время, чтобы можно было создать только 5 групп за день итд. Находится в ветке конфига preferences>security>rateLimits>eventsLimit. На момент написания этого текста регулирование постинга и отправки заявки в друзья не было продублировано в презентеры, мб так и оставить. --- VKAPI/Handlers/Friends.php | 4 + VKAPI/Handlers/Gifts.php | 4 + VKAPI/Handlers/Groups.php | 4 + VKAPI/Handlers/VKAPIRequestHandler.php | 5 ++ VKAPI/Handlers/Wall.php | 6 +- Web/Models/Entities/Traits/TSubscribable.php | 2 +- Web/Models/Entities/User.php | 48 ++++++++++ Web/Presenters/GiftsPresenter.php | 4 + Web/Presenters/GroupPresenter.php | 11 +++ Web/Presenters/ReportPresenter.php | 4 + Web/Presenters/UserPresenter.php | 6 ++ Web/Presenters/WallPresenter.php | 4 + Web/Presenters/templates/Report/Tabs.xml | 2 +- Web/Util/EventRateLimiter.php | 95 ++++++++++++++++++++ install/sqls/00057-event-limiting.sql | 3 + locales/en.strings | 2 + locales/ru.strings | 2 + openvk-example.yml | 10 +++ 18 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 Web/Util/EventRateLimiter.php create mode 100644 install/sqls/00057-event-limiting.sql 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..f1fdf978 100644 --- a/VKAPI/Handlers/Wall.php +++ b/VKAPI/Handlers/Wall.php @@ -713,6 +713,10 @@ final class Wall extends VKAPIRequestHandler $post->setSuggested(1); } + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "wall.post")) { + $this->failTooOften(); + } + $post->save(); } catch (\LogicException $ex) { $this->fail(100, "One of the parameters specified was missing or invalid"); @@ -723,7 +727,7 @@ 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(); } return (object) ["post_id" => $post->getVirtualId()]; diff --git a/Web/Models/Entities/Traits/TSubscribable.php b/Web/Models/Entities/Traits/TSubscribable.php index 898f33a8..2e3f3bcd 100644 --- a/Web/Models/Entities/Traits/TSubscribable.php +++ b/Web/Models/Entities/Traits/TSubscribable.php @@ -34,9 +34,9 @@ trait TSubscribable "target" => $this->getId(), ]; $sub = $ctx->table("subscriptions")->where($data); - 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..2f336f4a 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -1738,4 +1738,52 @@ class User extends RowModel { return DatabaseConnection::i()->getContext()->table("blacklist_relations")->where("author", $this->getId())->count(); } + + public function getEventCounters(array $list): array + { + $count_of_keys = sizeof(array_keys($list)); + $ev_str = $this->getRecord()->events_counters; + $counters = []; + + if (!$ev_str) { + for ($i = 0; $i < sizeof(array_keys($list)); $i++) { + $counters[] = 0; + } + } else { + $counters = unpack("S" . $count_of_keys, base64_decode($ev_str, true)); + } + + return [ + 'counters' => array_combine(array_keys($list), $counters), + 'refresh_time' => $this->getRecord()->events_refresh_time, + ]; + } + + public function stateEvents(array $state_list): void + { + $pack_str = ""; + + foreach ($state_list as $item => $id) { + $pack_str .= "S"; + } + + $this->stateChanges("events_counters", base64_encode(pack($pack_str, ...array_values($state_list)))); + + if (!$this->getRecord()->events_refresh_time) { + $this->stateChanges("events_refresh_time", time()); + } + } + + public function resetEvents(array $list): void + { + $values = []; + + foreach ($list as $key => $val) { + $values[$key] = 0; + } + + $this->stateEvents($values); + $this->stateChanges("events_refresh_time", time()); + $this->save(); + } } diff --git a/Web/Presenters/GiftsPresenter.php b/Web/Presenters/GiftsPresenter.php index 007be976..ec2993a4 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")) { + $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..99d5fa33 100644 --- a/Web/Presenters/GroupPresenter.php +++ b/Web/Presenters/GroupPresenter.php @@ -68,6 +68,10 @@ final class GroupPresenter extends OpenVKPresenter $club->setAbout(empty($this->postParam("about")) ? null : $this->postParam("about")); $club->setOwner($this->user->id); + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "groups.create")) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + try { $club->save(); } catch (\PDOException $ex) { @@ -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")); @@ -103,6 +108,12 @@ final class GroupPresenter extends OpenVKPresenter $this->flashFail("err", tr("error"), tr("forbidden")); } + if (!$club->getSubscriptionStatus($this->user->identity)) { + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "groups.sub")) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + } + $club->toggleSubscription($this->user->identity); $this->redirect($club->getURL()); diff --git a/Web/Presenters/ReportPresenter.php b/Web/Presenters/ReportPresenter.php index f58eaa5c..19254ab0 100644 --- a/Web/Presenters/ReportPresenter.php +++ b/Web/Presenters/ReportPresenter.php @@ -103,6 +103,10 @@ final class ReportPresenter extends OpenVKPresenter exit(json_encode([ "error" => "You can't report yourself" ])); } + if ($this->user->identity->isBannedInSupport()) { + exit(json_encode([ "reason" => $this->queryParam("reason") ])); + } + if (in_array($this->queryParam("type"), ["post", "photo", "video", "group", "comment", "note", "app", "user", "audio", "doc"])) { if (count(iterator_to_array($this->reports->getDuplicates($this->queryParam("type"), $id, null, $this->user->id))) <= 0) { $report = new Report(); diff --git a/Web/Presenters/UserPresenter.php b/Web/Presenters/UserPresenter.php index 7d152df4..869d601e 100644 --- a/Web/Presenters/UserPresenter.php +++ b/Web/Presenters/UserPresenter.php @@ -418,6 +418,12 @@ final class UserPresenter extends OpenVKPresenter if ($this->postParam("act") == "rej") { $user->changeFlags($this->user->identity, 0b10000000, true); } else { + if ($user->getSubscriptionStatus($this->user->identity) == \openvk\Web\Models\Entities\User::SUBSCRIPTION_ABSENT) { + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "friends.outgoing_sub")) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + } + $user->toggleSubscription($this->user->identity); } diff --git a/Web/Presenters/WallPresenter.php b/Web/Presenters/WallPresenter.php index dbdfdde0..53e662f6 100644 --- a/Web/Presenters/WallPresenter.php +++ b/Web/Presenters/WallPresenter.php @@ -356,6 +356,10 @@ final class WallPresenter extends OpenVKPresenter $this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big")); } + if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->user->identity, "wall.post")) { + $this->flashFail("err", tr("error"), tr("limit_exceed_exception")); + } + $should_be_suggested = $wall < 0 && !$wallOwner->canBeModifiedBy($this->user->identity) && $wallOwner->getWallType() == 2; try { $post = new Post(); diff --git a/Web/Presenters/templates/Report/Tabs.xml b/Web/Presenters/templates/Report/Tabs.xml index 1107b44c..8cf548dd 100644 --- a/Web/Presenters/templates/Report/Tabs.xml +++ b/Web/Presenters/templates/Report/Tabs.xml @@ -67,7 +67,7 @@ }, success: (response) => { if (response?.reports?.length != _content) { - NewNotification("Обратите внимание", "В списке появились новые жалобы. Работа ждёт :)"); + // NewNotification("Обратите внимание", "В списке появились новые жалобы. Работа ждёт :)"); } if (response.reports.length > 0) { diff --git a/Web/Util/EventRateLimiter.php b/Web/Util/EventRateLimiter.php new file mode 100644 index 00000000..b7d31657 --- /dev/null +++ b/Web/Util/EventRateLimiter.php @@ -0,0 +1,95 @@ +config = OPENVK_ROOT_CONF["openvk"]["preferences"]["security"]["rateLimits"]["eventsLimit"]; + } + + public function tryToLimit(?User $user, string $event_type, bool $is_update = true): bool + { + /* + Checks count of actions for last x seconds + + Uses OPENVK_ROOT_CONF["openvk"]["preferences"]["security"]["rateLimits"]["eventsLimit"] + + This check should be peformed only after checking other conditions cuz by default it increments counter + + Returns: + + true — limit has exceed and the action must be restricted + + false — the action can be performed + + Also returns "true" if this option is disabled + */ + + $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; + } + + $eventsStats = $user->getEventCounters($eventsList); + $limitForThatEvent = $eventsList[$event_type]; + + $counters = $eventsStats["counters"]; + $refresh_time = $eventsStats["refresh_time"]; + $is_restrict_over = $refresh_time < (time() - $restrictionTime); + $event_counter = $counters[$event_type]; + + if ($refresh_time && $is_restrict_over) { + $user->resetEvents($eventsList); + + return false; + } + + $is_limit_exceed = $event_counter >= $limitForThatEvent; + + if (!$is_limit_exceed && $is_update) { + $this->incrementEvent($counters, $event_type, $user); + } + + return $is_limit_exceed; + } + + public function incrementEvent(array $old_values, string $event_type, User $initiator): bool + { + /* + Updates counter for user + */ + $isEnabled = $this->config['enable']; + $eventsList = $this->config['list']; + + if (!$isEnabled) { + return false; + } + + $old_values[$event_type] += 1; + + $initiator->stateEvents($old_values); + $initiator->save(); + + 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 40ba188e..a13173b7 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..c1b483ac 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: 30 blacklists: limit: 100 applyToAdmins: true