8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

開発初心者が Nuxt.js + Firebase + WooCommerce + Apps Script で同人イベントの電子チケットアプリを約1週間で作った話

Last updated at Posted at 2020-12-24

こちらの記事は Nuxt.js Advent Calendar 2020 25日目の記事です。
24日目は @nagachan1192 さんの「nuxtで作ったアプリをtwa化してandroidのplaystoreでリリースするまで」ですが、まだ投稿されていないみたいです。そもそもTWAというのを初めて聞きましたね、最近はこんなことまでできるのか...自分も試してみようかな...

自己紹介

はじめまして、かずえもんと申します。

歌・ピアノ・配信・動画編集・MIX・プログラミング・デザイン・Web制作など色々やってる裏方好きで多趣味な18歳の高校3年生です。
「東京都 新型コロナウイルス感染症対策サイト」にCSSを1行だけ書いて「LGTMです!」って言われて喜んでたら、後日デザインが変わってて跡形すら残っていないというのが今年のハイライトです。
アドベントカレンダー投稿は初で、どんな書き方がいいのか分かんない状態ですが、開発記録的な感じで書いていこうかと思います。

TL;DR

myfes2020_ticketapp_demo.gif

これを(実質的なコーディング期間)1週間で作ったよっていう記録と振り返りです。
少し長くなりますが、最後まで読んでいただければ幸いです。

注意事項

  • 開発初心者なので、冗長な設計、数々のアンチパターンやウンコードを多用しております。
    見てストレスを感じたら即ブラウザバックをオススメします。
  • 運営の内部情報を少し書いておりますが、運営トップから掲載許可を頂いてるのでご安心ください。

まず マイフェス2020 とは?

image.png

公式サイト: https://myfes.npjp.net
キービジュアル: 立花でこ

「マイフェス2020」は世界各国、様々な年代の方に遊ばれているゲーム「Minecraft(マインクラフト)」のユーザー(クラフター)による日本最大級のオンリー同人イベントです!

(気になる方はチェックしてみてください、書きすぎると宣伝になっちゃうので:thinking:)

開発をすることになったきっかけ

マイフェスは今年で2回目なのですが、昨年開催時は 200枚近い電子チケットのデータ反映を開催前日に全手動で行っていた らしいんですね。

…いやおかしいでしょ!

ってことで、チケットを購入してからチケットを表示するまでのフローをもっと効率的にしようぜという事で私がやることになりました。
image.png

...ということで以下、チケシス開発のために集められた精鋭たちをご紹介します。

  • PM: わたし
  • デザイン: わたし
  • プログラム: わたし
  • 運用: わたし
  • コードレビュー: いない

うん、1人ですね。
精鋭でもないし、チームですらない。

技術選定

ワイ「うーん、とりあえずフロントエンドは前少し触ったNuxt.jsにしようかな」

ワイ「ショップは元からWooCommerceで構築されてるから、新しく作ってIDとパス送るのめんどくさいし、アカウント管理はWP OAuth Serverでいいかな。@nuxtjs/authも使って楽しよう」

ワイ「手動でごにょごにょするのめんどくさいからWebhookで自動発行させよう」

ワイ「チケットデータをWPに置くのはめんどくさいから、とりあえずFirestoreにでもぶっこんどくか」

ワイ「バックエンドはCloudFunctionsでもいいけど...NuxtのserverMiddleware使ってみたいな」

ワイ「ついでにチケットの使用状況とか見やすくしておきたいからGoogleスプレッドシートにでも表記させるか」

ワイ「オンプレは管理大変だからHerokuにでもデプロイしとこう」

ワイ「チケット発行お知らせメールは既に使ってるmailgunで配信しよう」

突如漂う某太郎さん感ですが、実際こういうノリで技術選定しました。
仕様書は作ってません、私が仕様書だ。

(´-`).。oO( いや、ダメだろ )

いざ開発開始

...とさっそく躓いた

image.png
Nuxtかきかき開始...の直後に @nuxtjs/auth の挙動が思ったものと違うことに気が付きました。

ドキュメント には

Each endpoint is used to make requests using axios. They are basically extending Axios Request Config.

と書いてあるので「tokenとかにはaxiosのリクエストコンフィグをぶち込めるのか!」と(多分)勘違いして

scheme: 'oauth2',
endpoints: {
  token: {
    url: 'https://[わーどぷれすのあどれす]/oauth/token',
    // Axiosの設定に準拠していろいろかいた
  }
}

としたのですが、どうしてもアクセス先が http://localhost:3000/[object%20object] になってしまうんですね、まあ何となく察しました。

それで 実装 を確認してみたところ、どうやらschemeoauth2を選択している場合はリクエスト先のアドレスしか入れられないことが判明。

axiosの設定をpluginsでごにょごにょすれば全く問題ないのかもしれないのですが、いろいろ大変そうだと感じて @nuxtjs/auth を外して、自前実装することにしました。

ちなみに後々わかったのですが、この時点で自分の思っているOAuth実装は間違っていたので、そのままでもたぶんできたと思います。

直後にテスト期間突入

12月7日~11日までは期末テストだったので作業はしていませんが、OAuthの実装を勉強するなどしていました。

(´-`).。oO( え、テスト勉強は? )

OAuthクライアントを自前実装する

テスト期間中にしっかりとOAuthを勉強したので、ここからクライアントを自前で実装していきます:muscle:

(´-`).。oO( テスト勉強って何だろう... )

① ログイン処理用ページに飛ばす。

pages/login.vue
<!-- ログイン用処理ページに飛ばす -->
<a class="button button--big button__primary" href="/api/auth/authorize">
  ログイン
</a>

② stateを生成してからcookieに保存させ、クエリに付与して認証ページに飛ばす。
(今考えるとstate保持の時間長すぎたなと思ってます)

api/auth.js
const randomString = require('randomstring');
app.get('/authorize', async function (req, res){

  // ランダムな文字列を生成
  let state = randomString.generate();
  res.status(302);
  // state用にクッキーを付与する
  res.cookie('token1' , state, {
    maxAge: 60 * 60 * 1000,
    domain: process.env.APP_DOMAIN,
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production' ?  true : false,
  });
  // 認証ページに飛ばす
  res.header(
    'Location',
    'https://[わーどぷれすのあどれす]/oauth/authorize?client_id=' + process.env.WP_OAUTH_CLIENT_ID + '&response_type=code&state=' + state
  )
  res.end();
});

③ コールバックしてきたら、serverMiddleware上にあるトークン取得APIを叩く。

pages/callback.vue
<script>
export default {
  name: "callback",
  async mounted(){
    try {
      const res = await this.$axios({
        method: 'post',
        url: '/api/auth/token',
        data: {
          code: this.$route.query.code,
          state: this.$route.query.state
        },
        withCredientials: true
      })
      this.$router.replace({
        path: '/'
      });
    } catch (e) {
      this.$nuxt.error({
        statusCode: e.response.data.error.code,
        message: e.response.data.error.message
      })
    }
  }
}
</script>

④stateが正しいかどうかを検証し、既存のアクセストークンがあれば破棄。
取得したcodeを用いてアクセストークンを取得、Cookieに付与。

api/auth.js
app.post('/token', async function (req, res) {

  // stateトークンがセットされているか確認する
  if (!req.cookies.token1) {
    return res.status(400).json({
      error: {
        code: 400,
        message: '正常にログイン処理ができませんでした。Cookieが有効になっているかどうか確認してください。'
      }
    })
  }

  // stateトークンの検証
  if (req.cookies.token1 !== req.body.state){
    console.error(
      '不正なリクエストを検知しました / Cookie: ' + req.cookies.token1 + ' / State: ' + req.body.state
    )
    return res.status(400).json({
      error: {
        code: 400,
        message: 'アクセスの検証に失敗しました。再度お試しください。'
      }
    });
  }

  // 既にアクセストークンがCookieにある場合は無効にする
  if (req.cookies.token2) {
    await axios({
      method: 'post',
      url: 'https://[わーどぷれすのあどれす]/oauth/revoke',
      data: {
        token: req.cookies.token2
      }
    }).catch(function (err) {
      console.error("トークン無効化処理失敗");
      console.error(err.response.data.error);
    })
  }

  // アクセストークン取得処理
  try {
    const res2 = await axios({
      method: 'post',
      url: 'https://[わーどぷれすのあどれす]/oauth/token',
      data: {
        grant_type: "authorization_code",
        code: req.body.code,
        client_id: process.env.WP_OAUTH_CLIENT_ID,
        client_secret: process.env.WP_OAUTH_CLIENT_SECRET,
        state: req.body.state
      }
    });
    res.cookie('token2' , res2.data.access_token, {
      maxAge: 60 * 60 * 1000,
      domain: process.env.APP_DOMAIN,
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production' ?  true : false,
    });
    res.sendStatus(200);
  } catch (e) {
    if (e.response) {
      var errres = {
        code: 500,
        message: '不明なエラーが発生しました。運営チームにお問い合わせください。'
      };
      switch (e.response.data.error){
        case 'invalid_request':
        case 'invalid_grant':
          errres.code = 400;
          errres.message = '認証に失敗しました。お手数ですが、初めからやり直してください。';
          break;
      }
      return res.status(errres.code).json({
        error: errres
      })
    } else {
      return res.status(500).json({
        error: {
          code: 500,
          message: 'サーバー側でエラーが発生しました。何度も発生する場合は運営チームにお問い合わせください。'
        }
      });
    }
  }
});

とまあこんな感じです。
一応この実装はWPOAuthのアクセストークンをそのままセッション管理に用いているので、恐らくアンチパターンなのではないかと思います。
securehttpOnlyなCookieにはしていますが。

結論から言います。普通のログインと同じように、認証できた段階でセッション ID cookie を発行してセッションを確立してください。
そして、そのセッションの有効期限を AT の有効期限と一致するように設定しましょう。

決して、自前のセッション管理を放棄しないでください。セッション管理を OAuth サーバーに押し付けないでください。リクエストの度に AT がまだ有効かどうかを確かめるためだけにプロフィール API を叩きに行ったりしないでください。お願いです。許してください。AT は、リソースにアクセスするための鍵であって、何かの有効性を管理するためのものではありません…。
引用: OAuth 認証を真面目に考える

ただ、一般ユーザーは権限がないのでWooCommerceのAPIは叩けないし 1、Cookieの有効期限をアクセストークンの有効期限に設定していてアクセスのたびにOAuthのエンドポイントを叩く必要はないので2、とりあえずこれで行くことにしました。

リフレッシュはめんどくさい期間がなかったので実装してません。

ちなみに、エラー時のレスポンスは WebAPIでエラーをどう表現すべき?15のサービスを調査してみた を参考に、HTTPステータスコードとそのままエラー画面に表示可能なメッセージを含める形にしました。

Herokuの環境を構築する

デプロイ先は前述の通り Heroku です。
gitの master リポジトリを自動でデプロイするようにしてあります。

当初はnuxtのserverMiddlewareではなくCloud Functionsを使う予定だったので、Nuxtはリポジトリのルートにおいてなかったのですが
image.png

Herokuは(多分)CUI以外からディレクトリを指定してデプロイできないようです。
なので subdir-heroku-buildpack を導入してBuildPacksを以下のように設定して

image.png

環境変数 PROJECT_PATH にディレクトリ名を指定してあげます。

image.png

ログを確認すると正しくデプロイできているようです。

-----> Subdir buildpack app detected
-----> Subdir buildpack in mf2020-ticket-app

Googleが落ちたぞ!!

GASでWooCommerceのWebhookを書いている間に「あれ、作業用に流してたYoutube止まってるやん、いつもの『再生を継続しますか』かな?」と思っていたら

2020-12-14 20_52_15-Oops.png

_人人人人人人人_
> 突然のサル <
 ̄Y^Y^Y^Y^Y^Y ̄

2020-12-14 21_00_47-無題のプロジェクト.png

_人人人人人人人_
GASも死んだ <
 ̄Y^Y^Y^Y^Y^Y ̄

ワイ「...とはいいつつ、これはもう作業できんな。今からフロントやるの面倒くさいし、寝るか。」

_人人人人_
復旧早っ
 ̄Y^Y^Y^Y ̄

さすが天下のGoogle様です。

デザインをする

例によって開発チームは私一人なので、CSS周りも私が行いました。
デザイン自体は結構前にFigmaで作っていたのですが、なんかチケット感ないですよねー。

Screenshot_20201202-212245.png

ってことで運用開始前日にデザインを変えました

Wordpress.png

これならチケット感あるし、いい感じです。
押せばよいところも調和していて目立ちすぎず、わかっていただける感じにできたかと。

チケットの切り欠きは疑似要素で、半円は [CSS]半円を表示する方法(半円上、半円下、半円左、半円右) を参考に作りました。
プロパティーの記述順序がバラバラなのは許してください...

pages/index.vue
<div v-else v-for="ticket of ticketsData" :key="ticket.ticketId" class="ticket">
  <div class="ticket__description">
    <h3 class="ticket__description__name">{{ticket.displayName}}</h3>
    <p class="ticket__description__id">ID: {{ticket.ticketId}}</p>
  </div>
  <div class="ticket__bottomAction" v-if="ticket.is_used">
    <p class="ticket__bottomAction__text">チケットは既に使用済みです</p>
  </div>
  <div v-else class="ticket__bottomAction--active" @click="useTicketDialogOpen(ticket.ticketId)">
    <p class="ticket__bottomAction__text">チケットを使用済みにする</p>
  </div>
</div>
assets/css/_layout.scss
.ticket__description {

  color: white;
  position: relative;
  border-radius: 10px 10px 0 0;
  padding: 20px 35px;
  background-color: #0072DB;
  border-bottom: 1px dashed $bg-color;
  letter-spacing: 1px;

  .ticket__description__name {
    font-weight: bold;
  }

  .ticket__description__id {
    font-size: 0.7em;
  }

  &::before {
    position: absolute;
    content: '';
    bottom: -8px;
    left: 0;
    width: 8px;
    height: 16px;
    background-color: $bg-color;
    border-radius: 0 8px 8px 0;
  }

  &::after {
    position: absolute;
    content: '';
    bottom: -8px;
    right: 0;
    width: 8px;
    height: 16px;
    background-color: $bg-color;
    border-radius: 8px 0 0 8px;
  }
}

.ticket__bottomAction {
  border-radius: 0 0 10px 10px;
  padding: 15px;
  text-align: center;
  border: dashed #B7B7B7;
  border-width: 0px 1px 1px 1px;
}

.ticket__bottomAction--active {
  cursor: pointer;
  border-radius: 0 0 10px 10px;
  background-color: #3A96EB;
  padding: 15px;
  text-align: center;
  color: white;
  transition: .25s;

  &:hover {
    background-color: lighten(#3A96EB, 10%);
  }
}

.ticket__bottomAction__text {
  user-select: none;
}

BEM風だけどBEMの原則を守れていなさそうな命名してますね。

チケットを自動発行する&発行チケット一覧画面を作る

今回はfirestoreに以下のような感じでチケットデータを置くこととします。

- users
    - (WP上のユーザー固有番号)
        - displayName: かずえもん
        - tickets
            - MF20T0001: /tickets/MF20T0001 (参照)
- tickets
    - MF20T0001
        - created_at: 2020年1月1日 00:00:00 (タイムスタンプ)
        - displayName: 大人/小人 共通チケット
        - is_used: true (Boolean)
        - purchased_at: 2020年1月1日 00:00:00 (タイムスタンプ)
        - used_at: 2020年1月1日 00:00:00 (タイムスタンプ)

自動発行できるようにしたかったので、GASを活用します。
WooCommerceのWebhookをdoPost(req)で受信して注文のステータスが「処理中」(決済が完了して商品を発送する状態)になったらFirestore上にチケットを発行、注文のステータスを「完了」にし、発行ログをスプレッドシートに記載する、という処理をさせます。

実際の発行チケット一覧画面がこちらです。(ちゃんとデバッグ用のデータです)
image.png

その後、チケットアプリ上からチケットを使用済みにしたときにステータスを「使用済み」に変えるというところまでを実装します。

とその時、ある問題に直面。

GASはリクエストヘッダーを取れない

一般的にWebhookにはアクセス元を検証するためのシークレットがヘッダーに付与されていることが多いです。
WooCommerceの場合 X-WC-Webhook-Signature がついてきます。
(一応リクエストボディーにもあるって書いてあるけど見た感じついてきてない...?)

ただ、GASはリクエストヘッダーを取れません
参考: 【GoogleAppsScript】doPost のリクエストパラメータでHTTPヘッダを取得することはできない

プラグインを改造するのもめんどくさいので(アプデしたら死ぬし)、GETパラメーターに足して検証することにしました。

// 正常なリクエストかどうか確認
if (!req.parameter.secret && req.parameter.secret !== PropertiesService.getScriptProperties().getProperty('WEBHOOK_ENDPOINT_SECRET')) {
  logger('不正検知', '不正なリクエストを検知しました')
  return;
}

多分経路は安全だと思うので普通にこれで。
改造が出来ればヘッダーに入れてあげたほうが良いと思います。

GASからFirestoreを扱う便利な方法

FirestoreにはREST APIがあるのですが、非常にめんどくさいです。
詳しくは Cloud Firestore REST API の使用 を読んでいただければわかるかと思います。

と思っていたところに Firestoreのテストデータ投入をGASで楽にする を発見。

( ゚д゚) このライブラリ超便利やんこれ...

const firebaseAPI = FirestoreAPI.getFirestore(FIREBASE_SERVICE_ACCOUNT_EMAIL, FIREBASE_API_PRIVATE_KEY, FIREBASE_PROJECT_ID);
var fsUserDataUpdate = firebaseAPI.updateDocument(
  "users/" + orderData.customer_id, // ドキュメントの場所
  {
    "tickets": {
      ...fsUserDataOld.obj.tickets,
      ...ticketDatas
    }
  },
  true
);

これだけです。超便利。

「ライブラリの追加」に魔法のコード:1VUSl4b1r1eoNcRWotZM3e87ygkxvXltOgyDZhixqncz9lQ3MjfT1iKFw
を、grahamearleyさんへの感謝をしつつ入力しましょう。

image.png

完了です。

チケットのナンバリング

基本的にチケットIDは MF20T0000 (MyFes2020Ticket0000の略) の形式としました。
となると、ナンバリングをどのように処理するかというのが問題ですが、このような形にしました。

let ticketId = "MF20T" + ticketSheet.getDataRange().getValues().length.toString().padStart(4, '0');

getDataRange() でチケットシートのデータ範囲を取得すると各行ごとに配列になって帰ってきますので、その長さ(1行目は見出しなので、最終行ということになる)を番号にするという感じです。

奇跡的にWebhookが同時に動いた場合に同じ番号のチケットが発行される懸念がありますが、今回はなかったのでヨシ!です。

なんだかんだあって完成

ってことでかなり長くなりましたが、すべての機能を実装したのが18日の夜です。
前々日です。
image.png

デバッグ中にクッソ笑ったスクショを供養しておきます ![WordPress.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124224/39c04dbd-f5f0-0f61-a98b-16d87e42993d.png) いや、何枚だよ。 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124224/d2def0c2-20db-28d2-75ca-5d711ef38f35.png) いや、誰だよ。 ![unknown.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124224/93cbe150-c55c-b7f8-607e-db962cc414d5.png) ごごご入場ごごごごごごごごご (lang="en"になってて、間違って翻訳されてたみたい。htmlAttrsでは反映されなかったので、調べたところpwaのオプションとしてぶち込んで対処できたのだが、参考記事忘れてしまった...)

運用開始

いよいよ運用当日...機材トラブルで開場は11時30分になり、ログを見ながらドキドキ...

image.png

やったぁ!!!!!
ちゃんと動いたぞ!!!

無事DB上でもチケットが反映されていたので、とりあえず問題なく動いていました!

そのあとの死活管理はセルフアクセスで対処します(いきなり雑)

開発・運用を終えて

というわけで、無事1週間で作ったチケットアプリは運用を終え、用済みになりました。

運用中に特に大きな問題はなかったのですが、サークルさんの通行証を急遽アプリ上発行としていたのが、1サークルさんだけ私のミスでチケット番号を打ち間違えて、チケットアプリ上で正常に表示できなかったみたいです。
当該サークルさんには申し訳なかったですが、私のヒューマンエラーだけだったので、システム上には問題なく安心しました。

あと、イベント会場を管理されている方が当日いらっしゃって、**「チケットアプリ、使いやすいですね」**とおっしゃってくださってたとのことで、作った甲斐がありました。

後ほどログを確認してみたところ、スタッフ含め50人程度の方にご利用いただいたみたいです :clap:
決して多いわけではありませんでしたが、もっと人数が多ければ運用上の懸念はあったので、逆にこの規模でよかったという感じです。

学んだこと・活かしていきたいこと

  • 一気に知識をインプットできた
    • Nuxt.js, Firebase は以前も使っていたので大丈夫でしたが WooCommerce, Google Apps Script は今回扱うのが実質初めてのようなものだったので、必要な知識だけをネットから素早く仕入れてインプット/アウトプットができたのではないかと思います。
      いくつか躓いた点等で解決の参考リンクを記載させていただいておりますが、そういった先人の方々の記事がなかったらヤバかったです。本当に感謝しています。
  • 1週間でのサービス開発も悪くない
    • 私は結構「いろんなものを作ってみたいな~」と思って計画建てたりするのですが、学業や部活、その他もろもろをやってたら1か月後くらいにはGitには草すら生えず企画倒れになってることが結構あります。
    • ですから、今回の場合は**「期限が1週間後で正式運用することも決まっているし、自分が作らなくてはいけない」というプレッシャー、要はゆるめのハッカソンみたいな形式だったからこそ、ここまで開発できた**んじゃないかと思ってます。
  • 使い捨てじゃなく、ずっと使っていけるアプリにしたい
    • せっかく作ったのに、1日で役目を終えるのはちょっともったいないかなという感じがしました。
      ただ設計があまりよくないかと思うので、もう一度1から設計して、他のメンバーを呼んで共同開発したいなと思います。
  • セッション管理はもっと考える必要アリ
    • 世の中には セッションID、Cookie、localStorage、sessionStrage、メモリ上での保持 など、ログインセッションの保持・管理には様々な手法があります。今回の場合はCookieを採用しましたが、CSRF対策は十分にできていないと思います。
    • 今後いろんなサービスを作っていくにあたって、セキュリティー上の懸念というのができるだけ少なくなるような設計というのは最優先事項だと思いますので、そういった面を今後学習していけたらなと思います。

さいごに

ここまで書いて感じたのですが、めっちゃ長いですね。
もっと端的に書けなかったのかな...と今更思っていますが、もう遅いのでこれで出させていただきました。

来年4月からは大学1年生、もっといろんな技術にかかわって、つよつよなエンジニア目指して頑張ります!
最後まで読んでいただき、ありがとうございました!

よかったらTwitterでもフォローしてやってください
2021年4月まで→@kazuemon_0602
2021年4月から→@kazuemon0602

  1. https://wp-oauth.com/docs/how-to/enabling-woocommerce-api/

  2. チケットデータ取得のためには叩いてるので、結局は表示のたびに叩かれますが。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?