Créer des Posts

7

Traduction complétée à

Au programme de ce chapitre :

  • Apprendre à envoyer un post côté client.
  • Implémenter une vérification de sécurité simple.
  • Restreindre l'accès au formulaire d'envoi de post.
  • Apprendre à utiliser une Method côté serveur pour plus de sécurité.
  • Nous avons vu combien il était facile de créer des posts en passant par la console, avec l'appel à la base de données Posts.insert. Mais nous ne pouvons pas attendre de nos utilisateurs qu'ils ouvrent la console pour créer un nouveau post.

    À un moment, nous devrons créer une interface pour permettre à nos utilisateurs de poster de nouvelles histoires sur notre application.

    Construire la page d'ajout de post

    Nous commençons par définir une route pour notre nouvelle page :

    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

    Ajouter un lien à l'en-tête

    Une fois cette route définie, nous pouvons maintenant ajouter un lien à notre page d'envoi dans notre en-tête :

    <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'}}">Créer un post</a></li>
                </ul>
                <ul class="nav navbar-nav navbar-right">
                    {{> loginButtons}}
                </ul>
            </div>
        </nav>
    </template>
    
    client/templates/includes/header.html

    Configurer notre route signifie que si un utilisateur se rend à l'URL /submit, Meteor affichera le template postSubmit. Écrivons donc ce template :

    <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="Votre 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="Nommez votre article" class="form-control"/>
                </div>
            </div>
          <input type="submit" value="Submit" class="btn btn-primary"/>
        </form>
    </template>
    
    client/templates/posts/post_submit.html

    Note : ça fait beaucoup de code, mais ça vient simplement du fait que nous utilisons Twitter Bootstrap. Bien que seuls les éléments de formulaire soient essentiels, les balises supplémentaires aideront à rendre notre appli un peu plus jolie. Cela devrait maintenant ressembler à ça :

    Le formulaire de soumission d'article
    Le formulaire de soumission d'article

    C'est un formulaire simple. Nous n'avons pas à nous inquiéter de lui ajouter un champ ‘action’, puisque nous allons intercepter les événements sur le formulaire et mettre à jour les données en passant par JavaScript. Il n'y aurait pas de sens à fournir une solution alternative sans JavaScript quand on considère qu'une appli Meteor ne fonctionne plus du tout si JavaScript est désactivé.

    Créer des posts

    Lions un gestionnaire d'événement à l'événement submit du formulaire. Il est préférable d'utiliser l'événement submit (plutôt qu'un événement click sur le bouton par exemple), car cela permettra de prendre en compte toutes les façons possibles d'envoyer le formulaire (comme appuyer sur entrée par exemple).

    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

    Avec une page d'ajout de post et un lien vers celle-ci da…

    Cette fonction utilise jQuery pour vérifier les valeurs de nos divers champs de formulaire et construire un nouvel objet post à partir des résultats. Nous devons nous assurer d'utiliser preventDefault sur l'argument event de notre gestionnaire pour être sûr que le navigateur n'essaie pas de prendre les devants en essayant d'envoyer le formulaire.

    Finalement, nous pouvons router l'utilisateur vers la page de son nouveau post. La fonction insert() (utilisée sur une collection), renvoie l’id généré pour l'objet qui vient d'être inséré dans la base de données. La fonction go() du Router va utiliser cet id pour construire l'URL correspondante, et nous amener sur la bonne page.

    Ainsi, l'utilisateur envoie le formulaire, un nouveau post est créé, et l'utilisateur est instantanément dirigé vers la page de discussion de ce nouveau post.

    Ajouter un peu de sécurité

    Créer des posts est très bien, mais nous ne voulons pas laisser n'importe quel visiteur le faire : ils doivent être authentifiés pour pouvoir poster. Bien sûr, nous pouvons commencer en cachant la page d'ajout de post pour les utilisateurs non authentifiés. Cependant un utilisateur pourrait créer un post depuis la console du navigateur sans être authentifié, et nous ne pouvons pas nous le permettre.

    Heureusement la sécurité des données est incluse dans les collections Meteor. Elle est désactivée par défaut lorsque vous créez un nouveau projet. Cela vous permet de commencer facilement à créer votre application tout en laissant les trucs ennuyeux pour plus tard.

    Notre appli n'a plus besoin de ces petites roues, alors enlevons-les ! Supprimons le paquet insecure :

    meteor remove insecure
    
    Terminal

    Après avoir fait ça, vous remarquerez que le formulaire de post ne fonctionne plus. C'est parce que sans le paquet insecure, les insert() côté client sur la collection des posts ne sont plus autorisés.

    Nous devons soit donner quelques règles explicites à Meteor pour définir quand un client est autorisé à insérer un post, soit faire nos insertions côté serveur.

    Autoriser les insertions de post

    Pour commencer, nous allons voir comment autoriser les insertions de post côté client pour que notre formulaire fonctionne à nouveau. Il s'avère que nous finirons par adopter une technique différente, mais pour le moment, le simple code suivant permettra que les choses fonctionnent à nouveau :

    Posts = new Meteor.Collection('posts');
    
    Posts.allow({
      insert: function(userId, doc) {
        // autoriser les posts seulement si l'utilisateur est authentifié
        return !! userId;
      }
     });
    
    collections/posts.js

    Commit 7-2

    Sans Insecure et avec des règles de modifications sur les…

    Nous appelons Posts.allow, qui dit à Meteor que “ceci est un ensemble de circonstances dans lesquelles les clients sont autorisés à faire des choses à la collection Post”. Dans notre cas, nous disons “les clients peuvent insérer des posts du moment qu'ils ont un userId”.

    Le userId de l'utilisateur procédant à la modification est passé lors des appels à allow et deny (ou renvoie null si l'utilisateur n'est pas authentifié), ce qui est presque toujours utile. Et comme les comptes utilisateurs sont liés au noyau de Meteor, nous pouvons compter sur userId pour être toujours fiable.

    Nous avons réussi à nous assurer que l'on doit être authentifié pour créer un post. Essayez de vous déconnecter et de créer un post ; vous devriez voir ceci dans votre console :

    Échec de l'insertion : Accès refusé
    Échec de l'insertion : Accès refusé

    Cependant, nous avons encore à nous occuper de quelques problèmes :

    • Les utilisateurs non authentifiés peuvent toujours accéder au formulaire de création de post.
    • Le post n'est pas lié à l'utilisateur de quelque façon que ce soit (et il n'y a pas de code côté serveur pour s'occuper de ça).
    • Plusieurs posts peuvent être créés pointant vers la même URL.

    Résolvons ces problèmes.

    Sécuriser l'accès au nouveau formulaire

    Commençons par éviter l'accès des utilisateurs non authentifiés au formulaire d'ajout de post. Nous le ferons au niveau du routeur, en définissant un route hook.

    Un hook (“crochet” en français) intercepte le processus de routage et change potentiellement l'action prise par le routeur. Vous pouvez l'imaginer comme un garde de sécurité qui vérifie vos identifiants avant de vous laisser entrer (ou de vous refuser l'accès).

    Nous avons besoin de vérifier si l'utilisateur est authentifié, et sinon afficher le template accessDenied au lieu du postSubmit attendu (puis nous stoppons le routeur ici, il n'a rien d'autre à faire). Modifions donc 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

    Nous créons aussi le template pour la page 'accès refusé’, accessDenied :

    <template name="accessDenied">
        <div class="access-denied page jumbotron">
            <h2>Accès refusé</h2>
            <p>Vous ne pouvez pas accéder à cette page ! Veuillez vous connecter.</p>
        </div>
    </template>
    
    client/templates/includes/access_denied.html

    Commit 7-3

    Accès à la page d'ajout de post refusé lorsque non authen…

    Maintenant, si vous allez à http://localhost:3000/submit/ sans être authentifié, vous devriez voir ceci :

    Le template d'accès refusé
    Le template d'accès refusé

    Ce qui est pratique à propos des hooks de routage, c'est qu'ils sont eux aussi réactifs. C'est-à-dire que nous n'avons pas besoin de penser à des callbacks quand l'utilisateur s'authentifie : quand l'état d'authentification de l'utilisateur change, le template utilisé par le routeur change instantanément de accessDenied à postSubmit sans que nous ayons rien à écrire d'explicite pour le gérer (et cela fonctionne d'ailleurs pour tous les onglets).

    Authentifiez-vous, puis essayer de rafraîchir la page. Vous verrez peut-être parfois le template accessDenied s'afficher brièvement avant que la page d'ajout de post apparaisse. La raison en est que Meteor commence à afficher les templates dès que possible, avant même qu'il ait conversé avec le serveur est vérifié si l'utilisateur actuel (stocké dans la mémoire locale du navigateur) existe réellement.

    Pour éviter ce problème (qui est courant et que vous rencontrerez d'autant plus que vous allez vous pencher sur les subtilités de latence entre client et serveur), nous allons simplement afficher un écran de chargement pendant que nous attendons de savoir si l'utilisateur est authentifié ou pas.

    Après tout, à cet instant nous ne savons pas si l'utilisateur a les identifiants corrects, et nous ne pouvons afficher aucun des templates accessDenied ou postSubmit avant de le savoir.

    Nous modifions donc notre hook pour utiliser notre template de chargement pendant que Meteor.loggingIn() est vrai :

    //...
    
    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

    Afficher un écran de chargement pendant l'authentification.

    Cacher le lien

    La façon la plus simple d'éviter aux utilisateurs d'essayer d'atteindre cette page par erreur lorsqu'ils ne sont pas connectés est de leur cacher le lien. Nous pouvons faire cela facilement :

    //...
    
    <ul class="nav navbar-nav">
        {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Créer un post</a></li>{{/if}}
    </ul>
    
    //...
    
    client/templates/includes/header.html

    Commit 7-5

    Afficher le lien d'ajout de post seulement si authentifié.

    Le helper currentUser est fourni par le paquet accounts et est l'équivalent Spacebars de Meteor.user(). Puisqu'il est réactif, le lien apparaîtra ou disparaîtra selon que vous vous connectez ou déconnectez de l'application.

    Meteor Method : une meilleure abstraction et sécurité

    Nous avons réussi à sécuriser l'accès à la page d'ajout de post pour les utilisateurs non authentifiés, et éviter qu'ils puissent créer des posts même s'ils trichent en utilisant la console. Nous avons cependant encore plusieurs choses à faire :

    • Marquer la date du post avec un timestamp
    • S'assurer que la même URL ne peut pas être postée plus d'une fois
    • Ajouter des détails à propos de l'auteur du post (ID, nom d'utilisateur, etc.)

    Vous pourriez penser que nous pouvons faire tout cela dans notre gestionnaire de l'événement submit. Cependant, en réalité, nous arriverions vite à plusieurs problèmes :

    • Pour le timestamp, nous aurions à espérer que l'heure soit correcte sur l'ordinateur de l'utilisateur, ce qui ne sera pas toujours le cas
    • Le côté client ne sera pas au courant de toutes les URL postées sur le site. Il connaîtra seulement les posts que l'utilisateur peut actuellement voir (nous verrons plus tard comment ceci fonctionne exactement). Il n'y a donc pas de moyen d'assurer l'unicité des URL du côté client.
    • Enfin, même si nous pourrions ajouter les détails utilisateurs côté client, nous ne nous assurerions pas de leur exactitude, ce qui pourrait exposer notre appli à une exploitation par des gens utilisant la console.

    Pour toutes ces raisons, il vaut mieux garder nos gestionnaires d'événements simples. Et si nous faisons plus que les insertions et mises à jour les plus basiques, utilisons plutôt une Méthode.

    Une Méthode Meteor est une fonction côté serveur qui est appelée côté client. Elles ne nous sont pas totalement étrangères – en fait, en coulisses, les fonctions insert, update, et remove de Collection sont toutes des Méthodes. Voyons comment créer la nôtre.

    Revenons à post_submit.js. Plutôt que d'insérer directement dans la collection Posts, nous allons appeler une Méthode nommée 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) {
                // affiche l'erreur à l'utilisateur et s'interrompt
                if (error)
                    return alert(error.reason);
    
                Router.go('postPage', {_id: result._id});
            });
        }
    });
    
    client/templates/posts/post_submit.js

    La fonction Meteor.call appelle une Methode nommée par son premier argument. Vous pouvez fournir des arguments pour l'appel (ici, l'objet post construit depuis le formulaire), et finalement y attacher un callback, qui sera executé quand la Methode côté serveur sera terminé.

    Les callbacks des méthodes meteor ont toujours deux arguments, error et result. Si pour une raison ou une autre, l'argument error existe, nous alerterons l'utilisateur (en utilisant return pour interrompre le callback). Si tout fonctionne normalement, nous redirigerons l'utilisateur vers la page de discussion du post fraîchement créée.

    Test de sécurité

    Nous allons profiter de cette opportunité pour sécuriser notre méthode un peu plus en utilisant le paquet audit-argument-checks.

    Ce paquet vous permet de tester n'importe quel objet JavaScript selon un schéma prédéfini. Dans notre cas, nous l'utiliserons pour tester que l'utilisateur qui utilise la méthode est bien authentifié (en nous assurant que Meteor.userId() est une chaîne (String), et que l'objet postAttributes passé en tant qu'argument à la méthode contient les chaînes title et url, pour ne pas nous retrouver à entrer des morceaux aléatoires de données dans notre base de données.

    Définissons donc la méthode postInsert dans notre fichier collections/posts.js. Nous supprimerons le bloc allow() de posts.js puisque de toute façon les méthodes Meteor les ignorent.

    Nous allons ensuite étendre (extend) l'objet postAttributes avec trois nouvelles propriétés : l’_id et le nom (username) de l'utilisateur, ainsi que l'horodatage du post soumis (submitted), avant d'insérer le tout dans notre base de donnée et de retourner l’_id final au client (en d'autres mots, celui à l'origine de l'appel de cette méthode) dans un objet 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

    Notez que la méthode _.extend() fait partie de la librairie Underscore, et qu'elle vous permet simplement d’“étendre” un objet avec les propriétés d'un autre.

    Commit 7-6

    En utilisant une méthode pour soumettre le post.

    Bye Bye Allow/Deny

    Les méthodes Meteor sont exécutées sur le serveur, et donc Meteor leur fait confiance. C'est pourquoi les méthodes Meteor ignorent n'importe quel callback allow/deny.

    Si vous voulez exécuter du code avant chaque insert, update, ou remove même sur le serveur, nous vous suggérons de jeter un œil au paquet collection-hooks.

    Éviter les doublons

    Nous ferons un dernier test avant d'intégrer notre méthode. Si un post avec la même url a déjà été créé auparavant, nous n'ajouterons pas le lien une nouvelle fois, mais au lieu de cela nous allons rediriger l'utilisateur vers ce post.

    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

    Nous cherchons dans notre base de données un post avec la même url. Si nous en trouvons un, nous retournons (return) l’_id de ce posts avec la propriété postExists:true pour informer le client de ce cette situation particulière.

    Et puisque nous déclenchons un appel à return, la méthode s'interrompt à ce moment sans exécuter la déclaration insert, évitant ainsi avec élégance de créer des doublons.

    Tout ce qui nous reste à faire est d'utiliser cette nouvelle information postExists dans notre helper d'événement côté client pour afficher un message d'avertissement :

    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) {
                // affiche l'erreur à l'utilisateur et s'interrompt
                if (error)
                    return alert(error.reason);
    
                // affiche ce résultat mais route tout de même
                if (result.postExists)
                    alert('Ce lien a déjà été utilisé');
    
                Router.go('postPage', {_id: result._id});
            });
        }
    });
    
    client/templates/posts/post_submit.js

    Commit 7-7

    S'assurer de l'unicité de l'url du post.

    Trier les posts

    Maintenant que nous avons une date de création sur tous nos posts, il semble bienvenu de s'assurer qu'ils soient classés en fonction de cet attribut. Pour cela, nous pouvons simplement utiliser l'opérateur sort de Mongo, qui attend un objet constitué des clés par lesquelles trier et un signe indiquant si le tri doit être croissant ou décroissant.

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

    Commit 7-8

    Tri des posts par date de création.

    Ça a demandé un peu de travail, mais nous avons maintenant une interface utilisateur qui permet d'ajouter du contenu de façon sécurisée à notre appli !

    Mais toute application qui permet à ses utilisateurs de créer du contenu doit aussi leur donner la possibilité de le modifier ou de le supprimer. Ce sera le sujet du prochain chapitre.