Add logs support to DBEntity (#14)

* DBEntity.php with logs

* Fix

* Update DBEntity.php

* Fix 2.0

* Update Log.php

* Update Log.php

* Update Log.php
This commit is contained in:
n1rwana 2023-08-11 16:42:40 +03:00 committed by GitHub
parent 73eeaa8b3c
commit 011a88d3ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 384 additions and 22 deletions

View file

@ -7,6 +7,9 @@ chandler:
appendExtension: "xhtml"
adminUrl: "/chandlerd"
exposeChandler: true
logs:
enabled: true
entitiesNamespace: "openvk\\Web\\Models\\Entities\\"
extensions:
path: null

View file

@ -141,6 +141,7 @@ class Bootstrap
$this->registerDebugger();
$this->igniteExtensions();
\Chandler\Database\CurrentUser::get($_SERVER["REMOTE_ADDR"], $_SERVER["HTTP_USER_AGENT"]);
if(!$headless) {
header("Referrer-Policy: strict-origin-when-cross-origin");
$this->defineIP();

View file

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
class CurrentUser
{
private static $instance = null;
private $ip;
private $useragent;
public function __construct(?string $ip = NULL, ?string $useragent = NULL)
{
if ($ip)
$this->ip = $ip;
if ($useragent)
$this->useragent = $useragent;
}
public static function get($ip, $useragent)
{
if (self::$instance === null) self::$instance = new self($ip, $useragent);
return self::$instance;
}
public function getIP(): string
{
return $this->ip;
}
public function getUserAgent(): string
{
return $this->useragent;
}
public static function i()
{
return self::$instance;
}
}

View file

@ -1,9 +1,14 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
use Chandler\Database\DatabaseConnection;
use Chandler\Security\Authenticator;
use Chandler\Security\User;
use Nette\Database\Table\Selection;
use Nette\Database\Table\ActiveRow;
use Nette\InvalidStateException as ISE;
use Chandler\Database\Logs;
abstract class DBEntity
@ -11,23 +16,25 @@ abstract class DBEntity
protected $record;
protected $changes;
protected $deleted;
private $user;
protected $tableName;
function __construct(?ActiveRow $row = NULL)
{
if(is_null($row)) return;
if (is_null($row)) return;
$_table = $row->getTable()->getName();
if($_table !== $this->tableName)
if ($_table !== $this->tableName)
throw new ISE("Invalid data supplied for model: table $_table is not compatible with table" . $this->tableName);
$this->record = $row;
$this->user = Authenticator::i()->getUser();
}
function __call(string $fName, array $args)
{
if(substr($fName, 0, 3) === "set") {
if (substr($fName, 0, 3) === "set") {
$field = mb_strtolower(substr($fName, 3));
$this->stateChanges($field, $args[0]);
} else {
@ -47,7 +54,7 @@ abstract class DBEntity
protected function stateChanges(string $column, $value): void
{
if(!is_null($this->record))
if (!is_null($this->record))
$t = $this->record->{$column}; #Test if column exists
$this->changes[$column] = $value;
@ -60,20 +67,22 @@ abstract class DBEntity
function isDeleted(): bool
{
return (bool) $this->getRecord()->deleted;
return (bool)$this->getRecord()->deleted;
}
function unwrap(): object
{
return (object) $this->getRecord()->toArray();
return (object)$this->getRecord()->toArray();
}
function delete(bool $softly = true): void
{
if(is_null($this->record))
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?");
if($softly) {
(new Logs)->create($this->user->getId(), $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();
@ -83,25 +92,46 @@ abstract class DBEntity
function undelete(): void
{
if(is_null($this->record))
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?");
(new Logs)->create($this->user->getId(), $this->getTable()->getName(), get_class($this), 3, $this->record->toArray(), ["deleted" => false]);
$this->getTable()->where("id", $this->record->id)->update(["deleted" => false]);
}
function save(): void
function save(?bool $log = true): void
{
if(is_null($this->record)) {
if ($log) {
$user_id = Authenticator::i()->getUser()->getId();
}
if (is_null($this->record)) {
$this->record = $this->getTable()->insert($this->changes);
} else if($this->deleted) {
$this->record = $this->getTable()->insert((array) $this->record);
if ($log && $this->getTable()->getName() !== "ChandlerLogs" && CHANDLER_ROOT_CONF["preferences"]["logs"]["enabled"]) {
(new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 0, $this->record->toArray(), $this->changes);
}
} else {
$this->getTable()->get($this->record->id)->update($this->changes);
$this->record = $this->getTable()->get($this->record->id);
if ($log && $this->getTable()->getName() !== "ChandlerLogs" && CHANDLER_ROOT_CONF["preferences"]["logs"]["enabled"]) {
(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;
}

184
chandler/Database/Log.php Normal file
View file

@ -0,0 +1,184 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
use Chandler\Database\DatabaseConnection;
use Chandler\Security\User;
class Log extends DBEntity
{
protected $tableName = "ChandlerLogs";
function getId(): int
{
return (int) $this->getRecord()->id;
}
function getUser(): string
{
return $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 ["logs_added", "logs_edited", "logs_removed", "logs_restored"][$this->getTypeRaw()];
}
function getTypeNom(): string
{
return ["logs_adding", "logs_editing", "logs_removing", "logs_restoring"][$this->getTypeRaw()];
}
function getObjectType(): string
{
$type = tr("log_" . $this->getObjectTable());
if ($type === "@log_" . $this->getObjectTable()) {
return str_replace(CHANDLER_ROOT_CONF["preferences"]["logs"]["entitiesNamespace"], "", $this->getRecord()->object_model);
} else {
return $type;
}
}
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(): int
{
return $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']) ? "<del>" . implode(' ', $k['d']) . "</del> " : '') .
(!empty($k['i']) ? "<ins>" . implode(' ', $k['i']) . "</ins> " : '');
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);
$diff = preg_replace_callback("/<((?!\/?(?:del|ins)\b)[^>]*)>/i", function ($matches) {
return '[' . str_replace(['<', '>'], ['[', ']'], $matches[1]) . ']';
}, $this->htmlDiff((string)$result[$field], (string)$new_value));
$_changes[$field] = [
"field" => $field,
"old_value" => $result[$field],
"new_value" => strlen($new_value) > 0 ? $new_value : "(empty)",
"ts" => $this->getTime(),
"diff" => $diff
];
}
} 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;
}
}

View file

@ -0,0 +1,86 @@
<?php declare(strict_types=1);
namespace Chandler\Database;
use Chandler\Database\DatabaseConnection;
use Nette\Database\Table\ActiveRow;
use Chandler\Database\Log;
use Chandler\Database\CurrentUser;
class Logs
{
private $context;
private $logs;
function __construct()
{
$this->context = DatabaseConnection::i()->getContext();
$this->logs = $this->context->table("ChandlerLogs");
}
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(string $user, string $table, string $model, int $type, $object, $changes, ?string $ip = NULL, ?string $useragent = NULL): void
{
if ($model !== "Chandler\Database\Log") {
$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 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 `ChandlerLogs`")->fetchAll() as $type)
$types[] = str_replace(CHANDLER_ROOT_CONF["preferences"]["logs"]["entitiesNamespace"], "", $type->object_model);
return $types;
}
}

View file

@ -56,7 +56,25 @@ CREATE TABLE `ChandlerUsers` (
UNIQUE KEY `login` (`login`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `ChandlerLogs` (
`id` bigint(20) UNSIGNED NOT NULL,
`user` varchar(36) COLLATE utf8mb4_unicode_ci 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 COLLATE utf8mb4_unicode_ci NOT NULL,
`useragent` longtext COLLATE utf8mb4_unicode_ci NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `ChandlerLogs`
ADD PRIMARY KEY (`id`);
ALTER TABLE `ChandlerLogs`
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
INSERT INTO `ChandlerGroups` VALUES ("c75fe4de-1e62-11ea-904d-42010aac0003", "Users", NULL);