ニフクラmBaaSお役立ちブログ

スマホアプリ開発にニフクラmBaaS。アプリ開発に役立つ情報をおとどけ!

mBaaSを使ってブログを作る(その3)「編集画面/権限を追加する」

mBaaSをモバイルではなくバックエンドのデータベースとして活用する記事になります。前回で認証機能まで作りましたので、今回はそれを使って権限管理や編集機能を作ってみたいと思います。

今回実装するのは以下の通りです。

  1. 編集画面
  2. 更新機能
  3. アクセス制限

編集画面を作る

ブログ記事の編集機能を実装します。これは GET /posts/:objectId/edit というURLにアクセスした際に実行されるようにします。/routes/posts.js を編集します。処理としては記事詳細を表示する処理と殆ど変わりません。

router.get('/:objectId/edit', function(req, res, next) {
  let Post = ncmb.DataStore('Post');
  Post.equalTo('objectId', req.params.objectId)
    .fetch()
    .then(function(post) {
      res.render('posts/edit', { post: post });
    });
});

これでも良いのですが、このままでは編集権限がないユーザがアクセスすると記事詳細が返ってこないために空っぽのフォームが表示されてしまいます。

これを防ぐため、セッショントークンを適用したいと思います。この手の処理は他でも必要なのでライブラリにまとめることにします。 /libs/common.js を作成します。

module.exports = {
  setSessionToken: function(req, ncmb) {
    if (req.session.currentUser) {
      var currentUser = new ncmb.User(req.session.currentUser);
      if (currentUser) {
        ncmb.sessionToken = currentUser.sessionToken;
        ncmb.currentUser = currentUser;
      }
    }
  }
};

セッション情報があれば、それを使ってncmbオブジェクトにセッショントークンを適用します。これを同じように使う場所で呼び出すようにします。今回の GET /posts/:objectId/edit では次のようになります。

var common = require('../libs/common');
  :
router.get('/:objectId/edit', function(req, res, next) {
  common.setSessionToken(req, ncmb); // 追加
  let Post = ncmb.DataStore('Post');
  Post.equalTo('objectId', req.params.objectId)
    .fetch()
    .then(function(post) {
      res.render('posts/edit', { post: post });
    });
});

これで権限に応じたデータが取得できるようになります。

続いて編集画面を作ります。なお、この画面は新規作成画面とほぼ同じなので共通部品を include で切り出したいと思います。

<!DOCTYPE html>
<html>
  <head>
    <title>投稿の編集</title>
    <link rel='stylesheet' href='/vendors/bootstrap/dist/css/bootstrap.min.css' />
  </head>
  <body>
    <div class="container">
      <div class="row">
        <div class="col-md-8 col-md-offset-2">
          <h1>投稿の編集</h1>
          <p>ブログ記事を編集します</p>
          <form id="post_edit_form">
            <input type="hidden" name="objectId" value="<%= post.objectId %>" />
            <!-- 切り出し -->
            <%- include('form', {post: post}) %>
          </form>
        </div>
      </div>
    </div>
    <script src='/vendors/jquery/dist/jquery.min.js'></script>
    <script src='/vendors/bootstrap/dist/js/bootstrap.min.js'></script>
    <script src='/javascripts/app.js'></script>
  </body>
</html>

form.ejsは次の通りです。

<div class="form-group">
  <label for="inputTitle">記事のタイトル</label>
  <input type="text" class="form-control" id="inputTtitle" name="title" placeholder="Post title" value="<%= post.title %>">
</div>
<div class="form-group">
  <label for="inputBody">記事の内容</label>
  <textarea id="body" class="form-control" cols="50" rows="10" name="body"><%= post.body %></textarea>
</div>
<div class="checkbox">
  <label>
    <input type="checkbox" name="public" value="true" <%= post.public ? 'checked' : '' %>> 公開する
  </label>
</div>
<button type="submit" class="btn btn-default"><%= post.objectId ? "更新" : "投稿" %></button>

これに伴って views/posts/new.ejs も次のように変更します。

<!DOCTYPE html>
<html>
  <head>
    <title>新規投稿</title>
    <link rel='stylesheet' href='/vendors/bootstrap/dist/css/bootstrap.min.css' />
  </head>
  <body>
    <div class="container">
      <div class="row">
        <div class="col-md-8 col-md-offset-2">
          <h1>新規投稿</h1>
          <p>新しいブログ記事を書きます</p>
          <form id="post_form">
            <%- include('form', {post: {}}) %>
          </form>
        </div>
      </div>
    </div>
    <script src='/vendors/jquery/dist/jquery.min.js'></script>
    <script src='/vendors/bootstrap/dist/js/bootstrap.min.js'></script>
    <script src='/javascripts/app.js'></script>
  </body>
</html>

postを空のオブジェクトとして渡すことで空のフォームを表示できるようにしています。

これで編集フォームが表示できるようになりました。

更新機能を作る

次に更新機能を実装します。これは PUT /posts/:objectId に対して行います。まずWebブラウザ側を実装するので、public/javascripts/app.js を編集します。 this.elements.objectId.value には更新対象のobjectIdが入ります。

$('#post_edit_form').on('submit', function(e) {
  e.preventDefault();
  sendForm({url: '/posts/'+this.elements.objectId.value, method: 'PUT', form: $(this).serialize()})
 });

なお、悪意のあるユーザによって他のobjectIdを指定して更新されるといった問題はよくありますが、mBaaSの場合objectIdごとに権限設定ができていますので不用意な更新を避けることができます。

次にサーバ側の機能を実装します。この機能は記事の新規作成時と似ているので関数にまとめます。具体的な違いとして、新規作成時には post.save が、更新時には post.update が呼ばれるという点があります。

router.post('/', function(req, res, next) {
  common.setSessionToken(req, ncmb); // 先ほど作ったメソッドでセッショントークンを指定
  post = buildPost(req);  // まとめた部分
  post.save()
    .then(function(obj) {
      res.status(201).json(obj);
    })
    .catch(function(err) {
      res.status(401).json(err);
    })
});

router.put('/:objectId', function(req, res, next) {
  common.setSessionToken(req, ncmb); // 先ほど作ったメソッドでセッショントークンを指定
  post = buildPost(req); // まとめた部分
  post.update()
    .then(function(obj) {
      res.status(200).json(obj);
    })
    .catch(function(err) {
      res.status(401).json(err);
    })
});

function buildPost(req) {
  // ユーザの作成
  var currentUser = new ncmb.User(req.session.currentUser);
  let Post = ncmb.DataStore('Post');
  var post = new Post;
  post.set('objectId', req.params.objectId);
  post.set('title', req.body.title);
  post.set('body', req.body.body);
  var acl = new ncmb.Acl;
  post.set('public', req.body.public || false);
  if (req.body.public) {
    acl.setPublicReadAccess(true);
  }
  acl.setUserReadAccess(currentUser, true);
  acl.setUserWriteAccess(currentUser, true);
  post.set('acl', acl);
  
  return post;
}

これで編集機能が完成です。

アクセス制限する

今回の場合、記事を表示する機能は全ユーザに付与されますが、更新権限は記事の作成者に限定されます。そこで、権限がないユーザがアクセスした場合はログイン画面にリダイレクトするようにします。

修正点としては app.js になります。

var checkAuth = express.Router();
checkAuth.use(function(req, res, next) {
  if (req.method === 'GET' && req.url.match(/^\/[a-zA-Z0-9]$/)) {
    return next()
  }
  if (typeof req.session.currentUser === 'undefined')
    return res.redirect(`/login?path=${encodeURI(req.url)}`);
  next();
});

app.use('/posts', checkAuth, posts); // checkAuthを追加

GET /posts/:objectId へのアクセスは許可しますが、それ以外のアクセスにおいてログインしていないと判断された場合は /login へリダイレクトします。問題がない場合は next() を実行して次の処理につなぎます。

GET /login へのアクセス時には元々のパスを付与するようにします。こうすることでログイン処理が終わったら元の画面に戻ってこれるようになります。その際、クエリー文字列を扱いやすくするため、 websanova/js-url: url() - A simple, lightweight url parser for JavaScriptをインストールします。

$ bower install js-url --save

そして、これを views/sessions/new.ejs にて読み込みます。

<script src='/vendors/js-url/url.min.js'></script>

さらに public/javascripts/app.js のログイン処理を修正します。

$("#login_form").on('submit', function(e) {
  e.preventDefault();
  sendForm({url: '/sessions', form: $(this).serialize()}, function() {
    var path = url('?path');
    if (path && path.match(/^\/.*/)) {
      location.href = path;
    }
  });
});

これで権限を踏まえたアクセス制限処理ができあがりました。

一覧画面の変更

最後に一覧画面において、編集権限を持つ記事は編集ボタンを出すようにします。これは各記事の状態を判断して出すものになります。このために、まずは models/post.js を作成します。

module.exports = function(ncmb) {
  var Post = ncmb.DataStore('Post');
  Post.prototype.editable = function() {
    if (typeof ncmb.sessionToken === 'undefined') {
      return false;
    }
    if (this.acl['*'] && this.acl['*'].write)
      return true;
    objectId = ncmb.currentUser.objectId;
    if (this.acl[objectId] && this.acl[objectId].write)
      return true;
    return false;
  }
  return Post;
}

これはあらかじめデータストアのPostを作成し、それを Prototype ベースで拡張するものです。これにより、Postのインスタンスに対してeditableメソッドが追加されます。

そしてこれを routes/index.js で読み込みます。

var common = require('../libs/common');
var Post   = require('../models/post')(ncmb); // NCMBを引数で渡します

/* GET home page. */
router.get('/', function(req, res, next) {
  // ここにあったvar Post = ncmb.DataStore("Post")は削除します
  common.setSessionToken(req, ncmb);
  Post.order('createDate', true).fetchAll()
    .then(function(posts) {
      res.render('index', { posts: posts, currentUser: req.session.currentUser, Post: Post});
    })
});

編集権限があるかどうかを post.editableというメソッドで判定できます。一覧表示を行う views/index.ejs においては次のように変更します。

<% for (var i = 0; i < posts.length; i++) { %>
  <% var post = posts[i]; %>
  <div class="row">
    <div class="col-md-10">
      <h2><a href="/posts/<%= post.objectId %>"><%= post.title %></a>
        <% if (!post.public) {%>
          <span class="label label-default">未公開</span>
        <% } %>
      </h2>
    </div>
    <div class="col-md-2">
      <% if (post.editable()) {%>
        <a href="/posts/<%= post.objectId %>/edit" class="btn btn-default">編集</a>
      <% } %>
    </div>
  </div>
<% } %>

post.editable() で編集権限があるかどうかを判定できます。このようにしてデータストアの拡張が可能です。

テンプレートの再利用

テンプレートでは同じようなヘッダー/フッターを使っていますのでincludeを使ってまとめてしまって良いでしょう。例えばログイン画面(views/sessions/new.ejs)を例に取ると次のようになります。

views/shared/header.ejs

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
    <link rel='stylesheet' href='/vendors/bootstrap/dist/css/bootstrap.min.css' />
  </head>
  <body>
    <div class="container">

views/shared/footer.ejs

    </div>
    <script src='/vendors/jquery/dist/jquery.min.js'></script>
    <script src='/vendors/bootstrap/dist/js/bootstrap.min.js'></script>
    <script src='/vendors/js-url/url.min.js'></script>
    <script src='/javascripts/app.js'></script>
  </body>
</html>

views/sessions/new.ejs

<%- include('../shared/header', {title: "ログイン"}) %>
<div class="row">
  <div class="col-md-8 col-md-offset-2">
    <h1>ログイン</h1>
    <p>IDとパスワードを入力してください</p>
    <form id="login_form">
      <div class="form-group">
        <label for="inputUserName">ユーザID</label>
        <input type="text" class="form-control" id="inputUserName" name="userName" placeholder="ユーザID" />
      </div>
      <div class="form-group">
        <label for="inputPassword">パスワード</label>
        <input type="password" id="password" class="form-control" name="password" />
      </div>
      <button type="submit" class="btn btn-default">ログイン</button>
    </form>
  </div>
</div>
<%- include('../shared/footer') %>

このように置き換えれば再利用性が高まるでしょう。


編集権限を使う際はセッショントークンとACLを使って管理できます。セキュアに更新できるユーザを制限できますので、サーバサイドなどでありがちな処理を書かなくとも良くなります。ぜひ使ってみてください。

今回のコードは NCMBMania/ncmb_blog_nodejs にアップしてあります。参考にしてください。