From bd8f4bd2771e574dfeedb1a7cd5b46bd2bf552d9 Mon Sep 17 00:00:00 2001 From: Hugo Sales Date: Sat, 8 Aug 2020 16:10:25 +0000 Subject: [PATCH] [AVATAR] Fixed avatar upload, added avatar inline download and updated template and base controller --- components/Media/Controller/Avatar.php | 60 +++++++++++++++++++++ components/Media/Media.php | 48 +++++++++++++++++ public/assets/default-avatar.svg | 35 ++++++++++++ src/Controller/UserPanel.php | 41 ++++++++------- src/Core/Controller.php | 10 +++- src/Core/Entity.php | 4 +- src/Core/GNUsocial.php | 7 ++- src/Core/Router/Router.php | 37 +++++++++++-- src/Entity/Avatar.php | 53 +++++-------------- src/Entity/File.php | 20 +++++-- src/Entity/Profile.php | 73 ++++++++++++++++---------- src/Util/Common.php | 10 +++- templates/left/left.html.twig | 2 +- 13 files changed, 303 insertions(+), 97 deletions(-) create mode 100644 components/Media/Controller/Avatar.php create mode 100644 public/assets/default-avatar.svg diff --git a/components/Media/Controller/Avatar.php b/components/Media/Controller/Avatar.php new file mode 100644 index 0000000000..734526d457 --- /dev/null +++ b/components/Media/Controller/Avatar.php @@ -0,0 +1,60 @@ +. + +// }}} + +namespace Component\Media\Controller; + +use App\Core\Controller; +use App\Core\DB\DB; +use function App\Core\I18n\_m; +use App\Core\Log; +use App\Entity\Avatar as EAvatar; +use Component\Media\Media; +use Exception; +use Symfony\Component\HttpFoundation\Request; + +class Avatar extends Controller +{ + public function send(Request $request, string $nickname, string $size) + { + switch ($size) { + case 'full': + $result = DB::createQuery('select f.file_hash, f.mimetype, f.title from ' . + 'App\\Entity\\File f join App\\Entity\\Avatar a with f.id = a.file_id ' . + 'join App\\Entity\\Profile p with p.id = a.profile_id ' . + 'where p.nickname = :nickname') + ->setParameter('nickname', $nickname) + ->getResult(); + + if (count($result) != 1) { + Log::error('Avatar query returned more than one result for nickname ' . $nickname); + throw new Exception(_m('Internal server error')); + } + + $res = $result[0]; + Media::sendFile(EAvatar::getFilePath($res['file_hash']), $res['mimetype'], $res['title']); + die(); + // TODO FIX THIS + break; + default: + throw new Exception('Not implemented'); + } + } +} diff --git a/components/Media/Media.php b/components/Media/Media.php index 37893d2570..bb5f78dbe4 100644 --- a/components/Media/Media.php +++ b/components/Media/Media.php @@ -21,6 +21,8 @@ namespace Component\Media; use App\Core\Module; use App\Entity\File; +use App\Util\Common; +use App\Util\Nickname; use Symfony\Component\HttpFoundation\File\File as SymfonyFile; class Media extends Module @@ -42,4 +44,50 @@ class Media extends Module // TODO Normalize file types return $file; } + + /** + * Include $filepath in the response, for viewing and downloading. + * + * @throws ServerException + */ + public static function sendFile(string $filepath, string $mimetype, string $output_filename, string $disposition = 'inline'): void + { + $x_delivery = Common::config('site', 'x_static_delivery'); + if (is_string($x_delivery)) { + $tmp = explode(INSTALLDIR, $filepath); + $relative_path = end($tmp); + Log::debug("Using Static Delivery with header for: {$relative_path}"); + header("{$x_delivery}: {$relative_path}"); + } else { + if (file_exists($filepath)) { + header('Content-Description: File Transfer'); + header("Content-Type: {$mimetype}"); + header("Content-Disposition: {$disposition}; filename=\"{$output_filename}\""); + header('Expires: 0'); + header('Content-Transfer-Encoding: binary'); + + $filesize = filesize($filepath); + + http_response_code(200); + header("Content-Length: {$filesize}"); + // header('Cache-Control: private, no-transform, no-store, must-revalidate'); + + $ret = @readfile($filepath); + + if ($ret === false) { + http_response_code(404); + Log::error("Couldn't read file at {$filepath}."); + } elseif ($ret !== $filesize) { + http_response_code(500); + Log::error('The lengths of the file as recorded on the DB (or on disk) for the file ' . + "{$filepath} differ from what was sent to the user ({$filesize} vs {$ret})."); + } + } + } + } + + public function onAddRoute($r) + { + $r->connect('avatar', '/{nickname<' . Nickname::DISPLAY_FMT . '>}/avatar/{size?full}', [Controller\Avatar::class, 'send']); + } } diff --git a/public/assets/default-avatar.svg b/public/assets/default-avatar.svg new file mode 100644 index 0000000000..7a4d8cd297 --- /dev/null +++ b/public/assets/default-avatar.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Controller/UserPanel.php b/src/Controller/UserPanel.php index 2c300e748d..3447e31387 100644 --- a/src/Controller/UserPanel.php +++ b/src/Controller/UserPanel.php @@ -38,6 +38,7 @@ use App\Core\Event; use App\Core\Form; use function App\Core\I18n\_m; use App\Core\Log; +use App\Entity\Avatar; use App\Entity\File; use App\Util\ClientException; use App\Util\Common; @@ -102,16 +103,16 @@ class UserPanel extends AbstractController public function avatar(Request $request) { - $avatar = Form::create([ + $form = Form::create([ ['avatar', FileType::class, ['label' => _m('Avatar'), 'help' => _m('You can upload your personal avatar. The maximum file size is 2MB.')]], ['hidden', HiddenType::class, []], ['save', SubmitType::class, ['label' => _m('Submit')]], ]); - $avatar->handleRequest($request); + $form->handleRequest($request); - if ($avatar->isSubmitted() && $avatar->isValid()) { - $data = $avatar->getData(); + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); $sfile = $file_title = null; if (isset($data['hidden'])) { // Cropped client side @@ -120,13 +121,13 @@ class UserPanel extends AbstractController list(, $mimetype_user, , $encoding_user, $data_user) = $matches; if ($encoding_user == 'base64') { $data_user = base64_decode($data_user); - $tmp_file = tmpfile(); - fwrite($tmp_file, $data_user); + $filename = tempnam('/tmp/', 'avatar'); + file_put_contents($filename, $data_user); try { - $sfile = new SymfonyFile(stream_get_meta_data($tmp_file)['uri']); + $sfile = new SymfonyFile($filename); $file_title = $data['avatar']->getFilename(); } finally { - fclose($tmp_file); + // fclose($tmp_file); } } else { Log::info('Avatar upload got an invalid encoding, something\'s fishy and/or wrong'); @@ -138,21 +139,25 @@ class UserPanel extends AbstractController } else { throw new ClientException('Invalid form'); } + $profile_id = Common::profile()->getId(); + $file = Media::validateAndStoreFile($sfile, Common::config('avatar', 'dir'), $file_title); + $avatar = null; try { - $profile_id = Common::user()->getProfile()->getId(); - $file = Media::validateAndStoreFile($sfile, Common::config('avatar', 'dir'), $file_title); - $fs_files_to_delete = DB::find('avatar', ['profile_id' => $profile_id])->delete(); - DB::persist(Avatar::create(['profile_id' => $profile_id, 'file_id' => $file->getId()])); - DB::persist($file); - DB::flush(); - // Only delete files if the commit went through - File::deleteFiles($fs_files_to_delete); + $avatar = DB::find('avatar', ['profile_id' => $profile_id]); } catch (Exception $e) { - throw $e; } + if ($avatar != null) { + $avatar->delete(); + } else { + DB::persist($file); + DB::persist(Avatar::create(['profile_id' => $profile_id, 'file_id' => $file->getId()])); + } + DB::flush(); + // Only delete files if the commit went through + File::deleteFiles($fs_files_to_delete ?? []); } - return ['_template' => 'settings/avatar.html.twig', 'avatar' => $avatar->createView()]; + return ['_template' => 'settings/avatar.html.twig', 'avatar' => $form->createView()]; } public function notifications(Request $request) diff --git a/src/Core/Controller.php b/src/Core/Controller.php index 690c03bc5b..48906381e1 100644 --- a/src/Core/Controller.php +++ b/src/Core/Controller.php @@ -32,6 +32,8 @@ namespace App\Core; +use App\Core\DB\DB; +use App\Util\Common; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -60,7 +62,13 @@ class Controller extends AbstractController implements EventSubscriberInterface $controller = $event->getController(); $request = $event->getRequest(); - $this->vars = ['controler' => $controller, 'request' => $request]; + if (($avatar = DB::find('avatar', ['profile_id' => Common::profile()->getId()])) != null) { + $avatar_filename = $avatar->getUrl(); + } else { + $avatar_filename = '/public/assets/default_avatar.svg'; + } + + $this->vars = ['controler' => $controller, 'request' => $request, 'user_avatar' => $avatar_filename]; Event::handle('StartTwigPopulateVars', [&$this->vars]); return $event; diff --git a/src/Core/Entity.php b/src/Core/Entity.php index 5a5e048b95..f2346b45e6 100644 --- a/src/Core/Entity.php +++ b/src/Core/Entity.php @@ -23,6 +23,7 @@ namespace App\Core; use App\Core\DB\DB; use App\Util\Formatting; +use DateTime; class Entity { @@ -33,7 +34,8 @@ class Entity $args['created'] = $args['modified'] = new DateTime(); foreach ($args as $prop => $val) { if (property_exists($class, $prop)) { - $obj->{$prop} = $val; + $set = 'set' . Formatting::snakeCaseToCamelCase($prop); + $obj->{$set}($val); } else { Log::error("Property {$class}::{$prop} doesn't exist"); } diff --git a/src/Core/GNUsocial.php b/src/Core/GNUsocial.php index 118dfaa80b..ba7ccae99f 100644 --- a/src/Core/GNUsocial.php +++ b/src/Core/GNUsocial.php @@ -58,6 +58,7 @@ use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Security\Core\Security as SSecurity; use Symfony\Component\Security\Http\Util\TargetPathTrait; @@ -72,6 +73,7 @@ class GNUsocial implements EventSubscriberInterface protected TranslatorInterface $translator; protected EntityManagerInterface $entity_manager; protected RouterInterface $router; + protected UrlGeneratorInterface $url_generator; protected FormFactoryInterface $form_factory; protected MessageBusInterface $message_bus; protected EventDispatcherInterface $event_dispatcher; @@ -87,6 +89,7 @@ class GNUsocial implements EventSubscriberInterface TranslatorInterface $trans, EntityManagerInterface $em, RouterInterface $router, + UrlGeneratorInterface $url_gen, FormFactoryInterface $ff, MessageBusInterface $mb, EventDispatcherInterface $ed, @@ -99,6 +102,7 @@ class GNUsocial implements EventSubscriberInterface $this->translator = $trans; $this->entity_manager = $em; $this->router = $router; + $this->url_generator = $url_gen; $this->form_factory = $ff; $this->message_bus = $mb; $this->event_dispatcher = $ed; @@ -126,6 +130,7 @@ class GNUsocial implements EventSubscriberInterface Queue::setMessageBus($this->message_bus); Security::setHelper($this->security); Mailer::setMailer($this->mailer); + Router::setRouter($this->router, $this->url_generator); DefaultSettings::setDefaults(); @@ -134,8 +139,6 @@ class GNUsocial implements EventSubscriberInterface // Events are proloaded on compilation, but set at runtime $this->module_manager->loadModules(); - Router::setRouter($this->router); - $this->initialized = true; } } diff --git a/src/Core/Router/Router.php b/src/Core/Router/Router.php index 424e634eff..92911d3794 100644 --- a/src/Core/Router/Router.php +++ b/src/Core/Router/Router.php @@ -30,15 +30,46 @@ namespace App\Core\Router; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Router as SRouter; abstract class Router { - public static ?SRouter $router = null; + /** + * Generates an absolute URL, e.g. "http://example.com/dir/file". + */ + const ABSOLUTE_URL = UrlGeneratorInterface::ABSOLUTE_URL; - public static function setRouter($rtr): void + /** + * Generates an absolute path, e.g. "/dir/file". + */ + const ABSOLUTE_PATH = UrlGeneratorInterface::ABSOLUTE_PATH; + + /** + * Generates a relative path based on the current request path, e.g. "../parent-file". + * + * @see UrlGenerator::getRelativePath() + */ + const RELATIVE_PATH = UrlGeneratorInterface::RELATIVE_PATH; + + /** + * Generates a network path, e.g. "//example.com/dir/file". + * Such reference reuses the current scheme but specifies the host. + */ + const NETWORK_PATH = UrlGeneratorInterface::NETWORK_PATH; + + public static ?SRouter $router = null; + public static ?UrlGeneratorInterface $url_gen = null; + + public static function setRouter($rtr, $gen): void { - self::$router = $rtr; + self::$router = $rtr; + self::$url_gen = $gen; + } + + public static function url(string $id, array $args, int $type = self::ABSOLUTE_PATH): string + { + return self::$url_gen->generate($id, $args, $type); } public static function __callStatic(string $name, array $args) diff --git a/src/Entity/Avatar.php b/src/Entity/Avatar.php index 68293a94d6..971be60fac 100644 --- a/src/Entity/Avatar.php +++ b/src/Entity/Avatar.php @@ -21,6 +21,8 @@ namespace App\Entity; use App\Core\DB\DB; use App\Core\Entity; +use App\Core\Router\Router; +use App\Util\Common; use DateTimeInterface; /** @@ -42,9 +44,6 @@ class Avatar extends Entity // {{{ Autocode private int $profile_id; - private int $width; - private int $height; - private ?bool $is_original; private int $file_id; private \DateTimeInterface $created; private \DateTimeInterface $modified; @@ -54,46 +53,18 @@ class Avatar extends Entity $this->profile_id = $profile_id; return $this; } + public function getProfileId(): int { return $this->profile_id; } - public function setWidth(int $width): self - { - $this->width = $width; - return $this; - } - public function getWidth(): int - { - return $this->width; - } - - public function setHeight(int $height): self - { - $this->height = $height; - return $this; - } - public function getHeight(): int - { - return $this->height; - } - - public function setIsOriginal(?bool $is_original): self - { - $this->is_original = $is_original; - return $this; - } - public function getIsOriginal(): ?bool - { - return $this->is_original; - } - public function setFileId(int $file_id): self { $this->file_id = $file_id; return $this; } + public function getFileId(): int { return $this->file_id; @@ -104,6 +75,7 @@ class Avatar extends Entity $this->created = $created; return $this; } + public function getCreated(): DateTimeInterface { return $this->created; @@ -114,6 +86,7 @@ class Avatar extends Entity $this->modified = $modified; return $this; } + public function getModified(): DateTimeInterface { return $this->modified; @@ -123,18 +96,20 @@ class Avatar extends Entity private ?File $file = null; + public function getUrl(): string + { + return Router::url('avatar', ['nickname' => Profile::getNicknameFromId($this->profile_id)]); + } + public function getFile(): File { $this->file = $this->file ?: DB::find('file', ['id' => $this->file_id]); return $this->file; } - public function getFilePath(): string + public function getFilePath(?string $filename = null): string { - $file_name = $this->getFile()->getFileName(); - if ($this->is_original) { - return Common::config('avatar', 'dir') . '/' . $file_name; - } + return Common::config('avatar', 'dir') . '/' . $filename ?: $this->getFile()->getFileName(); } /** @@ -146,7 +121,7 @@ class Avatar extends Entity if (!$cascading) { $files = $this->getFile()->delete($cascade = true, $file_flush = false, $delete_files_now); } else { - DB::remove(DB::getReference('avatar', ['profile_id' => $this->profile_id, 'width' => $this->width, 'height' => $this->height])); + DB::remove(DB::getReference('avatar', ['profile_id' => $this->profile_id])); $file_path = $this->getFilePath(); $files[] = $file_path; if ($flush) { diff --git a/src/Entity/File.php b/src/Entity/File.php index b0c69c3401..6442d08aef 100644 --- a/src/Entity/File.php +++ b/src/Entity/File.php @@ -19,6 +19,7 @@ namespace App\Entity; +use App\Core\DB\DB; use App\Core\Entity; use DateTimeInterface; @@ -57,6 +58,7 @@ class File extends Entity $this->id = $id; return $this; } + public function getId(): int { return $this->id; @@ -67,6 +69,7 @@ class File extends Entity $this->url = $url; return $this; } + public function getUrl(): ?string { return $this->url; @@ -77,6 +80,7 @@ class File extends Entity $this->is_url_protected = $is_url_protected; return $this; } + public function getIsUrlProtected(): ?bool { return $this->is_url_protected; @@ -87,6 +91,7 @@ class File extends Entity $this->url_hash = $url_hash; return $this; } + public function getUrlHash(): ?string { return $this->url_hash; @@ -97,6 +102,7 @@ class File extends Entity $this->file_hash = $file_hash; return $this; } + public function getFileHash(): ?string { return $this->file_hash; @@ -107,6 +113,7 @@ class File extends Entity $this->mimetype = $mimetype; return $this; } + public function getMimetype(): ?string { return $this->mimetype; @@ -117,6 +124,7 @@ class File extends Entity $this->size = $size; return $this; } + public function getSize(): ?int { return $this->size; @@ -127,6 +135,7 @@ class File extends Entity $this->title = $title; return $this; } + public function getTitle(): ?string { return $this->title; @@ -137,6 +146,7 @@ class File extends Entity $this->timestamp = $timestamp; return $this; } + public function getTimestamp(): ?int { return $this->timestamp; @@ -147,6 +157,7 @@ class File extends Entity $this->is_local = $is_local; return $this; } + public function getIsLocal(): ?bool { return $this->is_local; @@ -157,6 +168,7 @@ class File extends Entity $this->modified = $modified; return $this; } + public function getModified(): DateTimeInterface { return $this->modified; @@ -180,9 +192,11 @@ class File extends Entity $files = []; if ($cascade) { // An avatar can own a file, and it becomes invalid if the file is deleted - $avatar = DB::find('avatar', ['file_id' => $this->id]); - $files[] = $avatar->getFilePath(); - $avatar->delete($flush, $delete_files_now, $cascading = true); + $avatar = DB::findBy('avatar', ['file_id' => $this->id]); + foreach ($avatar as $a) { + $files[] = $a->getFilePath(); + $a->delete($flush, $delete_files_now, $cascading = true); + } foreach (DB::findBy('file_thumbnail', ['file_id' => $this->id]) as $ft) { $files[] = $ft->delete($flush, $delete_files_now, $cascading); } diff --git a/src/Entity/Profile.php b/src/Entity/Profile.php index 2a2f40e764..45f75e6131 100644 --- a/src/Entity/Profile.php +++ b/src/Entity/Profile.php @@ -20,8 +20,8 @@ namespace App\Entity; use App\Core\DB\DB; +use App\Core\Entity; use App\Core\UserRoles; -use DateTime; use DateTimeInterface; use Functional as F; @@ -39,7 +39,7 @@ use Functional as F; * @copyright 2020 Free Software Foundation, Inc http://www.fsf.org * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later */ -class Profile +class Profile extends Entity { // {{{ Autocode @@ -62,6 +62,7 @@ class Profile $this->id = $id; return $this; } + public function getId(): int { return $this->id; @@ -72,6 +73,7 @@ class Profile $this->nickname = $nickname; return $this; } + public function getNickname(): string { return $this->nickname; @@ -82,6 +84,7 @@ class Profile $this->fullname = $fullname; return $this; } + public function getFullname(): ?string { return $this->fullname; @@ -92,6 +95,7 @@ class Profile $this->roles = $roles; return $this; } + public function getRoles(): int { return $this->roles; @@ -102,6 +106,7 @@ class Profile $this->homepage = $homepage; return $this; } + public function getHomepage(): ?string { return $this->homepage; @@ -112,6 +117,7 @@ class Profile $this->bio = $bio; return $this; } + public function getBio(): ?string { return $this->bio; @@ -122,6 +128,7 @@ class Profile $this->location = $location; return $this; } + public function getLocation(): ?string { return $this->location; @@ -132,6 +139,7 @@ class Profile $this->lat = $lat; return $this; } + public function getLat(): ?float { return $this->lat; @@ -142,6 +150,7 @@ class Profile $this->lon = $lon; return $this; } + public function getLon(): ?float { return $this->lon; @@ -152,6 +161,7 @@ class Profile $this->location_id = $location_id; return $this; } + public function getLocationId(): ?int { return $this->location_id; @@ -162,6 +172,7 @@ class Profile $this->location_service = $location_service; return $this; } + public function getLocationService(): ?int { return $this->location_service; @@ -172,6 +183,7 @@ class Profile $this->created = $created; return $this; } + public function getCreated(): DateTimeInterface { return $this->created; @@ -182,6 +194,7 @@ class Profile $this->modified = $modified; return $this; } + public function getModified(): DateTimeInterface { return $this->modified; @@ -189,13 +202,39 @@ class Profile // }}} Autocode - public function __construct(string $nickname) + public function getFromId(int $id): ?self { - $this->nickname = $nickname; + return DB::find('profile', ['id' => $id]); + } - // TODO auto update created and modified - $this->created = new DateTime(); - $this->modified = new DateTime(); + public function getFromNickname(string $nickname): ?self + { + return DB::findOneBy('profile', ['nickname' => $nickname]); + } + + public static function getNicknameFromId(int $id): string + { + return self::getFromId($id)->getNickname(); + } + + public function getSelfTags(): array + { + return DB::findBy('profile_tag', ['tagger' => $this->id, 'tagged' => $this->id]); + } + + public function setSelfTags(array $tags, array $pt_existing): void + { + $tag_existing = F\map($pt_existing, function ($pt) { return $pt->getTag(); }); + $tag_to_add = array_diff($tags, $tag_existing); + $tag_to_remove = array_diff($tag_existing, $tags); + $pt_to_remove = F\filter($pt_existing, function ($pt) use ($tag_to_remove) { return in_array($pt->getTag(), $tag_to_remove); }); + foreach ($tag_to_add as $tag) { + $pt = new ProfileTag($this->id, $this->id, $tag); + DB::persist($pt); + } + foreach ($pt_to_remove as $pt) { + DB::remove($pt); + } } public static function schemaDef(): array @@ -230,24 +269,4 @@ class Profile return $def; } - - public function getSelfTags(): array - { - return DB::findBy('profile_tag', ['tagger' => $this->id, 'tagged' => $this->id]); - } - - public function setSelfTags(array $tags, array $pt_existing): void - { - $tag_existing = F\map($pt_existing, function ($pt) { return $pt->getTag(); }); - $tag_to_add = array_diff($tags, $tag_existing); - $tag_to_remove = array_diff($tag_existing, $tags); - $pt_to_remove = F\filter($pt_existing, function ($pt) use ($tag_to_remove) { return in_array($pt->getTag(), $tag_to_remove); }); - foreach ($tag_to_add as $tag) { - $pt = new ProfileTag($this->id, $this->id, $tag); - DB::persist($pt); - } - foreach ($pt_to_remove as $pt) { - DB::remove($pt); - } - } } diff --git a/src/Util/Common.php b/src/Util/Common.php index 43fa697917..ea3cf408d5 100644 --- a/src/Util/Common.php +++ b/src/Util/Common.php @@ -35,8 +35,9 @@ namespace App\Util; use App\Core\DB\DB; use App\Core\Router; use App\Core\Security; +use App\Entity\LocalUser; +use App\Entity\Profile; use Functional as F; -use Symfony\Component\Security\Core\User\UserInterface; abstract class Common { @@ -69,11 +70,16 @@ abstract class Common DB::flush(); } - public static function user(): UserInterface + public static function user(): LocalUser { return Security::getUser(); } + public static function profile(): Profile + { + return self::user()->getProfile(); + } + /** * Is the given string identical to a system path or route? * This could probably be put in some other class, but at diff --git a/templates/left/left.html.twig b/templates/left/left.html.twig index 7a56fec4af..4969d1fe04 100644 --- a/templates/left/left.html.twig +++ b/templates/left/left.html.twig @@ -17,7 +17,7 @@