Creando posts

7

Porcentaje completado

En este capítulo:

  • Aprenderemos a enviar posts desde el cliente.
  • Implementaremos un sencillo control de seguridad.
  • Restringiremos el acceso al formulario de envío.
  • Aprenderemos a utilizar métodos de servidor para mejorar la seguridad.
  • Hemos visto lo fácil que es crear posts llamando a Posts.insert a través de la consola pero, no podemos esperar que nuestros usuarios hagan lo mismo.

    Necesitamos construir algún tipo de interfaz de usuario para que los usuarios creen nuevas entradas en la aplicación.

    Creando la página de envío

    Empezaremos definiendo una ruta para nuestra nueva página en lib/router.js:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    Router.route('/', {name: 'postsList'});
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/submit', {name: 'postSubmit'});
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    
    lib/router.js

    Añadiendo un enlace en la cabecera

    Con la ruta definida, ahora podemos añadir un enlace a la cabecera de nuestra página:

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
        </div>
        <div class="collapse navbar-collapse" id="navigation">
          <ul class="nav navbar-nav">
            <li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>
          </ul>
          <ul class="nav navbar-nav navbar-right">
            {{> loginButtons}}
          </ul>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html

    Configurar una ruta implica que si un usuario navega a /submit, Meteor mostrará la plantilla postSubmit. Así que vamos a escribir esa plantilla:

    <template name="postSubmit">
      <form class="main form page">
        <div class="form-group">
          <label class="control-label" for="url">URL</label>
          <div class="controls">
              <input name="url" id="url" type="text" value="" placeholder="Your URL" class="form-control"/>
          </div>
        </div>
        <div class="form-group">
          <label class="control-label" for="title">Title</label>
          <div class="controls">
              <input name="title" id="title" type="text" value="" placeholder="Name your post" class="form-control"/>
          </div>
        </div>
        <input type="submit" value="Submit" class="btn btn-primary"/>
      </form>
    </template>
    
    client/templates/posts/post_submit.html

    Aquí hay un montón de markup, pero es solo porque usamos el CSS de Twitter Bootstrap. Aunque sólo son esenciales los elementos del formulario, el marcado adicional ayudará a que nuestra aplicación se vea un poco mejor. Ahora debería tener un aspecto similar a este:

    El formulario de creación de posts
    El formulario de creación de posts

    Es un simple formulario. No tenemos que preocuparnos de programar una acción para él, porque interceptaremos su evento submit y actualizaremos los datos vía JavaScript. (No tiene sentido proporcionar un fallback no-JS si tenemos en cuenta que Meteor no funciona con JavaScript desactivado).

    Creando posts

    Vamos a enlazar un controlador de eventos al evento submit del formulario. Es mejor usar el evento submit (en lugar de un click en un botón), ya que cubrirá todas las posibles formas de envío (como por ejemplo pulsar intro).

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        post._id = Posts.insert(post);
        Router.go('postPage', post);
      }
    });
    
    client/templates/posts/post_submit.js

    Commit 7-1

    Nueva página de envío y enlace a ella desde la cabecera.

    Esta función utiliza jQuery para analizar los valores de los distintos campos del formulario y rellenar un objeto post con los resultados. Tenemos que asegurarnos de usar preventDefault para que el navegador no intente enviar el formulario si volvemos atrás o adelante después.

    Al final, podemos dirigirnos a la página de nuestro nuevo post. La función insert() devuelve el identificador _id del objeto que se ha insertado en la base de datos, que podemos pasar a la función go() del router para que nos lleve a la página correcta.

    El resultado es que el usuario pulsa en submit, se crea un nuevo post, y vamos inmediatamente a la página de discusión de ese nuevo post.

    Añadiendo algo de seguridad

    Tal como está ahora, cualquiera que visite la web puede crear posts. Para evitarlo, debemos hacer que los usuarios inicien sesión. Podríamos ocultar el nuevo formulario, pero aún así, se podría seguir haciendo desde la consola.

    Afortunadamente, Meteor gestiona la seguridad de las colecciones de la forma adecuada, lo que ocurre es que, por defecto, esta característica viene desactivada. Esto es así para permitirnos empezar con facilidad a construir la aplicación, dejando las cosas aburridas para más tarde.

    Es el momento de eliminar el paquete insecure:

    meteor remove insecure
    
    Terminal

    Después de hacerlo, nos damos cuenta de que el formulario de posts ya no funciona. Esto es así, porque sin el paquete insecure, no se permiten inserciones en la colección de posts desde el lado del cliente.

    Necesitamos escribir reglas explícitas para decirle a Meteor qué usuarios pueden insertar posts o hacer que las inserciones se hagan en el lado del servidor.

    Permitir insertar posts

    Para que nuestro formulario funcione de nuevo, vamos a ver cómo permitir posts del lado del cliente. Como veremos, al final usaremos una técnica diferente, pero por ahora, lo pondremos todo a funcionar de nuevo, de una forma sencilla: en collections/posts.js:

    Posts = new Mongo.Collection('posts');
    
    Posts.allow({
      insert: function(userId, doc) {
        // only allow posting if you are logged in
        return !! userId;
      }
    });
    
    lib/collections/posts.js

    Commit 7-2

    Eliminado el paquete `insecure` y permitido añadir posts …

    Llamamos a Posts.allow, que le dice a Meteor que “se trata de un conjunto de circunstancias en las que a los clientes se les permite hacer cosas en la colección de Posts”. En este caso, estamos diciendo: “a los clientes se les permite insertar posts siempre y cuando tengan un userId”.

    El userId que realiza la modificación se pasa a las funciones allow y deny (o devuelve null si no hay ningún usuario conectado). Como las cuentas de usuario forman parte del núcleo de Meteor, podemos confiar en que el userId siempre será el correcto.

    Nos las hemos arreglado para asegurarnos de que un usuario tiene que estar registrado para crear un mensaje. Salimos de la sesión e intentamos crear un post para ver lo que sale por la consola del navegador:

    Error en la inserción: Acceso denegado.
    Error en la inserción: Acceso denegado.

    Sin embargo, todavía tenemos que tratar con unas cuantas cosas:

    • Los usuarios que no han iniciado sesión aún pueden ver el formulario.
    • El post no está vinculado al usuario de ninguna forma.
    • Se pueden crear múltiples posts que apunten a la misma URL.

    Vamos a corregir estos problemas.

    Asegurar el acceso al formulario

    Vamos a empezar por evitar que los usuarios no registrados puedan ver el formulario de envío de posts. Lo haremos a nivel de router, definiendo una acción (hook) del router.

    Una acción intercepta el proceso de enrutamiento y, potencialmente, cambia la acción que lleva acabo el router. Puedes pensar en él como en un guardia de seguridad que verifica tus credenciales antes de dejarte entrar.

    Lo que tenemos que hacer es comprobar si el usuario está conectado. Si no lo está, mostramos la plantilla accessDenied en lugar de la plantilla postSubmit (en este momento le diremos al router que no haga nada más). Así que vamos a modificar router.js:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      notFoundTemplate: 'notFound',
      waitOn: function() { return Meteor.subscribe('posts'); }
    });
    
    Router.route('/', {name: 'postsList'});
    
    Router.route('/posts/:_id', {
      name: 'postPage',
      data: function() { return Posts.findOne(this.params._id); }
    });
    
    Router.route('/submit', {name: 'postSubmit'});
    
    var requireLogin = function() {
      if (! Meteor.user()) {
        this.render('accessDenied');
      } else {
        this.next();
      }
    }
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
    
    lib/router.js

    Además, tenemos que crear una plantilla para la página de error:

    <template name="accessDenied">
      <div class="access-denied page jumbotron">
        <h2>Access Denied</h2>
        <p>You can't get here! Please log in.</p>
      </div>
    </template>
    
    client/templates/includes/access_denied.html

    Commit 7-3

    Acceso denegado al envío de posts a usuarios no registrados.

    Si ahora nos dirigimos a http://localhost:3000/submit/ sin estar registrados, veremos el mensaje de error:

    Plantilla de error de acceso
    Plantilla de error de acceso

    Lo bueno de las acciones del router es que son reactivas. Esto significa que no necesitamos pensar en funciones de retorno cuando el usuario se autentica: cuando el estado de autenticación del usuario cambia, la plantilla del Router cambia instantáneamente de accessDenied a postSubmit sin tener que escribir explícitamente código para manejarlo (y además, esto funciona incluso en las otras pestañas del navegador).

    Iniciemos sesión, y vayamos a la página para crear un nuevo post. Ahora actualizar la página en el navegador. Veremos que, por un instante, se ve la plantilla accessDenied antes de que aparezca el formulario. Esto es porque Meteor empieza a mostrar las plantillas tan pronto como sea posible, antes de haber hablado con el servidor y comprobado si el usuario existe.

    Para evitar este problema (que es uno de los más comunes que nos podemos encontrar cuando tratamos de lidiar con la latencia entre el cliente y el servidor), solo mostraremos una pantalla de espera durante un instante en el que esperamos para ver si el usuario tiene acceso o no.

    Después de todo en este momento no sabemos si el usuario tiene acceso y no podemos mostrar ninguna de las plantillas, ya sea la de accessDenied o la de postSubmit hasta que lo sepamos.

    Así que vamos a modificar nuestra acción para añadir la plantilla de espera mientras Meteor.loggingIn() sea verdadero en:

    //...
    
    var requireLogin = function() {
      if (! Meteor.user()) {
        if (Meteor.loggingIn()) {
          this.render(this.loadingTemplate);
        } else {
          this.render('accessDenied');
        }
      } else {
        this.next();
      }
    }
    
    Router.onBeforeAction('dataNotFound', {only: 'postPage'});
    Router.onBeforeAction(requireLogin, {only: 'postSubmit'});
    
    lib/router.js

    Commit 7-4

    Mostrar la pantalla de carga mientras esperamos al login.

    Ocultando el enlace

    La forma fácil de evitar que los usuarios lleguen al formulario es esconder el enlace. Podemos hacerlo fácilmente desde header.html:

    //...
    
    <ul class="nav navbar-nav">
      {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
    </ul>
    
    //...
    
    client/templates/includes/header.html

    Commit 7-5

    No mostrar el enlace a la página de envío si el usuario n…

    El paquete accounts nos ofrece el ayudante currentUser que es el equivalente a Meteor.user() en Spacebars. Puesto que es reactivo, el enlace aparecerá o desaparecerá según el estado del usuario.

    Meteor.methods para mejorar la seguridad y la abstracción

    Nos las hemos arreglado para asegurar el acceso a la página de entrada de posts, y no permitir crear posts a usuarios no registrados incluso si intentan hacerlo desde la consola. Sin embargo, todavía quedan cosas que debemos mejorar:

    • Añadir el timestamp de los posts.
    • Asegurarse de que no hay URLs duplicadas.
    • Añadir detalles sobre el autor del post (ID, nombre de usuario, etc.)

    Podríamos pensar en hacer todo esto en nuestro controlador submit. Pero, haciéndolo de esta forma, nos encontraríamos con un montón de problemas.

    • Para el timestamp, tendríamos que confiar en la hora de la máquina del usuario.
    • Los clientes no conocerán todas las URL publicadas. Solo conocen los posts que pueden ver en ese momento (veremos porqué), así que no podemos asegurar desde el lado del cliente que las URLs sean únicas.
    • Por último, aunque podríamos añadir la información de usuario en el lado del cliente, estaríamos abriendo nuestra aplicación a posibles ataques de usuarios usando la consola del navegador.

    Por todas estas razones, es mejor mantener nuestros controladores de eventos simples y, si queremos hacer más inserciones o actualizaciones en las colecciones, debemos usar métodos.

    Un método en Meteor es una función del lado del servidor que se llama desde el lado del cliente. Ya estamos familiarizados con ellos – de hecho, entre bastidores, la inserción, la actualización y el borrado de datos de la colección, son métodos. Vamos a ver cómo crear el nuestro.

    Volvamos a post_submit.js. En lugar de insertar directamente en la colección Posts, vamos a llamar a un método llamado postInsert:

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return alert(error.reason);
    
          Router.go('postPage', {_id: result._id});
        });
      }
    });
    
    client/templates/posts/post_submit.js

    La función Meteor.call llama a un método nombrado por su primer argumento. Se pueden proporcionar argumentos a la llamada (en este caso, pasamos el objeto post que hemos construido del formulario), y, finalmente, habilitamos un callback, que se ejecutará cuando el método del lado del servidor finalice.

    Las funciones de retorno de los métodos Meteor siempre tienen dos argumentos, error y result. Si por cualquier razón el argumento error existe, avisaremos al usuario (usando return para finalizar la función). Si todo ha funcionado bien, redirigiremos al usuario a la página de discusión del post recién creado.

    Comprobaciones de seguridad

    Aprovecharemos esta oportunidad para añadir algo de seguridad a nuestros métodos usando el paquete audit-argument-checks.

    Este paquete nos permite realizar comprobaciones sobre un objeto JavaScript usando patrones predefinidos. En nuestro caso, lo usaremos para comprobar que el usuario que está invocando el método está correctamente autenticado (asegurándonos que Meteor.userId() es de tipo String), y que el objeto postAttributes pasado como argumento al método contiene las cadenas title y url, para no terminar insertando cualquier dato extraño en nuestra base de datos.

    Vamos a definir el método postInsert en nuestro fichero collections/posts.js. Eliminaremos el bloque allow() del fichero posts.js porque usando métodos, Meteor no lo evalúa.

    Extenderemos (extend) el objeto postAttributes con tres propiedades más: el identificador del usuario _id y el username, además de la fecha y hora submitted, antes de insertarlos en nuestra base de datos y devolver el _id al cliente (en otras palabras, a la función original que llamó a este método) como un objeto JavaScript.

    Posts = new Mongo.Collection('posts');
    
    Meteor.methods({
      postInsert: function(postAttributes) {
        check(Meteor.userId(), String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var user = Meteor.user();
        var post = _.extend(postAttributes, {
          userId: user._id,
          author: user.username,
          submitted: new Date()
        });
    
        var postId = Posts.insert(post);
    
        return {
          _id: postId
        };
      }
    });
    
    lib/collections/posts.js

    Fíjate que el método _.extend() forma parte de la librería Underscore, que simplemente nos permite “extender” un objeto con propiedades de otro.

    Commit 7-6

    Usando un método para enviar un post.

    Adiós Allow/Deny

    Los métodos Meteor son ejecutados en el servidor, por lo que Meteor supone que son de confianza. Por tanto, los métodos Meteor obvian las llamadas a allow y deny.

    Si quieres ejecutar algún código antes de cada operación de insert, update, o remove incluso en el lado servidor, te sugerimos echar un vistazo al paquete collection-hooks.

    Evitando duplicidades

    Vamos a hacer una comprobación más antes de dar por bueno nuestro método. Si ya tenemos un post con la misma URL, no vamos a permitir que se añada una segunda vez, por el contrario, redirijamos al usuario al post ya existente.

    Meteor.methods({
      postInsert: function(postAttributes) {
        check(this.userId, String);
        check(postAttributes, {
          title: String,
          url: String
        });
    
        var postWithSameLink = Posts.findOne({url: postAttributes.url});
        if (postWithSameLink) {
          return {
            postExists: true,
            _id: postWithSameLink._id
          }
        }
    
        var user = Meteor.user();
        var post = _.extend(postAttributes, {
          userId: user._id,
          author: user.username,
          submitted: new Date()
        });
    
        var postId = Posts.insert(post);
    
        return {
          _id: postId
        };
      }
    });
    
    lib/collections/posts.js

    Buscamos en nuestra base de datos las URLs duplicadas. Si se encuentra alguna, devolvemos (return) el _id del post junto con una marca postExists: true para informar al cliente sobre esta situación especial.

    Y como estamos lanzando una llamada return, el método se detiene en este punto sin llegar a ejecutar la sentencia insert, evitándonos elegantemente cualquier duplicidad.

    Sólo falta usar postExists en nuestro ayudante de eventos en el lado del cliente para mostrarnos un mensaje de aviso:

    Template.postSubmit.events({
      'submit form': function(e) {
        e.preventDefault();
    
        var post = {
          url: $(e.target).find('[name=url]').val(),
          title: $(e.target).find('[name=title]').val()
        };
    
        Meteor.call('postInsert', post, function(error, result) {
          // display the error to the user and abort
          if (error)
            return alert(error.reason);
    
          // show this result but route anyway
          if (result.postExists)
            alert('This link has already been posted');
    
          Router.go('postPage', {_id: result._id});
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Commit 7-7

    Forzando la unicidad de las URLs.

    Ordenando los posts

    Ahora que tenemos una fecha de envío en todos nuestros posts, tiene sentido asegurarnos que se están ordenando usando este atributo. Para ello usaremos el operador sort de Mongo que espera un objeto que consta de las claves de ordenación, y un signo que indica si son ascendentes o descendentes:

    Template.postsList.helpers({
      posts: function() {
        return Posts.find({}, {sort: {submitted: -1}});
      }
    });
    
    client/templates/posts/posts_list.js

    Commit 7-8

    Posts ordenados por fecha de envío.

    Ha costado, pero ¡Finalmente tenemos una interfaz en la que los usuarios introducen posts de forma segura en nuestra aplicación!

    Sin embargo, cualquier aplicación que permita a los usuarios crear contenido también debe permitir editarla o borrarla. Eso es de lo que hablaremos en el siguiente capítulo.