Создание постов

7

На данный момент переведено

В этой главе вы:

  • Узнаете как создать новый пост на клиенте.
  • Создадите простую проверку данных.
  • Ограничите доступ к форме создания поста.
  • Научитесь использовать методы проверки данных на сервере для лучшей безопасности.
  • Мы уже знаем как создавать новые посты через консоль командой Posts.insert. Но было бы жестоко заставлять наших пользователей открывать консоль и печатать команды обращения к базе данных.

    Нам нужно создать дружелюбный пользовательский интерфейс для добавления новых постов.

    Создаем страницу добавления постов

    Для начала создадим маршрут (route) к нашей новой странице:

    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

    Добавляем линк в заголовок

    Теперь, когда у нас есть маршрут новой страницы, давайте добавим на нее ссылку в заголовок:

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <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>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html

    Создание маршрута означает, что если пользователь решит открыть адрес /submit в браузере, Meteor отрендерит шаблон postSubmit. Давайте создадим этот шаблон:

    <template name="postSubmit">
      <form class="main form">
        <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

    Внимание: куча новой разметки. Но вся она родом из Twitter Bootstrap. Да, нам нужна только форма нового поста. Но дополнительные теги и классы сделают все гораздо симпатичнее. Теперь наша новая страница будет выглядеть примерно так:

    Форма для нового поста
    Форма для нового поста

    Нам не нужно волноваться насчет параметра action для этой формы, так как мы перехватим событие и отправим данные с помощью JavaScript. Также не стоит волноваться насчет варианта когда JavaScript отключен в браузере - ведь тогда приложение Meteor просто не будет работать.

    Создание постов

    Давайте создадим обработчик событий для кнопки Submit. Проще всего использовать событие submit (чем, например, ловить событие click на кнопке), ведь тогда мы охватим все возможные сценарии событий (например, нажатие кнопки Enter в форме).

    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

    Коммит 7-1

    Добавлена страница с новым постом, и линк на нее в заголо…

    Эта функция использует jQuery чтобы собрать данные со всех полей формы и создать объект нового поста. Мы также добавили вызов preventDefault для события event, чтобы браузер не попытался отправить форму традиционным способом.

    Наконец, мы можем перенаправить пользователя на страницу с новым постом. Вызов функции insert() у коллекции вернет свежий _id объекта, который только что был добавлен в базу данных. Этот параметр мы добавим в вызов Router.go() - он будет добавлен в адресную строку.

    Теперь пользователь может отправить новый пост кнопкой Submit - пост будет создан, а пользователь будет перенаправлен на страницу обсуждения этого поста.

    Добавим немного безопасности

    Все это замечательно, но было бы неплохо ограничить доступ к созданию новых постов только для зарегистрированных пользователей. Мы могли бы спрятать форму на странице, но не смотря на это любой разбирающийся в браузерах человек мог бы открыть консоль и создать пост оттуда.

    К счастью, защита данных зашита прямо в коллекции Meteor. Просто она по-умолчанию отключена для новых проектов. Это позволяет легко начать новое приложение не тратя время на скучные вещи.

    Нашему приложению больше не нужны эти костыли, поэтому настало время от них избавиться. Давайте удалим пакет insecure:

    meteor remove insecure
    
    Терминал

    Возможно вы заметили что форма добавления постов сломалась. Это потому, что без пакета insecure редактирование коллекций на клиенте запрещено.

    Нам нужно или специально сообщить Meteor'у что клиентам можно создавать новые посты, или делать вставку постов на сервере.

    Разрешаем создание новых постов

    Для начала мы быстро починим отправку формы, разрешив создание постов на клиенте. Вы увидите позже что мы остановимся на немного другом подходе, но сейчас мы можем все починить следующим образом:

    Posts = new Mongo.Collection('posts');
    
    Posts.allow({
      insert: function(userId, doc) {
        // разрешить постить только если пользователь залогинен
        return !! userId;
      }
    });
    
    lib/collections/posts.js

    Коммит 7-2

    Удалили пакет insecure, разрешили определенные записи в п…

    Вызов Posts.allow сообщает Meteor'у что “в этих обстоятельствах клиентам разрешено модифицировать коллекцию Posts”. В данном случае мы говорим что “клиентам можно создавать новые посты до тех пор пока в них есть userId”.

    Параметр userId пользователя, который создает пост, будет передан вызовам allow и deny (или функция вернет null если пользователь не залогинен), что очень полезно. Так как пользовательские аккаунты привязаны к ядру Meteor, мы можем рассчитывать на то что userId всегда верен.

    Мы ограничили создание постов только для авторизированных пользователей. Попробуйте выйти из аккаунта и создать пост - вы скорее всего увидите следующее в консоли:

    Insert failed: Access denied - Вставка провалилась: Отказано в доступе
    Insert failed: Access denied - Вставка провалилась: Отказано в доступе

    Отлично. Осталась еще пара вещей:

    • Неавторизованным пользователям все ещё доступна форма создания новых постов
    • Пост никак не привязан к пользователю (и у нас нет кода на сервере для этого)
    • Можно создать множество постов с одним и тем же URL.

    Давайте это исправим.

    Ограничиваем доступ к форме добавления постов

    Если пользователь не залогинен, ему не стоит показывать форму для новых постов. Верным местом для такого ограничения будет роутер. Для этого мы создадим router hook.

    Hook умеет вмешиваться в процесс маршрутизации - когда мы перенаправляем пользователей согласно адресу на определенные функции нашего приложения. Hook похож на охранника проверяющего документы прежде чем пропустить дальше (или не пропустить).

    Нам нужно проверить залогинен ли пользователь. Если нет - отрендерить шаблон accessDenied вместо привычного postSubmit, а также остановить маршрут и не дать ему больше ничего сделать. Давайте перепишем наш 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

    Теперь создадим шаблон для страницы “Доступ запрещен”:

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

    Коммит 7-3

    Доступ запрещен к странице с новыми постами если юзер не …

    Если вы попробуете открыть адрес http://localhost:3000/submit/ и при этом не будете залогинены, вы увидите нашу новую страницу:

    Шаблон - доступ запрещен
    Шаблон - доступ запрещен

    Routing hooks хороши ещё тем, что они тоже реактивны. Это значит, что нам не нужно заботиться о навешивании обратных вызовов функций (callbacks) на авторизацию: если пользователь залогинится, шаблон страницы Роутера автоматически изменится с accessDenied на postSubmit - нам не нужно дополнительно писать код для этого (и между прочим, это сработает даже между разными вкладками браузера).

    Авторизируйтесь и попробуйте обновить страницу. Возможно вы успеете заметить страницу “Доступ запрещен” на краткое мгновение перед тем, как появится форма нового поста. Это все потому, что Meteor начинает рендерить шаблоны как можно раньше, еще до того, как приложение успело побеседовать с сервером и спросить насчет существования текущего пользователя (который пока что сохранен в local storage браузера).

    Чтобы избежать этой проблемы - а это распространенная проблема, с которой вы еще столкнетесь когда будете разбирать пикантные детали задержек передачи данных между клиентом и сервером - просто покажем экран загрузки на тот краткий момент, пока мы выясняем, если ли у текущего пользователя право создавать новые посты.

    В данный момент мы еще не знаем, напечатал ли пользователь корректно свой логин и пароль. И мы не можем показать шаблон accessDenied или postSubmit до тех пор, пока это не выяснится.

    Перепишем наш hook чтобы использовать шаблон загрузки страницы пока Meteor.loggingIn() возвращает true:

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

    Коммит 7-4

    Показываем экран загрузки пока проверяется логин

    Прячем линк

    Самый простой способ предотвратить попытки пользователей зайти на страницу по ошибке - это просто спрятать линк когда они не залогинены. Легко:

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

    Коммит 7-5

    Показывать ссылку на создание поста только когда пользова…

    Хелпер currentUser доступен для нас через пакет accounts и является в шаблонах handlebars тем же самым, что и вызов Meteor.user(). Так как он реактивен, линк будет появляться и исчезать на странице когда статус логин пользователя будет меняться.

    Метод Meteor: абстракция и безопасность на новом уровне

    Мы закрыли доступ к странице с новыми постами для неавторизированных пользователей, а также запретили создание новых постов через консоль браузера. Осталось еще несколько моментов:

    • Дата и время создания для каждого поста
    • Добавить проверку уникальности URL в каждом посте. Один и тот же URL нельзя запостить дважды.
    • Добавить детали автора поста (ID, имя пользователя, и все такое)

    Первая мысль вероятно будет что мы можем все это воплотить в нашем обработчике события submit. На практике это вызвало бы массу проблем:

    • Для даты и времени поста нам пришлось бы рассчитывать на дату-время компьютера пользователя, которая вполне может оказаться неверной.
    • Браузер не сможет знать про все посты когда-либо отправленные на сайт. Только текущие посты будут сохранены в локальной базе данных браузера (чуть позже мы разберем как это работает). Так что мы никак не сможем проверить уникальность поля URL в новом посте.
    • Наконец, даже если мы и могли бы добавить детали автора на клиенте, мы никак не смогли бы проверить их верность, что открыло бы дыру в безопасности для людей, использующих консоль браузера.

    Из-за всех этих причин нам стоит делать обработчики событий простыми, а если мы собираемся совершать более продвинутые операции с добавлением и редактированием коллекций, стоит использовать Метод.

    Метод Meteor - это функция на сервере, которую можно вызвать со стороны клиента. Пока что мы плохо с ними знакомы - хотя на самом деле, за кулисами, методы insert, update, remove наших коллекций все являются Методами. Давайте узнаем, как создать наш собственный Метод.

    Вернемся к файлу post_submit.js. Вместо того чтобы добавлять новый пост напрямую в коллекцию Posts, мы вызовем Метод под названием 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) {
          // отобразить ошибку пользователю и прерваться
          if (error)
            return alert(error.reason);
    
          Router.go('postPage', {_id: result._id});  
        });
      }
    });
    
    client/templates/posts/post_submit.js

    Функция Meteor.call вызовет Метод по имени своего первого аргумента. Вы можете добавить аргументы к этому вызову (в данном случае объект post, созданный из формы), и еще добавить callback-функцию, которая будет вызвана когда Метод на сервере закончит обработку.

    Callback-функции в Методах должны обладать двумя аргументами: error и result (для ошибки и результата соответственно). Если, по какой-либо причине, в error было что-то передано, мы известим об этом пользователя (с использованием return для выхода из функции). Если же всё сработало как надо (в error не было ничего передано) мы перенаправим пользователя на страницу обсуждения вновь созданного поста.

    Проверка безопасности

    Мы воспользуемся возможностью и добавим некоторую безопасность нашему методу с помощью пакета audit-argument-checks.

    Этот пакет даёт возможность проверить любой JavaScript объект с помощью предустановленного паттерна. В нашем случае, мы используем его чтобы убедиться что пользователь, вызывающий Метод, залогинен (проверив что Meteor.userId() является String) и что объект postAttributes, который передаётся Методу как аргумент, содержит строки title и url (иначе пользователи смогли бы вставлять любые данные в нашу БД).

    Теперь объявим Метод postInsert в файле collections/posts.js. Мы уберем блок allow() из posts.js, так как Методы Meteor игнорируют их в любом случае.

    Перед тем, как вставить запись в базу данных и вернуть _id в виде объекта клиенту (иначе говоря, callback-функции на клиенте), мы дополним объект postAttributes тремя дополнительными полями: _id пользователя и его username, а также полем submitted, которое будет содержать временной код создания поста.

    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
        };
      }
    });
    
    collections/posts.js

    Заметьте что _.extend() метод взят из библиотеки Underscore, и просто позволяет вам дополнить один объект свойствами другого.

    Коммит 7-6

    Используем Метод для создания нового поста

    Пока пока, allow/deny

    Обратите внимание что Методы выполняются на сервере, и Meteor предполагает что им можно доверять. Таким образом, Методы Meteor'а игнорируют любые allow/deny проверки.

    Если вы хотите вызывать код перед любой операцией insert, update, или remove даже на сервере, мы предлагаем вам ознакомиться с пакетом collection-hooks.

    Preventing Duplicates

    Мы сделаем ещё одну проверку перед тем, как закончить с нашим Методом. Если пост с таким же URL уже был добавлен, мы вместо добавления дубликата просто перенаправим пользователя на этот ранее созданный пост.

    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
        };
      }
    });
    
    collections/posts.js

    Мы ищем в нашей базе данных любые посты, с таким же URL. Если таковой найден, то мы возвратим его _id вместе с флагом postExists: true чтобы уведомить клиента об исключительной ситуации.

    А так как для возврата используется return, Метод на этом закончит выполнение и инструкция insert не будет вызвана, мы тем самым элегантно предотвращаем создание дубликатов.

    Всё что осталось - это использовать новый флаг postExists в нашем хелпере на клиенте, чтобы отобразить предупреждение.

    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

    Коммит 7-7

    Enforce post URL uniqueness.

    Сортируем посты

    Теперь, когда у каждого поста есть время-дата, мы можем упорядочить все посты по этому атрибуту. Для этого воспользуемся оператором Mongo sort, который ожидает в качестве аргумента объект, состоящий из названий полей, по которым нужно сортировать, и индикаторов направления сортировки.

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

    Коммит 7-8

    Сортируем объекты по времени создания

    Всё это заняло у нас некоторое время - но теперь у нас есть полноценный интерфейс для создания контента!

    Вдобавок к созданию контента пользователи захотят редактировать уже существующие посты, а также удалять их. Об этом и будет следующая глава.