An interesting issue occurred regarding email registration with the FOSUserBundle when using Hotmail for account signups.
FOSUserBundle’s registration confirmation was enabled, so the user signing up would receive an email containing a link with a token used to verify and enable their account.
However, when clicking the verification link from any Hotmail email account, a 404 not found page was the result.
The NotFoundHttpException said:
The user with confirmation token “R_jrlvXPyi_qTb3kvbCE_x5Qka89SikV9F1FlL5xWU4” does not exist
There is information online regarding this FOSUserBundle error, but it all seems to relate to users accidentally clicking their link multiple times. In our case, the link was clicked once…and only once.
Checking the database, I could see that the account had been enabled successfully, and the verification token removed. This is exactly what you’d expect to see upon a successful account confirmation.
Looking back at the Hotmail email again, I noticed that it was including a link preview of our site in the email body (similar to what you see when sharing links on Facebook).
I realized that Hotmail was likely sending a bot to our verification link in order to create that preview.
Checking in the server logs confirmed it:
157.55.39.45 – – [30/Dec/2016:14:22:54 -0500] “GET /signup/confirm/R_jrlvXPyi_qTb3kvbCE_x5Qka89SikV9F1FlL5xWU4 HTTP/1.1” 302 4648 “-” “Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534+ (KHTML, like Gecko) BingPreview/1.0b”
A visit from their BingPreview crawler occurred the moment the email was opened.
Considering how FOSUserBundle is set up to work, this might cause some user confusion.
You see, the first time a confirmation link is visited successfully, FOSUserBundle enables the account, and removes the verification token from the database. Any attempts to visit the url again with that token will result in a 404 not found (as the token no longer exists in the database).
The user has no idea their account was already verified by the BingPreview crawler just by opening their email, and due to the 404 error page, will likely believe that something went wrong.
So, what can you do if you want to continue using FOSUserBundle with email verification?
One thing you could do is, set your .htaccess to block the BingPreview user agent from accessing your signup and password reset paths (Your paths may differ from this example):
|
RewriteEngine On RewriteCond %{REQUEST_URI} /signup/confirm/ [OR] RewriteCond %{REQUEST_URI} /resetting/reset/ RewriteCond %{HTTP_USER_AGENT} BingPreview RewriteRule . - [F,L] |
This will stop the BingPreview crawler, but I feel like it is an incomplete solution. You can block as many annoying user agents here as you want, but do you really want to spend all your time hunting down which email providers are providing similar link previews in their emails?
Another thing you can do is to override the way FOSUserBundle handles account confirmation.
If you don’t know how to override FOSUserBundle controllers, read this article: Overriding Default FOSUserBundle Controllers
Once your bundle override is in place, you can do something like suggested by GitHub user dmaicher here in your RegistrationController.php: https://github.com/FriendsOfSymfony/FOSUserBundle/issues/2106#issuecomment-212027852
Make sure that you also
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; in your file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
// src/MyCompany/UserBundle/Controller/RegistrationController.php namespace MyCompany\UserBundle\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use FOS\UserBundle\Controller\RegistrationController as BaseController; class RegistrationController extends BaseController { /** * Redirect to login page when token confirmation fails. */ public function confirmAction(Request $request, $token) { try { return parent::confirmAction($request, $token); } catch (NotFoundHttpException $e) { return $this->redirectToRoute('fos_user_security_login'); } } } |
In that example, if another email provider decides to start visiting your confirmation links, the users would at least be redirected to a login page rather than getting a confusing 404.
You can get creative with it, and I’ll post again if I come up with something better.
Would love to hear others’ opinions on this, and/or if you have had issues with this kind of behavior from email providers before.
The other day I noticed a new image was not displaying on a site I was working on. Doing an inspection of the image, I could see that the img src path was correct, and the file was loading fine. But there was also a
style="display: none !important;" which was being added to this <img> tag. Removing that style resulted in it coming back instantly.
I also noticed a Failed to load resource: net::ERR_BLOCKED_BY_CLIENT message in the console.
Turns out the ad blocker I was using, AdBlock Plus, blocks anything here:
/media/ad/*
The path to my blocked file happened to be here:
/media/ad/76/ad763f1c400bea2c54c3d14008a968d331720090.jpg
That path was generated automatically by a class in a bundle I created for managing file names and paths in Symfony: MassMediaBundle
It creates a filename using a hash algorithm, and splits letters off the first part of the name to create folders. In this case, it just happened to create one which started with the letters “ad”, causing AdBlock Plus to filter it when preceded by a folder named: /media/
It seems that /media/ad/ was a popular enough destination for advertisements that it got added to the AdBlock filter list, which you can have a look at here:
https://easylist-downloads.adblockplus.org/easylist.txt
I see 3 possible options:
1) Ignore the problem, and allow AdBlock users (millions of people) to not view your images.
2) Tell your users to whitelist your site in their ad blocker, and hope they will comply.
3) Rename the filtered folder(s) to something unlikely to be blacklisted.
Option 3 is the route I ended up taking in this case.
Just a friendly reminder to test your websites in popular ad blockers, and to consider the effect your upload paths can have on files being filtered by them.
This is Part 4 in a series on setting up Tags in a ManyToMany association with an Entity in Symfony. You can read the previous three articles here:
- Managing Tags in ManyToMany association with Symfony
- Setting up Form Collection for Tags in Symfony
- Using a Data Transformer in Symfony to handle duplicate tags
By now, you should have your Product and Tag entities set up, along with your forms, and a data transformer for preventing duplicate tags from being added to the database. At the end of the previous article, we also created a controller which renders the submitProduct.html.twig template that we’ll be using for displaying our form to the world.
As mentioned in the previous article, we are going to need to add some JavaScript to add/remove
tag.name inputs for our form. There are multiple ways you can do this, and the Symfony documention on Allowing “new” Tags with the “Prototype” shows you a good example.
In this article, we will be using the jQuery Tag-it plugin, which allows you to have a tag input box on your page which works something like this:
You can read Tag-it’s documentation for more information on it. For this tutorial, go ahead and download/unzip Tag-it, and copy these files:
css/jquery.tagit.css
css/tagit.ui-zendesk.css
js/tag-it.min.js
Into your Symfony web assets folder:
web/css/jquery.tagit.css
web/css/tagit.ui-zendesk.css
web/js/tag-it.min.js
Make sure you also have jQuery and jQuery-ui included in your template, as they are required by Tag-it.
Ok, now how to set up this plugin with our Symfony form?
Remember in the Symfony example using prototype, that you are replacing __name__ with the index of the next item in the array:
product[tags][__name__][name]
Using a find replace on __name__ you can create new inputs for your form:
|
<input id="product_tags_0_name" name="product[tags][0][name]" type="text"> <input id="product_tags_1_name" name="product[tags][1][name]" type="text"> <input id="product_tags_2_name" name="product[tags][2][name]" type="text"> |
The way that Tag-it works is to add a hidden form input for each tag. It has a fieldName option which allows you to set the name you want to use for your inputs. You might think of doing something like this there:
|
$('#tags').tagit({ fieldName: 'product[tags][][name]' }); |
That will actually work to add your tags, but will not play nicely when you later remove a tag from the middle of a group of tags associated with a Product, and add another to it for example. The index of the item in the array needs to be set in the field name, so that it knows exactly which item to remove/add.
So how to increment the field name’s index in Tag-it?
In the Tag-it documentation, you will notice there is a beforeTagAdded event. We can use this to increment our index, and reset the Tag-it fieldName value each time a new tag is added. Here is a basic example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
<ul id="tags"></ul> <script> $(document).ready(function () { var prototypeFieldName = 'product[tags][__name__][name]'; var fieldIndex = 0; $('#tags').tagit({ fieldName: prototypeFieldName.replace('__name__', fieldIndex), beforeTagAdded: function (event, ui) { fieldIndex++; $(this).tagit({fieldName: prototypeFieldName.replace('__name__', fieldIndex)}); } }); }); </script> |
Test that by typing 3 new tags into your Tag-it input box: car, Japanese, import
And you’ll see that Tag-it adds the hidden inputs to the form just we as we want them:
|
<input value="car" name="product[tags][0][name]" class="tagit-hidden-field" type="hidden"> <input value="Japanese" name="product[tags][1][name]" class="tagit-hidden-field" type="hidden"> <input value="import" name="product[tags][2][name]" class="tagit-hidden-field" type="hidden"> |
If you prefer not to hand code the field name value in, but want to use Symfony’s form prototype instead, you could do something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
{% do form.tags.setRendered %} <ul id="tags" data-prototype-field-name="{{ form.tags.vars.prototype.name.vars.full_name }}"></ul> <script> $(document).ready(function () { var $tags = $('ul#tags'); var prototypeFieldName = $tags.data('prototype-field-name'); var fieldIndex = 0; $tags.tagit({ fieldName: prototypeFieldName.replace(/__name__/g, fieldIndex), beforeTagAdded: function (event, ui) { fieldIndex++; $(this).tagit({fieldName: prototypeFieldName.replace(/__name__/g, fieldIndex)}); } }); }); </script> |
Notice the
{% do form.tags.setRendered %} added in the first line there. You will need this if you are using Symfony’s
{{ form_end(form) }} to end your form. The reason is that
form_end will output anything it thinks is missing from your form. It will believe that
'tags' are missing since we are actually creating the tag inputs ourselves. So we tell it that
form.tags are already rendered to prevent that.
Another option would be to just close the form yourself:
</form> . But don’t forget to render the CSRF token (or anything else your form requires) in that case.
The final result of your form might look something like this (using Bootstrap here):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
|
{# app/Resources/views/product/submitProduct.html.twig #} <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>New Product</title> <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet"> <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css" rel="stylesheet"> <link href="https://code.jquery.com/ui/1.10.4/themes/start/jquery-ui.css" rel="stylesheet"> <link href="{{ asset('css/jquery.tagit.css') }}" rel="stylesheet"> <link href="{{ asset('css/tagit.ui-zendesk.css') }}" rel="stylesheet"> </head> <body> <div class="container"> <h2>New Product</h2> {{ form_start(form) }} <div class="row"> {# Product Name #} <div class="col-sm-3"> {{ form_label(form.name) }} {{ form_widget(form.name, { 'attr': { 'class': 'form-control' } }) }} {{ form_errors(form.name) }} </div> </div> <div class="row"> {# Tags #} <div class="col-sm-6"> {% do form.tags.setRendered %} {{ form_label(form.tags) }} <ul id="tags" data-prototype-field-name="{{ form.tags.vars.prototype.name.vars.full_name }}"></ul> {{ form_errors(form.tags) }} </div> </div> <div class="row"> {# Submit #} <div class="col-sm-12"> <button type="submit" class="btn btn-default">Submit</button> </div> </div> {{ form_end(form) }} </div> <script src="https://code.jquery.com/jquery-1.11.3.min.js"></script> <script src="https://code.jquery.com/ui/1.11.4/jquery-ui.min.js"></script> <script src="{{ asset('js/tag-it.min.js') }}"></script> <script> $(document).ready(function () { var $tags = $('ul#tags'); var prototypeField = $tags.data('prototype-field-name'); var fieldIndex = 0; $tags.tagit({ fieldName: prototypeField.replace(/__name__/g, fieldIndex), beforeTagAdded: function (event, ui) { fieldIndex++; $(this).tagit({fieldName: prototypeField.replace(/__name__/g, fieldIndex)}); } }); }); </script> </body> </html> |
If you want to edit the tags of an existing Product, you could pass that Product to the view in your controller, and then output all of its tags inside the unordered list like so:
|
<ul id="tags" data-prototype-field-name="{{ form.tags.vars.prototype.name.vars.full_name }}"> {% if product is defined %} {% for tag in product.getTags() %} <li>{{ tag.name }}</li> {% endfor %} {% endif %} </ul> |
The final result is a setup which meets the requirements outlined in the first article of this tutorial.
I hope that this has given you some ideas on ways you can manage tags for your entities in a ManyToMany association in Symfony. If you have any questions or suggestions, please leave them in the comments!
This is Part 3 in a series on setting up Tags in a ManyToMany association with an Entity in Symfony. You can read the previous two articles here:
- Managing Tags in ManyToMany association with Symfony
- Setting up Form Collection for Tags in Symfony
In the last article, I talked about the problem of duplicate Tags being added when creating a new Product. Here, I will show one way to handle that using a Data Transformer.
First, we will create the data transformer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
|
// src/AppBundle/Form/DataTransformer/TagsToCollectionTransformer.php namespace AppBundle\Form\DataTransformer; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\Form\DataTransformerInterface; use Doctrine\Common\Collections\ArrayCollection; class TagsToCollectionTransformer implements DataTransformerInterface { private $manager; public function __construct(ObjectManager $manager) { $this->manager = $manager; } public function transform($tags) { return $tags; } public function reverseTransform($tags) { $tagCollection = new ArrayCollection(); $tagsRepository = $this->manager ->getRepository('AppBundle:Tag'); foreach ($tags as $tag) { $tagInRepo = $tagsRepository->findOneByName($tag->getName()); if ($tagInRepo !== null) { // Add tag from repository if found $tagCollection->add($tagInRepo); } else { // Otherwise add new tag $tagCollection->add($tag); } } return $tagCollection; } } |
Your data transformer implements the DataTransformerInterface, which requires these two methods:
|
public function transform($value); public function reverseTransform($value); |
transform allows you to alter the data going to your form. We don’t need to change this, so can just return the tags as is.
reverseTransform allows you to alter the data submitted by your form. This is where we want to make our changes.
In the constructor, the
ObjectManager is injected for checking a Tag’s existence in the database.
In the
reverseTransform method, an empty ArrayCollection is created to store all tags we’ll be returning. For each submitted Tag, it checks for its presence in the Tag repository. If present, we replace the form submitted Tag with the one already present in the Tag repository. If not, then we allow the new form submitted Tag into the
$tagCollection .
Finally, the
$tagCollection is returned.
You can do any other filtering you like there. But for the purposes of this tutorial, we’ll keep it simple like that.
The next thing we need to do is make a few changes to our ProductType:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
|
// src/AppBundle/Form/Type/ProductType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Doctrine\Common\Persistence\ObjectManager; use AppBundle\Form\DataTransformer\TagsToCollectionTransformer; class ProductType extends AbstractType { private $manager; public function __construct(ObjectManager $manager) { $this->manager = $manager; } public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('name') ->add('tags', CollectionType::class, array( 'entry_type' => TagType::class, 'allow_add' => true, 'allow_delete' => true, 'required' => false )); // Data Transformer $builder ->get('tags') ->addModelTransformer(new TagsToCollectionTransformer($this->manager)); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults(array( 'data_class' => 'AppBundle\Entity\Product', )); } } |
We’ll be using the
ObjectManager and our newly created
TagsToCollectionTransformer :
|
use Doctrine\Common\Persistence\ObjectManager; use AppBundle\Form\DataTransformer\TagsToCollectionTransformer; |
The
ObjectManager is injected in the constructor. (We’ll create a service for our ProductType class later)
We also get
'tags' from our form builder, add our transformer to it using the
addModelTransformer method, and pass in the
ObjectManager dependency to our
TagsToCollectionTransformer .
Finally, we create a service for our
ProductType class which injects the
ObjectManager dependency to it:
|
# app/config/services.yml services: app.form.type.product: class: AppBundle\Form\Type\ProductType arguments: ["@doctrine.orm.entity_manager"] tags: - { name: form.type } |
Our form is now ready to roll.
Let’s create a controller for submitting new Products:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
// src/AppBundle/Controller/ProductController.php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Component\HttpFoundation\Request; use AppBundle\Entity\Product; use AppBundle\Form\Type\ProductType; class ProductController extends Controller { /** * @Route("/new/product", name="new_product") */ public function newProductAction(Request $request) { $product = new Product(); $form = $this->createForm(ProductType::class, $product); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($product); $em->flush(); } return $this->render('product/submitProduct.html.twig', array( 'form' => $form->createView() )); } } |
This should be pretty self-explanatory if you’re familiar with controllers, routes, form submissions, and persisting objects to the database in Symfony. Basically we just create our form with a new
Product object, and have the form handle the
Request. If a form was submitted, and is valid, we persist the Product to the database. Finally, we render the submitProduct template which will be created later.
Note that while it’s not actually required to check
$form->isSubmitted() , it is recommended per the Symfony best practices on form submissions for readability:
Second, we recommend using $form->isSubmitted()
in the if
statement for clarity. This isn’t technically needed, since isValid()
first calls isSubmitted()
. But without this, the flow doesn’t read well as it looks like the form is always processed (even on the GET request).
The last thing to do is create the Twig template submitProduct.html.twig which will display our form. Since we’ll be adding and removing Tags from Products, we’ll need some JavaScript to add/remove our
tag.name inputs for us. You could accomplish this using the example in the Symfony documentation, but in the next article, we’ll be using the jQuery Tag-it plugin instead.
Next article: How to use jQuery Tag-it plugin with a Symfony form
This is the second article in a series on setting up a ManyToMany association for tags in Symfony. You can read the previous article where we created our Product and Tag entities here:
- Managing Tags in ManyToMany association with Symfony
Now that our entities have been created, it’s time to create our forms. Basically, what we want here is a Product submission form where you can add/remove a collection of Tags which will also be associated to our Product. First, let’s create a Tag form which will be added to our Product form later to collect tags.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
// src/AppBundle/Form/Type/TagType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class TagType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name'); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults(array( 'data_class' => 'AppBundle\Entity\Tag', )); } } |
We only need a
'name' for each tag.
Next, we’ll create the Product form:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
// src/AppBundle/Form/Type/ProductType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Form\Extension\Core\Type\CollectionType; class ProductType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('name') ->add('tags', CollectionType::class, array( 'entry_type' => TagType::class, 'allow_add' => true, 'allow_delete' => true, 'required' => false )); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults(array( 'data_class' => 'AppBundle\Entity\Product', )); } } |
We add
'tags' here to the builder as a
CollectionType , and set
'entry_type' to
TagType::class . That is the TagType form class we created above in TagType.php, with ::class to get the fully qualified class name.
We set
'allow_add' and
'allow_delete' to
true because we want to be able to add and delete the associations of Tags to each Product.
Finally, I set
'required' => false , because I want adding Tags to be optional.
We haven’t gotten to the point of setting up anything in our Controller or templates yet, but I want to show you what happens now if we leave things the way they are so far in this example.
Currently, if you added one Product (BMW) with the following tags:
When you check your database, you would see this in your tables:
product
product_tag
tag:
Great. Now imagine adding another Product (Honda) with a car tag again, and see what happens:
Check your tables and you will find:
product
product_tag
tag
As you can see, it is adding duplicate Tags to our tables. However, rather than adding an unnecessary duplicate car tag (tag.id: 3), we want the Honda (product.id: 2) to be associated to the original car tag (tag.id: 1) in the product_tag table. So how can we accomplish this?
The method which I decided on was to setup a Data Transformer which checks if a tag already exists in the database. If so, it replaces the tag submitted by the form with the one already in the database. This way, the Product will be associated with the original tag in the database, and no new duplicate tag will be added.
I cover this in the next article: Using a Data Transformer in Symfony to handle duplicate tags