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 @@ +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 e649975..8b2d634 100644 --- a/chandler/Database/DBEntity.php +++ b/chandler/Database/DBEntity.php @@ -1,9 +1,14 @@ 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; } diff --git a/chandler/Database/Log.php b/chandler/Database/Log.php new file mode 100644 index 0000000..27d312e --- /dev/null +++ b/chandler/Database/Log.php @@ -0,0 +1,184 @@ +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']) ? "" . 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); + $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 @@ +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);