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:
n1rwana 2023-08-11 16:43:39 +03:00 committed by GitHub
parent 7f46d683c3
commit 8265dc0fc6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 365 additions and 11 deletions

140
DBEntity.updated.php Normal file
View 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;
}

View file

@ -85,4 +85,9 @@ class Comment extends Post
} }
return $res; return $res;
} }
function getURL(): string
{
return "/wall" . $this->getTarget()->getPrettyId() . "#_comment" . $this->getId();
}
} }

View file

@ -92,7 +92,7 @@ class IP extends RowModel
$this->stateChanges("rate_limit_counter", $aCounter); $this->stateChanges("rate_limit_counter", $aCounter);
$this->stateChanges("rate_limit_violation_counter_start", $vCounterSessionStart); $this->stateChanges("rate_limit_violation_counter_start", $vCounterSessionStart);
$this->stateChanges("rate_limit_violation_counter", $vCounter); $this->stateChanges("rate_limit_violation_counter", $vCounter);
$this->save(); $this->save(false);
} }
} }
@ -105,11 +105,11 @@ class IP extends RowModel
$this->stateChanges("ip", $ip); $this->stateChanges("ip", $ip);
} }
function save(): void function save($log): void
{ {
if(is_null($this->getRecord())) if(is_null($this->getRecord()))
$this->stateChanges("first_seen", time()); $this->stateChanges("first_seen", time());
parent::save(); parent::save($log);
} }
} }

View file

@ -1016,7 +1016,7 @@ class User extends RowModel
{ {
$this->setOnline(time()); $this->setOnline(time());
$this->setClient_name($platform); $this->setClient_name($platform);
$this->save(); $this->save(false);
return true; return true;
} }
@ -1034,7 +1034,7 @@ class User extends RowModel
function adminNotify(string $message): bool function adminNotify(string $message): bool
{ {
$admId = OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"]; $admId = (int) OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"];
if(!$admId) if(!$admId)
return false; return false;
else if(is_null($admin = (new Users)->get($admId))) else if(is_null($admin = (new Users)->get($admId)))

View 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;
}
}

View file

@ -24,7 +24,7 @@ class IPs
if(!$res) { if(!$res) {
$res = new IP; $res = new IP;
$res->setIp($ip); $res->setIp($ip);
$res->save(); $res->save(false);
return $res; return $res;
} }

View file

@ -1,7 +1,9 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace openvk\Web\Presenters; 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\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; use Chandler\Database\DatabaseConnection;
final class AdminPresenter extends OpenVKPresenter final class AdminPresenter extends OpenVKPresenter
@ -12,6 +14,7 @@ final class AdminPresenter extends OpenVKPresenter
private $gifts; private $gifts;
private $bannedLinks; private $bannedLinks;
private $chandlerGroups; private $chandlerGroups;
private $logs;
function __construct(Users $users, Clubs $clubs, Vouchers $vouchers, Gifts $gifts, BannedLinks $bannedLinks, ChandlerGroups $chandlerGroups) 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->gifts = $gifts;
$this->bannedLinks = $bannedLinks; $this->bannedLinks = $bannedLinks;
$this->chandlerGroups = $chandlerGroups; $this->chandlerGroups = $chandlerGroups;
$this->logs = DatabaseConnection::i()->getContext()->table("ChandlerLogs");
parent::__construct(); parent::__construct();
} }
@ -551,4 +555,38 @@ final class AdminPresenter extends OpenVKPresenter
$this->redirect("/admin/users/id" . $user->getId()); $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();
}
} }

View file

@ -1,7 +1,7 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
namespace openvk\Web\Presenters; namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\{IP, User, PasswordReset, EmailVerification}; 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\Models\Exceptions\InvalidUserNameException;
use openvk\Web\Util\Validator; use openvk\Web\Util\Validator;
use Chandler\Session\Session; use Chandler\Session\Session;
@ -110,7 +110,7 @@ final class AuthPresenter extends OpenVKPresenter
$this->flashFail("err", tr("failed_to_register"), tr("user_already_exists")); $this->flashFail("err", tr("failed_to_register"), tr("user_already_exists"));
$user->setUser($chUser->getId()); $user->setUser($chUser->getId());
$user->save(); $user->save(false);
if(!is_null($referer)) { if(!is_null($referer)) {
$user->toggleSubscription($referer); $user->toggleSubscription($referer);
@ -130,7 +130,9 @@ final class AuthPresenter extends OpenVKPresenter
} }
$this->authenticator->authenticate($chUser->getId()); $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()); $this->redirect("/id" . $user->getId());
$user->save();
} }
} }

View file

@ -7,7 +7,7 @@ use Chandler\Security\Authenticator;
use Latte\Engine as TemplatingEngine; use Latte\Engine as TemplatingEngine;
use openvk\Web\Models\Entities\IP; use openvk\Web\Models\Entities\IP;
use openvk\Web\Themes\Themepacks; 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; use WhichBrowser;
abstract class OpenVKPresenter extends SimplePresenter abstract class OpenVKPresenter extends SimplePresenter
@ -211,6 +211,7 @@ abstract class OpenVKPresenter extends SimplePresenter
$this->user->id = $this->user->identity->getId(); $this->user->id = $this->user->identity->getId();
$this->template->thisUser = $this->user->identity; $this->template->thisUser = $this->user->identity;
$this->template->userTainted = $user->isTainted(); $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->isDeleted() && !$this->deactivationTolerant) {
if($this->user->identity->isDeactivated()) { 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())) { if($this->user->identity->onlineStatus() == 0 && !($this->user->identity->isDeleted() || $this->user->identity->isBanned())) {
$this->user->identity->setOnline(time()); $this->user->identity->setOnline(time());
$this->user->identity->setClient_name(NULL); $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); $this->template->ticketAnsweredCount = (new Tickets)->getTicketsCountByUserId($this->user->id, 1);

View file

@ -124,6 +124,9 @@
<li> <li>
<a href="/admin/settings/tuning">{_admin_settings_tuning}</a> <a href="/admin/settings/tuning">{_admin_settings_tuning}</a>
</li> </li>
<li>
<a href="/admin/logs">Логи</a>
</li>
<li> <li>
<a href="/admin/settings/appearance">{_admin_settings_appearance}</a> <a href="/admin/settings/appearance">{_admin_settings_appearance}</a>
</li> </li>

View 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}">&laquo;</a>
<a n:if="$isLast" class="aui-button" href="?p={($_GET['p'] ?? 1) + 1}">&raquo;</a>
</div>
{/block}

View file

@ -124,6 +124,7 @@
{/if} {/if}
{if $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)} {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/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}
{if $club->getSubscriptionStatus($thisUser) == false} {if $club->getSubscriptionStatus($thisUser) == false}
<form action="/setSub/club" method="post"> <form action="/setSub/club" method="post">

View file

@ -118,6 +118,9 @@
<a href="javascript:warnUser()" class="profile_link" style="width: 194px;"> <a href="javascript:warnUser()" class="profile_link" style="width: 194px;">
{_warn_user_action} {_warn_user_action}
</a> </a>
<a href="/admin/logs?uid={$user->getId()}" class="profile_link" style="width: 194px;">
Последние действия
</a>
{/if} {/if}
{if $thisUser->getChandlerUser()->can('write')->model('openvk\Web\Models\Entities\TicketReply')->whichBelongsTo(0)} {if $thisUser->getChandlerUser()->can('write')->model('openvk\Web\Models\Entities\TicketReply')->whichBelongsTo(0)}

View file

@ -341,6 +341,8 @@ routes:
handler: "Admin->chandlerGroup" handler: "Admin->chandlerGroup"
- url: "/admin/chandler/users/{slug}" - url: "/admin/chandler/users/{slug}"
handler: "Admin->chandlerUser" handler: "Admin->chandlerUser"
- url: "/admin/logs"
handler: "Admin->logs"
- url: "/internal/wall{num}" - url: "/internal/wall{num}"
handler: "Wall->wallEmbedded" handler: "Wall->wallEmbedded"
- url: "/robots.txt" - url: "/robots.txt"

View file

@ -1210,6 +1210,15 @@
"admin_banned_link_not_specified" = "The link is not specified"; "admin_banned_link_not_specified" = "The link is not specified";
"admin_banned_link_not_found" = "Link not found"; "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 (deprecated) */
"paginator_back" = "Back"; "paginator_back" = "Back";

View file

@ -1096,6 +1096,14 @@
"admin_banned_link_initiator" = "Инициатор"; "admin_banned_link_initiator" = "Инициатор";
"admin_banned_link_not_specified" = "Ссылка не указана"; "admin_banned_link_not_specified" = "Ссылка не указана";
"admin_banned_link_not_found" = "Ссылка не найдена"; "admin_banned_link_not_found" = "Ссылка не найдена";
"logs_adding" = "Создание";
"logs_editing" = "Редактирование";
"logs_removing" = "Удаление";
"logs_restoring" = "Восстановление";
"logs_added" = "добавил";
"logs_edited" = "отредактировал";
"logs_removed" = "удалил";
"logs_restored" = "восстановил";
/* Paginator (deprecated) */ /* Paginator (deprecated) */

View file

@ -102,6 +102,7 @@ openvk:
fartscroll: false fartscroll: false
testLabel: false testLabel: false
defaultMobileTheme: "" defaultMobileTheme: ""
logs: true
telemetry: telemetry:
plausible: plausible: