feat(rate_limits) (#1353)

Добавляет возможность ограничить такие действия как отправка подарка,
заявка в друзья, джойн в группу, создание группы на время, чтобы можно
было создать только 5 групп за день итд. Находится в ветке конфига
preferences>security>rateLimits>eventsLimit. На момент написания этого
текста регулирование постинга и отправки заявки в друзья не было
продублировано в презентеры, мб так и оставить.
This commit is contained in:
mr❤️🤢 2025-06-29 17:11:33 +03:00 committed by GitHub
parent 4f126d1b05
commit 93b1202a13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 213 additions and 3 deletions

View file

@ -98,6 +98,10 @@ final class Friends extends VKAPIRequestHandler
switch ($user->getSubscriptionStatus($this->getUser())) { switch ($user->getSubscriptionStatus($this->getUser())) {
case 0: case 0:
if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "friends.outgoing_sub")) {
$this->failTooOften();
}
$user->toggleSubscription($this->getUser()); $user->toggleSubscription($this->getUser());
return 1; return 1;

View file

@ -61,6 +61,10 @@ final class Gifts extends VKAPIRequestHandler
$this->fail(-105, "Commerce is disabled on this instance"); $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) $user = (new UsersRepo())->get((int) $user_ids); # FAKE прогноз погоды (в данном случае user_ids)
if (!$user || $user->isDeleted()) { if (!$user || $user->isDeleted()) {

View file

@ -312,6 +312,10 @@ final class Groups extends VKAPIRequestHandler
$isMember = !is_null($this->getUser()) ? (int) $club->getSubscriptionStatus($this->getUser()) : 0; $isMember = !is_null($this->getUser()) ? (int) $club->getSubscriptionStatus($this->getUser()) : 0;
if ($isMember == 0) { if ($isMember == 0) {
if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "groups.sub")) {
$this->failTooOften();
}
$club->toggleSubscription($this->getUser()); $club->toggleSubscription($this->getUser());
} }

View file

@ -25,6 +25,11 @@ abstract class VKAPIRequestHandler
throw new APIErrorException($message, $code); throw new APIErrorException($message, $code);
} }
protected function failTooOften(): never
{
$this->fail(9, "Rate limited");
}
protected function getUser(): ?User protected function getUser(): ?User
{ {
return $this->user; return $this->user;

View file

@ -713,6 +713,10 @@ final class Wall extends VKAPIRequestHandler
$post->setSuggested(1); $post->setSuggested(1);
} }
if (\openvk\Web\Util\EventRateLimiter::i()->tryToLimit($this->getUser(), "wall.post")) {
$this->failTooOften();
}
$post->save(); $post->save();
} catch (\LogicException $ex) { } catch (\LogicException $ex) {
$this->fail(100, "One of the parameters specified was missing or invalid"); $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()) { 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()]; return (object) ["post_id" => $post->getVirtualId()];

View file

@ -34,9 +34,9 @@ trait TSubscribable
"target" => $this->getId(), "target" => $this->getId(),
]; ];
$sub = $ctx->table("subscriptions")->where($data); $sub = $ctx->table("subscriptions")->where($data);
if (!($sub->fetch())) { if (!($sub->fetch())) {
$ctx->table("subscriptions")->insert($data); $ctx->table("subscriptions")->insert($data);
return true; return true;
} }

View file

@ -1738,4 +1738,52 @@ class User extends RowModel
{ {
return DatabaseConnection::i()->getContext()->table("blacklist_relations")->where("author", $this->getId())->count(); 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();
}
} }

View file

@ -106,6 +106,10 @@ final class GiftsPresenter extends OpenVKPresenter
return; 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; $comment = empty($c = $this->postParam("comment")) ? null : $c;
$notification = new GiftNotification($user, $this->user->identity, $gift, $comment); $notification = new GiftNotification($user, $this->user->identity, $gift, $comment);
$notification->emit(); $notification->emit();

View file

@ -68,6 +68,10 @@ final class GroupPresenter extends OpenVKPresenter
$club->setAbout(empty($this->postParam("about")) ? null : $this->postParam("about")); $club->setAbout(empty($this->postParam("about")) ? null : $this->postParam("about"));
$club->setOwner($this->user->id); $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 { try {
$club->save(); $club->save();
} catch (\PDOException $ex) { } catch (\PDOException $ex) {
@ -79,6 +83,7 @@ final class GroupPresenter extends OpenVKPresenter
} }
$club->toggleSubscription($this->user->identity); $club->toggleSubscription($this->user->identity);
$this->redirect("/club" . $club->getId()); $this->redirect("/club" . $club->getId());
} else { } else {
$this->flashFail("err", tr("error"), tr("error_no_group_name")); $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")); $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); $club->toggleSubscription($this->user->identity);
$this->redirect($club->getURL()); $this->redirect($club->getURL());

View file

@ -103,6 +103,10 @@ final class ReportPresenter extends OpenVKPresenter
exit(json_encode([ "error" => "You can't report yourself" ])); 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 (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) { if (count(iterator_to_array($this->reports->getDuplicates($this->queryParam("type"), $id, null, $this->user->id))) <= 0) {
$report = new Report(); $report = new Report();

View file

@ -418,6 +418,12 @@ final class UserPresenter extends OpenVKPresenter
if ($this->postParam("act") == "rej") { if ($this->postParam("act") == "rej") {
$user->changeFlags($this->user->identity, 0b10000000, true); $user->changeFlags($this->user->identity, 0b10000000, true);
} else { } 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); $user->toggleSubscription($this->user->identity);
} }

View file

@ -356,6 +356,10 @@ final class WallPresenter extends OpenVKPresenter
$this->flashFail("err", tr("failed_to_publish_post"), tr("post_is_empty_or_too_big")); $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; $should_be_suggested = $wall < 0 && !$wallOwner->canBeModifiedBy($this->user->identity) && $wallOwner->getWallType() == 2;
try { try {
$post = new Post(); $post = new Post();

View file

@ -67,7 +67,7 @@
}, },
success: (response) => { success: (response) => {
if (response?.reports?.length != _content) { if (response?.reports?.length != _content) {
NewNotification("Обратите внимание", "В списке появились новые жалобы. Работа ждёт :)"); // NewNotification("Обратите внимание", "В списке появились новые жалобы. Работа ждёт :)");
} }
if (response.reports.length > 0) { if (response.reports.length > 0) {

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace openvk\Web\Util;
use openvk\Web\Models\Entities\User;
use openvk\Web\Models\RowModel;
use Chandler\Patterns\TSimpleSingleton;
class EventRateLimiter
{
use TSimpleSingleton;
private $config;
public function __construct()
{
$this->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;
}
}

View file

@ -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`;

View file

@ -1657,6 +1657,8 @@
"error_geolocation" = "Error while trying to pin geolocation"; "error_geolocation" = "Error while trying to pin geolocation";
"error_no_geotag" = "There is no geo-tag pinned in this post"; "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 */ /* Admin actions */
"login_as" = "Login as $1"; "login_as" = "Login as $1";

View file

@ -1561,6 +1561,8 @@
"error_geolocation" = "Ошибка при прикреплении геометки"; "error_geolocation" = "Ошибка при прикреплении геометки";
"error_no_geotag" = "У поста не указана гео-метка"; "error_no_geotag" = "У поста не указана гео-метка";
"limit_exceed_exception" = "Вы совершаете это действие слишком часто. Повторите позже.";
/* Admin actions */ /* Admin actions */
"login_as" = "Войти как $1"; "login_as" = "Войти как $1";

View file

@ -41,6 +41,16 @@ openvk:
maxViolations: 50 maxViolations: 50
maxViolationsAge: 120 maxViolationsAge: 120
autoban: true 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: blacklists:
limit: 100 limit: 100
applyToAdmins: true applyToAdmins: true