diff --git a/DBEntity.updated.php b/DBEntity.updated.php new file mode 100644 index 00000000..5414a2e9 --- /dev/null +++ b/DBEntity.updated.php @@ -0,0 +1,135 @@ +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 = []; + } + + use \Nette\SmartObject; +} diff --git a/Web/Models/Entities/Comment.php b/Web/Models/Entities/Comment.php index 7c6222e4..d813d6be 100644 --- a/Web/Models/Entities/Comment.php +++ b/Web/Models/Entities/Comment.php @@ -85,4 +85,9 @@ class Comment extends Post } return $res; } + + function getURL(): string + { + return "/wall" . $this->getTarget()->getPrettyId() . "#_comment" . $this->getId(); + } } diff --git a/Web/Models/Entities/IP.php b/Web/Models/Entities/IP.php index ecea92ca..df2c9787 100644 --- a/Web/Models/Entities/IP.php +++ b/Web/Models/Entities/IP.php @@ -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); } } diff --git a/Web/Models/Entities/Log.php b/Web/Models/Entities/Log.php new file mode 100644 index 00000000..eabfd36f --- /dev/null +++ b/Web/Models/Entities/Log.php @@ -0,0 +1,190 @@ +getRecord()->id; + } + + function getUser(): ?User + { + return (new Users)->get((int) $this->getRecord()->user); + } + + function getObjectTable(): string + { + return $this->getRecord()->object_table; + } + + function getObjectId(): int + { + return $this->getRecord()->object_id; + } + + function getObject() + { + $model = $this->getRecord()->object_model; + return new $model(DatabaseConnection::i()->getContext()->table($this->getObjectTable())->get($this->getObjectId())); + } + + function getTypeRaw(): int + { + return $this->getRecord()->type; + } + + function getType(): string + { + return ["добавил", "отредактировал", "удалил", "восстановил"][$this->getTypeRaw()]; + } + + function getTypeNom(): string + { + return ["Создание", "Редактирование", "Удаление", "Восстановление"][$this->getTypeRaw()]; + } + + function getObjectType(): string + { + return [ + "albums" => "Альбом", + "groups" => "Сообщество", + "profiles" => "Профиль", + "comments" => "Комментарий", + "ip" => "IP-адрес", + "posts" => "Запись", + "tickets" => "Вопрос", + "tickets_comments" => "Комментарий к тикету", + ][$this->getRecord()->object_table] ?? $this->getRecord()->object_model; + } + + function getObjectName(): string + { + $object = $this->getObject(); + if (method_exists($object, 'getCanonicalName')) + return $object->getCanonicalName(); + else return "[#" . $this->getObjectId() . "] " . $this->getObjectType(); + } + + function getLogsText(): string + { + return $this->getRecord()->logs_text; + } + + function getObjectURL(): string + { + $object = $this->getObject(); + if (method_exists($object, "getURL") && $this->getObjectTable() !== "videos") + return $this->getObject()->getURL(); + else + return "#"; + } + + function getObjectAvatar(): ?string + { + $object = $this->getObject(); + if (method_exists($object, 'getAvatarURL')) + return $object->getAvatarURL("normal"); + else return NULL; + } + + function getOldValue(): ?array + { + return (array) json_decode($this->getRecord()->xdiff_old, true, JSON_UNESCAPED_UNICODE) ?? null; + } + + function getNewValue(): ?array + { + return (array) json_decode($this->getRecord()->xdiff_new, true, JSON_UNESCAPED_UNICODE) ?? null; + } + + function getTime(): DateTime + { + return new DateTime($this->getRecord()->ts); + } + + function diff($old, $new): array + { + $matrix = array(); + $maxlen = 0; + foreach ($old as $oindex => $ovalue) { + $nkeys = array_keys($new, $ovalue); + foreach ($nkeys as $nindex) { + $matrix[$oindex][$nindex] = isset($matrix[$oindex - 1][$nindex - 1]) ? + $matrix[$oindex - 1][$nindex - 1] + 1 : 1; + if ($matrix[$oindex][$nindex] > $maxlen) { + $maxlen = $matrix[$oindex][$nindex]; + $omax = $oindex + 1 - $maxlen; + $nmax = $nindex + 1 - $maxlen; + } + } + } + if ($maxlen == 0) return array(array('d' => $old, 'i' => $new)); + return array_merge( + $this->diff(array_slice($old, 0, $omax), array_slice($new, 0, $nmax)), + array_slice($new, $nmax, $maxlen), + $this->diff(array_slice($old, $omax + $maxlen), array_slice($new, $nmax + $maxlen))); + } + + function htmlDiff($old, $new): string + { + $ret = ''; + $diff = $this->diff(preg_split("/[\s]+/", $old), preg_split("/[\s]+/", $new)); + foreach ($diff as $k) { + if (is_array($k)) + $ret .= (!empty($k['d']) ? "" . implode(' ', $k['d']) . " " : '') . + (!empty($k['i']) ? "" . implode(' ', $k['i']) . " " : ''); + else $ret .= $k . ' '; + } + return $ret; + } + + function getChanges(): array + { + $result = $this->getOldValue(); + $_changes = []; + + if ($this->getTypeRaw() === 1) { // edit + $changes = $this->getNewValue(); + + foreach ($changes as $field => $value) { + $new_value = xdiff_string_patch((string) $result[$field], (string) $value); + $_changes[$field] = [ + "field" => $field, + "old_value" => $result[$field], + "new_value" => strlen($new_value) > 0 ? $new_value : "(empty)", + "ts" => $this->getTime(), + "diff" => $this->htmlDiff((string) $result[$field], (string) $new_value) + ]; + } + } else if ($this->getTypeRaw() === 0) { // create + foreach ($result as $field => $value) { + $_changes[$field] = [ + "field" => $field, + "old_value" => $value, + "ts" => $this->getTime() + ]; + } + } else if ($this->getTypeRaw() === 2) { // delete + $_changes[] = [ + "field" => "deleted", + "old_value" => 0, + "new_value" => 1, + "ts" => $this->getTime(), + "diff" => $this->htmlDiff("0", "1") + ]; + } + + return $_changes; + } +} diff --git a/Web/Models/Entities/User.php b/Web/Models/Entities/User.php index ffc30a51..6b8c1051 100644 --- a/Web/Models/Entities/User.php +++ b/Web/Models/Entities/User.php @@ -1086,7 +1086,7 @@ class User extends RowModel { $this->setOnline(time()); $this->setClient_name($platform); - $this->save(); + $this->save(false); return true; } diff --git a/Web/Models/Repositories/CurrentUser.php b/Web/Models/Repositories/CurrentUser.php new file mode 100644 index 00000000..c6cb942b --- /dev/null +++ b/Web/Models/Repositories/CurrentUser.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/Web/Models/Repositories/IPs.php b/Web/Models/Repositories/IPs.php index c4485b73..59fc6570 100644 --- a/Web/Models/Repositories/IPs.php +++ b/Web/Models/Repositories/IPs.php @@ -24,7 +24,7 @@ class IPs if(!$res) { $res = new IP; $res->setIp($ip); - $res->save(); + $res->save(false); return $res; } diff --git a/Web/Models/Repositories/Logs.php b/Web/Models/Repositories/Logs.php new file mode 100644 index 00000000..6f7d3937 --- /dev/null +++ b/Web/Models/Repositories/Logs.php @@ -0,0 +1,96 @@ +context = DatabaseConnection::i()->getContext(); + $this->logs = $this->context->table("logs"); + } + + private function toLog(?ActiveRow $ar): ?Log + { + return is_null($ar) ? NULL : new Log($ar); + } + + function get(int $id): ?Log + { + return $this->toLog($this->logs->get($id)); + } + + function create(int $user, string $table, string $model, int $type, $object, $changes): void + { + if (OPENVK_ROOT_CONF["openvk"]["preferences"]["logs"] === true) { + $fobject = (is_array($object) ? $object : $object->unwrap()); + $nobject = []; + $_changes = []; + + if ($type === 1) { + foreach ($changes as $field => $value) { + $nobject[$field] = $fobject[$field]; + } + + foreach (array_diff_assoc($nobject, $changes) as $field => $value) { + if (str_starts_with($field, "rate_limit")) continue; + if ($field === "online") continue; + $_changes[$field] = xdiff_string_diff((string)$nobject[$field], (string)$changes[$field]); + } + + if (count($_changes) === 0) return; + } else if ($type === 0) { // if new + $nobject = $fobject; + foreach ($fobject as $field => $value) { + $_changes[$field] = xdiff_string_diff("", (string)$value); + } + } else if ($type === 2 || $type === 3) { // if deleting or restoring + $_changes["deleted"] = (int)($type === 2); + } + + $log = new Log; + $log->setUser($user); + $log->setType($type); + $log->setObject_Table($table); + $log->setObject_Model($model); + $log->setObject_Id(is_array($object) ? $object["id"] : $object->getId()); + $log->setXdiff_Old(json_encode($nobject)); + $log->setXdiff_New(json_encode($_changes)); + $log->setTs(time()); + $log->setIp(CurrentUser::i()->getIP()); + $log->setUserAgent(CurrentUser::i()->getUserAgent()); + $log->save(); + } + } + + function find(string $query, array $pars = [], string $sort = "id DESC", int $page = 1, ?int $perPage = NULL): \Traversable + { + $query = "%$query%"; + $result = $this->logs->where("id LIKE ? OR object_table LIKE ?", $query, $query); + + return new Util\EntityStream("Log", $result->order($sort)); + } + + function search($filter): \Traversable + { + foreach ($this->logs->where($filter)->order("id DESC") as $log) + yield new Log($log); + } + + function getTypes(): array + { + $types = []; + foreach ($this->context->query("SELECT DISTINCT(`object_model`) AS `object_model` FROM `logs`")->fetchAll() as $type) + $types[] = str_replace("openvk\\Web\\Models\\Entities\\", "", $type->object_model); + + return $types; + } +} diff --git a/Web/Presenters/AdminPresenter.php b/Web/Presenters/AdminPresenter.php index 15c80e5f..8ad00380 100644 --- a/Web/Presenters/AdminPresenter.php +++ b/Web/Presenters/AdminPresenter.php @@ -1,7 +1,7 @@ users = $users; $this->clubs = $clubs; @@ -21,6 +22,7 @@ final class AdminPresenter extends OpenVKPresenter $this->gifts = $gifts; $this->bannedLinks = $bannedLinks; $this->chandlerGroups = $chandlerGroups; + $this->logs = $logs; parent::__construct(); } @@ -572,4 +574,43 @@ 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 = (int) $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; + } + + if (count($filter) === 0) { + $this->template->logs = $this->searchResults($this->logs, $this->template->count); + } else { + $this->template->logs = $this->logs->search($filter); + } + + $this->template->object_types = (new Logs)->getTypes(); + } } diff --git a/Web/Presenters/AuthPresenter.php b/Web/Presenters/AuthPresenter.php index 7a879dd2..52113bcf 100644 --- a/Web/Presenters/AuthPresenter.php +++ b/Web/Presenters/AuthPresenter.php @@ -1,7 +1,7 @@ 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); $this->redirect("/id" . $user->getId()); + $user->save(); } } diff --git a/Web/Presenters/OpenVKPresenter.php b/Web/Presenters/OpenVKPresenter.php old mode 100755 new mode 100644 index 5edc870a..80ab0621 --- a/Web/Presenters/OpenVKPresenter.php +++ b/Web/Presenters/OpenVKPresenter.php @@ -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, Reports}; +use openvk\Web\Models\Repositories\{IPs, Users, APITokens, Tickets, Reports, CurrentUser}; 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); diff --git a/Web/Presenters/templates/Admin/@layout.xml b/Web/Presenters/templates/Admin/@layout.xml index 7b1a30f3..f254dd9a 100644 --- a/Web/Presenters/templates/Admin/@layout.xml +++ b/Web/Presenters/templates/Admin/@layout.xml @@ -124,6 +124,9 @@
  • {_admin_settings_tuning}
  • +
  • + Логи +
  • {_admin_settings_appearance}
  • diff --git a/Web/Presenters/templates/Admin/Logs.xml b/Web/Presenters/templates/Admin/Logs.xml new file mode 100644 index 00000000..e30df251 --- /dev/null +++ b/Web/Presenters/templates/Admin/Logs.xml @@ -0,0 +1,99 @@ +{extends "@layout.xml"} + +{block title} + Логи +{/block} + +{block heading} + Логи +{/block} + +{block content} + {var $logs = iterator_to_array($logs)} + {var $amount = sizeof($logs)} + + +
    +
    + + + +
    +
    +
    + + + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    IDПользовательОбъектТипИзмененияВремя
    {$log->getId()} + + + {$log->getUser()->getCanonicalName()} + + + {$log->getUser()->getCanonicalName()} + + + + {$log->getObjectName()} + + + {$log->getObjectName()} + {$log->getTypeNom()} + {foreach $log->getChanges() as $change} +
    + {$change["field"]}: + {if array_key_exists('diff', $change)} + {$change["diff"]|noescape} + {else} + {$change["old_value"]} + {/if} +
    + {/foreach} +
    + {$change["ts"]} +
    +
    +
    + {var $isLast = ((20 * (($_GET['p'] ?? 1) - 1)) + $amount) < $count} + + « + » +
    +{/block} diff --git a/Web/Presenters/templates/Group/View.xml b/Web/Presenters/templates/Group/View.xml index 67f348d6..42cdbde3 100644 --- a/Web/Presenters/templates/Group/View.xml +++ b/Web/Presenters/templates/Group/View.xml @@ -124,6 +124,7 @@ {/if} {if $thisUser->getChandlerUser()->can("access")->model("admin")->whichBelongsTo(NULL)} {_manage_group_action} + Последние действия {/if} {if $club->getSubscriptionStatus($thisUser) == false}
    diff --git a/Web/Presenters/templates/User/View.xml b/Web/Presenters/templates/User/View.xml index 977933d4..debe5620 100644 --- a/Web/Presenters/templates/User/View.xml +++ b/Web/Presenters/templates/User/View.xml @@ -121,6 +121,9 @@ Блокировки + + Последние действия + {/if} {if $thisUser->getChandlerUser()->can('write')->model('openvk\Web\Models\Entities\TicketReply')->whichBelongsTo(0)} diff --git a/Web/di.yml b/Web/di.yml index 57d46f14..89dac41d 100644 --- a/Web/di.yml +++ b/Web/di.yml @@ -52,3 +52,4 @@ services: - openvk\Web\Models\Repositories\ChandlerGroups - openvk\Web\Presenters\MaintenancePresenter - openvk\Web\Presenters\NoSpamPresenter + - openvk\Web\Models\Repositories\Logs diff --git a/Web/routes.yml b/Web/routes.yml index 44e61cf1..2e922df9 100644 --- a/Web/routes.yml +++ b/Web/routes.yml @@ -357,6 +357,8 @@ routes: handler: "NoSpam->index" - url: "/al_abuse/search" handler: "NoSpam->search" + - url: "/admin/logs" + handler: "Admin->logs" - url: "/internal/wall{num}" handler: "Wall->wallEmbedded" - url: "/robots.txt" diff --git a/install/sqls/00038-logs.sql b/install/sqls/00038-logs.sql new file mode 100644 index 00000000..e136c83f --- /dev/null +++ b/install/sqls/00038-logs.sql @@ -0,0 +1,21 @@ +CREATE TABLE `logs` +( + `id` bigint(20) UNSIGNED NOT NULL, + `user` bigint(20) UNSIGNED NOT NULL, + `type` int(11) NOT NULL, + `object_table` tinytext COLLATE utf8mb4_unicode_ci NOT NULL, + `object_model` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL, + `object_id` bigint(20) UNSIGNED NOT NULL, + `xdiff_old` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `xdiff_new` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `ts` bigint(20) NOT NULL, + `ip` tinytext NOT NULL, + `useragent` longtext NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE `logs` + ADD PRIMARY KEY (`id`); + +ALTER TABLE `logs` + MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT; +COMMIT; diff --git a/openvk-example.yml b/openvk-example.yml index e3fd1c3a..4b45e7ba 100644 --- a/openvk-example.yml +++ b/openvk-example.yml @@ -102,6 +102,7 @@ openvk: fartscroll: false testLabel: false defaultMobileTheme: "" + logs: true telemetry: plausible: