Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Aprovechando el propietario de la pregunta

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Ahora que cada Question tiene un owner -un objeto User -, ¡es hora de celebrarlo! En el frontend, podemos empezar a renderizar datos reales... en lugar de tener siempre la misma foto del gato y la misma pregunta escrita por la misma Tisha. Ambas están codificadas, aunque nos encanta la gata Tisha aquí en SymfonyCasts.

Empieza en la página de inicio. Abre templates/question/homepage.html.twig. Y... aquí es donde hacemos un bucle con las preguntas. Primero, para el avatar, podemos utilizar el método de ayuda que hemos creado antes: {{ question.owner.avatarUri }}:

... lines 1 - 2
{% block body %}
... lines 4 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in pager %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar">
... lines 24 - 29
</div>
... lines 31 - 38
</div>
</div>
... lines 41 - 45
</div>
</div>
{% endfor %}
... lines 49 - 50
</div>
</div>
{% endblock %}
... lines 54 - 55

A continuación... hacia abajo, aquí es donde imprimimos el nombre del propietario de la pregunta. Vamos a utilizar question.owner.displayName:

... lines 1 - 2
{% block body %}
... lines 4 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in pager %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
... lines 22 - 30
<div class="col">
... line 32
<div class="q-display p-3">
... lines 34 - 35
<p class="pt-4"><strong>--{{ question.owner.displayName }}</strong></p>
</div>
</div>
</div>
</div>
... lines 41 - 45
</div>
</div>
{% endfor %}
... lines 49 - 50
</div>
</div>
{% endblock %}
... lines 54 - 55

100 puntos de experiencia por utilizar dos métodos personalizados seguidos.

Y ahora... ¡nuestra página empieza a parecer real! Haz clic en una pregunta. Hagamos lo mismo para la página del programa. Abre esa plantilla: show.html.twig.

Para el avatar, utiliza question.owner.avatarUri:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar">
... lines 15 - 32
</div>
... lines 34 - 41
</div>
</div>
</div>
</div>
</div>
... lines 47 - 59
</div>
{% endblock %}

Luego... aquí abajo, para el nombre, {{ question.owner.displayName }}:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<h1 class="q-title-show">{{ question.name }}</h1>
<div class="q-display p-3">
... lines 37 - 38
<p class="pt-4"><strong>--{{ question.owner.displayName }}</strong></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
... lines 47 - 59
</div>
{% endblock %}

Ah, y se me ha olvidado hacer una cosa. Copia eso, vuelve a subir al avatar... para que también podamos actualizar el atributo alt:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar">
... lines 15 - 32
</div>
... lines 34 - 41
</div>
</div>
</div>
</div>
</div>
... lines 47 - 59
</div>
{% endblock %}

También tengo que hacerlo en la página de inicio... aquí está:

... lines 1 - 2
{% block body %}
... lines 4 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in pager %}
<div class="col-12 mb-3">
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container p-4">
<div class="row">
<div class="col-2 text-center">
<img src="{{ question.owner.avatarUri }}" width="100" height="100" alt="{{ question.owner.displayName}} avatar">
... lines 24 - 29
</div>
... lines 31 - 38
</div>
</div>
... lines 41 - 45
</div>
</div>
{% endfor %}
... lines 49 - 50
</div>
</div>
{% endblock %}
... lines 54 - 55

¡Probemos esto! Actualiza la página y... ¡somos dinámicos!

Crear la página de edición de preguntas

En un sitio real, probablemente vamos a necesitar una página en la que el propietario de esta pregunta pueda editar sus detalles. No vamos a construir esto hasta el final -no quiero sumergirme en el sistema de formularios- pero vamos a ponerlo en marcha. Y esto nos va a llevar a una situación de seguridad realmente interesante.

En src/Controller/QuestionController.php... encuentra la acción show(). Vamos a hacer trampa copiando esto y pegándolo. Cambia la URL a /questions/edit/{slug}, modifica el nombre de la ruta y actualiza el nombre del método. Dentro, sólo hay que renderizar una plantilla: question/edit.html.twig:

... lines 1 - 18
class QuestionController extends AbstractController
{
... lines 21 - 69
/**
* @Route("/questions/edit/{slug}", name="app_question_edit")
*/
public function edit(Question $question)
{
return $this->render('question/edit.html.twig', [
'question' => $question,
]);
}
... lines 79 - 98
}

¡Genial! En templates/question/, crea esto: edit.html.twig.

Pondré una plantilla básica:

{% extends 'base.html.twig' %}
{% block title %}Edit Question: {{ question.name }}{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="my-4">Edit Question</h1>
<blockquote>{{ question.question }}</blockquote>
TODO
</div>
</div>
</div>
{% endblock %}

Aquí no hay nada especial, excepto que estoy imprimiendo el texto de la pregunta dinámica. En realidad no hay un formulario... ya que nos estamos centrando en la seguridad... pero haz como si lo hubiera.

Enlace a la página de edición

Antes de probar esta página, vuelve a la plantilla de presentación de preguntas. Vamos a añadir un enlace de edición para ayudar al propietario. En realidad, busca el h1. Aquí vamos.

Envuelve esto en un div con class="d-flex justify-content-between"... y luego ciérralo y haz una sangría:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<div class="d-flex justify-content-between">
<h1 class="q-title-show">{{ question.name }}</h1>
... lines 37 - 40
</div>
... lines 42 - 46
</div>
</div>
</div>
</div>
</div>
</div>
... lines 53 - 65
</div>
{% endblock %}

Ahora añade un enlace con href= path('app_question_edit') . Y, por supuesto, tenemos que pasarle a esto el comodín: id ajustado a question.id. Oh... espera, en realidad, el comodín es slug:

... lines 1 - 18
class QuestionController extends AbstractController
{
... lines 21 - 69
/**
* @Route("/questions/edit/{slug}", name="app_question_edit")
*/
public function edit(Question $question)
{
... lines 75 - 77
}
... lines 79 - 98
}

Así que usa slug ajustado a question.slug:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<div class="d-flex justify-content-between">
<h1 class="q-title-show">{{ question.name }}</h1>
<a href="{{ path('app_question_edit', {
slug: question.slug
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a>
</div>
... lines 42 - 46
</div>
</div>
</div>
</div>
</div>
</div>
... lines 53 - 65
</div>
{% endblock %}

Genial. Después, di "Editar"... y dale a esto unas cuantas clases para que quede más bonito.

Gracias a esto... ¡tenemos un botón de edición! Oh, ¡pero necesitamos un poco de margen! Añade mb-2:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="my-4">Question:</h2>
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);">
<div class="q-container-show p-4">
<div class="row">
... lines 13 - 33
<div class="col">
<div class="d-flex justify-content-between">
... lines 36 - 37
<a href="{{ path('app_question_edit', {
slug: question.slug
}) }}" class="btn btn-secondary btn-sm mb-2">Edit</a>
</div>
... lines 42 - 46
</div>
</div>
</div>
</div>
</div>
</div>
... lines 53 - 65
</div>
{% endblock %}

y... mucho mejor. Haz clic en eso. Esta es la página de edición de la pregunta... que en realidad no es una página de edición... pero finge que lo es.

Ahora volvamos al tema de la seguridad. Porque... no podemos dejar que cualquiera acceda a esta página: sólo el propietario de esta pregunta debe poder editarla.

Así que dentro de QuestionController, necesitamos una comprobación de seguridad. Primero tenemos que asegurarnos de que el usuario está conectado. Hazlo con $this->denyAccessUnlessGranted()pasando por IS_AUTHENTICATED_REMEMBERED:

... lines 1 - 18
class QuestionController extends AbstractController
{
... lines 21 - 72
public function edit(Question $question)
{
$this->denyAccessUnlessGranted('ROLE_USER');
... lines 76 - 82
}
... lines 84 - 103
}

Gracias a esto, tenemos garantizado que obtendremos un objeto User si decimos $this->getUser(). Podemos utilizarlo: si $question->getOwner() no es igual a $this->getUser(), entonces alguien que no es el propietario está intentando acceder a esta página. Niega el acceso con throw $this->createAccessDeniedException(). Diré:

¡No eres el propietario!

Pero, recuerda, estos mensajes de error sólo se muestran a los desarrolladores:

... lines 1 - 18
class QuestionController extends AbstractController
{
... lines 21 - 72
public function edit(Question $question)
{
$this->denyAccessUnlessGranted('ROLE_USER');
if ($question->getOwner() !== $this->getUser()) {
throw $this->createAccessDeniedException('You are not the owner!');
}
... lines 79 - 82
}
... lines 84 - 103
}

Vale, pues ahora mismo no estoy conectado en absoluto. Así que si refrescamos, nos devuelve a la página de inicio de sesión. Así que... ¡bien! ¡Acabamos de evitar con éxito que cualquier persona que no sea el propietario acceda a esta página de edición!

Pero... malas noticias amigos: No me gusta esta solución. No me gusta poner ninguna lógica de seguridad manual dentro de mi controlador. ¿Por qué? Porque significa que vamos a tener que repetir esa lógica en Twig para ocultar o mostrar el botón de edición. ¿Y qué pasa si nuestra lógica se vuelve más compleja? ¿Qué pasa si puedes editar una pregunta si eres el propietario o si tienes ROLE_ADMIN? Ahora tendríamos que actualizar y mantener la lógica duplicada en dos lugares como mínimo. No, no queremos duplicar nuestras reglas de seguridad.

Así que a continuación vamos a aprender sobre el sistema de votantes, que es la clave para centralizar toda esta lógica de autorización de una forma bonita.

Leave a comment!

¡Este tutorial también funciona muy bien para Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}