KurocoとNuxt.jsで、コンテンツにコメント機能を追加する

Kurocoを利用したNuxt.jsプロジェクトで、アクティビティ:コメント機能の実装方法を紹介します。
今回は例として、下記流れにて処理を実装します。

  1. API・エンドポイントの作成
  2. ニュース詳細画面にコメント機能を追加

事前準備

Nuxt.jsプロジェクトの作成について

このページは、KurocoとNuxt.jsでのプロジェクトが構築済み、 かつ既に何らかの記事が閲覧できること、またprofileエンドポイントが有効であることを前提としています。
まだNuxt.jsプロジェクトを構築していない場合、チュートリアル ->KurocoとNuxt.jsで、コンテンツ一覧ページを作成するを参照してください。
またprofileなどはチュートリアル ->KurocoとNuxt.jsで、ログイン画面を構築するを参考にしてください。 今回はcookieによるログイン制御を前提とします。

作成済みの「お知らせ詳細」のエンドポイントをコピー、未ログインメンバーによるコメントの許可設定をした後、お知らせ詳細とそれに紐づくコメントのフォームを作成します。

APIの作成

未承認ユーザーからの操作を許可するために、新しくAPIを作成します。
Kuroco管理画面のAPIより「追加」をクリックします。
Image from Gyazo

API作成画面が表示されます。
Image from Gyazo

下記入力し「追加する」をクリックします。
Image from Gyazo

項目設定内容
タイトルComment Test
1.0
説明コメント機能の確認用API

CORSの設定

次にCORSの設定をします。[CORSを設定する] をクリックします。
Image from Gyazo

CORS_ALLOW_ORIGINSの [Add Origin] をクリックし、下記を追加します。

CORS_ALLOW_METHODSの [Add Method] をクリックし、下記を追加します。

  • GET
  • POST
  • OPTIONS

設定できたら[保存する]をクリックし、CORSの設定が完了です。
Image from Gyazo

ログイン不要でコメント機能の動作を確認する

アクティビティ定義の作成

未承認ユーザーからのコメント操作を許可するために、アクティビティ定義を編集します。
[アクティビティ定義]をクリックします。
Image from Gyazo

[追加する]をクリックします。
Image from Gyazo

APIリクエスト制限、投稿制限を閲覧可即公開として[追加する]をクリックします。
Image from Gyazo

後ほど利用するので、作成したアクティビティIDをメモしておきます。
Image from Gyazo

エンドポイントの作成

エンドポイントを作成します。今回は下記エンドポイントを作成します。

  • newsdetailエンドポイント -> ニュース詳細
  • commentsエンドポイント -> コメントの取得用
  • commentエンドポイント -> コメントの追加用
  • comment_deleteエンドポイント -> コメントの削除用

Comment TestのAPIで「新しいエンドポイントの追加」をクリックし、それぞれ作成します。
Image from Gyazo

newsdetailエンドポイントの作成

newsdetailエンドポイントを下記設定にて作成します。

Image from Gyazo

項目設定内容
パスnewsdetail
カテゴリーコンテンツ
モデルTopics v1
オペレーションdetails
APIリクエスト制限None
topics_group_id表示するコンテンツ定義ID(9)

設定完了後、「追加する」をクリックしnewsdetailエンドポイント完成です。

commentsエンドポイントの作成

commentsエンドポイントを下記設定にて作成します。
Image from Gyazo

項目設定内容
パスcomments
カテゴリーアクティビティ
モデルComment v1
オペレーションlist
APIリクエスト制限None
idアクティビティID(37)
module_typetopics
new_order_flgチェックを入れる

設定完了後、「追加する」をクリックしcommentsエンドポイント完成です。

commentエンドポイントの作成

commentエンドポイントを下記設定にて作成します。
Image from Gyazo

項目設定内容
パスcomment
カテゴリーアクティビティ
モデルComment v1
オペレーションinsert
APIリクエスト制限None
idアクティビティID(37)

設定完了後、「追加する」をクリックしcommentエンドポイント完成です。

comment_deleteエンドポイントの作成

comment_deleteエンドポイントを下記設定にて作成します。
Image from Gyazo

項目設定内容
パスcomment_delete
カテゴリーアクティビティ
モデルComment v1
オペレーションdelete
APIリクエスト制限None
idアクティビティID(37)

設定完了後、「追加する」をクリックしcomment_deleteエンドポイント完成です。

今回の例では、コメントの追加/削除するエンドポイントはセキュリティNoneにしていますが、実際のサイトではコメントの追加/削除を許可するグループを設定してください。

フロントエンドの設定

コメント機能付きニュース詳細ページの追加

今回は既存のニュース詳細画面を参考に画面を作成し、同ページにコメント機能を追加します。
まずは未承認のユーザーでもコメントの閲覧/投稿がきるようにし動作の確認をします。

画面を表示する際にニュースへ紐づく全てのコメントを取得し、表示します。

ユーザー名入力欄とコメント投稿のフォームが存在し、ユーザー名入力のあと、追加したコメントは即時に画面へ反映されます。
またコメントそれぞれには削除ボタンが存在します。

コメントの削除はコメントの投稿・削除にパスワード(delkey)を使用するように構築するか、ログインを前提として、コメントを投稿した本人でないとできません。
この段階では削除ボタンをクリックした場合、Code 422 "パスワードが一致しないか、書き込んだ本人でないため削除できません。" のエラーが発生します。

pages/news/test_with_comment.vueを追加します。

<template>
  <div>
    <h1 class="title">{{ response.details.subject }}</h1>
    <div class="post" v-html="response.details.contents"></div>
    <p v-if="resultMessage !== null">
      {{ resultMessage }}
    </p>
    <div>
        please type your name: <input v-model="userName" type="text" placeholder="your name">
    </div>
    <div>
        <ul v-for="comment in comments" :key="comment.comment_id">
            <li>
                {{ comment.note }} by {{ comment.name }}
                <button type="button" @click="() => deleteComment(comment.comment_id)">
                    delete
                </button>
            </li>
        </ul>
        <form @submit.prevent="submitComment">
            <input v-model="inputComment" type="text" placeholder="comment">
            <button type="submit" :disabled="inputComment === '' || userName === ''">
                submit
            </button>
        </form>
    </div>
  </div>
</template>

<script>
async function getAllComments (topics_id) {
    const { list } = await this.$axios.$get(
        '/rcms-api/21/comments',
        {
            params: {
                module_id: topics_id,
                cnt: 999
            }
        }
    )
    return list
}

export default {
  async asyncData({ $axios, params }) {
    try {
      const response = await $axios.$get(
        '/rcms-api/21/newsdetail/1047'
      )
      return { response, comments: await getAllComments.call({ $axios }, response.details.topics_id) }
    } catch (e) {
      console.log(e.message)
    }
  },
  data () {
      return {
          userName: '',
          response: null,
          comments: [],
          inputComment: '',
          resultMessage: null,
      }
  },
  methods: {
    async submitComment () {
        await this.$axios.$post('/rcms-api/21/comment', {
            module_id: this.response.details.topics_id,
            name: this.userName,
            note: this.inputComment
            })
            this.comments = await getAllComments.call(this, this.response.details.topics_id)
            this.inputComment = ''
    },
    async deleteComment (commentId) {
      try{
        await this.$axios.$post(`/rcms-api/21/comment_delete/${commentId}`, {})
        this.comments = await getAllComments.call(this, this.response.details.topics_id)
        this.inputComment = ''
      } catch (error) {
         this.resultMessage = error.response.data.errors[0].message
      }
    }
  }
}
</script>

以下のようにコメントの追加ができることを確認します。
Image from Gyazo

コメントを削除可能にする(delkeyの使用)

未承認のユーザーでもコメントの削除ができるようにするため、delkeyを使用して削除をリクエストするように変更します。

delkeyはコメントを追加する際に付与できる任意の値です。
今回は以下の仕様で実装します。

  • コメントを追加する段階でdelkeyを自動的に付与
  • delkeyはブラウザのローカルストレージに保存
  • 削除時にブラウザのローカルストレージからdelkeyを呼び出す

ブラウザへの保存はローカルストレージを使用するため、ブラウザの変更や異なる端末で操作再開する場合には削除できない点にご注意ください。

pages/news/test_with_comment.vueを下記のように変更します。

@@ -1,85 +1,106 @@
 <template>
   <div>
     <h1 class="title">{{ response.details.subject }}</h1>
     <div class="post" v-html="response.details.contents"></div>
     <p v-if="resultMessage !== null">
       {{ resultMessage }}
     </p>
     <div>
         please type your name: <input v-model="userName" type="text" placeholder="your name">
     </div>
     <div>
         <ul v-for="comment in comments" :key="comment.comment_id">
             <li>
                 {{ comment.note }} by {{ comment.name }}
-                <button type="button" @click="() => deleteComment(comment.comment_id)">
+                <button v-if="commentHistory.map(({ id }) => id).includes(comment.comment_id)" type="button" @click="() => deleteComment(comment.comment_id)">
                     delete
                 </button>
             </li>
         </ul>
         <form @submit.prevent="submitComment">
             <input v-model="inputComment" type="text" placeholder="comment">
             <button type="submit" :disabled="inputComment === '' || userName === ''">
                 submit
             </button>
         </form>
     </div>
   </div>
 </template>

 <script>
 async function getAllComments (topics_id) {
     const { list } = await this.$axios.$get(
         '/rcms-api/21/comments',
         {
             params: {
                 module_id: topics_id,
                 cnt: 999
             }
         }
     )
     return list
 }

+const COMMENT_HISTORY_KEY = 'CommentHistory'
+
 export default {
   async asyncData({ $axios, params }) {
     try {
       const response = await $axios.$get(
         '/rcms-api/21/newsdetail/1047'
       )
       return { response, comments: await getAllComments.call({ $axios }, response.details.topics_id) }
     } catch (e) {
       console.log(e.message)
     }
   },
   data () {
       return {
           userName: '',
           response: null,
           comments: [],
           inputComment: '',
+          commentHistory: [],
           resultMessage: null,
       }
   },
+  mounted () {
+        this.commentHistory = JSON.parse(localStorage.getItem(COMMENT_HISTORY_KEY)) || []
+  },
   methods: {
     async submitComment () {
-        await this.$axios.$post('/rcms-api/21/comment', {
+         const delkey = `${this.userName}_${Date.now()}`
+         const submitResponse = await this.$axios.$post('/rcms-api/21/comment', {
             module_id: this.response.details.topics_id,
             name: this.userName,
-            note: this.inputComment
+            note: this.inputComment,
+            delkey
             })
+            this.addCommentHistory({ id: submitResponse.id, delkey })
             this.comments = await getAllComments.call(this, this.response.details.topics_id)
             this.inputComment = ''
     },
     async deleteComment (commentId) {
       try{
-        await this.$axios.$post(`/rcms-api/21/comment_delete/${commentId}`, {})
+        await this.$axios.$post(`/rcms-api/21/comment_delete/${commentId}`, {
+            delkey: this.commentHistory.find(({ id }) => `${id}` === `${commentId}`).delkey
+        })
+        this.deleteCommentHistory(commentId)
         this.comments = await getAllComments.call(this, this.response.details.topics_id)
         this.inputComment = ''
       } catch (error) {
          this.resultMessage = error.response.data.errors[0].message
       }
+    },
+    addCommentHistory (payload) {
+        const restored = JSON.parse(localStorage.getItem(COMMENT_HISTORY_KEY)) || []
+        restored.push(payload)
+        localStorage.setItem(COMMENT_HISTORY_KEY, JSON.stringify(restored))
+        this.commentHistory = restored
+    },
+    deleteCommentHistory (commentId) {
+        const restored = JSON.parse(localStorage.getItem(COMMENT_HISTORY_KEY)) || []
+        const filtered = restored.filter(({ id }) => `${id}` !== `${commentId}`)
+        localStorage.setItem(COMMENT_HISTORY_KEY, JSON.stringify(filtered))
+        this.commentHistory = filtered
     }
   }
 }
 </script>

以下のようにコメント済みのもののみ削除ボタンが表示して削除ができることをを確認します。
Image from Gyazo

コメントをログイン必須にする

コメント機能のような変更/削除のためのPOSTエンドポイントを無施策に開放してしまうと、DOM攻撃(1万件一気にPOST->DBがパンクして落ちる)に弱くなります。
Kurocoのエンドポイントでは、同じIPアドレスから短時間に何回もコメントをされた場合に、投稿を受け付けないエラーを返す機能を持っていますが、本チュートリアルではページの閲覧、コメントの表示・投稿・削除にログインを必須とさせるように実装します。

アクティビティ定義の更新

[アクティビティ定義]をクリックします。
Image from Gyazo

作成したアクティビティのタイトルをクリックします。
Image from Gyazo

未ログインメンバーのAPIリクエスト制限、投稿制限を閲覧不可受け付けないとして[更新する]をクリックします。
Image from Gyazo

フロントエンドの更新

pages/news/test_with_comment.vueを次のように修正します。

<template>
  <div>
    <h1 class="title">{{ response.details.subject }}</h1>
    <div class="post" v-html="response.details.contents"></div>
    <div>
        <p v-if="resultMessage !== null">
          {{ resultMessage }}
        </p>
        <ul v-for="comment in comments" :key="comment.comment_id">
            <li>
                {{ comment.note }} by {{ comment.name }}
                <button type="button" @click="() => deleteComment(comment.comment_id)">
                    delete
                </button>
            </li>
        </ul>
        <form @submit.prevent="submitComment">
            <input v-model="inputComment" type="text" placeholder="comment">
            <button type="submit" :disabled="inputComment === ''">
                submit
            </button>
        </form>
    </div>
  </div>
</template>

<script>
async function getAllComments (topics_id) {
    const { list } = await this.$axios.$get(
        '/rcms-api/21/comments',
        {
            params: {
                module_id: topics_id,
                cnt: 999
            }
        }
    )
    return list
}

import { mapActions } from 'vuex'
export default {
  middleware: 'auth',
  async asyncData ({ $axios, params }) {
    try {
      const profile = await $axios.$get('/rcms-api/18/profile')
      const response = await $axios.$get(
        '/rcms-api/21/newsdetail/1047'
      )
      return { profile, response, comments: await getAllComments.call({ $axios }, response.details.topics_id) }
    } catch (e) {
      console.log(e.message)
    }
  },
  data () {
      return {
          userName: '',
          response: null,
          comments: [],
          inputComment: '',
          resultMessage: null,
      }
  },
  methods: {
    async submitComment () {
        await this.$axios.$post('/rcms-api/21/comment', {
            module_id: this.response.details.topics_id,
            name: `${this.profile.name1} ${this.profile.name2}`,
            mail: this.profile.email,
            note: this.inputComment
            })
            this.comments = await getAllComments.call(this, this.response.details.topics_id)
            this.inputComment = ''
    },
    async deleteComment (commentId) {
      try{
        await this.$axios.$post(`/rcms-api/21/comment_delete/${commentId}`, {})
        this.comments = await getAllComments.call(this, this.response.details.topics_id)
        this.inputComment = ''
      } catch (error) {
         this.resultMessage = error.response.data.errors[0].message
      }
    }
  }
}
</script>

最後に動作確認をして完了です。
Image from Gyazo

お探しのページは見つかりましたか?解決しない場合は、問い合わせフォームからお問い合わせいただくか、Slackコミュニティにご参加ください。