From d0b3750594e85c63fffe94b3f298f092a1a05c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Fri, 24 Jan 2020 22:57:04 +0100 Subject: [PATCH 01/11] Added an basic table to show log entries. --- src/Controller/LogController.php | 58 +++ .../Column/LogEntryTargetColumn.php | 104 +++++ src/DataTables/LogDataTable.php | 120 ++++++ src/Entity/Base/DBElement.php | 2 +- src/Entity/LogSystem/AbstractLogEntry.php | 375 ++++++++++++++++++ .../LogSystem/ConfigChangedLogEntry.php | 39 ++ .../LogSystem/DatabaseUpdatedLogEntry.php | 39 ++ .../LogSystem/ElementCreatedLogEntry.php | 34 ++ .../LogSystem/ElementDeletedLogEntry.php | 33 ++ .../LogSystem/ElementEditedLogEntry.php | 33 ++ src/Entity/LogSystem/ExceptionLogEntry.php | 40 ++ .../LogSystem/InstockChangedLogEntry.php | 34 ++ src/Entity/LogSystem/UserLoginLogEntry.php | 34 ++ src/Entity/LogSystem/UserLogoutLogEntry.php | 34 ++ .../LogSystem/UserNotAllowedLogEntry.php | 41 ++ src/Exceptions/LogEntryObsoleteException.php | 28 ++ src/Repository/LogEntryRepository.php | 75 ++++ src/Services/ElementTypeNameGenerator.php | 14 +- templates/LogSystem/log_list.html.twig | 16 + .../Entity/LogSystem/AbstractLogEntryTest.php | 143 +++++++ .../Services/ElementTypeNameGeneratorTest.php | 3 + 21 files changed, 1292 insertions(+), 7 deletions(-) create mode 100644 src/Controller/LogController.php create mode 100644 src/DataTables/Column/LogEntryTargetColumn.php create mode 100644 src/DataTables/LogDataTable.php create mode 100644 src/Entity/LogSystem/AbstractLogEntry.php create mode 100644 src/Entity/LogSystem/ConfigChangedLogEntry.php create mode 100644 src/Entity/LogSystem/DatabaseUpdatedLogEntry.php create mode 100644 src/Entity/LogSystem/ElementCreatedLogEntry.php create mode 100644 src/Entity/LogSystem/ElementDeletedLogEntry.php create mode 100644 src/Entity/LogSystem/ElementEditedLogEntry.php create mode 100644 src/Entity/LogSystem/ExceptionLogEntry.php create mode 100644 src/Entity/LogSystem/InstockChangedLogEntry.php create mode 100644 src/Entity/LogSystem/UserLoginLogEntry.php create mode 100644 src/Entity/LogSystem/UserLogoutLogEntry.php create mode 100644 src/Entity/LogSystem/UserNotAllowedLogEntry.php create mode 100644 src/Exceptions/LogEntryObsoleteException.php create mode 100644 src/Repository/LogEntryRepository.php create mode 100644 templates/LogSystem/log_list.html.twig create mode 100644 tests/Entity/LogSystem/AbstractLogEntryTest.php diff --git a/src/Controller/LogController.php b/src/Controller/LogController.php new file mode 100644 index 00000000..1bd3620e --- /dev/null +++ b/src/Controller/LogController.php @@ -0,0 +1,58 @@ +createFromType(LogDataTable::class) + ->handleRequest($request); + + if ($table->isCallback()) { + return $table->getResponse(); + } + + return $this->render('LogSystem/log_list.html.twig', [ + 'datatable' => $table + ]); + } +} \ No newline at end of file diff --git a/src/DataTables/Column/LogEntryTargetColumn.php b/src/DataTables/Column/LogEntryTargetColumn.php new file mode 100644 index 00000000..75d9535d --- /dev/null +++ b/src/DataTables/Column/LogEntryTargetColumn.php @@ -0,0 +1,104 @@ +em = $entityManager; + $this->entryRepository = $entityManager->getRepository(AbstractLogEntry::class); + + $this->entityURLGenerator = $entityURLGenerator; + $this->elementTypeNameGenerator = $elementTypeNameGenerator; + $this->translator = $translator; + } + + /** + * @inheritDoc + */ + public function normalize($value) + { + return $value; + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + } + + public function render($value, $context) + { + /** @var AbstractLogEntry $context */ + $target = $this->entryRepository->getTargetElement($context); + + //The element is existing + if ($target instanceof NamedDBElement) { + return sprintf( + '%s', + $this->entityURLGenerator->infoURL($target), + $this->elementTypeNameGenerator->getTypeNameCombination($target, true) + ); + } + + //Target does not have a name + if ($target instanceof DBElement) { + return sprintf( + '%s: %s', + $this->elementTypeNameGenerator->getLocalizedTypeLabel($target), + $target->getID() + ); + } + + //Element was deleted + if ($target === null && $context->hasTarget()) { + return sprintf( + '%s: %s [%s]', + $this->elementTypeNameGenerator->getLocalizedTypeLabel($context->getTargetClass()), + $context->getTargetID(), + $this->translator->trans('log.target_deleted') + ); + } + + //Log is not associated with an element + return ""; + } +} \ No newline at end of file diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php new file mode 100644 index 00000000..31f25e8b --- /dev/null +++ b/src/DataTables/LogDataTable.php @@ -0,0 +1,120 @@ +elementTypeNameGenerator = $elementTypeNameGenerator; + $this->translator = $translator; + } + + public function configure(DataTable $dataTable, array $options) + { + $dataTable->add('id', TextColumn::class, [ + 'label' => $this->translator->trans('log.id'), + 'visible' => false, + ]); + + $dataTable->add('timestamp', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('log.timestamp'), + 'timeFormat' => 'medium' + ]); + + $dataTable->add('type', TextColumn::class, [ + 'label' => $this->translator->trans('log.type'), + 'propertyPath' => 'type', + 'render' => function (string $value, AbstractLogEntry $context) { + return $this->translator->trans('log.type.' . $value); + } + + ]); + + $dataTable->add('level', TextColumn::class, [ + 'label' => $this->translator->trans('log.level'), + 'propertyPath' => 'levelString', + 'render' => function (string $value, AbstractLogEntry $context) { + return $this->translator->trans('log.level.' . $value); + } + ]); + + + $dataTable->add('user', TextColumn::class, [ + 'label' => $this->translator->trans('log.user'), + 'propertyPath' => 'user.name', + ]); + + + + $dataTable->add('target_type', TextColumn::class, [ + 'label' => $this->translator->trans('log.target_type'), + 'visible' => false, + 'render' => function ($value, AbstractLogEntry $context) { + $class = $context->getTargetClass(); + if ($class !== null) { + return $this->elementTypeNameGenerator->getLocalizedTypeLabel($class); + } + return ''; + } + ]); + + $dataTable->add('target', LogEntryTargetColumn::class, [ + 'label' => $this->translator->trans('log.target') + ]); + + $dataTable->addOrderBy('timestamp', DataTable::SORT_DESCENDING); + + $dataTable->createAdapter(ORMAdapter::class, [ + 'entity' => AbstractLogEntry::class, + 'query' => function (QueryBuilder $builder): void { + $this->getQuery($builder); + }, + ]); + } + + protected function getQuery(QueryBuilder $builder): void + { + $builder->distinct()->select('log') + ->addSelect('user') + ->from(AbstractLogEntry::class, 'log') + ->leftJoin('log.user', 'user'); + } +} \ No newline at end of file diff --git a/src/Entity/Base/DBElement.php b/src/Entity/Base/DBElement.php index 174e24e7..8d260e34 100644 --- a/src/Entity/Base/DBElement.php +++ b/src/Entity/Base/DBElement.php @@ -80,7 +80,7 @@ abstract class DBElement * * @return int|null the ID of this element */ - final public function getID(): ?int + public function getID(): ?int { return $this->id; } diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php new file mode 100644 index 00000000..21d8b3ae --- /dev/null +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -0,0 +1,375 @@ + LogLevel::EMERGENCY, + self::LEVEL_ALERT => LogLevel::ALERT, + self::LEVEL_CRITICAL => LogLevel::CRITICAL, + self::LEVEL_ERROR => LogLevel::ERROR, + self::LEVEL_WARNING => LogLevel::WARNING, + self::LEVEL_NOTICE => LogLevel::NOTICE, + self::LEVEL_INFO => LogLevel::INFO, + self::LEVEL_DEBUG => LogLevel::DEBUG, + ]; + + + protected const TARGET_CLASS_MAPPING = [ + self::TARGET_TYPE_USER => User::class, + self::TARGET_TYPE_ATTACHEMENT => Attachment::class, + self::TARGET_TYPE_ATTACHEMENTTYPE => AttachmentType::class, + self::TARGET_TYPE_CATEGORY => Category::class, + self::TARGET_TYPE_DEVICE => Device::class, + self::TARGET_TYPE_DEVICEPART => DevicePart::class, + self::TARGET_TYPE_FOOTPRINT => Footprint::class, + self::TARGET_TYPE_GROUP => Group::class, + self::TARGET_TYPE_MANUFACTURER => Manufacturer::class, + self::TARGET_TYPE_PART => Part::class, + self::TARGET_TYPE_STORELOCATION => Storelocation::class, + self::TARGET_TYPE_SUPPLIER => Supplier::class, + ]; + + /** @var User $user The user which has caused this log entry + * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User") + * @ORM\JoinColumn(name="id_user") + */ + protected $user; + + /** @var DateTime The datetime the event associated with this log entry has occured. + * @ORM\Column(type="datetime", name="datetime") + */ + protected $timestamp; + + /** @var integer The priority level of the associated level. 0 is highest, 7 lowest + * @ORM\Column(type="integer", name="level", columnDefinition="TINYINT") + */ + protected $level; + + /** @var int $target_id The ID of the element targeted by this event + * @ORM\Column(name="target_id", type="integer", nullable=false) + */ + protected $target_id = 0; + + /** @var int $target_type The Type of the targeted element + * @ORM\Column(name="target_type", type="smallint", nullable=false) + */ + protected $target_type = 0; + + /** @var string The type of this log entry, aka the description what has happened. + * The mapping between the log entry class and the discriminator column is done by doctrine. + * Each subclass should override this string to specify a better string. + */ + protected $typeString = "unknown"; + + /** + * Get the user that caused the event associated with this log entry. + * @return User + */ + public function getUser(): User + { + return $this->user; + } + + /** + * Sets the user that caused the event. + * @param User $user + * @return $this + */ + public function setUser(User $user): self + { + $this->user = $user; + return $this; + } + + /** + * Returns the timestamp when the event that caused this log entry happened + * @return DateTime + */ + public function getTimestamp(): DateTime + { + return $this->timestamp; + } + + /** + * Sets the timestamp when the event happened. + * @param DateTime $timestamp + * @return $this + */ + public function setTimestamp(DateTime $timestamp): AbstractLogEntry + { + $this->timestamp = $timestamp; + return $this; + } + + /** + * Get the priority level of this log entry. 0 is highest and 7 lowest level. + * See LEVEL_* consts in this class for more info + * @return int + */ + public function getLevel(): int + { + //It is always alerting when a wrong int is saved in DB... + if ($this->level < 0 || $this->level > 7) { + return self::LEVEL_ALERT; + } + return $this->level; + } + + /** + * Sets the new level of this log entry. + * @param int $level + * @return $this + */ + public function setLevel(int $level): AbstractLogEntry + { + if ($level < 0 || $this->level > 7) { + throw new \InvalidArgumentException(sprintf('$level must be between 0 and 7! %d given!', $level)); + } + $this->level = $level; + return $this; + } + + /** + * Get the priority level of this log entry as PSR3 compatible string + * @return string + */ + public function getLevelString(): string + { + return self::levelIntToString($this->getLevel()); + } + + /** + * Sets the priority level of this log entry as PSR3 compatible string + * @param string $level + * @return $this + */ + public function setLevelString(string $level): AbstractLogEntry + { + $this->setLevel(self::levelStringToInt($level)); + return $this; + } + + /** + * Returns the type of the event this log entry is associated with. + * @return string + */ + public function getType(): string + { + return $this->typeString; + } + + /** + * @inheritDoc + */ + public function getIDString(): string + { + return "LOG".$this->getID(); + } + + /** + * Returns the class name of the target element associated with this log entry. + * Returns null, if this log entry is not associated with an log entry. + * @return string|null The class name of the target class. + */ + public function getTargetClass(): ?string + { + if ($this->target_type === self::TARGET_TYPE_NONE) { + return null; + } + + return self::targetTypeIdToClass($this->target_type); + } + + /** + * Returns the ID of the target element associated with this log entry. + * Returns null, if this log entry is not associated with an log entry. + * @return int|null The ID of the associated element. + */ + public function getTargetID(): ?int + { + if ($this->target_id === 0) { + return null; + } + + return $this->target_id; + } + + /** + * Checks if this log entry is associated with an element + * @return bool True if this log entry is associated with an element, false otherwise. + */ + public function hasTarget(): bool + { + return $this->getTargetID() !== null && $this->getTargetClass() !== null; + } + + /** + * Sets the target element associated with this element + * @param DBElement $element The element that should be associated with this element. + * @return $this + */ + public function setTargetElement(?DBElement $element): self + { + if ($element === null) { + $this->target_id = 0; + $this->target_type = self::TARGET_TYPE_NONE; + return $this; + } + + $this->target_type = static::targetTypeClassToID(get_class($element)); + $this->target_id = $element->getID(); + + return $this; + } + + /** + * This function converts the internal numeric log level into an PSR3 compatible level string. + * @param int $level The numerical log level + * @return string The PSR3 compatible level string + */ + final public static function levelIntToString(int $level): string + { + if (!isset(self::LEVEL_ID_TO_STRING[$level])) { + throw new \InvalidArgumentException('No level with this int is existing!'); + } + + return self::LEVEL_ID_TO_STRING[$level]; + } + + /** + * This function converts a PSR3 compatible string to the internal numeric level string. + * @param string $level the PSR3 compatible string that should be converted + * @return int The internal int representation. + */ + final public static function levelStringToInt(string $level): int + { + $tmp = array_flip(self::LEVEL_ID_TO_STRING); + if (!isset($tmp[$level])) { + throw new \InvalidArgumentException('No level with this string is existing!'); + } + + return $tmp[$level]; + } + + /** + * Converts an target type id to an full qualified class name. + * @param int $type_id The target type ID + * @return string + */ + final public static function targetTypeIdToClass(int $type_id): string + { + if (!isset(self::TARGET_CLASS_MAPPING[$type_id])) { + throw new \InvalidArgumentException('No target type with this ID is existing!'); + } + + return self::TARGET_CLASS_MAPPING[$type_id]; + } + + /** + * Convert a class name to a target type ID. + * @param string $class The name of the class (FQN) that should be converted to id + * @return int The ID of the associated target type ID. + */ + final public static function targetTypeClassToID(string $class): int + { + $tmp = array_flip(self::TARGET_CLASS_MAPPING); + //Check if we can use a key directly + if (isset($tmp[$class])) { + return $tmp[$class]; + } + + //Otherwise we have to iterate over everything and check for inheritance + foreach ($tmp as $compare_class => $class_id) { + if (is_a($class, $compare_class, true)) { + return $class_id; + } + } + + throw new \InvalidArgumentException('No target ID for this class is existing!'); + } + + +} \ No newline at end of file diff --git a/src/Entity/LogSystem/ConfigChangedLogEntry.php b/src/Entity/LogSystem/ConfigChangedLogEntry.php new file mode 100644 index 00000000..2c06cd51 --- /dev/null +++ b/src/Entity/LogSystem/ConfigChangedLogEntry.php @@ -0,0 +1,39 @@ +findBy(['element' => $element], ['timestamp' => $order], $limit, $offset); + } + + /** + * Gets the target element associated with the logentry. + * @param AbstractLogEntry $logEntry + * @return DBElement|null Returns the associated DBElement or null if the log either has no target or the element + * was deleted from DB. + */ + public function getTargetElement(AbstractLogEntry $logEntry): ?DBElement + { + $class = $logEntry->getTargetClass(); + $id = $logEntry->getTargetID(); + + if ($class === null || $id === null) { + return null; + } + + return $this->getEntityManager()->find($class, $id); + } +} \ No newline at end of file diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index f2338053..ed539e22 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -82,22 +82,24 @@ class ElementTypeNameGenerator * Useful when the type should be shown to user. * Throws an exception if the class is not supported. * - * @param DBElement $entity The element for which the label should be generated + * @param DBElement|string $entity The element or class for which the label should be generated * - * @return string the locatlized label for the entity type + * @return string the localized label for the entity type * * @throws EntityNotSupportedException when the passed entity is not supported */ - public function getLocalizedTypeLabel(DBElement $entity): string + public function getLocalizedTypeLabel($entity): string { + $class = is_string($entity) ? $entity : get_class($entity); + //Check if we have an direct array entry for our entity class, then we can use it - if (isset($this->mapping[get_class($entity)])) { - return $this->mapping[get_class($entity)]; + if (isset($this->mapping[$class])) { + return $this->mapping[$class]; } //Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed) foreach ($this->mapping as $class => $translation) { - if ($entity instanceof $class) { + if (is_a($entity, $class, true)) { return $translation; } } diff --git a/templates/LogSystem/log_list.html.twig b/templates/LogSystem/log_list.html.twig new file mode 100644 index 00000000..1b2cc2ed --- /dev/null +++ b/templates/LogSystem/log_list.html.twig @@ -0,0 +1,16 @@ +{% extends "base.html.twig" %} + +{% block title %}{% trans %}log.list.title{% endtrans %}{% endblock %} + +{% block content %} +
+
+
+
+

{% trans %}part_list.loading.caption{% endtrans %}

+
{% trans %}part_list.loading.message{% endtrans %}
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/tests/Entity/LogSystem/AbstractLogEntryTest.php b/tests/Entity/LogSystem/AbstractLogEntryTest.php new file mode 100644 index 00000000..fdbcf1e3 --- /dev/null +++ b/tests/Entity/LogSystem/AbstractLogEntryTest.php @@ -0,0 +1,143 @@ +expectException(\InvalidArgumentException::class); + } + $this->assertSame($expected_string, AbstractLogEntry::levelIntToString($int)); + } + + /** + * @dataProvider levelDataProvider + */ + public function testLevelStringToInt(int $expected_int, string $string, bool $expect_exception = false) + { + if ($expect_exception) { + $this->expectException(\InvalidArgumentException::class); + } + $this->assertSame($expected_int, AbstractLogEntry::levelStringToInt($string)); + } + + /** + * @dataProvider targetTypeDataProvider + */ + public function testTargetTypeIdToClass(int $int, string $expected_class, bool $expect_exception = false) + { + if ($expect_exception) { + $this->expectException(\InvalidArgumentException::class); + } + $this->assertSame($expected_class, AbstractLogEntry::targetTypeIdToClass($int)); + } + + /** + * @dataProvider targetTypeDataProvider + */ + public function testTypeClassToID(int $expected_id, string $class, bool $expect_exception = false) + { + if ($expect_exception) { + $this->expectException(\InvalidArgumentException::class); + } + $this->assertSame($expected_id, AbstractLogEntry::targetTypeClassToID($class)); + } + + public function testTypeClassToIDSubclasses() + { + //Test if class mapping works for subclasses + $this->assertSame(2, AbstractLogEntry::targetTypeClassToID(PartAttachment::class)); + } + + public function testSetGetTarget() + { + $part = $this->createMock(Part::class); + $part->method('getID')->willReturn(10); + + $log = new class extends AbstractLogEntry {}; + $log->setTargetElement($part); + + $this->assertSame(Part::class, $log->getTargetClass()); + $this->assertSame(10, $log->getTargetID()); + + $log->setTargetElement(null); + $this->assertSame(null, $log->getTargetClass()); + $this->assertSame(null, $log->getTargetID()); + } +} diff --git a/tests/Services/ElementTypeNameGeneratorTest.php b/tests/Services/ElementTypeNameGeneratorTest.php index eb55e2cb..ab2f2f41 100644 --- a/tests/Services/ElementTypeNameGeneratorTest.php +++ b/tests/Services/ElementTypeNameGeneratorTest.php @@ -59,6 +59,9 @@ class ElementTypeNameGeneratorTest extends WebTestCase //Test inheritance $this->assertSame('Attachment', $this->service->getLocalizedTypeLabel(new PartAttachment())); + //Test for class name + $this->assertSame('Part', $this->service->getLocalizedTypeLabel(Part::class)); + //Test exception for unknpwn type $this->expectException(EntityNotSupportedException::class); $this->service->getLocalizedTypeLabel(new class() extends DBElement { From bc89ff7a685c7ffe3a77653ecb608048bbacb6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 20:20:46 +0100 Subject: [PATCH 02/11] Added translations for log table. --- src/DataTables/LogDataTable.php | 2 +- translations/messages.de.xlf | 102 ++++++++++++++++++++++++++++++++ translations/messages.en.xlf | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php index 31f25e8b..6395b785 100644 --- a/src/DataTables/LogDataTable.php +++ b/src/DataTables/LogDataTable.php @@ -72,7 +72,7 @@ class LogDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('log.level'), 'propertyPath' => 'levelString', 'render' => function (string $value, AbstractLogEntry $context) { - return $this->translator->trans('log.level.' . $value); + return $value; } ]); diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 6da81864..d075a733 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -6293,5 +6293,107 @@ Element 3 Dateigröße + + + log.id + ID + + + + + log.timestamp + Zeitstempel + + + + + log.type + Ereignis + + + + + log.level + Level + + + + + log.user + Benutzer + + + + + log.target_type + Zieltyp + + + + + log.list.title + Systemlog + + + + + log.target + Ziel + + + + + log.type.element_edited + Element bearbeitet + + + + + log.type.user_login + Nutzer eingeloggt + + + + + log.type.unknown + Unbekannt + + + + + log.type.database_updated + Datenbank aktualisiert + + + + + log.type.exception + Unbehandelte Exception (veraltet) + + + + + log.target_deleted + gelöscht + + + + + log.type.user_logout + Nutzer ausgeloggt + + + + + log.type.element_created + Element angelegt + + + + + log.type.element_deleted + Element gelöscht + + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index e56e391d..8034254d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -6271,5 +6271,107 @@ Element 3 File size + + + log.id + ID + + + + + log.timestamp + Timestamp + + + + + log.type + Event + + + + + log.level + Level + + + + + log.user + User + + + + + log.target_type + Target type + + + + + log.target + Target + + + + + log.type.exception + Unhandled exception (obsolete) + + + + + log.type.user_login + User login + + + + + log.type.user_logout + User logout + + + + + log.type.unknown + Unknown + + + + + log.type.element_created + Element created + + + + + log.type.element_edited + Element edited + + + + + log.type.element_deleted + Element deleted + + + + + log.target_deleted + deleted + + + + + log.type.database_updated + Database updated + + + + + log.list.title + System log + + From b0ccb95a17da5d7c5991ac787515f9aa8e40dd2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 20:28:00 +0100 Subject: [PATCH 03/11] Added link to user info page in user field. --- src/Controller/UserController.php | 2 +- src/DataTables/LogDataTable.php | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index b1a99fc1..61bd30c2 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -130,7 +130,7 @@ class UserController extends AdminPages\BaseAdminController /** * @Route("/info", name="user_info_self") - * @Route("/{id}/info") + * @Route("/{id}/info", name="user_info") */ public function userInfo(?User $user, Packages $packages) { diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php index 6395b785..e71caa94 100644 --- a/src/DataTables/LogDataTable.php +++ b/src/DataTables/LogDataTable.php @@ -27,6 +27,7 @@ use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\LogEntryTargetColumn; use App\Entity\Attachments\Attachment; use App\Entity\LogSystem\AbstractLogEntry; +use App\Entity\UserSystem\User; use App\Services\ElementTypeNameGenerator; use Doctrine\ORM\QueryBuilder; use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; @@ -34,17 +35,21 @@ use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTableTypeInterface; use SebastianBergmann\CodeCoverage\Report\Text; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; class LogDataTable implements DataTableTypeInterface { protected $elementTypeNameGenerator; protected $translator; + protected $urlGenerator; - public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator) + public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator, + UrlGeneratorInterface $urlGenerator) { $this->elementTypeNameGenerator = $elementTypeNameGenerator; $this->translator = $translator; + $this->urlGenerator = $urlGenerator; } public function configure(DataTable $dataTable, array $options) @@ -79,7 +84,14 @@ class LogDataTable implements DataTableTypeInterface $dataTable->add('user', TextColumn::class, [ 'label' => $this->translator->trans('log.user'), - 'propertyPath' => 'user.name', + 'render' => function ($value, AbstractLogEntry $context) { + $user = $context->getUser(); + return sprintf( + '%s', + $this->urlGenerator->generate('user_info', ['id' => $user->getID()]), + $user->getFullName(true) + ); + } ]); From 6b7e5f7ba3e38d703ad5917d81d9554ee5173f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 20:41:56 +0100 Subject: [PATCH 04/11] [Eventlog] Color the rows based on their level. --- assets/ts_src/ajax_ui.ts | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/assets/ts_src/ajax_ui.ts b/assets/ts_src/ajax_ui.ts index 5b56355a..47d81310 100644 --- a/assets/ts_src/ajax_ui.ts +++ b/assets/ts_src/ajax_ui.ts @@ -386,8 +386,8 @@ class AjaxUI { switch(request.status) { case 500: - title = 'Internal Server Error!'; - break; + title = 'Internal Server Error!'; + break; case 404: title = "Site not found!"; break; @@ -396,7 +396,7 @@ class AjaxUI { break; } - var alert = bootbox.alert( + var alert = bootbox.alert( { size: 'large', message: function() { @@ -418,10 +418,10 @@ class AjaxUI { //@ts-ignore alert.init(function (){ var dstFrame = document.getElementById('iframe'); - //@ts-ignore - var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; - dstDoc.write(request.responseText); - dstDoc.close(); + //@ts-ignore + var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; + dstDoc.write(request.responseText); + dstDoc.close(); }); @@ -530,7 +530,31 @@ class AjaxUI { "extend": 'colvis', 'className': 'mr-2 btn-light', "text": "" - }] + }], + "rowCallback": function( row, data, index ) { + //Check if we have a level, then change color of this row + if (data.level) { + let style = ""; + switch(data.level) { + case "emergency": + case "alert": + case "critical": + case "error": + style = "table-danger"; + break; + case "warning": + style = "table-warning"; + break; + case "notice": + style = "table-info"; + break; + } + + if (style){ + $(row).addClass(style); + } + } + } }); //Register links. From f7d0524f57fbc441758819568304cff9638b3692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 20:55:30 +0100 Subject: [PATCH 05/11] [Eventlog] Show icon describing the level in event table. --- src/DataTables/LogDataTable.php | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php index e71caa94..a3ba86e1 100644 --- a/src/DataTables/LogDataTable.php +++ b/src/DataTables/LogDataTable.php @@ -34,6 +34,7 @@ use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTableTypeInterface; +use Psr\Log\LogLevel; use SebastianBergmann\CodeCoverage\Report\Text; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -54,6 +55,43 @@ class LogDataTable implements DataTableTypeInterface public function configure(DataTable $dataTable, array $options) { + $dataTable->add('symbol', TextColumn::class, [ + 'label' => '', + 'render' => function ($value, AbstractLogEntry $context) { + switch ($context->getLevelString()) { + case LogLevel::DEBUG: + $symbol = 'fa-bug'; + break; + case LogLevel::INFO: + $symbol = 'fa-info'; + break; + case LogLevel::NOTICE: + $symbol = 'fa-flag'; + break; + case LogLevel::WARNING: + $symbol = 'fa-exclamation-circle'; + break; + case LogLevel::ERROR: + $symbol = 'fa-exclamation-triangle'; + break; + case LogLevel::CRITICAL: + $symbol = 'fa-bolt'; + break; + case LogLevel::ALERT: + $symbol = 'fa-radiation'; + break; + case LogLevel::EMERGENCY: + $symbol = 'fa-skull-crossbones'; + break; + default: + $symbol = 'fa-question-circle'; + break; + } + + return sprintf('', $symbol); + } + ]); + $dataTable->add('id', TextColumn::class, [ 'label' => $this->translator->trans('log.id'), 'visible' => false, From 8b1eccc48dcf86f603d2183997e058648b1497fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 22:52:34 +0100 Subject: [PATCH 06/11] [Eventlog] Show extra data in log table. --- src/DataTables/Column/LogEntryExtraColumn.php | 121 ++++++++++++++++++ src/DataTables/LogDataTable.php | 5 + src/Entity/LogSystem/AbstractLogEntry.php | 13 +- .../LogSystem/DatabaseUpdatedLogEntry.php | 27 ++++ .../LogSystem/ElementCreatedLogEntry.php | 18 +++ .../LogSystem/ElementDeletedLogEntry.php | 5 + .../LogSystem/ElementEditedLogEntry.php | 9 ++ src/Entity/LogSystem/ExceptionLogEntry.php | 37 ++++++ .../LogSystem/InstockChangedLogEntry.php | 71 ++++++++++ src/Entity/LogSystem/UserLoginLogEntry.php | 20 +++ src/Entity/LogSystem/UserLogoutLogEntry.php | 20 +++ .../LogSystem/UserNotAllowedLogEntry.php | 7 +- 12 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 src/DataTables/Column/LogEntryExtraColumn.php diff --git a/src/DataTables/Column/LogEntryExtraColumn.php b/src/DataTables/Column/LogEntryExtraColumn.php new file mode 100644 index 00000000..42cf7d49 --- /dev/null +++ b/src/DataTables/Column/LogEntryExtraColumn.php @@ -0,0 +1,121 @@ +translator = $translator; + } + + /** + * @inheritDoc + */ + public function normalize($value) + { + return $value; + } + + public function render($value, $context) + { + if ($context instanceof UserLoginLogEntry || $context instanceof UserLogoutLogEntry) { + return sprintf( + "%s: %s", + $this->translator->trans('log.user_login.ip'), + htmlspecialchars($context->getIPAddress()) + ); + } + + if ($context instanceof ExceptionLogEntry) { + return sprintf( + '%s %s:%d : %s', + htmlspecialchars($context->getExceptionClass()), + htmlspecialchars($context->getFile()), + $context->getLine(), + htmlspecialchars($context->getMessage()) + ); + } + + if ($context instanceof DatabaseUpdatedLogEntry) { + return sprintf( + '%s %s %s', + $this->translator->trans($context->isSuccessful() ? 'log.database_updated.success' : 'log.database_updated.failure'), + $context->getOldVersion(), + $context->getNewVersion() + ); + } + + if ($context instanceof ElementCreatedLogEntry && $context->hasCreationInstockValue()) { + return sprintf( + '%s: %s', + $this->translator->trans('log.element_created.original_instock'), + $context->getCreationInstockValue() + ); + } + + if ($context instanceof ElementDeletedLogEntry) { + return sprintf( + '%s: %s', + $this->translator->trans('log.element_deleted.old_name'), + $context->getOldName() + ); + } + + if ($context instanceof ElementEditedLogEntry && !empty($context->getMessage())) { + return htmlspecialchars($context->getMessage()); + } + + if ($context instanceof InstockChangedLogEntry) { + return sprintf( + '%s; %s %s (%s); %s: %s', + $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added'), + $context->getOldInstock(), + $context->getNewInstock(), + (!$context->isWithdrawal() ? '+' : '-') . $context->getDifference(true), + $this->translator->trans('log.instock_changed.comment'), + htmlspecialchars($context->getComment()) + ); + } + + if ($context instanceof UserNotAllowedLogEntry) { + return htmlspecialchars($context->getMessage()); + } + + return ""; + } +} \ No newline at end of file diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php index a3ba86e1..d6c0306a 100644 --- a/src/DataTables/LogDataTable.php +++ b/src/DataTables/LogDataTable.php @@ -24,6 +24,7 @@ namespace App\DataTables; use App\DataTables\Column\EntityColumn; use App\DataTables\Column\LocaleDateTimeColumn; +use App\DataTables\Column\LogEntryExtraColumn; use App\DataTables\Column\LogEntryTargetColumn; use App\Entity\Attachments\Attachment; use App\Entity\LogSystem\AbstractLogEntry; @@ -150,6 +151,10 @@ class LogDataTable implements DataTableTypeInterface 'label' => $this->translator->trans('log.target') ]); + $dataTable->add('extra', LogEntryExtraColumn::class, [ + 'label' => $this->translator->trans('log.extra') + ]); + $dataTable->addOrderBy('timestamp', DataTable::SORT_DESCENDING); $dataTable->createAdapter(ORMAdapter::class, [ diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index 21d8b3ae..fe5474dc 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -56,7 +56,8 @@ use Psr\Log\LogLevel; * 6 = "ElementCreatedLogEntry", * 7 = "ElementEditedLogEntry", * 8 = "ConfigChangedLogEntry", - * 9 = "DatabaseUpdatedLogEntry" + * 9 = "InstockChangedLogEntry", + * 10 = "DatabaseUpdatedLogEntry" * }) */ abstract class AbstractLogEntry extends DBElement @@ -144,6 +145,11 @@ abstract class AbstractLogEntry extends DBElement */ protected $typeString = "unknown"; + /** @var array The extra data in raw (short form) saved in the DB + * @ORM\Column(name="extra", type="json") + */ + protected $extra = []; + /** * Get the user that caused the event associated with this log entry. * @return User @@ -305,6 +311,11 @@ abstract class AbstractLogEntry extends DBElement return $this; } + public function getExtraData(): array + { + return $this->extra; + } + /** * This function converts the internal numeric log level into an PSR3 compatible level string. * @param int $level The numerical log level diff --git a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php index f142d666..a51af69c 100644 --- a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php +++ b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php @@ -36,4 +36,31 @@ class DatabaseUpdatedLogEntry extends AbstractLogEntry throw new LogEntryObsoleteException(); } + /** + * Checks if the database update was successful. + * @return bool + */ + public function isSuccessful(): bool + { + return $this->extra['s']; + } + + /** + * Gets the database version before update. + * @return int + */ + public function getOldVersion(): int + { + return $this->extra['o']; + } + + /** + * Gets the (target) database version after update. + * @return int + */ + public function getNewVersion(): int + { + return $this->extra['n']; + } + } \ No newline at end of file diff --git a/src/Entity/LogSystem/ElementCreatedLogEntry.php b/src/Entity/LogSystem/ElementCreatedLogEntry.php index c95a9ea4..97d6f6c0 100644 --- a/src/Entity/LogSystem/ElementCreatedLogEntry.php +++ b/src/Entity/LogSystem/ElementCreatedLogEntry.php @@ -31,4 +31,22 @@ use Doctrine\ORM\Mapping as ORM; class ElementCreatedLogEntry extends AbstractLogEntry { protected $typeString = "element_created"; + + /** + * Gets the instock when the part was created + * @return int|null + */ + public function getCreationInstockValue(): ?int + { + return $this->extra['i'] ?? null; + } + + /** + * Checks if a creation instock value was saved with this entry. + * @return bool + */ + public function hasCreationInstockValue(): bool + { + return $this->getCreationInstockValue() !== null; + } } \ No newline at end of file diff --git a/src/Entity/LogSystem/ElementDeletedLogEntry.php b/src/Entity/LogSystem/ElementDeletedLogEntry.php index 2b05e117..5756d2a0 100644 --- a/src/Entity/LogSystem/ElementDeletedLogEntry.php +++ b/src/Entity/LogSystem/ElementDeletedLogEntry.php @@ -30,4 +30,9 @@ use Doctrine\ORM\Mapping as ORM; class ElementDeletedLogEntry extends AbstractLogEntry { protected $typeString = "element_deleted"; + + public function getOldName(): string + { + return $this->extra['n']; + } } \ No newline at end of file diff --git a/src/Entity/LogSystem/ElementEditedLogEntry.php b/src/Entity/LogSystem/ElementEditedLogEntry.php index 36f32954..87855cea 100644 --- a/src/Entity/LogSystem/ElementEditedLogEntry.php +++ b/src/Entity/LogSystem/ElementEditedLogEntry.php @@ -30,4 +30,13 @@ use Doctrine\ORM\Mapping as ORM; class ElementEditedLogEntry extends AbstractLogEntry { protected $typeString = "element_edited"; + + /** + * Returns the message associated with this edit change + * @return string + */ + public function getMessage() : string + { + return $this->extra['m'] ?? ''; + } } \ No newline at end of file diff --git a/src/Entity/LogSystem/ExceptionLogEntry.php b/src/Entity/LogSystem/ExceptionLogEntry.php index 20099ce0..abf1935f 100644 --- a/src/Entity/LogSystem/ExceptionLogEntry.php +++ b/src/Entity/LogSystem/ExceptionLogEntry.php @@ -37,4 +37,41 @@ class ExceptionLogEntry extends AbstractLogEntry { throw new LogEntryObsoleteException(); } + + /** + * The class name of the exception that caused this log entry. + * @return string + */ + public function getExceptionClass(): string + { + return $this->extra['t'] ?? "Unknown Class"; + } + + /** + * Returns the file where the exception happened. + * @return string + */ + public function getFile(): string + { + return $this->extra['f']; + } + + /** + * Returns the line where the exception happened + * @return int + */ + public function getLine(): int + { + return $this->extra['l']; + } + + /** + * Return the message of the exception. + * @return string + */ + public function getMessage(): string + { + return $this->extra['m']; + } + } \ No newline at end of file diff --git a/src/Entity/LogSystem/InstockChangedLogEntry.php b/src/Entity/LogSystem/InstockChangedLogEntry.php index c1111f90..079bb62b 100644 --- a/src/Entity/LogSystem/InstockChangedLogEntry.php +++ b/src/Entity/LogSystem/InstockChangedLogEntry.php @@ -22,6 +22,7 @@ namespace App\Entity\LogSystem; +use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; /** @@ -31,4 +32,74 @@ use Doctrine\ORM\Mapping as ORM; class InstockChangedLogEntry extends AbstractLogEntry { protected $typeString = "instock_changed"; + + /** + * Get the old instock + * @return int + */ + public function getOldInstock(): int + { + return $this->extra['o']; + } + + /** + * Get the new instock + * @return int + */ + public function getNewInstock(): int + { + return $this->extra['n']; + } + + /** + * Gets the comment associated with the instock change + * @return string + */ + public function getComment(): string + { + return $this->extra['c']; + } + + /** + * Returns the price that has to be payed for the change (in the base currency). + * @param $absolute bool Set this to true, if you want only get the absolute value of the price (without minus) + * @return float + */ + public function getPrice(bool $absolute = false): float + { + if ($absolute) { + return abs($this->extra['p']); + } + return $this->extra['p']; + } + + /** + * Returns the difference value of the change ($new_instock - $old_instock). + * @param $absolute bool Set this to true if you want only the absolute value of the difference. + * @return int Difference is positive if instock has increased, negative if decreased. + */ + public function getDifference(bool $absolute = false): int + { + // Check if one of the instock values is unknown + if ($this->getNewInstock() == -2 || $this->getOldInstock() == -2) { + return 0; + } + + $difference = $this->getNewInstock() - $this->getOldInstock(); + if ($absolute) { + return abs($difference); + } + + return $difference; + } + + /** + * Checks if the Change was an withdrawal of parts. + * @return bool True if the change was an withdrawal, false if not. + */ + public function isWithdrawal(): bool + { + return $this->getNewInstock() < $this->getOldInstock(); + } + } \ No newline at end of file diff --git a/src/Entity/LogSystem/UserLoginLogEntry.php b/src/Entity/LogSystem/UserLoginLogEntry.php index 5c186b92..876260c1 100644 --- a/src/Entity/LogSystem/UserLoginLogEntry.php +++ b/src/Entity/LogSystem/UserLoginLogEntry.php @@ -31,4 +31,24 @@ use Doctrine\ORM\Mapping as ORM; class UserLoginLogEntry extends AbstractLogEntry { protected $typeString = "user_login"; + + /** + * Return the (anonymized) IP address used to login the user. + * @return string + */ + public function getIPAddress(): string + { + return $this->extra['i']; + } + + /** + * Sets the IP address used to login the user + * @param string $ip The IP address used to login the user. + * @return $this + */ + public function setIPAddress(string $ip): self + { + $this->extra['i'] = $ip; + return $this; + } } \ No newline at end of file diff --git a/src/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php index a6857305..5bb463e8 100644 --- a/src/Entity/LogSystem/UserLogoutLogEntry.php +++ b/src/Entity/LogSystem/UserLogoutLogEntry.php @@ -31,4 +31,24 @@ use Doctrine\ORM\Mapping as ORM; class UserLogoutLogEntry extends AbstractLogEntry { protected $typeString = "user_logout"; + + /** + * Return the (anonymized) IP address used to login the user. + * @return string + */ + public function getIPAddress(): string + { + return $this->extra['i']; + } + + /** + * Sets the IP address used to login the user + * @param string $ip The IP address used to login the user. + * @return $this + */ + public function setIPAddress(string $ip): self + { + $this->extra['i'] = $ip; + return $this; + } } \ No newline at end of file diff --git a/src/Entity/LogSystem/UserNotAllowedLogEntry.php b/src/Entity/LogSystem/UserNotAllowedLogEntry.php index b95199d8..71aaf626 100644 --- a/src/Entity/LogSystem/UserNotAllowedLogEntry.php +++ b/src/Entity/LogSystem/UserNotAllowedLogEntry.php @@ -31,11 +31,16 @@ use Doctrine\ORM\Mapping as ORM; */ class UserNotAllowedLogEntry extends AbstractLogEntry { - protected $type = 'user_not_allowed'; + protected $typeString = 'user_not_allowed'; public function __construct() { //Obsolete, use server log now. throw new LogEntryObsoleteException(); } + + public function getMessage(): string + { + return $this->extra['p'] ?? ''; + } } \ No newline at end of file From b0dacbf570213b1679d715e4ae90296898264cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 25 Jan 2020 23:17:06 +0100 Subject: [PATCH 07/11] [EventLog] Added permission checking and link in tools tree. --- src/Controller/LogController.php | 4 +- src/Security/Voter/LogEntryVoter.php | 70 +++++++++++++++++++++++++ src/Security/Voter/PermissionVoter.php | 3 +- src/Services/Trees/ToolsTreeBuilder.php | 7 +++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/Security/Voter/LogEntryVoter.php diff --git a/src/Controller/LogController.php b/src/Controller/LogController.php index 1bd3620e..b1f7e343 100644 --- a/src/Controller/LogController.php +++ b/src/Controller/LogController.php @@ -42,8 +42,10 @@ class LogController extends AbstractController * * @return JsonResponse|Response */ - public function showCategory(Request $request, DataTableFactory $dataTable) + public function showLogs(Request $request, DataTableFactory $dataTable) { + $this->denyAccessUnlessGranted('@system.show_logs'); + $table = $dataTable->createFromType(LogDataTable::class) ->handleRequest($request); diff --git a/src/Security/Voter/LogEntryVoter.php b/src/Security/Voter/LogEntryVoter.php new file mode 100644 index 00000000..1e2a8476 --- /dev/null +++ b/src/Security/Voter/LogEntryVoter.php @@ -0,0 +1,70 @@ +resolver->inherit($user, 'system', 'delete_logs') ?? false; + } + + if ($attribute === 'read') { + //Allow read of the users own log entries + if ( + $subject->getUser() === $user + && $this->resolver->inherit($user, 'self', 'show_logs') + ) { + return true; + } + + return $this->resolver->inherit($user, 'system','show_logs') ?? false; + } + } + + return false; + } + + /** + * @inheritDoc + */ + protected function supports($attribute, $subject) + { + if ($subject instanceof AbstractLogEntry) { + return in_array($subject, static::ALLOWED_OPS); + } + + return false; + } +} \ No newline at end of file diff --git a/src/Security/Voter/PermissionVoter.php b/src/Security/Voter/PermissionVoter.php index bd708e39..868a20a9 100644 --- a/src/Security/Voter/PermissionVoter.php +++ b/src/Security/Voter/PermissionVoter.php @@ -28,6 +28,7 @@ use App\Entity\UserSystem\User; /** * This voter allows you to directly check permissions from the permission structure, without passing an object. + * This use the syntax like "@permission.op" * However you should use the "normal" object based voters if possible, because they are needed for a future ACL system. */ class PermissionVoter extends ExtendedVoter @@ -44,7 +45,7 @@ class PermissionVoter extends ExtendedVoter $attribute = ltrim($attribute, '@'); [$perm, $op] = explode('.', $attribute); - return $this->resolver->inherit($user, $perm, $op); + return $this->resolver->inherit($user, $perm, $op) ?? false; } /** diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 414ec679..b482c8b2 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -212,6 +212,13 @@ class ToolsTreeBuilder ); } + if ($this->security->isGranted('@system.show_logs')) { + $nodes[] = new TreeViewNode( + $this->translator->trans('tree.tools.system.event_log'), + $this->urlGenerator->generate('log_view') + ); + } + return $nodes; } } From c8375bfa8b4abded4b88cc8e01472d7130527ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jan 2020 13:59:30 +0100 Subject: [PATCH 08/11] Add log entries on user login or logout. --- config/packages/security.yaml | 2 +- config/services.yaml | 14 ++ src/Entity/LogSystem/AbstractLogEntry.php | 2 +- src/Entity/LogSystem/UserLoginLogEntry.php | 14 +- src/Entity/LogSystem/UserLogoutLogEntry.php | 16 ++- src/EventSubscriber/LoginSuccessListener.php | 17 ++- src/EventSubscriber/LogoutListener.php | 57 ++++++++ src/Services/LogSystem/EventLogger.php | 137 +++++++++++++++++++ tests/Services/LogSystem/EventLoggerTest.php | 69 ++++++++++ 9 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/EventSubscriber/LogoutListener.php create mode 100644 src/Services/LogSystem/EventLogger.php create mode 100644 tests/Services/LogSystem/EventLoggerTest.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d142736b..54fbd6b1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -31,7 +31,6 @@ security: # https://symfony.com/doc/current/security/form_login_setup.html form_login: - login_path: login check_path: login csrf_token_generator: security.csrf.token_manager @@ -41,6 +40,7 @@ security: logout: path: logout target: homepage + handlers: [App\EventSubscriber\LogoutListener] remember_me: secret: '%kernel.secret%' diff --git a/config/services.yaml b/config/services.yaml index db0a8adb..36fbb12a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -20,6 +20,8 @@ parameters: sender_email: 'noreply@partdb.changeme' # The email address from which all emails are sent from sender_name: 'Part-DB Mailer' # The name that will be used for all mails sent by Part-DB allow_email_pw_reset: '%env(validMailDSN:MAILER_DSN)%' # Config if users are able, to reset their password by email. By default this enabled, when a mail server is configured. + # If this option is activated, IP addresses are anonymized to be GPDR compliant + gpdr_compliance: true services: # default configuration for services in *this* file @@ -28,6 +30,7 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: bool $demo_mode: '%demo_mode%' + bool $gpdr_compliance : '%gpdr_compliance%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -47,6 +50,17 @@ services: $email: '%sender_email%' $name: '%sender_name%' + App\Services\LogSystem\EventLogger: + arguments: + # By default only log events which has minimum info level (debug levels are not logged) + # 7 is lowest level (debug), 0 highest (emergency + $minimum_log_level: 6 + # Event classes specified here are not saved to DB + $blacklist: [] + # Only the event classes specified here are saved to DB (set to []) to log all events + $whitelist: [] + + Liip\ImagineBundle\Service\FilterService: alias: 'liip_imagine.service.filter' diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index fe5474dc..492859d3 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -154,7 +154,7 @@ abstract class AbstractLogEntry extends DBElement * Get the user that caused the event associated with this log entry. * @return User */ - public function getUser(): User + public function getUser(): ?User { return $this->user; } diff --git a/src/Entity/LogSystem/UserLoginLogEntry.php b/src/Entity/LogSystem/UserLoginLogEntry.php index 876260c1..cbfbc0f9 100644 --- a/src/Entity/LogSystem/UserLoginLogEntry.php +++ b/src/Entity/LogSystem/UserLoginLogEntry.php @@ -22,6 +22,7 @@ namespace App\Entity\LogSystem; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\IpUtils; /** * This log entry is created when a user logs in. @@ -32,6 +33,12 @@ class UserLoginLogEntry extends AbstractLogEntry { protected $typeString = "user_login"; + public function __construct(string $ip_address, bool $anonymize = true) + { + $this->level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + /** * Return the (anonymized) IP address used to login the user. * @return string @@ -44,10 +51,15 @@ class UserLoginLogEntry extends AbstractLogEntry /** * Sets the IP address used to login the user * @param string $ip The IP address used to login the user. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @return $this */ - public function setIPAddress(string $ip): self + public function setIPAddress(string $ip, bool $anonymize = true): self { + if ($anonymize) { + $ip = IpUtils::anonymize($ip); + } + $this->extra['i'] = $ip; return $this; } diff --git a/src/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php index 5bb463e8..c99c2b55 100644 --- a/src/Entity/LogSystem/UserLogoutLogEntry.php +++ b/src/Entity/LogSystem/UserLogoutLogEntry.php @@ -23,6 +23,7 @@ namespace App\Entity\LogSystem; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\HttpFoundation\IpUtils; /** * @ORM\Entity() @@ -32,6 +33,12 @@ class UserLogoutLogEntry extends AbstractLogEntry { protected $typeString = "user_logout"; + public function __construct(string $ip_address, bool $anonymize = true) + { + $this->level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + /** * Return the (anonymized) IP address used to login the user. * @return string @@ -44,11 +51,18 @@ class UserLogoutLogEntry extends AbstractLogEntry /** * Sets the IP address used to login the user * @param string $ip The IP address used to login the user. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @return $this */ - public function setIPAddress(string $ip): self + public function setIPAddress(string $ip, bool $anonymize = true): self { + if ($anonymize) { + $ip = IpUtils::anonymize($ip); + } + $this->extra['i'] = $ip; return $this; } + + } \ No newline at end of file diff --git a/src/EventSubscriber/LoginSuccessListener.php b/src/EventSubscriber/LoginSuccessListener.php index 3aa57029..be9bed58 100644 --- a/src/EventSubscriber/LoginSuccessListener.php +++ b/src/EventSubscriber/LoginSuccessListener.php @@ -24,6 +24,9 @@ declare(strict_types=1); namespace App\EventSubscriber; +use App\Entity\LogSystem\UserLoginLogEntry; +use App\Entity\UserSystem\User; +use App\Services\LogSystem\EventLogger; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; @@ -37,15 +40,27 @@ final class LoginSuccessListener implements EventSubscriberInterface { protected $translator; protected $flashBag; + protected $eventLogger; + protected $gpdr_compliance; - public function __construct(TranslatorInterface $translator, FlashBagInterface $flashBag) + public function __construct(TranslatorInterface $translator, FlashBagInterface $flashBag, EventLogger $eventLogger, bool $gpdr_compliance) { $this->translator = $translator; $this->flashBag = $flashBag; + $this->eventLogger = $eventLogger; + $this->gpdr_compliance = $gpdr_compliance; } public function onLogin(InteractiveLoginEvent $event): void { + $ip = $event->getRequest()->getClientIp(); + $log = new UserLoginLogEntry($ip, $this->gpdr_compliance); + $user = $event->getAuthenticationToken()->getUser(); + if ($user instanceof User) { + $log->setTargetElement($user); + } + $this->eventLogger->logAndFlush($log); + $this->flashBag->add('notice', $this->translator->trans('flash.login_successful')); } diff --git a/src/EventSubscriber/LogoutListener.php b/src/EventSubscriber/LogoutListener.php new file mode 100644 index 00000000..63f64846 --- /dev/null +++ b/src/EventSubscriber/LogoutListener.php @@ -0,0 +1,57 @@ +logger = $logger; + $this->gpdr_compliance = $gpdr_compliance; + } + + /** + * @inheritDoc + */ + public function logout(Request $request, Response $response, TokenInterface $token) + { + $log = new UserLogoutLogEntry($request->getClientIp(), $this->gpdr_compliance); + $user = $token->getUser(); + if ($user instanceof User) { + $log->setTargetElement($user); + } + + $this->logger->logAndFlush($log); + } +} \ No newline at end of file diff --git a/src/Services/LogSystem/EventLogger.php b/src/Services/LogSystem/EventLogger.php new file mode 100644 index 00000000..acbf8f5d --- /dev/null +++ b/src/Services/LogSystem/EventLogger.php @@ -0,0 +1,137 @@ +minimum_log_level = $minimum_log_level; + $this->blacklist = $blacklist; + $this->whitelist = $whitelist; + $this->em = $em; + $this->security = $security; + } + + /** + * Adds the given log entry to the Log, if the entry fullfills the global configured criterias. + * The change will not be flushed yet. + * @param AbstractLogEntry $logEntry + * @return bool Returns true, if the event was added to log. + */ + public function log(AbstractLogEntry $logEntry): bool + { + $user = $this->security->getUser(); + //If the user is not specified explicitly, set it to the current user + if (($user === null || $user instanceof User) && $logEntry->getUser() === null) { + if ($user === null) { + $repo = $this->em->getRepository(User::class); + $user = $repo->getAnonymousUser(); + } + $logEntry->setUser($user); + } + + if ($this->shouldBeAdded($logEntry)) { + $this->em->persist($logEntry); + return true; + } + + return false; + } + + /** + * Adds the given log entry to the Log, if the entry fullfills the global configured criterias and flush afterwards. + * @param AbstractLogEntry $logEntry + * @return bool Returns true, if the event was added to log. + */ + public function logAndFlush(AbstractLogEntry $logEntry): bool + { + $tmp = $this->log($logEntry); + $this->em->flush(); + return $tmp; + } + + public function shouldBeAdded( + AbstractLogEntry $logEntry, + ?int $minimum_log_level = null, + ?array $blacklist = null, + ?array $whitelist = null + ): bool { + //Apply the global settings, if nothing was specified + $minimum_log_level = $minimum_log_level ?? $this->minimum_log_level; + $blacklist = $blacklist ?? $this->blacklist; + $whitelist = $whitelist ?? $this->whitelist; + + //Dont add the entry if it does not reach the minimum level + if ($logEntry->getLevel() > $minimum_log_level) { + return false; + } + + //Check if the event type is black listed + if (!empty($blacklist) && $this->isObjectClassInArray($logEntry, $blacklist)) { + return false; + } + + //Check for whitelisting + if (!empty($whitelist) && !$this->isObjectClassInArray($logEntry, $whitelist)) { + return false; + } + + // By default all things should be added + return true; + } + + /** + * Check if the object type is given in the classes array. This also works for inherited types + * @param object $object The object which should be checked + * @param string[] $classes The list of class names that should be used for checking. + * @return bool + */ + protected function isObjectClassInArray(object $object, array $classes): bool + { + //Check if the class is directly in the classes array + if (in_array(get_class($object), $classes)) { + return true; + } + + //Iterate over all classes and check for inheritance + foreach ($classes as $class) { + if (is_a($object, $class)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/tests/Services/LogSystem/EventLoggerTest.php b/tests/Services/LogSystem/EventLoggerTest.php new file mode 100644 index 00000000..7d1fa232 --- /dev/null +++ b/tests/Services/LogSystem/EventLoggerTest.php @@ -0,0 +1,69 @@ +service = self::$container->get(EventLogger::class); + } + + public function testShouldBeAdded() + { + $event1 = new UserLoginLogEntry('127.0.0.1'); + $event2 = new UserLogoutLogEntry('127.0.0.1'); + $event2->setLevel(AbstractLogEntry::LEVEL_CRITICAL); + + + //Test without restrictions + $this->assertTrue($this->service->shouldBeAdded($event1, 7, [], [])); + + //Test minimum log level + $this->assertFalse($this->service->shouldBeAdded($event1, 2, [], [])); + $this->assertTrue($this->service->shouldBeAdded($event2, 2, [], [])); + + //Test blacklist + $this->assertFalse($this->service->shouldBeAdded($event1, 7, [UserLoginLogEntry::class], [])); + $this->assertTrue($this->service->shouldBeAdded($event2, 7, [UserLoginLogEntry::class], [])); + + //Test whitelist + $this->assertFalse($this->service->shouldBeAdded($event1, 7, [], [UserLogoutLogEntry::class])); + $this->assertTrue($this->service->shouldBeAdded($event2, 7, [], [UserLogoutLogEntry::class])); + } +} From 3178dcbb6ce49b23ab74d34e1987e052cd4f4cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jan 2020 20:12:08 +0100 Subject: [PATCH 09/11] Added a console command to view the event log. --- src/Command/ShowEventLogCommand.php | 160 ++++++++++++++++++ src/DataTables/Column/LogEntryExtraColumn.php | 70 +------- src/Repository/LogEntryRepository.php | 12 ++ .../LogSystem/LogEntryExtraFormatter.php | 138 +++++++++++++++ 4 files changed, 315 insertions(+), 65 deletions(-) create mode 100644 src/Command/ShowEventLogCommand.php create mode 100644 src/Services/LogSystem/LogEntryExtraFormatter.php diff --git a/src/Command/ShowEventLogCommand.php b/src/Command/ShowEventLogCommand.php new file mode 100644 index 00000000..e9cc82f0 --- /dev/null +++ b/src/Command/ShowEventLogCommand.php @@ -0,0 +1,160 @@ +entityManager = $entityManager; + $this->translator = $translator; + $this->elementTypeNameGenerator = $elementTypeNameGenerator; + $this->formatter = $formatter; + + $this->repo = $this->entityManager->getRepository(AbstractLogEntry::class); + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('List the last event log entries.') + ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'How many log entries should be shown per page.', 50 ) + ->addOption('oldest_first', null, InputOption::VALUE_NONE,'Show older entries first.') + ->addOption('page', 'p', InputOption::VALUE_REQUIRED, 'Which page should be shown?', 1) + ->addOption('onePage', null, InputOption::VALUE_NONE, 'Show only one page (dont ask to go to next).') + ->addOption('showExtra', 'x', InputOption::VALUE_NONE, 'Show a column with the extra data.'); + ; + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $onePage = $input->getOption('onePage'); + + $desc = $input->getOption('oldest_first'); + $limit = $input->getOption('count'); + $page = $input->getOption('page'); + $showExtra = $input->getOption('showExtra'); + + $total_count = $this->repo->count([]); + $max_page = ceil($total_count / $limit); + + if ($page > $max_page) { + $io->error("There is no page $page! The maximum page is $max_page."); + return 1; + } + + $io->note("There are a total of $total_count log entries in the DB."); + + $continue = true; + while ($continue && $page <= $max_page) { + $this->showPage($output, $desc, $limit, $page, $max_page, $showExtra); + + if ($onePage) { + return 0; + } + + $continue = $io->confirm('Do you want to show the next page?'); + $page++; + } + + return 0; + } + + protected function showPage(OutputInterface $output, bool $desc, int $limit, int $page, int $max_page, bool $showExtra): void + { + $sorting = $desc ? 'ASC' : 'DESC'; + $offset = ($page - 1) * $limit; + + /** @var AbstractLogEntry[] $entries */ + $entries = $this->repo->getLogsOrderedByTimestamp($sorting, $limit, $offset); + + $table = new Table($output); + $table->setHeaderTitle("Page $page / $max_page"); + $headers = ['ID', 'Timestamp', 'Type', 'User', 'Target Type', 'Target']; + if ($showExtra) { + $headers[] = 'Extra data'; + } + $table->setHeaders($headers); + + foreach ($entries as $entry) { + $this->addTableRow($table, $entry, $showExtra); + } + + $table->render(); + } + + protected function addTableRow(Table $table, AbstractLogEntry $entry, bool $showExtra): void + { + $target = $this->repo->getTargetElement($entry); + $target_name = ""; + if ($target instanceof NamedDBElement) { + $target_name = $target->getName() . ' (' . $target->getID() . ')'; + } elseif ($entry->getTargetID()) { + $target_name = '(' . $entry->getTargetID() . ')'; + } + + $target_class = ""; + if ($entry->getTargetClass() !== null) { + $target_class = $this->elementTypeNameGenerator->getLocalizedTypeLabel($entry->getTargetClass()); + } + + $row = [ + $entry->getID(), + $entry->getTimestamp()->format('Y-m-d H:i:s'), + $entry->getType(), + $entry->getUser()->getFullName(true), + $target_class, + $target_name + ]; + + if ($showExtra) { + $row[] = $this->formatter->formatConsole($entry); + } + + $table->addRow($row); + } +} \ No newline at end of file diff --git a/src/DataTables/Column/LogEntryExtraColumn.php b/src/DataTables/Column/LogEntryExtraColumn.php index 42cf7d49..e3d9d5c9 100644 --- a/src/DataTables/Column/LogEntryExtraColumn.php +++ b/src/DataTables/Column/LogEntryExtraColumn.php @@ -31,16 +31,19 @@ use App\Entity\LogSystem\InstockChangedLogEntry; use App\Entity\LogSystem\UserLoginLogEntry; use App\Entity\LogSystem\UserLogoutLogEntry; use App\Entity\LogSystem\UserNotAllowedLogEntry; +use App\Services\LogSystem\LogEntryExtraFormatter; use Omines\DataTablesBundle\Column\AbstractColumn; use Symfony\Contracts\Translation\TranslatorInterface; class LogEntryExtraColumn extends AbstractColumn { protected $translator; + protected $formatter; - public function __construct(TranslatorInterface $translator) + public function __construct(TranslatorInterface $translator, LogEntryExtraFormatter $formatter) { $this->translator = $translator; + $this->formatter = $formatter; } /** @@ -53,69 +56,6 @@ class LogEntryExtraColumn extends AbstractColumn public function render($value, $context) { - if ($context instanceof UserLoginLogEntry || $context instanceof UserLogoutLogEntry) { - return sprintf( - "%s: %s", - $this->translator->trans('log.user_login.ip'), - htmlspecialchars($context->getIPAddress()) - ); - } - - if ($context instanceof ExceptionLogEntry) { - return sprintf( - '%s %s:%d : %s', - htmlspecialchars($context->getExceptionClass()), - htmlspecialchars($context->getFile()), - $context->getLine(), - htmlspecialchars($context->getMessage()) - ); - } - - if ($context instanceof DatabaseUpdatedLogEntry) { - return sprintf( - '%s %s %s', - $this->translator->trans($context->isSuccessful() ? 'log.database_updated.success' : 'log.database_updated.failure'), - $context->getOldVersion(), - $context->getNewVersion() - ); - } - - if ($context instanceof ElementCreatedLogEntry && $context->hasCreationInstockValue()) { - return sprintf( - '%s: %s', - $this->translator->trans('log.element_created.original_instock'), - $context->getCreationInstockValue() - ); - } - - if ($context instanceof ElementDeletedLogEntry) { - return sprintf( - '%s: %s', - $this->translator->trans('log.element_deleted.old_name'), - $context->getOldName() - ); - } - - if ($context instanceof ElementEditedLogEntry && !empty($context->getMessage())) { - return htmlspecialchars($context->getMessage()); - } - - if ($context instanceof InstockChangedLogEntry) { - return sprintf( - '%s; %s %s (%s); %s: %s', - $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added'), - $context->getOldInstock(), - $context->getNewInstock(), - (!$context->isWithdrawal() ? '+' : '-') . $context->getDifference(true), - $this->translator->trans('log.instock_changed.comment'), - htmlspecialchars($context->getComment()) - ); - } - - if ($context instanceof UserNotAllowedLogEntry) { - return htmlspecialchars($context->getMessage()); - } - - return ""; + return $this->formatter->format($context); } } \ No newline at end of file diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php index e81ae101..e4742131 100644 --- a/src/Repository/LogEntryRepository.php +++ b/src/Repository/LogEntryRepository.php @@ -55,6 +55,18 @@ class LogEntryRepository extends EntityRepository return $this->findBy(['element' => $element], ['timestamp' => $order], $limit, $offset); } + /** + * Gets the last log entries ordered by timestamp + * @param string $order + * @param null $limit + * @param null $offset + * @return array + */ + public function getLogsOrderedByTimestamp($order = 'DESC', $limit = null, $offset = null) + { + return $this->findBy([], ['timestamp' => $order], $limit, $offset); + } + /** * Gets the target element associated with the logentry. * @param AbstractLogEntry $logEntry diff --git a/src/Services/LogSystem/LogEntryExtraFormatter.php b/src/Services/LogSystem/LogEntryExtraFormatter.php new file mode 100644 index 00000000..5934d9e2 --- /dev/null +++ b/src/Services/LogSystem/LogEntryExtraFormatter.php @@ -0,0 +1,138 @@ +translator = $translator; + } + + /** + * Return an user viewable representation of the extra data in a log entry, styled for console output. + * @param AbstractLogEntry $logEntry + * @return string + */ + public function formatConsole(AbstractLogEntry $logEntry): string + { + $tmp = $this->format($logEntry); + + //Just a simple tweak to make the console output more pretty. + $search = ['', '', '', '', ' ']; + $replace = ['', '', '', '', '→']; + + return str_replace($search, $replace, $tmp); + } + + /** + * Return a HTML formatted string containing a user viewable form of the Extra data + * @param AbstractLogEntry $context + * @return string + */ + public function format(AbstractLogEntry $context): string + { + if ($context instanceof UserLoginLogEntry || $context instanceof UserLogoutLogEntry) { + return sprintf( + "%s: %s", + $this->translator->trans('log.user_login.ip'), + htmlspecialchars($context->getIPAddress()) + ); + } + + if ($context instanceof ExceptionLogEntry) { + return sprintf( + '%s %s:%d : %s', + htmlspecialchars($context->getExceptionClass()), + htmlspecialchars($context->getFile()), + $context->getLine(), + htmlspecialchars($context->getMessage()) + ); + } + + if ($context instanceof DatabaseUpdatedLogEntry) { + return sprintf( + '%s %s %s', + $this->translator->trans($context->isSuccessful() ? 'log.database_updated.success' : 'log.database_updated.failure'), + $context->getOldVersion(), + $context->getNewVersion() + ); + } + + if ($context instanceof ElementCreatedLogEntry && $context->hasCreationInstockValue()) { + return sprintf( + '%s: %s', + $this->translator->trans('log.element_created.original_instock'), + $context->getCreationInstockValue() + ); + } + + if ($context instanceof ElementDeletedLogEntry) { + return sprintf( + '%s: %s', + $this->translator->trans('log.element_deleted.old_name'), + $context->getOldName() + ); + } + + if ($context instanceof ElementEditedLogEntry && !empty($context->getMessage())) { + return htmlspecialchars($context->getMessage()); + } + + if ($context instanceof InstockChangedLogEntry) { + return sprintf( + '%s; %s %s (%s); %s: %s', + $this->translator->trans($context->isWithdrawal() ? 'log.instock_changed.withdrawal' : 'log.instock_changed.added'), + $context->getOldInstock(), + $context->getNewInstock(), + (!$context->isWithdrawal() ? '+' : '-') . $context->getDifference(true), + $this->translator->trans('log.instock_changed.comment'), + htmlspecialchars($context->getComment()) + ); + } + + if ($context instanceof UserNotAllowedLogEntry) { + return htmlspecialchars($context->getMessage()); + } + + return ""; + } +} \ No newline at end of file From d5f018a73831cfedf5f4d5f8c3ee74dcd099c3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jan 2020 20:27:37 +0100 Subject: [PATCH 10/11] Added an database migration to improve the log schema. --- config/packages/doctrine.yaml | 2 +- src/Entity/LogSystem/AbstractLogEntry.php | 2 +- src/Migrations/Version20200126191823.php | 43 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/Migrations/Version20200126191823.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a5605d4c..7772d817 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -12,7 +12,7 @@ doctrine: date: class: App\Helpers\UTCDateTimeType - schema_filter: ~^(?!internal|log)~ + schema_filter: ~^(?!internal)~ profiling_collect_backtrace: true orm: diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index 492859d3..c5812f4e 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -115,7 +115,7 @@ abstract class AbstractLogEntry extends DBElement /** @var User $user The user which has caused this log entry * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User") - * @ORM\JoinColumn(name="id_user") + * @ORM\JoinColumn(name="id_user", nullable=false) */ protected $user; diff --git a/src/Migrations/Version20200126191823.php b/src/Migrations/Version20200126191823.php new file mode 100644 index 00000000..16532889 --- /dev/null +++ b/src/Migrations/Version20200126191823.php @@ -0,0 +1,43 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE log CHANGE datetime datetime DATETIME NOT NULL, CHANGE level level TINYINT, CHANGE extra extra LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\''); + $this->addSql('DROP INDEX id_user ON log'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES `users` (id)'); + $this->addSql('CREATE INDEX IDX_8F3F68C56B3CA4B ON log (id_user)'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE log DROP FOREIGN KEY FK_8F3F68C56B3CA4B'); + $this->addSql('ALTER TABLE log DROP FOREIGN KEY FK_8F3F68C56B3CA4B'); + $this->addSql('ALTER TABLE log CHANGE datetime datetime DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CHANGE level level TINYINT(1) NOT NULL, CHANGE extra extra MEDIUMTEXT CHARACTER SET utf8 NOT NULL COLLATE `utf8_general_ci`'); + $this->addSql('DROP INDEX idx_8f3f68c56b3ca4b ON log'); + $this->addSql('CREATE INDEX id_user ON log (id_user)'); + $this->addSql('ALTER TABLE log ADD CONSTRAINT FK_8F3F68C56B3CA4B FOREIGN KEY (id_user) REFERENCES `users` (id)'); + } +} From bfa43e68a9250a800cdaecd7c362974fc3de22ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 26 Jan 2020 21:31:48 +0100 Subject: [PATCH 11/11] Add an entry to log, if the database is updated. --- config/services.yaml | 5 ++ src/Entity/LogSystem/AbstractLogEntry.php | 6 ++ .../LogSystem/DatabaseUpdatedLogEntry.php | 17 ++-- src/Entity/LogSystem/UserLoginLogEntry.php | 1 + src/Entity/LogSystem/UserLogoutLogEntry.php | 1 + src/EventSubscriber/MigrationListener.php | 87 +++++++++++++++++++ 6 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 src/EventSubscriber/MigrationListener.php diff --git a/config/services.yaml b/config/services.yaml index 56ce3f10..dfe3de8f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -73,6 +73,11 @@ services: tags: - { name: "doctrine.orm.entity_listener" } + App\EventSubscriber\MigrationListener: + tags: + - { name: 'doctrine.event_subscriber' } + + tree_invalidation_listener: class: App\EntityListeners\TreeCacheInvalidationListener tags: diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php index c5812f4e..a7750b04 100644 --- a/src/Entity/LogSystem/AbstractLogEntry.php +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -150,6 +150,12 @@ abstract class AbstractLogEntry extends DBElement */ protected $extra = []; + public function __construct() + { + $this->timestamp = new DateTime(); + $this->level = self::LEVEL_WARNING; + } + /** * Get the user that caused the event associated with this log entry. * @return User diff --git a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php index a51af69c..9d7cb9af 100644 --- a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php +++ b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php @@ -31,9 +31,11 @@ class DatabaseUpdatedLogEntry extends AbstractLogEntry { protected $typeString = "database_updated"; - public function __construct() + public function __construct(string $oldVersion, string $newVersion) { - throw new LogEntryObsoleteException(); + parent::__construct(); + $this->extra['o'] = $oldVersion; + $this->extra['n'] = $newVersion; } /** @@ -42,25 +44,26 @@ class DatabaseUpdatedLogEntry extends AbstractLogEntry */ public function isSuccessful(): bool { - return $this->extra['s']; + //We dont save unsuccessful updates now, so just assume it to save space. + return $this->extra['s'] ?? true; } /** * Gets the database version before update. * @return int */ - public function getOldVersion(): int + public function getOldVersion(): string { - return $this->extra['o']; + return (string) $this->extra['o']; } /** * Gets the (target) database version after update. * @return int */ - public function getNewVersion(): int + public function getNewVersion(): string { - return $this->extra['n']; + return (string) $this->extra['n']; } } \ No newline at end of file diff --git a/src/Entity/LogSystem/UserLoginLogEntry.php b/src/Entity/LogSystem/UserLoginLogEntry.php index cbfbc0f9..734010d5 100644 --- a/src/Entity/LogSystem/UserLoginLogEntry.php +++ b/src/Entity/LogSystem/UserLoginLogEntry.php @@ -35,6 +35,7 @@ class UserLoginLogEntry extends AbstractLogEntry public function __construct(string $ip_address, bool $anonymize = true) { + parent::__construct(); $this->level = self::LEVEL_INFO; $this->setIPAddress($ip_address, $anonymize); } diff --git a/src/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php index c99c2b55..b3fe0124 100644 --- a/src/Entity/LogSystem/UserLogoutLogEntry.php +++ b/src/Entity/LogSystem/UserLogoutLogEntry.php @@ -35,6 +35,7 @@ class UserLogoutLogEntry extends AbstractLogEntry public function __construct(string $ip_address, bool $anonymize = true) { + parent::__construct(); $this->level = self::LEVEL_INFO; $this->setIPAddress($ip_address, $anonymize); } diff --git a/src/EventSubscriber/MigrationListener.php b/src/EventSubscriber/MigrationListener.php new file mode 100644 index 00000000..dd7c2441 --- /dev/null +++ b/src/EventSubscriber/MigrationListener.php @@ -0,0 +1,87 @@ +eventLogger = $eventLogger; + } + + public function onMigrationsMigrated(MigrationsEventArgs $args): void + { + //Dont do anything if this was a dry run + if ($args->isDryRun()) { + return; + } + + //Save the version after the migration + $this->new_version = $args->getConfiguration()->getCurrentVersion(); + + //After everything is done, write the results to DB log + $this->old_version = empty($this->old_version) ? 'legacy/empty' : $this->old_version; + $this->new_version = empty($this->new_version) ? 'unknown' : $this->new_version; + + + try { + $log = new DatabaseUpdatedLogEntry($this->old_version, $this->new_version); + $this->eventLogger->logAndFlush($log); + } catch (\Exception $exception) { + //Ignore any exception occuring here... + } + + } + + + public function onMigrationsMigrating(MigrationsEventArgs $args): void + { + // Save the version before any migration + if ($this->old_version == null) { + $this->old_version = $args->getConfiguration()->getCurrentVersion(); + } + } + + /** + * @inheritDoc + */ + public function getSubscribedEvents() + { + return [ + Events::onMigrationsMigrated, + Events::onMigrationsMigrating, + ]; + } +} \ No newline at end of file