mirror of
https://github.com/openvk/openvk
synced 2024-11-14 02:59:12 +03:00
Add logging system (#940)
* Логи * Update DBEntity.updated.php * Сбор IP и UserAgent + фикс логирования в IPs * Fixes * Совместимость с новыми логами * Update Logs.xml * Logs i18n * Update Logs.xml * Update AdminPresenter.php
This commit is contained in:
parent
7f46d683c3
commit
8265dc0fc6
17 changed files with 365 additions and 11 deletions
140
DBEntity.updated.php
Normal file
140
DBEntity.updated.php
Normal file
|
@ -0,0 +1,140 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace Chandler\Database;
|
||||
use Chandler\Database\DatabaseConnection;
|
||||
use Nette\Database\Table\Selection;
|
||||
use Nette\Database\Table\ActiveRow;
|
||||
use Nette\InvalidStateException as ISE;
|
||||
use openvk\Web\Models\Repositories\CurrentUser;
|
||||
use openvk\Web\Models\Repositories\Logs;
|
||||
|
||||
|
||||
abstract class DBEntity
|
||||
{
|
||||
protected $record;
|
||||
protected $changes;
|
||||
protected $deleted;
|
||||
protected $user;
|
||||
|
||||
protected $tableName;
|
||||
|
||||
function __construct(?ActiveRow $row = NULL)
|
||||
{
|
||||
if(is_null($row)) return;
|
||||
|
||||
$_table = $row->getTable()->getName();
|
||||
if($_table !== $this->tableName)
|
||||
throw new ISE("Invalid data supplied for model: table $_table is not compatible with table" . $this->tableName);
|
||||
|
||||
$this->record = $row;
|
||||
}
|
||||
|
||||
function __call(string $fName, array $args)
|
||||
{
|
||||
if(substr($fName, 0, 3) === "set") {
|
||||
$field = mb_strtolower(substr($fName, 3));
|
||||
$this->stateChanges($field, $args[0]);
|
||||
} else {
|
||||
throw new \Error("Call to undefined method " . get_class($this) . "::$fName");
|
||||
}
|
||||
}
|
||||
|
||||
private function getTable(): Selection
|
||||
{
|
||||
return DatabaseConnection::i()->getContext()->table($this->tableName);
|
||||
}
|
||||
|
||||
protected function getRecord(): ?ActiveRow
|
||||
{
|
||||
return $this->record;
|
||||
}
|
||||
|
||||
protected function stateChanges(string $column, $value): void
|
||||
{
|
||||
if(!is_null($this->record))
|
||||
$t = $this->record->{$column}; #Test if column exists
|
||||
|
||||
$this->changes[$column] = $value;
|
||||
}
|
||||
|
||||
function getId()
|
||||
{
|
||||
return $this->getRecord()->id;
|
||||
}
|
||||
|
||||
function isDeleted(): bool
|
||||
{
|
||||
return (bool) $this->getRecord()->deleted;
|
||||
}
|
||||
|
||||
function unwrap(): object
|
||||
{
|
||||
return (object) $this->getRecord()->toArray();
|
||||
}
|
||||
|
||||
function delete(bool $softly = true): void
|
||||
{
|
||||
$user = CurrentUser::i()->getUser();
|
||||
$user_id = is_null($user) ? (int) OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"] : $user->getId();
|
||||
|
||||
if(is_null($this->record))
|
||||
throw new ISE("Can't delete a model, that hasn't been flushed to DB. Have you forgotten to call save() first?");
|
||||
|
||||
(new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 2, $this->record->toArray(), $this->changes);
|
||||
|
||||
if($softly) {
|
||||
$this->record = $this->getTable()->where("id", $this->record->id)->update(["deleted" => true]);
|
||||
} else {
|
||||
$this->record->delete();
|
||||
$this->deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
function undelete(): void
|
||||
{
|
||||
if(is_null($this->record))
|
||||
throw new ISE("Can't undelete a model, that hasn't been flushed to DB. Have you forgotten to call save() first?");
|
||||
|
||||
$user = CurrentUser::i()->getUser();
|
||||
$user_id = is_null($user) ? (int) OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"] : $user->getId();
|
||||
|
||||
(new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 3, $this->record->toArray(), ["deleted" => false]);
|
||||
|
||||
$this->getTable()->where("id", $this->record->id)->update(["deleted" => false]);
|
||||
}
|
||||
|
||||
function save(?bool $log = true): void
|
||||
{
|
||||
if ($log) {
|
||||
$user = CurrentUser::i();
|
||||
$user_id = is_null($user) ? (int)OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"] : $user->getUser()->getId();
|
||||
}
|
||||
|
||||
if(is_null($this->record)) {
|
||||
$this->record = $this->getTable()->insert($this->changes);
|
||||
|
||||
if ($log && $this->getTable()->getName() !== "logs") {
|
||||
(new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 0, $this->record->toArray(), $this->changes);
|
||||
}
|
||||
} else {
|
||||
if ($log && $this->getTable()->getName() !== "logs") {
|
||||
(new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 1, $this->record->toArray(), $this->changes);
|
||||
}
|
||||
|
||||
if ($this->deleted) {
|
||||
$this->record = $this->getTable()->insert((array)$this->record);
|
||||
} else {
|
||||
$this->getTable()->get($this->record->id)->update($this->changes);
|
||||
$this->record = $this->getTable()->get($this->record->id);
|
||||
}
|
||||
}
|
||||
|
||||
$this->changes = [];
|
||||
}
|
||||
|
||||
function getTableName(): string
|
||||
{
|
||||
return $this->getTable()->getName();
|
||||
}
|
||||
|
||||
use \Nette\SmartObject;
|
||||
}
|
|
@ -85,4 +85,9 @@ class Comment extends Post
|
|||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
function getURL(): string
|
||||
{
|
||||
return "/wall" . $this->getTarget()->getPrettyId() . "#_comment" . $this->getId();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ class IP extends RowModel
|
|||
$this->stateChanges("rate_limit_counter", $aCounter);
|
||||
$this->stateChanges("rate_limit_violation_counter_start", $vCounterSessionStart);
|
||||
$this->stateChanges("rate_limit_violation_counter", $vCounter);
|
||||
$this->save();
|
||||
$this->save(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -105,11 +105,11 @@ class IP extends RowModel
|
|||
$this->stateChanges("ip", $ip);
|
||||
}
|
||||
|
||||
function save(): void
|
||||
function save($log): void
|
||||
{
|
||||
if(is_null($this->getRecord()))
|
||||
$this->stateChanges("first_seen", time());
|
||||
|
||||
parent::save();
|
||||
parent::save($log);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1016,7 +1016,7 @@ class User extends RowModel
|
|||
{
|
||||
$this->setOnline(time());
|
||||
$this->setClient_name($platform);
|
||||
$this->save();
|
||||
$this->save(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -1034,7 +1034,7 @@ class User extends RowModel
|
|||
|
||||
function adminNotify(string $message): bool
|
||||
{
|
||||
$admId = OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"];
|
||||
$admId = (int) OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"];
|
||||
if(!$admId)
|
||||
return false;
|
||||
else if(is_null($admin = (new Users)->get($admId)))
|
||||
|
|
49
Web/Models/Repositories/CurrentUser.php
Normal file
49
Web/Models/Repositories/CurrentUser.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Models\Repositories;
|
||||
use openvk\Web\Models\Entities\User;
|
||||
|
||||
class CurrentUser
|
||||
{
|
||||
private static $instance = null;
|
||||
private $user;
|
||||
private $ip;
|
||||
private $useragent;
|
||||
|
||||
public function __construct(?User $user = NULL, ?string $ip = NULL, ?string $useragent = NULL)
|
||||
{
|
||||
if ($user)
|
||||
$this->user = $user;
|
||||
|
||||
if ($ip)
|
||||
$this->ip = $ip;
|
||||
|
||||
if ($useragent)
|
||||
$this->useragent = $useragent;
|
||||
}
|
||||
|
||||
public static function get($user, $ip, $useragent)
|
||||
{
|
||||
if (self::$instance === null) self::$instance = new self($user, $ip, $useragent);
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getIP(): string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $this->useragent;
|
||||
}
|
||||
|
||||
public static function i()
|
||||
{
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ class IPs
|
|||
if(!$res) {
|
||||
$res = new IP;
|
||||
$res->setIp($ip);
|
||||
$res->save();
|
||||
$res->save(false);
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Presenters;
|
||||
use Chandler\Database\Log;
|
||||
use Chandler\Database\Logs;
|
||||
use openvk\Web\Models\Entities\{Voucher, Gift, GiftCategory, User, BannedLink};
|
||||
use openvk\Web\Models\Repositories\{ChandlerGroups, ChandlerUsers, Users, Clubs, Vouchers, Gifts, BannedLinks};
|
||||
use openvk\Web\Models\Repositories\{Bans, ChandlerGroups, ChandlerUsers, Photos, Posts, Users, Clubs, Videos, Vouchers, Gifts, BannedLinks};
|
||||
use Chandler\Database\DatabaseConnection;
|
||||
|
||||
final class AdminPresenter extends OpenVKPresenter
|
||||
|
@ -12,6 +14,7 @@ final class AdminPresenter extends OpenVKPresenter
|
|||
private $gifts;
|
||||
private $bannedLinks;
|
||||
private $chandlerGroups;
|
||||
private $logs;
|
||||
|
||||
function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups)
|
||||
{
|
||||
|
@ -21,6 +24,7 @@ final class AdminPresenter extends OpenVKPresenter
|
|||
$this->gifts = $gifts;
|
||||
$this->bannedLinks = $bannedLinks;
|
||||
$this->chandlerGroups = $chandlerGroups;
|
||||
$this->logs = DatabaseConnection::i()->getContext()->table("ChandlerLogs");
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
@ -551,4 +555,38 @@ final class AdminPresenter extends OpenVKPresenter
|
|||
|
||||
$this->redirect("/admin/users/id" . $user->getId());
|
||||
}
|
||||
|
||||
function renderLogs(): void
|
||||
{
|
||||
$filter = [];
|
||||
|
||||
if ($this->queryParam("id")) {
|
||||
$id = (int) $this->queryParam("id");
|
||||
$filter["id"] = $id;
|
||||
$this->template->id = $id;
|
||||
}
|
||||
if ($this->queryParam("type") !== NULL && $this->queryParam("type") !== "any") {
|
||||
$type = in_array($this->queryParam("type"), [0, 1, 2, 3]) ? (int) $this->queryParam("type") : 0;
|
||||
$filter["type"] = $type;
|
||||
$this->template->type = $type;
|
||||
}
|
||||
if ($this->queryParam("uid")) {
|
||||
$user = $this->queryParam("uid");
|
||||
$filter["user"] = $user;
|
||||
$this->template->user = $user;
|
||||
}
|
||||
if ($this->queryParam("obj_id")) {
|
||||
$obj_id = (int) $this->queryParam("obj_id");
|
||||
$filter["object_id"] = $obj_id;
|
||||
$this->template->obj_id = $obj_id;
|
||||
}
|
||||
if ($this->queryParam("obj_type") !== NULL && $this->queryParam("obj_type") !== "any") {
|
||||
$obj_type = "openvk\\Web\\Models\\Entities\\" . $this->queryParam("obj_type");
|
||||
$filter["object_model"] = $obj_type;
|
||||
$this->template->obj_type = $obj_type;
|
||||
}
|
||||
|
||||
$this->template->logs = (new Logs)->search($filter);
|
||||
$this->template->object_types = (new Logs)->getTypes();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?php declare(strict_types=1);
|
||||
namespace openvk\Web\Presenters;
|
||||
use openvk\Web\Models\Entities\{IP, User, PasswordReset, EmailVerification};
|
||||
use openvk\Web\Models\Repositories\{IPs, Users, Restores, Verifications};
|
||||
use openvk\Web\Models\Repositories\{IPs, Logs, Users, Restores, Verifications};
|
||||
use openvk\Web\Models\Exceptions\InvalidUserNameException;
|
||||
use openvk\Web\Util\Validator;
|
||||
use Chandler\Session\Session;
|
||||
|
@ -110,7 +110,7 @@ final class AuthPresenter extends OpenVKPresenter
|
|||
$this->flashFail("err", tr("failed_to_register"), tr("user_already_exists"));
|
||||
|
||||
$user->setUser($chUser->getId());
|
||||
$user->save();
|
||||
$user->save(false);
|
||||
|
||||
if(!is_null($referer)) {
|
||||
$user->toggleSubscription($referer);
|
||||
|
@ -130,7 +130,9 @@ final class AuthPresenter extends OpenVKPresenter
|
|||
}
|
||||
|
||||
$this->authenticator->authenticate($chUser->getId());
|
||||
(new Logs)->create($user->getId(), "profiles", "openvk\\Web\\Models\\Entities\\User", 0, $user, $user, $_SERVER["REMOTE_ADDR"], $_SERVER["HTTP_USER_AGENT"]);
|
||||
$this->redirect("/id" . $user->getId());
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ use Chandler\Security\Authenticator;
|
|||
use Latte\Engine as TemplatingEngine;
|
||||
use openvk\Web\Models\Entities\IP;
|
||||
use openvk\Web\Themes\Themepacks;
|
||||
use openvk\Web\Models\Repositories\{IPs, Users, APITokens, Tickets};
|
||||
use openvk\Web\Models\Repositories\{CurrentUser, IPs, Users, APITokens, Tickets};
|
||||
use WhichBrowser;
|
||||
|
||||
abstract class OpenVKPresenter extends SimplePresenter
|
||||
|
@ -211,6 +211,7 @@ abstract class OpenVKPresenter extends SimplePresenter
|
|||
$this->user->id = $this->user->identity->getId();
|
||||
$this->template->thisUser = $this->user->identity;
|
||||
$this->template->userTainted = $user->isTainted();
|
||||
CurrentUser::get($this->user->identity, $_SERVER["REMOTE_ADDR"], $_SERVER["HTTP_USER_AGENT"]);
|
||||
|
||||
if($this->user->identity->isDeleted() && !$this->deactivationTolerant) {
|
||||
if($this->user->identity->isDeactivated()) {
|
||||
|
@ -255,7 +256,7 @@ abstract class OpenVKPresenter extends SimplePresenter
|
|||
if($this->user->identity->onlineStatus() == 0 && !($this->user->identity->isDeleted() || $this->user->identity->isBanned())) {
|
||||
$this->user->identity->setOnline(time());
|
||||
$this->user->identity->setClient_name(NULL);
|
||||
$this->user->identity->save();
|
||||
$this->user->identity->save(false);
|
||||
}
|
||||
|
||||
$this->template->ticketAnsweredCount = (new Tickets)->getTicketsCountByUserId($this->user->id, 1);
|
||||
|
|
|
@ -124,6 +124,9 @@
|
|||
<li>
|
||||
<a href="/admin/settings/tuning">{_admin_settings_tuning}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/logs">Логи</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings/appearance">{_admin_settings_appearance}</a>
|
||||
</li>
|
||||
|
|
92
Web/Presenters/templates/Admin/Logs.xml
Normal file
92
Web/Presenters/templates/Admin/Logs.xml
Normal file
|
@ -0,0 +1,92 @@
|
|||
{extends "@layout.xml"}
|
||||
|
||||
{block title}
|
||||
Логи
|
||||
{/block}
|
||||
|
||||
{block heading}
|
||||
Логи
|
||||
{/block}
|
||||
|
||||
{block content}
|
||||
{var $amount = sizeof($logs)}
|
||||
|
||||
<style>
|
||||
del, ins { text-decoration: none; color: #000; }
|
||||
del { background: #fdd; }
|
||||
ins { background: #dfd; }
|
||||
</style>
|
||||
<form class="aui">
|
||||
<div>
|
||||
<select class="select medium-field" type="number" id="type" name="type" placeholder="Тип изменения">
|
||||
<option value="any" n:attr="selected => !$type">Любое</option>
|
||||
<option value="0" n:attr="selected => $type === 0">Создание</option>
|
||||
<option value="1" n:attr="selected => $type === 1">Редактирование</option>
|
||||
<option value="2" n:attr="selected => $type === 2">Удаление</option>
|
||||
<option value="3" n:attr="selected => $type === 3">Восстановление</option>
|
||||
</select>
|
||||
<input class="text medium-field" type="number" id="id" name="id" placeholder="ID записи" n:attr="value => $id"/>
|
||||
<input class="text medium-field" type="text" id="uid" name="uid" placeholder="UUID пользователя" n:attr="value => $user"/>
|
||||
</div>
|
||||
<div style="margin: 8px 0;" />
|
||||
<div>
|
||||
<select class="select medium-field" id="obj_type" name="obj_type" placeholder="Тип объекта">
|
||||
<option value="any" n:attr="selected => !$obj_type">Любой</option>
|
||||
<option n:foreach="$object_types as $type" n:attr="selected => $obj_type === $type">{$type}</option>
|
||||
</select>
|
||||
<input class="text medium-field" type="number" id="obj_id" name="obj_id" placeholder="ID объекта" n:attr="value => $obj_id"/>
|
||||
<input type="submit" class="aui-button aui-button-primary medium-field" value="Поиск" style="width: 165px;"/>
|
||||
</div>
|
||||
</form>
|
||||
<table class="aui aui-table-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Объект</th>
|
||||
<th>Тип</th>
|
||||
<th>Изменения</th>
|
||||
<th>Время</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr n:foreach="$logs as $log">
|
||||
<td>{$log->getId()}</td>
|
||||
<td>
|
||||
<a href="/admin/chandler/user/{$log->getUser()}" target="_blank">{$log->getUser()}</a>
|
||||
</td>
|
||||
<td>
|
||||
<span n:if="$log->getObjectAvatar()" class="aui-avatar aui-avatar-xsmall">
|
||||
<span class="aui-avatar-inner">
|
||||
<img src="{$log->getObjectAvatar()}" alt="{$log->getObjectName()}" style="object-fit: cover;" role="presentation" />
|
||||
</span>
|
||||
</span>
|
||||
<a href="{$log->getObjectURL()}">{$log->getObjectName()}</a>
|
||||
</td>
|
||||
<td>{_$log->getTypeNom()}</td>
|
||||
<td>
|
||||
{foreach $log->getChanges() as $change}
|
||||
<div>
|
||||
<b>{$change["field"]}</b>:
|
||||
{if array_key_exists('diff', $change)}
|
||||
{$change["diff"]|noescape}
|
||||
{else}
|
||||
<ins>{$change["old_value"]}</ins>
|
||||
{/if}
|
||||
</div>
|
||||
{/foreach}
|
||||
</td>
|
||||
<td>
|
||||
{=new openvk\Web\Util\DateTime($change["ts"])}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br/>
|
||||
<div align="right">
|
||||
{var $isLast = ((20 * (($_GET['p'] ?? 1) - 1)) + $amount) < $count}
|
||||
|
||||
<a n:if="($_GET['p'] ?? 1) > 1" class="aui-button" href="?p={($_GET['p'] ?? 1) - 1}">«</a>
|
||||
<a n:if="$isLast" class="aui-button" href="?p={($_GET['p'] ?? 1) + 1}">»</a>
|
||||
</div>
|
||||
{/block}
|
|
@ -124,6 +124,7 @@
|
|||
{/if}
|
||||
{if $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)}
|
||||
<a href="/admin/clubs/id{$club->getId()}" id="profile_link">{_manage_group_action}</a>
|
||||
<a href="/admin/logs?obj_id={$club->getId()}&obj_type=Club" class="profile_link">Последние действия</a>
|
||||
{/if}
|
||||
{if $club->getSubscriptionStatus($thisUser) == false}
|
||||
<form action="/setSub/club" method="post">
|
||||
|
|
|
@ -118,6 +118,9 @@
|
|||
<a href="javascript:warnUser()" class="profile_link" style="width: 194px;">
|
||||
{_warn_user_action}
|
||||
</a>
|
||||
<a href="/admin/logs?uid={$user->getId()}" class="profile_link" style="width: 194px;">
|
||||
Последние действия
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{if $thisUser->getChandlerUser()->can('write')->model('openvk\Web\Models\Entities\TicketReply')->whichBelongsTo(0)}
|
||||
|
|
|
@ -341,6 +341,8 @@ routes:
|
|||
handler: "Admin->chandlerGroup"
|
||||
- url: "/admin/chandler/users/{slug}"
|
||||
handler: "Admin->chandlerUser"
|
||||
- url: "/admin/logs"
|
||||
handler: "Admin->logs"
|
||||
- url: "/internal/wall{num}"
|
||||
handler: "Wall->wallEmbedded"
|
||||
- url: "/robots.txt"
|
||||
|
|
|
@ -1210,6 +1210,15 @@
|
|||
"admin_banned_link_not_specified" = "The link is not specified";
|
||||
"admin_banned_link_not_found" = "Link not found";
|
||||
|
||||
"logs_adding" = "Creation";
|
||||
"logs_editing" = "Editing";
|
||||
"logs_removing" = "Deletion";
|
||||
"logs_restoring" = "Restoring";
|
||||
"logs_added" = "created";
|
||||
"logs_edited" = "edited";
|
||||
"logs_removed" = "removed";
|
||||
"logs_restored" = "restored";
|
||||
|
||||
/* Paginator (deprecated) */
|
||||
|
||||
"paginator_back" = "Back";
|
||||
|
|
|
@ -1096,6 +1096,14 @@
|
|||
"admin_banned_link_initiator" = "Инициатор";
|
||||
"admin_banned_link_not_specified" = "Ссылка не указана";
|
||||
"admin_banned_link_not_found" = "Ссылка не найдена";
|
||||
"logs_adding" = "Создание";
|
||||
"logs_editing" = "Редактирование";
|
||||
"logs_removing" = "Удаление";
|
||||
"logs_restoring" = "Восстановление";
|
||||
"logs_added" = "добавил";
|
||||
"logs_edited" = "отредактировал";
|
||||
"logs_removed" = "удалил";
|
||||
"logs_restored" = "восстановил";
|
||||
|
||||
/* Paginator (deprecated) */
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@ openvk:
|
|||
fartscroll: false
|
||||
testLabel: false
|
||||
defaultMobileTheme: ""
|
||||
logs: true
|
||||
|
||||
telemetry:
|
||||
plausible:
|
||||
|
|
Loading…
Reference in a new issue