270 lines
10 KiB
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
|
|
}
|
|
}
|
|
}
|