<?php declare(strict_types=1);
/*
* This file is part of the Rapsys TreeBundle package.
*
* (c) Raphaël Gertz <symfony@rapsys.eu>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Rapsys\TreeBundle\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Container\ContainerInterface;
use Rapsys\PackBundle\Util\FacebookUtil;
use Rapsys\TreeBundle\Entity\Album;
use Rapsys\TreeBundle\Entity\Element;
use Rapsys\TreeBundle\RapsysTreeBundle;
use Rapsys\UserBundle\RapsysUserBundle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
/**
* {@inheritdoc}
*/
class TreeController extends AbstractController {
/**
* Alias string
*/
protected string $alias;
/**
* Config array
*/
protected array $config;
/**
* Context array
*/
protected array $context = [];
/**
* Count integer
*/
protected int $count;
/**
* Locale string
*/
protected string $locale;
/**
* Page integer
*/
protected int $page;
/**
* Request instance
*/
protected Request $request;
/**
* Route string
*/
protected string $route;
/**
* Route params array
*/
protected array $routeParams;
/**
* Creates a new tree controller
*
* @param AuthorizationCheckerInterface $checker The container instance
* @param ContainerInterface $container The ContainerInterface instance
* @param ManagerRegistry $doctrine The doctrine instance
* @param FacebookUtil $facebook The facebook instance
* @param RouterInterface $router The router instance
* @param Security $security The security instance
* @param RequestStack $stack The stack instance
* @param TranslatorInterface $translator The translator instance
* @param Environment $twig The twig environment instance
* @param integer $limit The page limit
*/
function __construct(protected AuthorizationCheckerInterface $checker, protected ContainerInterface $container, protected ManagerRegistry $doctrine, protected FacebookUtil $facebook, protected RouterInterface $router, protected Security $security, protected RequestStack $stack, protected TranslatorInterface $translator, protected Environment $twig, protected int $limit = 5) {
//Retrieve config
$this->config = $container->getParameter($this->alias = RapsysTreeBundle::getAlias());
//Get main request
$this->request = $this->stack->getMainRequest();
//Get current locale
$this->locale = $this->request->getLocale();
//Set canonical
$canonical = null;
//Set alternates
$alternates = [];
//Get current page
$this->page = (int) $this->request->query->get('page');
//With negative page
if ($this->page < 0) {
$this->page = 0;
}
//Set route
//TODO: default to not found route ???
//TODO: pour une url not found, cet attribut n'est pas défini, comment on fait ???
//XXX: on génère une route bidon par défaut ???
$this->route = $this->request->attributes->get('_route');
//Set route params
$this->routeParams = $this->request->attributes->get('_route_params');
//With route and routeParams
if ($this->route !== null && $this->routeParams !== null) {
//Set canonical
$canonical = $this->router->generate($this->route, $this->routeParams, UrlGeneratorInterface::ABSOLUTE_URL);
//Set alternates
$alternates = [
substr($this->locale, 0, 2) => [
'absolute' => $canonical
]
];
}
//Set the context
$this->context = [
'alternates' => $alternates,
'canonical' => $canonical,
'contact' => [
'address' => $this->config['contact']['address'],
'name' => $this->translator->trans($this->config['contact']['name'])
],
'copy' => [
'by' => $this->translator->trans($this->config['copy']['by']),
'link' => $this->config['copy']['link'],
'long' => $this->translator->trans($this->config['copy']['long']),
'short' => $this->translator->trans($this->config['copy']['short']),
'title' => $this->config['copy']['title']
],
'description' => null,
'donate' => $this->config['donate'],
'facebook' => [
'og:type' => 'article',
'og:site_name' => $title = $this->translator->trans($this->config['title']),
'og:url' => $canonical,
#'fb:admins' => $this->config['facebook']['admins'],
'fb:app_id' => $this->config['facebook']['apps']
],
//XXX: TODO: only generate it when fb robot request the url ???
'fbimage' => [
'texts' => [
$title => [
'font' => 'irishgrover',
'size' => 110
]
]
],
'icon' => $this->config['icon'],
'keywords' => null,
'locale' => str_replace('_', '-', $this->locale),
'logo' => $this->config['logo'],
'forms' => [],
'root' => $this->router->generate($this->config['root']),
'title' => [
'page' => null,
'section' => null,
'site' => $title
]
];
}
/**
* The album page
*
* Display album
*
* @param Request $request The request instance
* @param int $id The album id
* @param string $path The album path
* @param string $slug The album slug
* @return Response The rendered view
*/
public function album(Request $request, int $id, string $path, string $slug): Response {
//Get user
$user = $this->security->getUser();
//Check admin role
if (!$this->checker->isGranted('ROLE_'.strtoupper($this->container->getParameter(RapsysUserBundle::getAlias().'.default.admin')))) {
//Throw access denied
//XXX: prevent slugger reverse engineering by not displaying decoded mail
throw $this->createAccessDeniedException($this->translator->trans('Unable to access album', [], $this->alias));
}
//Without album
if (!($this->context['album'] = $this->doctrine->getRepository(Album::class)->findOneByIdPathAsArray($id, $path))) {
//Throw 404
throw $this->createNotFoundException($this->translator->trans('Unable to find album'));
}
//With slug not matching
if ($this->context['album']['slug'] !== $slug) {
//Redirect on clean slug
return $this->redirectToRoute($this->route, [ 'slug' => $this->context['album']['slug'] ]+$this->routeParams);
}
//Set modified
$this->modified = $this->context['album']['modified'];
//Create response
$response = new Response();
//With logged user
if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
//Set last modified
$response->setLastModified(new \DateTime('-1 year'));
//Set as private
$response->setPrivate();
//Without logged user
} else {
//Set etag
//XXX: only for public to force revalidation by last modified
$response->setEtag(md5(serialize($this->context['album'])));
//Set last modified
$response->setLastModified($this->modified);
//Set as public
$response->setPublic();
//Without role and modification
if ($response->isNotModified($request)) {
//Return 304 response
return $response;
}
}
//Set keywords
/*$this->context['head']['keywords'] = implode(
', ',
//Use closure to extract each unique article keywords sorted
(function ($t) {
//Return array
$r = [];
//Iterate on articles
foreach($t as $a) {
//Non empty keywords
if (!empty($a['keywords'])) {
//Iterate on keywords
foreach($a['keywords'] as $k) {
//Set keyword
$r[$k['title']] = $k['title'];
}
}
}
//Sort array
sort($r);
//Return array
return $r;
})($this->context['articles'])
);
//Get albums
$this->context['albums'] = $this->config['albums'];*/
//Return rendered response
return $this->render('@RapsysTree/album.html.twig', $this->context, $response);
}
/**
* The element page
*
* Display element
*
* @param Request $request The request instance
* @param int $id The element id
* @param ?string $path The element path
* @return Response The rendered view
*/
public function element(Request $request, int $id, string $path): Response {
//Get user
$user = $this->security->getUser();
//Without element
if (!($this->context['element'] = $this->doctrine->getRepository(Element::class)->findOneByUidIdPathAsArray($user?->getId(), $id, $path))) {
//Throw 404
throw $this->createNotFoundException($this->translator->trans('Unable to find element'));
}
//With realpath not matching path
//XXX: extra slashes removed by rewrite rule
if (($this->context['path'] = substr($this->context['element']['realpath'], strlen($this->context['element']['album']['path'])+1)) !== $path) {
//Redirect on clean path
return $this->redirectToRoute($this->route, [ 'path' => $this->context['path'] ]+$this->routeParams);
}
//Set modified
$this->modified = $this->context['element']['modified'];
//Create response
$response = new Response();
//With logged user
if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
//Set last modified
$response->setLastModified(new \DateTime('-1 year'));
//Set as private
$response->setPrivate();
//Without logged user
} else {
//Set etag
//XXX: only for public to force revalidation by last modified
$response->setEtag(md5(serialize($this->context['element'])));
//Set last modified
$response->setLastModified($this->modified);
//Set as public
$response->setPublic();
//Without role and modification
if ($response->isNotModified($request)) {
//Return 304 response
return $response;
}
}
//TODO: move that in a shared function to use in album member function too
/*
//With directory
if (is_dir($realpath)) {
//Set element directories
$this->context['element'] += [
//Directories
'directories' => [],
//Files
'files' => []
];
//Iterate on directory
foreach(array_diff(scandir($realpath), ['.', '..']) as $item) {
//Check item
if (
//Without item realpath
!($itempath = realpath($realpath.'/'.$item)) ||
//With item realpath not matching element path
(
$this->context['element']['album']['path'].$this->context['element']['path'] !==
substr($itempath, 0, strlen($this->context['element']['album']['path'].$this->context['element']['path']))
)
) {
//Skip
continue;
}
//With directory
if (is_dir($itempath)) {
//Append directory
$this->context['element']['directories'][$item] = $this->router->generate($this->route, [ 'path' => $this->context['path'].'/'.$item ]+$this->routeParams);
//With file
} elseif (is_file($itempath)) {
//Append file
$this->context['element']['files'][$item] = [
//Set mime
'mime' => mime_content_type($itempath),
//Set size
'size' => filesize($itempath),
//Set link
'link' => $this->router->generate($this->route, [ 'path' => $this->context['path'].'/'.$item ]+$this->routeParams)
];
//With unknown type
} else {
//Throw 404
throw $this->createNotFoundException($this->translator->trans('Unable to process element'));
}
}
//With file
} elseif (is_file($realpath)) {
//Append file
$this->context['element']['file'] = [
//Set mime
'mime' => mime_content_type($realpath),
//Set size
'size' => filesize($realpath),
//TODO: extra fields ? (preview, miniature, etc ?)
];
//TODO: mimetype decided extra fields ? (.pdf for doc, webm preview, img preview, etc ?)
//TODO: XXX: finish this !!!
//With unknown type
} else {
//Throw 404
throw $this->createNotFoundException($this->translator->trans('Unable to process element'));
}
*/
//Set keywords
/*$this->context['head']['keywords'] = implode(
', ',
//Use closure to extract each unique article keywords sorted
(function ($t) {
//Return array
$r = [];
//Iterate on articles
foreach($t as $a) {
//Non empty keywords
if (!empty($a['keywords'])) {
//Iterate on keywords
foreach($a['keywords'] as $k) {
//Set keyword
$r[$k['title']] = $k['title'];
}
}
}
//Sort array
sort($r);
//Return array
return $r;
})($this->context['articles'])
);
//Get albums
$this->context['albums'] = $this->config['albums'];*/
#header('Content-Type: text/plain');
#var_dump($this->context['element']);
#exit;
//Return rendered response
return $this->render('@RapsysTree/element.html.twig', $this->context, $response);
}
/**
* The index page
*
* Display index
*
* @param Request $request The request instance
* @return Response The rendered view
*/
public function index(Request $request): Response {
//Get user
$user = $this->security->getUser();
//With not enough albums
if (($this->count = $this->doctrine->getRepository(Album::class)->countByUidAsInt($user?->getId())) < $this->page * $this->limit) {
//Throw 404
throw $this->createNotFoundException($this->translator->trans('Unable to find albums'));
}
//Get albums
if ($this->context['albums'] = $this->doctrine->getRepository(Album::class)->findByUidAsArray($user?->getId(), $this->page, $this->limit)) {
//Set modified
$this->modified = max(array_map(function ($v) { return $v['modified']; }, $this->context['albums']));
//Without albums
} else {
//Set empty modified
$this->modified = new \DateTime('-1 year');
}
//Create response
$response = new Response();
//With logged user
if ($this->checker->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
//Set last modified
$response->setLastModified(new \DateTime('-1 year'));
//Set as private
$response->setPrivate();
//Without logged user
} else {
//Set etag
//XXX: only for public to force revalidation by last modified
$response->setEtag(md5(serialize($this->context['albums'])));
//Set last modified
$response->setLastModified($this->modified);
//Set as public
$response->setPublic();
//Without role and modification
if ($response->isNotModified($request)) {
//Return 304 response
return $response;
}
}
//Set keywords
/*$this->context['head']['keywords'] = implode(
', ',
//Use closure to extract each unique article keywords sorted
(function ($t) {
//Return array
$r = [];
//Iterate on articles
foreach($t as $a) {
//Non empty keywords
if (!empty($a['keywords'])) {
//Iterate on keywords
foreach($a['keywords'] as $k) {
//Set keyword
$r[$k['title']] = $k['title'];
}
}
}
//Sort array
sort($r);
//Return array
return $r;
})($this->context['articles'])
);
//Get albums
$this->context['albums'] = $this->config['albums'];*/
//Return rendered response
return $this->render('@RapsysTree/index.html.twig', $this->context, $response);
}
/**
* The directory page
*
* Display directory
*
* @param Request $request The request instance
* @param string $path The directory path
* @return Response The rendered view
*/
public function directory(Request $request, string $path): Response {
header('Content-Type: text/plain');
var_dump($path);
exit;
//Render template
$response = $this->render('@RapsysTree/directory.html.twig', $this->context);
$response->setEtag(md5($response->getContent()));
$response->setPublic();
$response->isNotModified($request);
//Return response
return $response;
}
/**
* The document page
*
* Display document
*
* @param Request $request The request instance
* @param string $path The directory path
* @return Response The rendered view
*/
public function document(Request $request, string $path): Response {
//Render template
$response = $this->render('@RapsysTree/document.html.twig', $this->context);
$response->setEtag(md5($response->getContent()));
$response->setPublic();
$response->isNotModified($request);
//Return response
return $response;
}
/**
* Renders a view
*
* {@inheritdoc}
*/
protected function render(string $view, array $parameters = [], Response $response = null): Response {
//Create response when null
$response ??= new Response();
//Without alternates
if (count($parameters['alternates']) <= 1) {
//Set routeParams
$routeParams = $this->routeParams;
//Iterate on locales excluding current one
foreach($this->config['locales'] as $locale) {
//With current locale
if ($locale !== $this->locale) {
//Set titles
$titles = [];
//Set route params locale
$routeParams['_locale'] = $locale;
//Iterate on other locales
foreach(array_diff($this->config['locales'], [$locale]) as $other) {
//Set other locale title
$titles[$other] = $this->translator->trans($this->config['languages'][$locale], [], null, $other);
}
//Set locale locales context
$parameters['alternates'][str_replace('_', '-', $locale)] = [
'absolute' => $this->router->generate($this->route, $routeParams, UrlGeneratorInterface::ABSOLUTE_URL),
'relative' => $this->router->generate($this->route, $routeParams),
'title' => implode('/', $titles),
'translated' => $this->translator->trans($this->config['languages'][$locale], [], null, $locale)
];
//Add shorter locale
if (empty($parameters['alternates'][$shortCurrent = substr($locale, 0, 2)])) {
//Set locale locales context
$parameters['alternates'][$shortCurrent] = $parameters['alternates'][str_replace('_', '-', $locale)];
}
}
}
}
//With canonical
if (!empty($parameters['canonical'])) {
//Set facebook url
$parameters['facebook']['og:url'] = $parameters['canonical'];
}
//With empty facebook title and title
if (empty($parameters['facebook']['og:title']) && !empty($parameters['title']['page'])) {
//Set facebook title
$parameters['facebook']['og:title'] = $parameters['title']['page'];
}
//With empty facebook description and description
if (empty($parameters['facebook']['og:description']) && !empty($parameters['description'])) {
//Set facebook description
$parameters['facebook']['og:description'] = $parameters['description'];
}
//With locale
if (!empty($this->locale)) {
//Set facebook locale
$parameters['facebook']['og:locale'] = $this->locale;
//With alternates
//XXX: locale change when fb_locale=xx_xx is provided is done in FacebookSubscriber
//XXX: see https://stackoverflow.com/questions/20827882/in-open-graph-markup-whats-the-use-of-oglocalealternate-without-the-locati
if (!empty($parameters['alternates'])) {
//Iterate on alternates
foreach($parameters['alternates'] as $lang => $alternate) {
if (strlen($lang) == 5) {
//Set facebook locale alternate
$parameters['facebook']['og:locale:alternate'] = str_replace('-', '_', $lang);
}
}
}
}
//Without facebook image defined and texts
if (empty($parameters['facebook']['og:image']) && !empty($this->request) && !empty($parameters['fbimage']['texts']) && !empty($this->modified)) {
//Get facebook image
$parameters['facebook'] += $this->facebook->getImage($this->request->getPathInfo(), $parameters['fbimage']['texts'], $this->modified->getTimestamp());
}
//Call twig render method
$content = $this->twig->render($view, $parameters);
//Invalidate OK response on invalid form
if (200 === $response->getStatusCode()) {
foreach ($parameters as $v) {
if ($v instanceof FormInterface && $v->isSubmitted() && !$v->isValid()) {
$response->setStatusCode(422);
break;
}
}
}
//Store content in response
$response->setContent($content);
//Return response
return $response;
}
}