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);