X-Git-Url: https://git.rapsys.eu/treebundle/blobdiff_plain/24d9ae0ef4b68b275bfcc3da3fccb0300ebab323..HEAD:/Controller/TreeController.php diff --git a/Controller/TreeController.php b/Controller/TreeController.php index b51c01d..7c2a38f 100644 --- a/Controller/TreeController.php +++ b/Controller/TreeController.php @@ -11,15 +11,696 @@ 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} */ - function __construct() { + 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; } }