Merge pull request #827 from bolt/feature/logging

Feature: Add logs, including Log Viewer screen
This commit is contained in:
Bob den Otter
2020-01-04 15:02:33 +01:00
committed by GitHub
16 changed files with 623 additions and 34 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
monolog:
channels: ['db']
handlers:
db:
channels: ['db']
type: service
id: Bolt\Log\LogHandler

View File

@@ -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: ~

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Bolt\Controller\Backend;
use Bolt\Controller\TwigAwareController;
use Bolt\Repository\LogRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Security("is_granted('ROLE_ADMIN')")
*/
class LogViewerController extends TwigAwareController implements BackendZone
{
/**
* @Route("/logviewer", name="bolt_logviewer", methods={"GET"})
*/
public function index(LogRepository $log, Request $request): Response
{
$amount = $this->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,
]);
}
}

View File

@@ -7,6 +7,7 @@ namespace Bolt\Controller\Frontend;
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 @@ class HomepageController extends TwigAwareController implements FrontendZone
$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]);
}
}

169
src/Entity/Log.php Normal file
View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Bolt\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass="AppBundle\Repository\LogRepository")
* @ORM\Table(name="log")
* @ORM\HasLifecycleCallbacks
*/
class Log
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @ORM\Column(name="message", type="text")
*/
private $message;
/**
* @ORM\Column(name="context", type="array", nullable=true)
*/
private $context;
/**
* @ORM\Column(name="level", type="smallint")
*/
private $level;
/**
* @ORM\Column(name="level_name", type="string", length=50)
*/
private $levelName;
/**
* @ORM\Column(name="created_at", type="datetime")
*/
private $createdAt;
/**
* @ORM\Column(name="extra", type="array", nullable=true)
*/
private $extra;
/**
* @ORM\Column(name="user", type="array", nullable=true)
*/
private $user;
/**
* @ORM\Column(name="location", type="array", nullable=true)
*/
private $location;
/**
* @ORM\PrePersist
*/
public function onPrePersist(): void
{
$this->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;
}
}

41
src/Log/LogHandler.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Bolt\Log;
use Bolt\Entity\Log;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\AbstractProcessingHandler;
class LogHandler extends AbstractProcessingHandler
{
/**
* @var EntityManagerInterface
*/
protected $em;
public function __construct(EntityManagerInterface $em)
{
parent::__construct();
$this->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();
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Bolt\Log;
use Bolt\Entity\User;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Security;
use Webmozart\PathUtil\Path;
class RequestProcessor
{
/** @var RequestStack */
protected $request;
/** @var Security */
private $security;
/** @var string */
private $projectDir;
public function __construct(RequestStack $request, Security $security, KernelInterface $kernel)
{
$this->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;
}
}

View File

@@ -8,6 +8,7 @@ use Bolt\Configuration\Config;
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 @@ final class BackendMenuBuilder implements BackendMenuBuilderInterface
],
]);
$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 @@ final class BackendMenuBuilder implements BackendMenuBuilderInterface
],
]);
$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 @@ final class BackendMenuBuilder implements BackendMenuBuilderInterface
],
]);
$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 @@ final class BackendMenuBuilder implements BackendMenuBuilderInterface
],
]);
// @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'),

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Bolt\Repository;
use Bolt\Entity\Log;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
/**
* @method Log|null find($id, $lockMode = null, $lockVersion = null)
* @method Log|null findOneBy(array $criteria, array $orderBy = null)
* @method Log[] findAll()
* @method Log[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class LogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Log::class);
}
public function getQueryBuilder(): QueryBuilder
{
return $this->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;
}
}

View File

@@ -8,6 +8,7 @@ use Bolt\Entity\User;
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 @@ class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
$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')

View File

@@ -6,6 +6,7 @@ namespace Bolt\Security;
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 @@ class LogoutListener implements LogoutHandlerInterface
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();
}

View File

@@ -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 %}
<table class="table table-striped">
<tr>
<th scope="col">{{ __('label.id') }}</th>
<th scope="col">{{ __('label.level') }}</th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col">{{ __('label.message') }}</th>
<th scope="col"></th>
<th scope="col">{{ __('label.timestamp') }}</th>
</tr>
{% for item in items %}
<tr>
<th scope="row" class="text-nowrap">
{{ item.id }}
</th>
<td>
{{ item.levelName }}
</td>
<td>
<div class="dropdown">
<button class="btn btn-tertiary btn-sm dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ macro.icon('cog') }} {{ __('label.request') }}
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton" style="width: 400px; font-size: 80%;">
<ul class="pl-4 pr-3 my-0">
{% for key, line in item.extra %}
<li><strong>{{ key }}:</strong> <code>{{ line|join(', ') }}</code></li>
{% endfor %}
</ul>
</div>
</div>
</td>
<td>
<div class="dropdown">
<button class="btn btn-tertiary btn-sm dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ macro.icon('road') }} {{ __('label.trace') }}
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton" style="width: 500px; font-size: 80%;">
<ul class="pl-4 pr-3 my-0">
{% for key, line in item.location %}
<li><strong>{{ key }}:</strong> <code>{{ line|join(', ') }}</code></li>
{% endfor %}
</ul>
</div>
</div>
</td>
<td>
{{ item.message|replace(item.context|default([])) }}
</td>
<td>
{% if item.context is iterable %}
<div class="dropdown">
<button class="btn btn-tertiary btn-sm dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ macro.icon('project-diagram') }} {{ __('label.context') }}
</button>
<div class="dropdown-menu" aria-labelledby="dropdownMenuButton" style="width: 500px; font-size: 80%;">
<ul class="pl-4 pr-3 my-0">
{% for key, line in item.context %}
<li><strong>{{ key }}:</strong> <code>{{ line|join(', ') }}</code></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</td>
<td class="text-nowrap">
{{ item.createdAt|date('Y-m-d H:i:s') }}<br>
{% if item.user %}
{{ __('label.user') }}: {{ item.user.username }}<small>(№ {{ item.user.id }})</small>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{{ pager(items, template = '@bolt/helpers/_pager_bootstrap.html.twig', class="justify-content-center") }}
{% endblock %}

View File

@@ -5,10 +5,12 @@ declare(strict_types=1);
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 @@ class LoginFormAuthenticatorTest extends TestCase
->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 @@ class LoginFormAuthenticatorTest extends TestCase
'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 @@ class LoginFormAuthenticatorTest extends TestCase
]);
$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)
);
}
}

View File

@@ -2103,5 +2103,59 @@
<target>Download</target>
</segment>
</unit>
<unit id="2MtT_h0" name="caption.logviewer">
<segment>
<source>caption.logviewer</source>
<target>Log Viewer</target>
</segment>
</unit>
<unit id="FlGE3qB" name="label.request">
<segment>
<source>label.request</source>
<target>Request</target>
</segment>
</unit>
<unit id="l71Tprl" name="label.trace">
<segment>
<source>label.trace</source>
<target>Trace</target>
</segment>
</unit>
<unit id="29ZEo1r" name="label.context">
<segment>
<source>label.context</source>
<target>Context</target>
</segment>
</unit>
<unit id="txRonia" name="label.id">
<segment>
<source>label.id</source>
<target>ID</target>
</segment>
</unit>
<unit id="IwLLxxx" name="label.level">
<segment>
<source>label.level</source>
<target>Level</target>
</segment>
</unit>
<unit id="51OQi14" name="label.message">
<segment>
<source>label.message</source>
<target>Message</target>
</segment>
</unit>
<unit id="S0f6iTL" name="label.timestamp">
<segment>
<source>label.timestamp</source>
<target>Timestamp</target>
</segment>
</unit>
<unit id="O2X4xZH" name="label.user">
<segment>
<source>label.user</source>
<target>User</target>
</segment>
</unit>
</file>
</xliff>