diff --git a/config/packages/dev/monolog.yaml b/config/packages/dev/monolog.yaml index 0487ef9cd..0ba7a2c14 100644 --- a/config/packages/dev/monolog.yaml +++ b/config/packages/dev/monolog.yaml @@ -1,4 +1,5 @@ monolog: + channels: ['db'] handlers: main: type: stream @@ -14,6 +15,10 @@ monolog: # type: chromephp # level: info console: - type: console + type: console process_psr_3_messages: false channels: ['!event', '!doctrine', '!console'] + db: + channels: ['db'] + type: service + id: Bolt\Log\LogHandler \ No newline at end of file diff --git a/config/packages/prod/monolog.yaml b/config/packages/prod/monolog.yaml index 04a2ae117..e1cf6f673 100644 --- a/config/packages/prod/monolog.yaml +++ b/config/packages/prod/monolog.yaml @@ -1,4 +1,5 @@ monolog: + channels: ['db'] handlers: main: type: fingers_crossed @@ -10,6 +11,10 @@ monolog: path: '%kernel.logs_dir%/%kernel.environment%.log' level: debug console: - type: console + type: console process_psr_3_messages: false channels: ['!event', '!doctrine'] + db: + channels: ['db'] + type: service + id: Bolt\Log\LogHandler diff --git a/config/packages/test/monolog.yaml b/config/packages/test/monolog.yaml new file mode 100644 index 000000000..e5ebc2825 --- /dev/null +++ b/config/packages/test/monolog.yaml @@ -0,0 +1,7 @@ +monolog: + channels: ['db'] + handlers: + db: + channels: ['db'] + type: service + id: Bolt\Log\LogHandler \ No newline at end of file diff --git a/config/services.yaml b/config/services.yaml index 66b95f914..cadcc1977 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -69,7 +69,7 @@ services: Bolt\Menu\BackendMenuBuilderInterface: '@Bolt\Menu\BackendMenuBuilder' Bolt\Menu\FrontendMenuBuilder: ~ - + Bolt\Menu\FrontendMenuBuilderInterface: '@Bolt\Menu\FrontendMenuBuilder' # Needed for SetContent from bolt/core @@ -87,5 +87,10 @@ services: Doctrine\ORM\Query\Expr: ~ + monolog.processor.request: + class: Bolt\Log\RequestProcessor + tags: + - { name: monolog.processor, method: processRecord, handler: db } + Twig\Extension\StringLoaderExtension: ~ diff --git a/src/Controller/Backend/LogViewerController.php b/src/Controller/Backend/LogViewerController.php new file mode 100644 index 000000000..403247866 --- /dev/null +++ b/src/Controller/Backend/LogViewerController.php @@ -0,0 +1,34 @@ +config->get('general/log/amount', 10); + $page = (int) $request->get('page', 1); + + /** @var Log $items */ + $items = $log->findLatest($page, $amount); + + return $this->renderTemplate('@bolt/pages/logviewer.html.twig', [ + 'items' => $items, + ]); + } +} diff --git a/src/Controller/Frontend/HomepageController.php b/src/Controller/Frontend/HomepageController.php index 1c47f739c..52460d849 100644 --- a/src/Controller/Frontend/HomepageController.php +++ b/src/Controller/Frontend/HomepageController.php @@ -7,6 +7,7 @@ use Bolt\Controller\TwigAwareController; use Bolt\Repository\ContentRepository; use Bolt\TemplateChooser; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; @@ -15,9 +16,13 @@ class HomepageController extends TwigAwareController implements FrontendZone /** @var TemplateChooser */ private $templateChooser; - public function __construct(TemplateChooser $templateChooser) + /** @var LoggerInterface */ + private $logger; + + public function __construct(TemplateChooser $templateChooser, LoggerInterface $dbLogger) { $this->templateChooser = $templateChooser; + $this->logger = $dbLogger; } /** @@ -40,6 +45,8 @@ public function homepage(ContentRepository $contentRepository): Response $templates = $this->templateChooser->forHomepage(); + $this->logger->notice('Huius, Lyco, oratione locuples, rebus ipsis ielunior. Quid autem habent admirationis, cum prope accesseris?!', ['foo' => 'bar']); + return $this->renderTemplate($templates, ['record' => $record]); } } diff --git a/src/Entity/Log.php b/src/Entity/Log.php new file mode 100644 index 000000000..dbb59e372 --- /dev/null +++ b/src/Entity/Log.php @@ -0,0 +1,169 @@ +createdAt = new \DateTime(); + } + + public function getId(): int + { + return $this->id; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getMessage(): string + { + return $this->message; + } + + public function setMessage(string $message): self + { + $this->message = $message; + return $this; + } + + public function getContext(): ?array + { + return $this->context; + } + + public function setContext(?array $context): self + { + $this->context = $context; + return $this; + } + + public function getLevel(): int + { + return $this->level; + } + + public function setLevel(int $level): self + { + $this->level = $level; + return $this; + } + + public function getLevelName(): string + { + return $this->levelName; + } + + public function setLevelName(string $levelName): self + { + $this->levelName = $levelName; + return $this; + } + + public function getExtra(): ?array + { + return $this->extra; + } + + public function setExtra(?array $extra): self + { + $this->extra = $extra; + return $this; + } + + public function getCreatedAt(): \DateTime + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getLocation(): ?array + { + return $this->location; + } + + public function setLocation(?array $location): self + { + $this->location = $location; + return $this; + } + + public function getUser(): ?array + { + return $this->user; + } + + public function setUser(?array $user): self + { + $this->user = $user; + return $this; + } +} diff --git a/src/Log/LogHandler.php b/src/Log/LogHandler.php new file mode 100644 index 000000000..26d859196 --- /dev/null +++ b/src/Log/LogHandler.php @@ -0,0 +1,41 @@ +em = $em; + } + + /** + * Called when writing to our database + */ + protected function write(array $record): void + { + $logEntry = new Log(); + $logEntry->setMessage($record['message']); + $logEntry->setLevel($record['level']); + $logEntry->setLevelName($record['level_name']); + $logEntry->setExtra($record['extra']); + $logEntry->setUser($record['user'] ?? null); + $logEntry->setLocation($record['location']); + $logEntry->setContext($record['context']); + + $this->em->persist($logEntry); + $this->em->flush(); + } +} diff --git a/src/Log/RequestProcessor.php b/src/Log/RequestProcessor.php new file mode 100644 index 000000000..49f5667ad --- /dev/null +++ b/src/Log/RequestProcessor.php @@ -0,0 +1,67 @@ +request = $request; + $this->security = $security; + $this->projectDir = $kernel->getProjectDir(); + } + + public function processRecord(array $record): array + { + $req = $this->request->getCurrentRequest(); + + /** @var User $user */ + $user = $this->security->getUser(); + + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + + $record['extra'] = [ + 'client_ip' => $req->getClientIp(), + 'client_port' => $req->getPort(), + 'uri' => $req->getUri(), + 'query_string' => $req->getQueryString(), + 'method' => $req->getMethod(), + 'request' => $req->request->all(), + ]; + + if (! empty($user)) { + $record['user'] = [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + 'roles' => $user->getRoles(), + ]; + } + + $record['location'] = [ + 'file' => '…/' . Path::makeRelative($trace[5]['file'], $this->projectDir), + 'line' => $trace[5]['line'], + 'class' => $trace[6]['class'], + 'type' => $trace[6]['type'], + 'function' => $trace[6]['function'], + ]; + + return $record; + } +} diff --git a/src/Menu/BackendMenuBuilder.php b/src/Menu/BackendMenuBuilder.php index d6ce975df..9d6402569 100644 --- a/src/Menu/BackendMenuBuilder.php +++ b/src/Menu/BackendMenuBuilder.php @@ -8,6 +8,7 @@ use Bolt\Configuration\Content\ContentType; use Bolt\Repository\ContentRepository; use Bolt\Twig\ContentExtension; +use Bolt\Version; use Knp\Menu\FactoryInterface; use Knp\Menu\ItemInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -207,6 +208,14 @@ private function createAdminMenu(): ItemInterface ], ]); + $menu->getChild('Maintenance')->addChild('Log viewer', [ + 'uri' => $this->urlGenerator->generate('bolt_logviewer'), + 'extras' => [ + 'name' => $t->trans('caption.logviewer'), + 'icon' => 'fa-clipboard', + ], + ]); + $menu->getChild('Maintenance')->addChild('Bolt API', [ 'uri' => $this->urlGenerator->generate('api_entrypoint'), 'extras' => [ @@ -215,13 +224,16 @@ private function createAdminMenu(): ItemInterface ], ]); - $menu->getChild('Maintenance')->addChild('Fixtures', [ - 'uri' => '', - 'extras' => [ - 'name' => $t->trans('caption.fixtures_dummy_content'), - 'icon' => 'fa-hat-wizard', - ], - ]); + /* + * @todo Make fixtures work from the backend + */ + // $menu->getChild('Maintenance')->addChild('Fixtures', [ + // 'uri' => '', + // 'extras' => [ + // 'name' => $t->trans('caption.fixtures_dummy_content'), + // 'icon' => 'fa-hat-wizard', + // ], + // ]); $menu->getChild('Maintenance')->addChild('Clear the cache', [ 'uri' => $this->urlGenerator->generate('bolt_clear_cache'), @@ -231,13 +243,16 @@ private function createAdminMenu(): ItemInterface ], ]); - $menu->getChild('Maintenance')->addChild('Installation checks', [ - 'uri' => '', - 'extras' => [ - 'name' => $t->trans('caption.installation_checks'), - 'icon' => 'fa-clipboard-check', - ], - ]); + /* + * @todo Make Installation checks work from the backend + */ + // $menu->getChild('Maintenance')->addChild('Installation checks', [ + // 'uri' => '', + // 'extras' => [ + // 'name' => $t->trans('caption.installation_checks'), + // 'icon' => 'fa-clipboard-check', + // ], + // ]); $menu->getChild('Maintenance')->addChild('Translations', [ 'uri' => $this->urlGenerator->generate('translation_index'), @@ -247,14 +262,16 @@ private function createAdminMenu(): ItemInterface ], ]); - // @todo When we're close to stable release, make this less prominent - $menu->getChild('Maintenance')->addChild('The Kitchensink', [ - 'uri' => $this->urlGenerator->generate('bolt_kitchensink'), - 'extras' => [ - 'name' => $t->trans('caption.kitchensink'), - 'icon' => 'fa-bath', - ], - ]); + // Hide this menu item, unless we're on a "Git clone" install. + if (Version::installType() === 'Git clone') { + $menu->getChild('Maintenance')->addChild('The Kitchensink', [ + 'uri' => $this->urlGenerator->generate('bolt_kitchensink'), + 'extras' => [ + 'name' => $t->trans('caption.kitchensink'), + 'icon' => 'fa-bath', + ], + ]); + } $menu->getChild('Maintenance')->addChild('About Bolt', [ 'uri' => $this->urlGenerator->generate('bolt_about'), diff --git a/src/Repository/LogRepository.php b/src/Repository/LogRepository.php new file mode 100644 index 000000000..cf1cfb149 --- /dev/null +++ b/src/Repository/LogRepository.php @@ -0,0 +1,49 @@ +createQueryBuilder('log'); + } + + public function findLatest(int $page = 1, int $amount = 6): Pagerfanta + { + $qb = $this->getQueryBuilder() + ->orderBy('log.createdAt', 'DESC') + ->setMaxResults($amount); + + return $this->createPaginator($qb->getQuery(), $page, $amount); + } + + private function createPaginator(Query $query, int $page, int $amountPerPage): Pagerfanta + { + $paginator = new Pagerfanta(new DoctrineORMAdapter($query, true, true)); + $paginator->setMaxPerPage($amountPerPage); + $paginator->setCurrentPage($page); + return $paginator; + } +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php index d7e033820..567c7d320 100644 --- a/src/Security/LoginFormAuthenticator.php +++ b/src/Security/LoginFormAuthenticator.php @@ -8,6 +8,7 @@ use Bolt\Entity\UserAuthToken; use Bolt\Repository\UserRepository; use Doctrine\Common\Persistence\ObjectManager; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\RouterInterface; @@ -39,13 +40,23 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator /** @var ObjectManager */ private $em; - public function __construct(UserRepository $userRepository, RouterInterface $router, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder, ObjectManager $em) - { + /** @var LoggerInterface */ + private $logger; + + public function __construct( + UserRepository $userRepository, + RouterInterface $router, + CsrfTokenManagerInterface $csrfTokenManager, + UserPasswordEncoderInterface $passwordEncoder, + ObjectManager $em, + LoggerInterface $dbLogger +) { $this->userRepository = $userRepository; $this->router = $router; $this->csrfTokenManager = $csrfTokenManager; $this->passwordEncoder = $passwordEncoder; $this->em = $em; + $this->logger = $dbLogger; } protected function getLoginUrl() @@ -114,6 +125,12 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token, $this->em->persist($user); $this->em->flush(); + $userArr = [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ]; + $this->logger->notice('User \'{username}\' logged in (manually)', $userArr); + return new RedirectResponse($request->getSession()->get( '_security.'.$providerKey.'.target_path', $this->router->generate('bolt_dashboard') diff --git a/src/Security/LogoutListener.php b/src/Security/LogoutListener.php index 5ce6a213c..c2b46a640 100644 --- a/src/Security/LogoutListener.php +++ b/src/Security/LogoutListener.php @@ -6,6 +6,7 @@ use Bolt\Entity\User; use Doctrine\Common\Persistence\ObjectManager; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -15,9 +16,13 @@ class LogoutListener implements LogoutHandlerInterface { private $em; - public function __construct(ObjectManager $em) + /** @var LoggerInterface */ + private $logger; + + public function __construct(ObjectManager $em, LoggerInterface $dbLogger) { $this->em = $em; + $this->logger = $dbLogger; } public function logout(Request $request, Response $response, TokenInterface $token): void @@ -26,6 +31,13 @@ public function logout(Request $request, Response $response, TokenInterface $tok if (! $user instanceof User) { return; } + + $userArr = [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ]; + $this->logger->notice('User \'{username}\' logged out (manually)', $userArr); + $this->em->remove($user->getUserAuthToken()); $this->em->flush(); } diff --git a/templates/pages/logviewer.html.twig b/templates/pages/logviewer.html.twig new file mode 100644 index 000000000..720376cc4 --- /dev/null +++ b/templates/pages/logviewer.html.twig @@ -0,0 +1,97 @@ +{% extends '@bolt/_base/layout.html.twig' %} +{% import '@bolt/_macro/_macro.html.twig' as macro %} + +{% block title %} + {{ macro.icon('clipboard-list') }} + {{ 'caption.logviewer'|trans }} +{% endblock title %} + +{# This 'topsection' gets output _before_ the main form, allowing `dump()`, without breaking Vue #} +{% block topsection %} + +{% endblock %} + +{# The 'main' section is the main contents of the page. Usually this is Vue-ified. #} +{% block main %} + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + {% endfor %} +
{{ __('label.id') }}{{ __('label.level') }}{{ __('label.message') }}{{ __('label.timestamp') }}
+ № {{ item.id }} + + {{ item.levelName }} + + + + + + {{ item.message|replace(item.context|default([])) }} + + {% if item.context is iterable %} + + {% endif %} + + {{ item.createdAt|date('Y-m-d H:i:s') }}
+ {% if item.user %} + {{ __('label.user') }}: {{ item.user.username }}(№ {{ item.user.id }}) + {% else %} + - + {% endif %} +
+ + {{ pager(items, template = '@bolt/helpers/_pager_bootstrap.html.twig', class="justify-content-center") }} + +{% endblock %} diff --git a/tests/php/Security/LoginFormAuthenticatorTest.php b/tests/php/Security/LoginFormAuthenticatorTest.php index 70ea9d6d6..b42b94f33 100644 --- a/tests/php/Security/LoginFormAuthenticatorTest.php +++ b/tests/php/Security/LoginFormAuthenticatorTest.php @@ -5,10 +5,12 @@ namespace Bolt\Tests\Security; use Bolt\Entity\User; +use Bolt\Log\LogHandler; use Bolt\Repository\UserRepository; use Bolt\Security\LoginFormAuthenticator; use Doctrine\Common\Persistence\ObjectManager; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; @@ -31,7 +33,7 @@ public function testGetLoginUrl(): void ->with('bolt_login') ->willReturn('test_route'); - $res = $this->getTestObj(null, $router, null, null)->start($this->createMock(Request::class)); + $res = $this->getTestObj(null, $router, null, null, null)->start($this->createMock(Request::class)); $this->assertSame('test_route', $res->getTargetUrl()); } @@ -44,7 +46,7 @@ public function testGetUser(): void 'isTokenValid' => true, ]); - $res = $this->getTestObj($userRepository, null, $csrfTokenManager, null)->getUser(self::TEST_TOKEN, $this->createMock(UserProviderInterface::class)); + $res = $this->getTestObj($userRepository, null, $csrfTokenManager, null, null)->getUser(self::TEST_TOKEN, $this->createMock(UserProviderInterface::class)); $this->assertInstanceOf(User::class, $res); } @@ -55,17 +57,18 @@ public function testGetUserThrows(): void ]); $this->expectException(InvalidCsrfTokenException::class); - $this->getTestObj(null, null, $csrfTokenManager, null)->getUser(self::TEST_TOKEN, $this->createMock(UserProviderInterface::class)); + $this->getTestObj(null, null, $csrfTokenManager, null, null)->getUser(self::TEST_TOKEN, $this->createMock(UserProviderInterface::class)); } - private function getTestObj(?UserRepository $userRepository, ?RouterInterface $router, ?CsrfTokenManagerInterface $csrfTokenManager, ?UserPasswordEncoderInterface $userPasswordEncoder): LoginFormAuthenticator + private function getTestObj(?UserRepository $userRepository, ?RouterInterface $router, ?CsrfTokenManagerInterface $csrfTokenManager, ?UserPasswordEncoderInterface $userPasswordEncoder, ?LoggerInterface $logger): LoginFormAuthenticator { return new LoginFormAuthenticator( $userRepository ?? $this->createMock(UserRepository::class), $router ?? $this->createMock(RouterInterface::class), $csrfTokenManager ?? $this->createMock(CsrfTokenManagerInterface::class), $userPasswordEncoder ?? $this->createMock(UserPasswordEncoderInterface::class), - $em ?? $this->createMock(ObjectManager::class) + $em ?? $this->createMock(ObjectManager::class), + $logger ?? $this->createMock(LoggerInterface::class) ); } } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index c1436a42f..f5f67d609 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -2103,5 +2103,59 @@ Download + + + caption.logviewer + Log Viewer + + + + + label.request + Request + + + + + label.trace + Trace + + + + + label.context + Context + + + + + label.id + ID + + + + + label.level + Level + + + + + label.message + Message + + + + + label.timestamp + Timestamp + + + + + label.user + User + +