アプリのサーバーを再起動するとストアでアプリが機能しなくなる

解決済
lead-yamauchi
観光客
7 0 1

長文になりますが、よろしくお願いいたします。

[環境について]
shopify-app-nodeをベースに作成
サーバー:heroku
DB:postgresDB

server.js
下記のコメントに従い、
// This should be replaced with your preferred storage strategy

InMemoryStorageから、CustomSessionStorageに変更しました。
storeCallback()でpostgresDBにセッションを保存に保存し、loadCallback()でセッションを取得しています。

[アプリの仕様]
アプリ内の商品登録画面で商品と商品に表示したいキャッチアイコンを登録します。

アプリ内のインストール画面でscript_tagをインストールすると同時に、assetAPIを呼び出して、ストアテーマの商品ページのliquidコードに"キャッチアイコンを表示する処理"を埋め込んでいます。
ストアを表示すると埋め込んだコードからapp_proxy経由でアプリにアクセスし、登録したキャッチアイコンを取得して表示します。

[問題]
herokuサーバーをデプロイして再起動すると、セッションかクッキーが消えてしまうせいか、アプリからストアに埋め込んだ処理が機能しなくなります。
直すにはアプリのトップページを開き、「Enable cookies」の画面を表示するとクライアントのクッキーが設定されますが、
サーバーを再起動する度にアプリのトップページを開き直さなければなりません。

screenshot 2021-09-11 12.06.13.png

[質問]
サーバーを再起動してもストアに埋め込んだのアプリの処理が機能するにはどうすれば良いでしょうか。

 

[推測]
複数の原因が推測され、箇所が特定できずに苦しんでいます。
①サーバー再起動時のstoreCallback(), loadCallback()がうまくいっていない。
②サーバー再起動時のloadCurrentSession()がうまくいっていない。
③初期表示時のACTIVE_SHOPIFY_SHOPSの設定処理に問題がある。
④verifyRequestで/authにリダイレクトされ続けることに問題がある。
⑤オンライントークンではなくオフライントークンを使う。

※③について、「ACTIVE_SHOPIFY_SHOPS」の定義箇所に説明がありますが、
// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
サーバーを再起動すると、ACTIVE_SHOPIFY_SHOPS が消えます。何らかの方法で復元する必要があるというのが推測の1つです。

※④について、GithubのShopify/koa-shopify-authのIssueのどこかで見まして、「verifyRequest」が怪しいのではないかと思いましたが、verifyRequestを入れると、ページを変更するたびに/authを呼び出してしまい不具合となりました。

※⑤について、セッションはオンライントークンにしています。オフライントークンを試したのですが、loadCallback(id)のidが設定されずexceptinが発生する為、中断しました。

 

server.js

import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth";
import Shopify, { ApiVersion } from "@shopify/shopify-api";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
import routes from "./router/index";
import session from "koa-session";
import { getProduct } from "./controller/product";

const {
  storeCallback,
  loadCallback,
  deleteCallback,
} = require("./database.js");

const { default: graphQLProxy } = require("@shopify/koa-shopify-graphql-proxy");
import { ApiVersion as gApiVersion } from "@shopify/koa-shopify-graphql-proxy";

dotenv.config();
const port = parseInt(process.env.PORT, 10) || 8081;
const dev = process.env.NODE_ENV !== "production";
const app = next({
  dev,
});
const handle = app.getRequestHandler();

Shopify.Context.initialize({
  API_KEY: process.env.SHOPIFY_API_KEY,
  API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
  SCOPES: process.env.SCOPES.split(","),
  HOST_NAME: process.env.HOSTLT.replace(/https:\/\//, ""),
  API_VERSION: ApiVersion.October20,
  IS_EMBEDDED_APP: true,
  // This should be replaced with your preferred storage strategy
  SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
    storeCallback,
    loadCallback,
    deleteCallback
  ),
});

// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
const ACTIVE_SHOPIFY_SHOPS = {};
// ↓機能しない
console.log("ACTIVE_SHOPIFY_SHOPS設定");
const tmpSession = loadCallback();
console.log("tmpSession確認", tmpSession);
if (tmpSession?.shop && tmpSession?.scope) {
  console.log("session取得成功", tmpSession);
  ACTIVE_SHOPIFY_SHOPS[tmpSession.shop] = tmpSession.scope;
}

app.prepare().then(async () => {
  const server = new Koa();
  const router = new Router();

  server.use(session({ secure: true, sameSite: "none" }, server));
  server.keys = [Shopify.Context.API_SECRET_KEY];
  server.use(
    createShopifyAuth({
      async afterAuth(ctx) {
        console.log("afterAuth");
        // Access token and shop available in ctx.state.shopify
        const { shop, accessToken, scope } = ctx.state.shopify;
        const host = ctx.query.host;
        ACTIVE_SHOPIFY_SHOPS[shop] = scope;

        const response = await Shopify.Webhooks.Registry.register({
          shop,
          accessToken,
          path: "/webhooks",
          topic: "APP_UNINSTALLED",
          webhookHandler: async (topic, shop, body) =>
            delete ACTIVE_SHOPIFY_SHOPS[shop],
        });

        if (!response.success) {
          console.log(
            `Failed to register APP_UNINSTALLED webhook: ${response.result}`
          );
        }

        // Redirect to app with shop parameter upon auth
        ctx.redirect(`/?shop=${shop}&host=${host}`);
      },
    })
  );

  async function injectSession(ctx, next) {
    const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res);
    ctx.sessionFromToken = session;
    console.log("middleware", session);

    if (session?.shop && session?.accessToken) {
      const client = new Shopify.Clients.Rest(
        session.shop,
        session.accessToken
      );
      ctx.myClient = client;
    }
    console.log("ctx.myClient:", JSON.stringify(ctx.myClient));
    return next();
  }

  const handleRequest = async (ctx) => {
    await handle(ctx.req, ctx.res);
    ctx.respond = false;
    ctx.res.statusCode = 200;
  };

  server.use(injectSession);
  server.use(routes());

  router.post("/webhooks", async (ctx) => {
    try {
      await Shopify.Webhooks.Registry.process(ctx.req, ctx.res);
      console.log(`Webhook processed, returned status code 200`);
    } catch (error) {
      console.log(`Failed to process webhook: ${error}`);
    }
  });

  router.post(
    "/graphql",
    verifyRequest({ returnHeader: true }),
    async (ctx, next) => {
      console.log("/graphql");
      await Shopify.Utils.graphqlProxy(ctx.req, ctx.res);
    }
  );

  router.get("(/_next/static/.*)", handleRequest); // Static content is clear
  router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear
  router.get("(/static/images/.*)", handleRequest); // 画像関係
  // router.get("(.*)", verifyRequest(), handleRequest); // Everything else must have sessions
  router.get("(.*)", async (ctx) => {
    const shop = ctx.query.shop;

    // This shop hasn't been seen yet, go through OAuth to create a session
    if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
      ctx.redirect(`/auth?shop=${shop}`);
    } else {
      await handleRequest(ctx);
    }
  });

  server.use(router.allowedMethods());
  server.use(router.routes());
  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});

 

database.js

const { Client } = require("pg");

const { Session } = require("@shopify/shopify-api/dist/auth/session");
require('dotenv').config();

const ssl =
  process.env.NODE_ENV === "production"
    ? {
        require: true,
        rejectUnauthorized: false,
      }
    : null;

console.log("postgres ssl: ", ssl);
const connection = new Client({
  user: `${process.env.PSQL_USER}`,
  password: `${process.env.PSQL_PASSWORD}`,
  host: `${process.env.PSQL_HOST}`,
  port: `${process.env.PSQL_PORT}`,
  database: `${process.env.PSQL_DATABASE}`,
  ssl,
});

connection.connect();

let domain = "";

async function storeCallback(session) {
  try {
    let data = session;
    console.log("storeCallback session", data);

    if (data.shop == undefined) return false;

    data.onlineAccessInfo = JSON.stringify(session.onlineAccessInfo);

    if (data.id.indexOf(`${data.shop}`) > -1) {
      domain = data.id;
    }
    let query = new Promise((resolve, reject) => {
      connection
        .query(
          `INSERT INTO shops (shop, session_id, domain_id, "access_token", scope, state, "is_online", "online_access_info") VALUES('${data.shop}', '${data.id}', '${domain}', '${data.accessToken}', '${data.scope}', '${data.state}', '${data.isOnline}', '${data.onlineAccessInfo}')
                ON CONFLICT (shop) DO UPDATE 
                SET session_id = '${data.id}', 
                domain_id = '${domain}',
                scope = '${data.scope}',
                state = '${data.state}',
                access_token = '${data.accessToken}',
                "online_access_info" = '${data.onlineAccessInfo}'
                ;`
        )
        .then((results) => {
          console.log("storeCallback", results);
        }) // access_tokenを勝手に追加➔あとから見ると正しかった
        .catch((e) => {
          throw e;
        });
      resolve();
    });
    await query;
    return true;
  } catch (err) {
    if (err) throw err;
  }
}
async function loadCallback(id) {
  try {
    console.log("loadCallback", id);
    if (!id) return false;

    const session = new Session(id);

    let query = new Promise((resolve, reject) => {
      connection
        .query(
          `SELECT * FROM shops WHERE session_id='${id}' OR domain_id='${id}' LIMIT 1`
        )
        .then((results) => {
          // console.log("results.rows", results.rows)
          session.shop = results.rows[0].shop;
          session.state = results.rows[0].state;
          session.isOnline = results.rows[0].is_online == "true" ? true : false;
          session.onlineAccessInfo = results.rows[0].online_access_info;
          session.accessToken = results.rows[0].access_token;
          session.scope = results.rows[0].scope;

          const date = new Date();
          date.setDate(date.getDate() + 1);
          session.expires = date;

          resolve();
        })
        .catch((e) => {
          throw e;
        });
    });

    await query;
    return session;
  } catch (err) {
    if (err) throw err;
  }
  return false;
}
async function deleteCallback(id) {
  try {
    return false;
  } catch (err) {
    if (err) throw err;
  }
}

 

0 件の「いいね!」
junichiokamura
Community Manager
Community Manager
1108 257 455

こちらは、最新のShopify CLI で作成されたものでしょうか? それとも古い Shopify App CLIを使われましたか? 

shopify-app-nodeをベースに作成

この元ソースのリンクを共有いただければと思います。

現在は、埋め込みアプリでクッキーやローカルストレージを使うのは非推奨で、代わりにJWTを使ったセッショントークンを使うことが推奨されています。

ご質問の直接の回答になっているかは分かりませんが、毎回Authを走らせることなく読み込みスピードを上げるセッションをクッキレスで行う方法は以下に説明がありますのでご参照ください。

https://www.shopify.jp/blog/partner-app-load-quickly

 

 

Technical Partner Manager, Japan
0 件の「いいね!」
junichiokamura
Community Manager
Community Manager
1108 257 455

コードはこれですかね?

https://github.com/Shopify/shopify-app-node

私の方でも、shopify node create でコードを作ってみましたが、内容は同じようですね。

コードを解析しきれてないですが、今の段階で言えるコメントは、

ストアを表示すると埋め込んだコードからapp_proxy経由でアプリにアクセスし、登録したキャッチアイコンを取得して表示します。

App proxyを経由するとクッキーは全て落ちてしまう(引き継がれない)ので、クッキーベースで動いてるアプリは正常に動かないと思います。

Enable Cookieをした時などの動作が見えないので、ご自身のコードをファイルの断片ではなくて、一式GitHubにあげてそのリンクをいただけますか?(全体を見ないと正しい調査はできないと思います)

ちなみに、

const tmpSession = loadCallback();

 

には、id が指定されてないですが。これは問題ないでしょうか?

Technical Partner Manager, Japan
0 件の「いいね!」
junichiokamura
Community Manager
Community Manager
1108 257 455

 > App proxyを経由するとクッキーは全て落ちてしまう(引き継がれない)ので

App proxyはテーマからアクセスするものなので、今回は使われてないですよね?(app proxyはコード単体ではなくて、アプリの設定画面から登録が必要です)

いずれにしてもコード全体を見ないと回答は出来なさそうです。

Technical Partner Manager, Japan
0 件の「いいね!」
lead-yamauchi
観光客
7 0 1

コマンドがshopify node serveですので、新しいShopify App CLIになります。

githubのコラボレーターに招待致しました。

コードを確認して頂けますと幸いです。

 

ストアテーマからアプリ内に保存した画像ファイルを参照するにはapp proxyを経由する方法しか分かりませんでした。

app proxy以外の方法がありましたら教えて頂きたいです。

 

0 件の「いいね!」
junichiokamura
Community Manager
Community Manager
1108 257 455

成功

 返信が遅くなりましたが、共有いただいたコードをローカルに落として起動してみました。PostgreSQLが手元になく、ちゃんと起動、デバッグできていないので完全な原因は特定できていないですが、以下問題解決の参考になりそうな部分を列挙しましたので、確認してみください。

「ACTIVE_SHOPIFDY_SHOPが再起動毎に消える」とおっしゃてますが、これはDBに保存しているセッションデータが取得できないという理解でよろしいでしょうか?

その場合、まずは、localCallbackでSQLで取得しているデータがちゃんと存在しているか、そもそもそのSQLが実行されているかご確認ください。

それと、CLIで元々作られたコードは以下だったかと思います。
https://github.com/Shopify/shopify-app-node/blob/master/server/server.js

共有いただいたコードは、ACTIVE_SHOPIFDY_SHOPの定義の後にご自分でlocalCallbackなどを呼んで復元されていますが、createAShopifyAuthの中でACTIVE_SHOPIFY_SHOPSを生成しているので、その上記の、// This should be replaced with your preferred storage strategy のコメントがあるSESSION_STORAGEの定義を置き換えるだけで動作するのでは、と推測します。(つまり、CLIの生成コードは、SESSION_STORAGEの定義をInMemoryタイプからStorageTypeにするだけで動作するようになっている)


このコードをたくさん修正されて学習されているようですので、大変ありがたいですが、一度にたくさん修正すると原因の特定はが難しくなるので、もう一度新しくCLIでコード生成して、上記のように少しづつ修正してでバックされるのはいかがでしょうか?


ストアテーマからアプリ内に保存した画像ファイルを参照するにはapp proxyを経由する方法しか分かりませんでした。

App proxyは、テーマのJSから、アプリのURLにアクセスする唯一の手段ですので、そうなります。(ご存知かと思いますが、普通にアプリの生のURLにアクセスすると、cross origin ではじかれますので)

使わない場合は、静的に <script src=>で埋め込むなどの処置になるかと思いますが、その中でajax通信する場合は、やはりcross originに引っかかってしまいます。

あとは、アプリ内ではないですが、先日リリースされたFile APIでShopifyストアのCDNを使うという手もあります。

 

Technical Partner Manager, Japan
0 件の「いいね!」
lead-yamauchi
観光客
7 0 1

ご対応誠にありがとうございました。

原因はご指摘の通り、loadCallbackに引数を渡していず、セッション取得に失敗していたことのようです。

様々のソースコードを模倣して作成したので、食い違いが発生していました。

loadCallbackではなく、リスタート用の別メソッドを作成してDBからセッションデータを取得することで一旦解決しました。