Symfony2: dynamic routing parameter - php

I'm turning to this forum because I can't find a valid solution to my problem.
I have taken over the management of a Symfony2 application which processes orders, invoices... inside a company and the problem is that there isn't archiving functions on it. So, the manager asked me to add archiving 'by year' functionalities to the application (simply display data depending on a chosen year).
So, I decided to prefix all application routes by /{year}/, parameter which will match the year the manager want to see and, as all the documents are dated, I just have to update Doctrine requests for picking those that match the chosen year. So far no problems.
routes.yml
mes_routes:
resource: "mes_routes.yml"
prefix: /{year}
defaults: {'year': %current_year%}
With this, I have created a Symfony Extension which fills the 'current_year' var by default in my route definition, with the actual year if no year is provided.
MyAppExtension.php
class MyAppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
// Fill parameter with the current year
$container->setParameter('current_year', date("Y"));
}
}
Next, I have created a RouteListener that stores a previous route and its parameters inside a session var, when a user displays a new page (in order to display a same page but with a different year next)
LastRouteListener.php
class LastRouteListener
{
public function onKernelRequest(GetResponseEvent $event)
{
// Don't store subrequests
if ($event->getRequestType() !== HttpKernel::MASTER_REQUEST) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
$routeName = $request->get('_route');
$routeParams = $request->get('_route_params');
if ($routeName[0] == "_") {
return;
}
$routeData = ['name' => $routeName, 'params' => $routeParams];
// Don't store the same route twice
$thisRoute = $session->get('this_route', []);
if ($thisRoute == $routeData) {
return;
}
$session->set('last_route', $thisRoute);
$session->set('this_route', $routeData);
}
}
services.yml
myapp.last_route_event_listener:
class: MyApp\EventListener\LastRouteListener
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 30 }
And finally, I have added a new controller which, via a dropdown menu in the application navbar, displays the current page the user is viewing, but with a different year
ArchiveController.php
class ArchiveController extends Controller
{
public function switchYearAction(Request $request, $year)
{
$session = $request->getSession();
$lastRoute = $session->get('last_route');
$route = $lastRoute["name"];
$routeParams = $lastRoute["params"];
if (array_key_exists("year", $routeParams)) {
$routeParams["year"] = $year;
$session->set("current_year", $year);
}
return $this->redirect($this->generateUrl($route, $routeParams));
}
}
Arrived here, everything work. If a user chose an other date, the application will display the same page but with the new date chosen.
However, and there is my problem, if, from a previous year, the user clicks on a link in the page, we come back to the actual year. Quite normal, because Twig paths in the application doesn't fill the 'year' routing parameter, and the router provide the current year by default.
So, my question is : How can I keep the chosen year in memory, and use it as a route parameter ?
First, I had thought about setting the local var 'current_year' when the application uses the switchYearAction(), but Symfony returns an error ('Frozen variable')
Next, I had thought about using a session var to store the chosen year, but I can't access to the session within my extension MyAppExtension.
There might be a third solution which consists in update all Twig paths and Controller redirect(), but it represents some much line to edit...
Or maybe with a routeEventListener... but I don't know how to proceed.
Thanks you in advance.

You can access the application session in Twig using {{ app.session }}. So something like this is possible:
{{ url('myapp.whatever', {year: app.session.get('current_year')}) }}
Since you have a bit of logic around your current year stuff (if it's not set in the session fallback to the current year, etc), a twig extension that provides a funtion to fetch the current year may be a better way to go. Quick, untested example:
<?php
use Symfony\Component\HttpFoundation\Session\SessionInterface;
class CurrentYearExtension extends \Twig_Extension
{
private $session;
private $defaultYear;
public function __construct(SessionInterface $session, $defaultYear)
{
$this->session = $session;
$this->defaultYear = $defaultYear;
}
public function getFunctions()
{
return [
new \Twig_SimpleFunction('current_year', [$this, 'currentYear']),
];
}
public function currentYear()
{
return $this->session->get('current_year') ?: $this->defaultYear;
}
}
Then add it to your container and tag it with the twig.extension tag.
<service id="myapp.currentyear_extension" class="CurrentYearExtension" public="false">
<argument type="service" id="session" />
<argument>%current_year%</argument>
<tag name="twig.extension" />
</service>
And use it in your route generation:
{{ url('myapp.whatever', {year: current_year()}) }}
If you need the current year other places than twig, then pull a re-usable object out of the twig extension and use that both with the extension and elsewhere.

Thanks to the answer given by #chrisguitarguy : https://stackoverflow.com/a/13495302/1031898 I found a way to resolve my problem.
In fact, I could use my routeListener to do the job.
I just needed to implement the Router Interface.
LastRouteListener.php (updated)
class LastRouteListener
{
private $router;
public function __construct(RouterInterface $router)
{
$this->router = $router;
}
public function onKernelRequest(GetResponseEvent $event)
{
// Don't store subrequests
if ($event->getRequestType() !== HttpKernel::MASTER_REQUEST) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
$context = $this->router->getContext();
// If current_year exists in session, replace route parameter with it
if ($session->has('current_year')) {
$context->setParameter('year', $session->get('current_year'));
}
// Else, we set the current year by default
else {
$context->setParameter('year', date('Y'));
}
$routeName = $request->get('_route');
$routeParams = $request->get('_route_params');
if ($routeName[0] == "_") {
return;
}
$routeData = ['name' => $routeName, 'params' => $routeParams];
// On ne sauvegarde pas la même route plusieurs fois
$thisRoute = $session->get('this_route', []);
if ($thisRoute == $routeData) {
return;
}
$session->set('last_route', $thisRoute);
$session->set('this_route', $routeData);
}
}
Don't forget to inject the #router argument in services.yml
myapp.last_route_event_listener:
class: MyApp\EventListener\LastRouteListener
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest, priority: 30 }
arguments: ['#router']
And then, no need to use 'current_year' default parameter in route config anymore.
routes.yml
mes_routes:
resource: "mes_routes.yml"
prefix: /{year}
MyAppExtension.php
class MyAppExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.yml');
}
}

Related

Error at Symfony3 : Entity of type passed to the choice field must be managed

When I upgraded the system running Symfony3.0 to version 3.4, the following error occured.
Since choice_list will not be supported due to version upgrade, choices I changed it to use.
Error Code
Entity of type "AppBundle\Model\Service\StaffService" passed to the choice field must be managed. Maybe you forget to persist it in the entity manager?
Code
ArticleType.php
// Contributor
$authorChoiceList = array($this->staffService, $options['login_staff']);
$builder->add("author", EntityType::class, array(
"required" => true,
"class" => "AhiSpCommonBundle:Staff",
"choices" => $authorChoiceList,
"placeholder" => "Please select",
));
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($authorChoiceList) {
$article = $event->getData();
$authorChoiceList->setCurrentStaff($article->getAuthor());
});
ChoiceList.php
use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory;
use Symfony\Component\Form\ChoiceList\LazyChoiceList;
class StaffChoiceList extends LazyChoiceList
{
private $staffService;
private $loginStaff;
private $currentStaff;
public function __construct($staffService, $loginStaff)
{
$this->staffService = $staffService;
$this->loginStaff = $loginStaff;
}
public function setCurrentStaff($currentStaff)
{
$this->currentStaff = $currentStaff;
}
protected function loadChoiceList()
{
//Get the same shop staff as the login staff
$staffs = $this->staffService->getStaffByShop($this->loginStaff->getShop());
// If the current staff is not included in the acquired staff (due to transfer etc.), add it to the end
if ($this->currentStaff && !array_search($this->currentStaff, $staffs)) {
$staffs[] = $this->currentStaff;
}
$factory = new DefaultChoiceListFactory();
return new ChoiceList($staffs, $staffs);
}
}
Version
Cent OS 6.7
PHP 7.3
Symfony 3.4
can't you just use the query_builder option?
see doc here -> https://symfony.com/doc/current/reference/forms/types/entity.html#using-a-custom-query-for-the-entities
you could implement a custom method in the repository and call it inside you callable

How I can add a "roles" to a "path" dynamically in Symfony2?

In SF2 I have the following scenario, according to the site itself documentation:
app\config\security.yml
security:
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/, roles: ROLE_ADMIN }
I need to manually add each "path" and "roles" that can access this "path".
How can I do this dynamically?
Like RBAC on Yii2:
Is there any ready Bundle or something in SF2 own documentation that allows this? As the hypothetical example:
app\config\security.yml
security:
access_control:
type: dynamically
If you want to make adding the roles easier you can use annotations.
Your question asks for dynamic security, which is complicated. The routes, and all roles, are compiled during the cache warmup phase. So, for this to work you'll first need to store your dynamic values. The database would be a good option for this. Here I am only going to show how to check the roles, the actual role manipulation I'll leave to you.
The easiest method is to inject the authorization checker into your controller.
services:
acme_controller:
class: "AcmeDemoBundle\Controller"
arguments: ["#security.authorization_checker"]
Then check the roles in the action(s):
public function __construct(AuthorizationCheckerInterface $authorizationChecker)
{
$this->authorizationChecker = $authorizationChecker;
}
public function listAction()
{
$role = /* load your role here */;
if (false === $this->authorizationChecker->isGranted($role)) {
throw new AccessDeniedException();
}
// ...
}
The above will cause duplicated code if you want it in many controllers, so you could also create a voter:
services:
acme.route.voter:
class: AcmeDemoBundle\RouteVoter
arguments:
- #security.role_hierarchy
public: false
tags:
- { name: security.voter, priority: 300 }
Example:
public function __construct ( RoleHierarchyInterface $roleHierarchy )
{
$this->roleVoter = new RoleHierarchyVoter( $roleHierarchy );
}
public function vote ( TokenInterface $token, $object, array $attributes )
{
if ( !$object instanceof Request ) {
return VoterInterface::ACCESS_ABSTAIN;
}
$requestUri = $object->getPathInfo();
if ( isset($this->votes[ $requestUri ]) ) {
return $this->votes[ $requestUri ];
}
$roles = /* load your roles */;
return $this->votes[ $requestUri ] = $this->roleVoter->vote( $token, $object, $roles );
}
Another method would be to replace the router service with your own implementation. This is the approach taken my the CMF Bundle.
You can manage role/route relation dynamically like this :
You create a listener on the kernel
<service id="toto.security.controller_listener" class="Administration\SecurityBundle\EventListener\SecurityListener">
<tag name="kernel.event_listener" event="kernel.controller" method="onKernelController" />
<argument type="service" id="service_container" />
</service>
and after in the listener you implement this method
public function onKernelController(FilterControllerEvent $event)
{
$controller = $event->getController();
if (!is_array($controller)) {
return;
}
$request = $event->getRequest();
$baseUrl = $request->getBaseUrl();
$requestUri = $request->getRequestUri();
$route = str_replace($baseUrl, "", $requestUri);
//you put your check logic
//you can implement a relation beetween routes and roles/ users in database etc. you got the entire control on what you do
if(!$this->accessMananager->isGrantAccess(User $user, $route)){
throw new AccessDeniedException("blah blah blah")
}
}
since this listener will always be called before any of your controller, consider creating a cache system

Symfony2 Deny User Login Based on Custom Status

I've followed the guide for implementing authentication/authorization and I can login.
I have one main difference though from what's in the guide. Instead of an isActive property I have a status table in my database.
I'm at a loss as to how I would deny/accept logins based on the values in the status table rather than the isActive property referenced in the guide.
I'm not sure what code to post because it works as it does in the guide and I'm pretty sure the Symfony security system handles all the authentication stuff where I can't see it.
Even if you just point me in the right direction I would be grateful.
Edit:
Using ChadSikorra's advice I came up with this code to implement the AdvancedUserInterface functions:
public function isAccountNonExpired()
{
$status = $this->getTblStatus()->getStatustext();
switch ($status){
case "expired":
return false;
default:
return true;
}
}
public function isAccountNonLocked()
{
$status = $this->getTblStatus()->getStatustext();
switch ($status){
case "locked":
return false;
case "suspended":
return false;
case "registered":
return false;
default:
return true;
}
}
public function isCredentialsNonExpired()
{
return $this->pwdexpired;
}
public function isEnabled()
{
$status = $this->getTblStatus()->getStatustext();
if($status != 'active')
return false
else
return true;
}
The next question I have then is how do I handle the exceptions that are thrown when a user has one of the statuses?
Based on what I have so far I think this is doable by catching the errors in the loginAction. What I don't know how to do is identify the errors, but I'll keep digging.
/**
* #Route("/Login", name="wx_exchange_login")
* #Template("WXExchangeBundle:User:login.html.twig")
* User login - Open to public
* Authenticates users to the system
*/
public function loginAction(Request $request)
{
$session = $request->getSession();
if ($this->get('security.context')->isGranted('IS_AUTHENTICATED_REMEMBERED'))
{
// redirect authenticated users to homepage
return $this->redirect($this->generateUrl('wx_exchange_default_index'));
}
// get the login error if there is one
if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
$error = $request->attributes->get(
SecurityContext::AUTHENTICATION_ERROR
);
} else {
$error = $session->get(SecurityContext::AUTHENTICATION_ERROR);
$session->remove(SecurityContext::AUTHENTICATION_ERROR);
}
if($error instanceof LockedException)
{
}
return $this->render(
'WXExchangeBundle:User:login.html.twig',
array(
// last username entered by the user
'last_username' => $session->get(SecurityContext::LAST_USERNAME),
'error' => $error,
)
);
}
I am now able to check for the type of Exception, but I'm at a loss as to how to get the specific status so that I can redirect to the correct place. This is the last piece of the puzzle.
You could add mapping to your custom status table on the user entity, like so:
/**
* #ORM\OneToOne(targetEntity="AccountStatus")
* #ORM\JoinColumn(name="status_id", referencedColumnName="id", nullable=true)
*/
private $accountStatus;
This would also require creating an entity describing the custom status table. Then you could use this mapping in your user entity by implementing Symfony\Component\Security\Core\User\AdvancedUserInterface as referenced in the guide you linked. Then implement the isEnabled function something like this...
public function isEnabled()
{
return $this->getAccountStatus()->getIsActive(); /* Or whatever you named it */
}
EDIT:
Based on the API Doc for AdvancedUserInterface, if you want to do custom logic for handling the different statuses you'll need to register an exception listener...
If you need to perform custom logic for any of these situations, then
you will need to register an exception listener and watch for the
specific exception instances thrown in each case. All exceptions are a
subclass of AccountStatusException
There's a pretty good Cookbook article for creating something like this here. The basic process in this instance would be to create the class for the listener...
src/Acme/DemoBundle/EventListener/AcmeExceptionListener.php
namespace Acme\DemoBundle\EventListener;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Core\Exception\LockedException;
class AcmeExceptionListener
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
$exception = $event->getException();
if ($exception instanceof DisabledException) {
// Customize your response object to display the exception details
$response = new Response();
$response->setContent('<html><body><h1>Custom disabled page!</h1></body></html>');
// Send the modified response object to the event
$event->setResponse($response);
}
elseif ($exception instanceof LockedException) {
// Or render a custom template as a subrequest instead...
$kernel = $event->getKernel();
$response = $kernel->forward('AcmeDemoBundle:AccountStatus:locked', array(
'exception' => $exception,
));
$event->setResponse($response);
}
// ... and so on
}
}
The above are just basic examples, but it gives you the gist anyway. Technically I guess you could also make custom exceptions by extending AccountStatusException and then throw them in your logic for your AdvancedUserInterface implementation. Then you would know exactly which status you are catching. Anyway, then make sure to register the listener as a service.
app/config/config.yml
services:
kernel.listener.your_listener_name:
class: Acme\DemoBundle\EventListener\AcmeExceptionListener
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }
Another way to go about this would be to implement some sort of a custom User Checker. See this question: Symfony2 custom user checker based on accepted eula

Dynamic route prefix in Symfony2

I am creating a SaaS with symfony2 providing private websites.
What I am trying to do is to let people access the website this way :
http://www.mydomain.com/w/{website_name}
Here is the routing configuration i am using :
websites:
resource: "#MyBundle/Resources/config/routing.yml"
prefix: /w/{website_name}
The problem is that when I try to access, for exemple, http://www.mydomain.com/w/chucknorris I am getting the error :
An exception has been thrown during the rendering of a template ("Some
mandatory parameters are missing ("website_name") to generate a URL
for route "websites_homepage".") in
"MyBundle:Publication:publicationsList.html.twig".
What I understood is that my route configuration is working well but when I am calling the router to generates url in the website it isn't aware of the "context" {website_name} url parameter.
One solution I've imagined is to find a way to automatically and seemlessly inject this parameter when it's set in the context.
Until now all I've been able to do is to create a service to get this parameter this way :
public function __construct(Registry $doctrine, ContainerInterface $container) {
$website_name = $container->get('request')->get("website_name");
if (!empty($website_name)) {
$repository = $doctrine->getManager()->getRepository('MyBundle:website');
$website = $repository->findOneByDomain($website_name);
if ($website) {
$this->website = $website;
} else {
throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException();
}
} else {
$this->isPortal = true;
}
}
My question is : How do I inject that argument to all url generated to avoid getting the error of parameter missing and without having to specify it manualy everytime I call the router in controllers or twig ? (I guess it's something about request event but i have no clue on how to do it and especialy how to do it according to symfony2 good usages)
UPDATE
Here is the listener i created base on locallistener provided by symfony:
<?php
class WebsiteNameRouteEventListener implements EventSubscriberInterface {
private $router;
public function __construct(RequestContextAwareInterface $router = null) {
$this->router = $router;
}
public function onKernelResponse(FilterResponseEvent $event) {
$request = $event->getRequest();
$this->setWebsiteName($request);
}
public function onKernelRequest(GetResponseEvent $event) {
$request = $event->getRequest();
$this->setWebsiteName($request);
}
public static function getSubscribedEvents() {
return array(
// must be registered after the Router to have access to the _locale
KernelEvents::REQUEST => array(array('onKernelRequest', 16)),
KernelEvents::RESPONSE => 'onKernelResponse',
);
}
private function setWebsiteName(Request $request) {
if (null !== $this->router) {
echo "NEW CODE IN ACTION";die();
$this->router->getContext()->setParameter('website_name', $request->attributes->get("website_name"));
}
}
}
But i am still getting this error :
An exception has been thrown during the rendering of a template ("Some
mandatory parameters are missing ("website_name") to generate a URL
for route "homepage".") in
"MyBundle:Publication:publicationsList.html.twig". 500 Internal
Server Error - Twig_Error_Runtime 1 linked Exception:
MissingMandatoryParametersException »
Without my echo "...."; die() being executed so i guess twig is not firing the event i am listening on when it execute the path(routename) code.
Any idea ?
You could use the router context.
$this->router->getContext()->setParameter('website_name', $website_name);
See this file: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/HttpKernel/EventListener/LocaleListener.php

Generate CSRF token dependig on datetime

Exists some way in Symfony 2 to generate CSRF token at each rendering of form?
In my controller I tried something like this:
$request = $this->get('request');
if ($request->getMethod() != 'POST') {
$csrf = $this->get('form.csrf_provider');
$date= new \DateTime("now");
$this->date = $date->format('Y-m-d H:i:s');
$token = $csrf->generateCsrfToken($this->date);
} elseif($request->getMethod() == "POST") {
$csrf = $this->get('form.csrf_provider');
$token = $csrf->generateCsrfToken($this->date);
}
$form = $this->createFormBuilder()
....
->add('_token','hidden',array(
'data' => $token
))->getForm();
if ($request->getMethod() == 'POST') {
$form->bindRequest($request);
if ($form->isValid()) {
DO something
}
}
All the time is in my request right token hash. But after bindRequest its change to default hash generated from security string in parameters.ini and isValid method returns certainly FALSE response. Exists some way, how to adjust it?
EDIT
In response to theunraveler answer, I edited my controller and create my CSRF provider, but still im geting "The CSRF token is invalid. Please try to resubmit the form" error. My CSRF provider looks like this:
namespace My\Namespace;
use Symfony\Component\HttpFoundation\Session;
use Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface;
class MyCsrfProvider implements CsrfProviderInterface
{
protected $session;
protected $secret;
protected $datetime;
public function __construct(Session $session, $secret)
{
$this->secret = $secret;
$this->datetime = new \DateTime('now');
$this->session = $session;
}
public function generateCsrfToken($intention)
{
return sha1($this->secret.$intention.$this->datetime->format('YmdHis').$this->getSessionId());
}
public function isCsrfTokenValid($intention, $token)
{
return $token === $this->generateCsrfToken($intention);
}
protected function getSessionId()
{
return $this->session->getId();
}
}
Than I add to config.yml service class:
services:
form.csrf_provider:
class: My\Namespace\MyCsrfProvider
arguments: [ #session, %kernel.secret% ]
And Controller I change To this:
//any form without _token field
$form = $this->createFormBuilder()
->add('field1')
->add('field2')->getForm()
if ($this->getRequest()->getMethod() == 'POST') {
$request = $this->getRequest();
$form->bindRequest($request);
if ($form->isValid()) {
Do something
return $this->redirect($this->generateUrl('somewhere'));
}
}
Cant be problem in seconds in my hash? Becouse if I remove seconds from datetime format, it works, but with 1 min expiration and its not a good solution. I think, the reason is, that the time was changed.
Symfony's Form component generates the CSRF token in a Form event. See the class Symfony\Component\Form\Extension\Csrf\EventListener\CsrfValidationListener on how the existing implementation works. That means when you call bindRequest() (or bind() in Symfony 2.2+) on your form, that form event gets called and overwrites the token you declared.
The correct way to define a different token is to create a CsrfProvider, which would basically be a class that you write that implements Symfony\Component\Form\Extension\Csrf\CsrfProvider\CsrfProviderInterface. Check out Symfony\Component\Form\Extension\Csrf\CsrfProvider\DefaultCsrfProvider for an example of how to implement this.
Once you write your class, in your app/config/parameters.yml (or another config file, it doesn't matter where), set the form.csrf_provider.class parameter to the name of your class.
After all of that, you should be able to remove all of the custom code you wrote from your controller, and just use the form system as though nothing were any different (i.e., remove the ->add('_token')... parts).
Hope that helps!
Are you aware of form_rest? Add this to the bottom of your form to have it automatically generate your CSRF token:
{{ form_rest(form) }}

Categories