diff --git a/chandler-example.yml b/chandler-example.yml
index d29705d..743da5b 100644
--- a/chandler-example.yml
+++ b/chandler-example.yml
@@ -7,6 +7,9 @@ chandler:
         appendExtension: "xhtml"
         adminUrl: "/chandlerd"
         exposeChandler: true
+        logs:
+            enabled: true
+            entitiesNamespace: "openvk\\Web\\Models\\Entities\\"
     
     extensions:
         path: null
diff --git a/chandler/Bootstrap.php b/chandler/Bootstrap.php
index 7f7a58d..c150a50 100644
--- a/chandler/Bootstrap.php
+++ b/chandler/Bootstrap.php
@@ -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();
diff --git a/chandler/Database/CurrentUser.php b/chandler/Database/CurrentUser.php
new file mode 100644
index 0000000..fdc8781
--- /dev/null
+++ b/chandler/Database/CurrentUser.php
@@ -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;
+    }
+}
diff --git a/chandler/Database/DBEntity.php b/chandler/Database/DBEntity.php
index ac4c47a..b7f2264 100644
--- a/chandler/Database/DBEntity.php
+++ b/chandler/Database/DBEntity.php
@@ -1,6 +1,10 @@
 <?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;
@@ -13,24 +17,25 @@ abstract class DBEntity
     protected $record;
     protected $changes;
     protected $deleted;
-    protected $user;
+    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 {
@@ -50,7 +55,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;
@@ -63,25 +68,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
     {
-        $user = CurrentUser::i()->getUser();
-        $user_id = is_null($user) ? (int) OPENVK_ROOT_CONF["openvk"]["preferences"]["support"]["adminAccount"] : $user->getId();
-
-        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?");
 
-        (new Logs)->create($user_id, $this->getTable()->getName(), get_class($this), 2, $this->record->toArray(), $this->changes);
+        (new Logs)->create($this->user->getId(), $this->getTable()->getName(), get_class($this), 2, $this->record->toArray(), $this->changes);
 
-        if($softly) {
+        if ($softly) {
             $this->record = $this->getTable()->where("id", $this->record->id)->update(["deleted" => true]);
         } else {
             $this->record->delete();
@@ -91,13 +93,10 @@ 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?");
 
-        $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]);
+        (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]);
     }
@@ -105,18 +104,17 @@ abstract class DBEntity
     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();
+            $user_id = Authenticator::i()->getUser()->getId();
         }
 
-        if(is_null($this->record)) {
+        if (is_null($this->record)) {
             $this->record = $this->getTable()->insert($this->changes);
 
-            if ($log && $this->getTable()->getName() !== "logs") {
+            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 {
-            if ($log && $this->getTable()->getName() !== "logs") {
+            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);
             }
 
diff --git a/chandler/Database/Log.php b/chandler/Database/Log.php
new file mode 100644
index 0000000..5131ef8
--- /dev/null
+++ b/chandler/Database/Log.php
@@ -0,0 +1,190 @@
+<?php declare(strict_types=1);
+namespace Chandler\Database;
+use Chandler\Database\DatabaseConnection;
+use Chandler\Security\User;
+use openvk\Web\Models\RowModel;
+use openvk\Web\Util\DateTime;
+
+class Log extends RowModel
+{
+    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 ["добавил", "отредактировал", "удалил", "восстановил"][$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']) ? "<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;
+    }
+}
diff --git a/chandler/Database/Logs.php b/chandler/Database/Logs.php
new file mode 100644
index 0000000..6850daa
--- /dev/null
+++ b/chandler/Database/Logs.php
@@ -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;
+    }
+}
diff --git a/install/init-db.sql b/install/init-db.sql
index 1f502da..425c433 100644
--- a/install/init-db.sql
+++ b/install/init-db.sql
@@ -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);