gnu-social/src/Core/Controller.php

270 lines
10 KiB
PHP

<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* Base class for controllers
*
* @package GNUsocial
* @category Controller
*
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace App\Core;
use function App\Core\I18n\_m;
use App\Util\Common;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\FeedController;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Validator\Exception\ValidatorException;
use Throwable;
/**
* @method ?int int(string $param, ?\Throwable $throw = null)
* @method ?bool bool(string $param, ?\Throwable $throw = null)
* @method ?string string(string $param, ?\Throwable $throw = null)
* @method ?string params(string $param)
* @method mixed handle(Request $request, mixed ...$extra)
*/
abstract class Controller extends AbstractController implements EventSubscriberInterface
{
private array $vars = [];
protected ?Request $request = null;
public function __construct(RequestStack $requestStack)
{
$this->request = $requestStack->getCurrentRequest();
}
/**
* TODO: Not currently used, so not tested, but should be
*
* @codeCoverageIgnore
*/
public function __invoke(Request $request)
{
$this->request = $request;
$class = static::class;
$method = 'on' . ucfirst(mb_strtolower($request->getMethod()));
$attributes = array_diff_key($request->attributes->get('_route_params'), array_flip(['_format', '_fragment', '_locale', 'template', 'accept', 'is_system_path']));
if (method_exists($class, $method)) {
return $this->{$method}($request, ...$attributes);
} else {
return $this->handle($request, ...$attributes);
}
}
/**
* Symfony event when it's searching for which controller to use
*/
public function onKernelController(ControllerEvent $event)
{
$controller = $event->getController();
$request = $event->getRequest();
$this->request = $request;
$this->vars = ['controller' => $controller, 'request' => $request];
$user = Common::user();
if ($user !== null) {
$this->vars['current_actor'] = $user->getActor();
}
$event->stopPropagation();
return $event;
}
/**
* Symfony event when the controller result is not a Response object
*
* @throws ClientException
* @throws ServerException
*/
public function onKernelView(ViewEvent $event)
{
$request = $event->getRequest();
$response = $event->getControllerResult();
if (!\is_array($response)) {
// This means it's not one of our custom format responses, nothing to do
// @codeCoverageIgnoreStart
return $event;
// @codeCoverageIgnoreEnd
}
$this->vars = array_merge_recursive($this->vars, $response);
$template = $this->vars['_template'] ?? null;
Event::handle('OverrideTemplate', [$this->vars, &$template]); // Allow plugins to replace the template used for anything
unset($this->vars['_template'], $response['_template']);
$redirect = $this->vars['_redirect'] ?? false;
$controller = $this->vars['controller'];
if (\is_array($controller)) {
$controller = $controller[0];
}
if (is_subclass_of($controller, FeedController::class)) {
$this->vars = $controller->postProcess($this->vars);
}
// Respond in the most preferred acceptable content type
$route = $request->get('_route');
$accept = $request->getAcceptableContentTypes() ?: ['text/html']; // Assume html if not specified, */* is considered specified
$format = $request->getFormat($accept[0]);
$potential_response = null;
if (Event::handle('ControllerResponseInFormat', [
'route' => $route,
'accept_header' => $accept,
'vars' => $this->vars,
'response' => &$potential_response,
]) !== Event::stop) {
switch ($format) {
case 'json':
$event->setResponse(new JsonResponse($response));
break;
default: // html (assume if not specified)
if ($redirect !== false) {
$event->setResponse(new RedirectResponse($redirect));
} elseif (!\is_null($template)) {
$event->setResponse($this->render($template, $this->vars));
break;
} else {
throw new ClientException(_m('Unsupported format: {format}', ['format' => $format]), 406); // 406 Not Acceptable
}
}
} else {
if (\is_null($potential_response)) {
throw new BugFoundException("ControllerResponseInFormat for route '{$route}' returned Event::stop but didn't provide a response");
}
$event->setResponse($potential_response); // @phpstan-ignore-line
}
// Set some inoffensive headers to every controller
// TODO: If response already has this set, do not reset!
$event->getResponse()->headers->set('permissions-policy', 'interest-cohort=()');
$event->getResponse()->headers->set('strict-transport-security', 'max-age=15768000; preload;');
$event->getResponse()->headers->set('vary', 'Accept-Encoding,Cookie');
$event->getResponse()->headers->set('x-frame-options', 'SAMEORIGIN');
$event->getResponse()->headers->set('x-xss-protection', '1; mode=block');
$policy = "default-src 'self' 'unsafe-inline'; frame-ancestors 'self'; form-action 'self'; style-src 'self' 'unsafe-inline'; img-src * blob: data:;";
$event->getResponse()->headers->set('Content-Security-Policy', $policy);
$event->getResponse()->headers->set('X-Content-Security-Policy', $policy);
$event->getResponse()->headers->set('X-WebKit-CSP', $policy);
Event::handle('CleanupModule');
return $event;
}
/**
* Symfony event when the controller throws an exception
*
* @codeCoverageIgnore
*/
public function onKernelException(ExceptionEvent $event)
{
$except = $event->getThrowable();
if ($_ENV['APP_ENV'] !== 'dev') {
// TODO: This is where our custom exception pages could go
// $event->setResponse((new Response())->setStatusCode(455));
}
do {
if ($except instanceof RedirectException) {
if (($redir = $except->redirect_response) != null) {
$event->setResponse($redir);
} else {
$event->setResponse(new RedirectResponse($event->getRequest()->getPathInfo()));
}
}
} while ($except != null && ($except = $except->getPrevious()) != null);
Event::handle('CleanupModule');
return $event;
}
/**
* @codeCoverageIgnore
*/
public static function getSubscribedEvents()
{
return [
KernelEvents::CONTROLLER => 'onKernelController',
KernelEvents::EXCEPTION => 'onKernelException',
KernelEvents::VIEW => 'onKernelView',
];
}
/**
* Get and convert GET parameters. Can be called with `int`, `bool`, `string`, etc
*
* @throws Exception
* @throws ValidatorException
*
* @return null|array|bool|int|string the value or null if no parameter exists
*/
public function __call(string $method, array $args): array|bool|int|string|null
{
switch ($method) {
case 'int':
case 'bool':
case 'string':
if ($this->request->query->has($args[0])) {
return match ($method) {
'int' => $this->request->query->getInt($args[0]),
'bool' => $this->request->query->getBoolean($args[0]),
'string' => $this->request->query->get($args[0]),
default => throw new BugFoundException('Inconsistent switch/match spotted'),
};
} elseif (\array_key_exists(1, $args) && $args[1] instanceof Throwable) {
throw $args[1];
} else {
return null;
}
case 'params':
return $this->request->query->all();
default:
// @codeCoverageIgnoreStart
Log::critical($m = "Method '{$method}' on class App\\Core\\Controller not found (__call)");
throw new Exception($m);
// @codeCoverageIgnoreEnd
}
}
}