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. 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/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 254ef939..dfe3de8f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -21,6 +21,8 @@ parameters: 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. locale_menu: ['en', 'de', 'ru'] # The languages that are shown in user drop down menu + # If this option is activated, IP addresses are anonymized to be GPDR compliant + gpdr_compliance: true services: @@ -30,6 +32,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 @@ -49,6 +52,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' @@ -59,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/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/Controller/LogController.php b/src/Controller/LogController.php new file mode 100644 index 00000000..b1f7e343 --- /dev/null +++ b/src/Controller/LogController.php @@ -0,0 +1,60 @@ +denyAccessUnlessGranted('@system.show_logs'); + + $table = $dataTable->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/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/Column/LogEntryExtraColumn.php b/src/DataTables/Column/LogEntryExtraColumn.php new file mode 100644 index 00000000..e3d9d5c9 --- /dev/null +++ b/src/DataTables/Column/LogEntryExtraColumn.php @@ -0,0 +1,61 @@ +translator = $translator; + $this->formatter = $formatter; + } + + /** + * @inheritDoc + */ + public function normalize($value) + { + return $value; + } + + public function render($value, $context) + { + return $this->formatter->format($context); + } +} \ 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..d6c0306a --- /dev/null +++ b/src/DataTables/LogDataTable.php @@ -0,0 +1,175 @@ +elementTypeNameGenerator = $elementTypeNameGenerator; + $this->translator = $translator; + $this->urlGenerator = $urlGenerator; + } + + 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, + ]); + + $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 $value; + } + ]); + + + $dataTable->add('user', TextColumn::class, [ + 'label' => $this->translator->trans('log.user'), + 'render' => function ($value, AbstractLogEntry $context) { + $user = $context->getUser(); + return sprintf( + '%s', + $this->urlGenerator->generate('user_info', ['id' => $user->getID()]), + $user->getFullName(true) + ); + } + ]); + + + + $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->add('extra', LogEntryExtraColumn::class, [ + 'label' => $this->translator->trans('log.extra') + ]); + + $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..a7750b04 --- /dev/null +++ b/src/Entity/LogSystem/AbstractLogEntry.php @@ -0,0 +1,392 @@ + 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", nullable=false) + */ + 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"; + + /** @var array The extra data in raw (short form) saved in the DB + * @ORM\Column(name="extra", type="json") + */ + 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 + */ + 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; + } + + 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 + * @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 @@ +extra['o'] = $oldVersion; + $this->extra['n'] = $newVersion; + } + + /** + * Checks if the database update was successful. + * @return bool + */ + public function isSuccessful(): bool + { + //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(): string + { + return (string) $this->extra['o']; + } + + /** + * Gets the (target) database version after update. + * @return int + */ + public function getNewVersion(): string + { + return (string) $this->extra['n']; + } + +} \ No newline at end of file diff --git a/src/Entity/LogSystem/ElementCreatedLogEntry.php b/src/Entity/LogSystem/ElementCreatedLogEntry.php new file mode 100644 index 00000000..97d6f6c0 --- /dev/null +++ b/src/Entity/LogSystem/ElementCreatedLogEntry.php @@ -0,0 +1,52 @@ +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 new file mode 100644 index 00000000..5756d2a0 --- /dev/null +++ b/src/Entity/LogSystem/ElementDeletedLogEntry.php @@ -0,0 +1,38 @@ +extra['n']; + } +} \ No newline at end of file diff --git a/src/Entity/LogSystem/ElementEditedLogEntry.php b/src/Entity/LogSystem/ElementEditedLogEntry.php new file mode 100644 index 00000000..87855cea --- /dev/null +++ b/src/Entity/LogSystem/ElementEditedLogEntry.php @@ -0,0 +1,42 @@ +extra['m'] ?? ''; + } +} \ No newline at end of file diff --git a/src/Entity/LogSystem/ExceptionLogEntry.php b/src/Entity/LogSystem/ExceptionLogEntry.php new file mode 100644 index 00000000..abf1935f --- /dev/null +++ b/src/Entity/LogSystem/ExceptionLogEntry.php @@ -0,0 +1,77 @@ +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 new file mode 100644 index 00000000..079bb62b --- /dev/null +++ b/src/Entity/LogSystem/InstockChangedLogEntry.php @@ -0,0 +1,105 @@ +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 new file mode 100644 index 00000000..734010d5 --- /dev/null +++ b/src/Entity/LogSystem/UserLoginLogEntry.php @@ -0,0 +1,67 @@ +level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + + /** + * 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. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant + * @return $this + */ + 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/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php new file mode 100644 index 00000000..b3fe0124 --- /dev/null +++ b/src/Entity/LogSystem/UserLogoutLogEntry.php @@ -0,0 +1,69 @@ +level = self::LEVEL_INFO; + $this->setIPAddress($ip_address, $anonymize); + } + + /** + * 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. + * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant + * @return $this + */ + 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/Entity/LogSystem/UserNotAllowedLogEntry.php b/src/Entity/LogSystem/UserNotAllowedLogEntry.php new file mode 100644 index 00000000..71aaf626 --- /dev/null +++ b/src/Entity/LogSystem/UserNotAllowedLogEntry.php @@ -0,0 +1,46 @@ +extra['p'] ?? ''; + } +} \ 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/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 diff --git a/src/Exceptions/LogEntryObsoleteException.php b/src/Exceptions/LogEntryObsoleteException.php new file mode 100644 index 00000000..b7e923ef --- /dev/null +++ b/src/Exceptions/LogEntryObsoleteException.php @@ -0,0 +1,28 @@ +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)'); + } +} diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php new file mode 100644 index 00000000..e4742131 --- /dev/null +++ b/src/Repository/LogEntryRepository.php @@ -0,0 +1,87 @@ +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 + * @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/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/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/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/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 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; } } 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 { 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])); + } +} 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 + +