shopify-app-template-node icon indicating copy to clipboard operation
shopify-app-template-node copied to clipboard

CLI Bug - Install Link Does Not Work in Production

Open PurplePineapple123 opened this issue 2 years ago • 6 comments

Issue summary

An app built with the Shopify CLI and deployed to Heroku or Fly.io is not able to be installed onto a store using the installation link. This causes the app to fail the review process.

However, the CLI app code will work when manually clicking the auth link directly.

To test this, I scaffolded a fresh project from the CLI and deployed directly to Heroku.

This issue only occurs on installation and I believe app.use(serveStatic(PROD_INDEX_PATH)); is the root cause.

If the app is installed through the auth link directly, then app.use(serveStatic(PROD_INDEX_PATH)); works and properly serves data without any issues.

Reduced test case

See this video demonstration of this issue: https://www.loom.com/share/b879ed56e4124736bdc10e5c954907f0

@paulomarg - any idea what's going on?

Server code:

// @ts-check
import { join } from "path";
import fs from "fs";
import express from "express";
import cookieParser from "cookie-parser";
import { Shopify, ApiVersion } from "@shopify/shopify-api";

import applyAuthMiddleware from "./middleware/auth.js";
import verifyRequest from "./middleware/verify-request.js";
import { setupGDPRWebHooks } from "./gdpr.js";
import { BillingInterval } from "./helpers/ensure-billing.js";

const USE_ONLINE_TOKENS = true;
const TOP_LEVEL_OAUTH_COOKIE = "shopify_top_level_oauth";

const PORT = parseInt(process.env.BACKEND_PORT || process.env.PORT, 10);
const isTest = process.env.NODE_ENV === "test" || !!process.env.VITE_TEST_BUILD;

const versionFilePath = "./version.txt";
let templateVersion = "unknown";
if (fs.existsSync(versionFilePath)) {
  templateVersion = fs.readFileSync(versionFilePath, "utf8").trim();
}

// TODO: There should be provided by env vars
const DEV_INDEX_PATH = `${process.cwd()}/frontend/`;
const PROD_INDEX_PATH = `${process.cwd()}/frontend/dist/`;

const DB_PATH = `${process.cwd()}/database.sqlite`;

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.HOST.replace(/https?:\/\//, ""),
  HOST_SCHEME: process.env.HOST.split("://")[0],
  API_VERSION: ApiVersion.April22,
  IS_EMBEDDED_APP: true,
  // This should be replaced with your preferred storage strategy
  SESSION_STORAGE: new Shopify.Session.SQLiteSessionStorage(DB_PATH),
  USER_AGENT_PREFIX: `Node App Template/${templateVersion}`,
});

// 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 = {};
Shopify.Webhooks.Registry.addHandler("APP_UNINSTALLED", {
  path: "/api/webhooks",
  webhookHandler: async (topic, shop, body) =>
    delete ACTIVE_SHOPIFY_SHOPS[shop],
});

// The transactions with Shopify will always be marked as test transactions, unless NODE_ENV is production.
// See the ensureBilling helper to learn more about billing in this template.
const BILLING_SETTINGS = {
  required: false,
  // This is an example configuration that would do a one-time charge for $5 (only USD is currently supported)
  // chargeName: "My Shopify One-Time Charge",
  // amount: 5.0,
  // currencyCode: "USD",
  // interval: BillingInterval.OneTime,
};

// This sets up the mandatory GDPR webhooks. You’ll need to fill in the endpoint
// in the “GDPR mandatory webhooks” section in the “App setup” tab, and customize
// the code when you store customer data.
//
// More details can be found on shopify.dev:
// https://shopify.dev/apps/webhooks/configuration/mandatory-webhooks
setupGDPRWebHooks("/api/webhooks");

// export for test use only
export async function createServer(
  root = process.cwd(),
  isProd = process.env.NODE_ENV === "production",
  billingSettings = BILLING_SETTINGS
) {
  const app = express();
  app.set("top-level-oauth-cookie", TOP_LEVEL_OAUTH_COOKIE);
  app.set("active-shopify-shops", ACTIVE_SHOPIFY_SHOPS);
  app.set("use-online-tokens", USE_ONLINE_TOKENS);

  app.use(cookieParser(Shopify.Context.API_SECRET_KEY));

  applyAuthMiddleware(app, {
    billing: billingSettings,
  });

  app.post("/api/webhooks", async (req, res) => {
    try {
      await Shopify.Webhooks.Registry.process(req, res);
      console.log(`Webhook processed, returned status code 200`);
    } catch (error) {
      console.log(`Failed to process webhook: ${error}`);
      if (!res.headersSent) {
        res.status(500).send(error.message);
      }
    }
  });

  // All endpoints after this point will require an active session
  app.use(
    "/api/*",
    verifyRequest(app, {
      billing: billingSettings,
    })
  );

  app.get("/api/products-count", async (req, res) => {
    const session = await Shopify.Utils.loadCurrentSession(req, res, true);
    const { Product } = await import(
      `@shopify/shopify-api/dist/rest-resources/${Shopify.Context.API_VERSION}/index.js`
    );

    const countData = await Product.count({ session });
    res.status(200).send(countData);
  });

  app.post("/api/graphql", async (req, res) => {
    try {
      const response = await Shopify.Utils.graphqlProxy(req, res);
      res.status(200).send(response.body);
    } catch (error) {
      res.status(500).send(error.message);
    }
  });

  app.use(express.json());

  app.use((req, res, next) => {
    const shop = req.query.shop;
    if (Shopify.Context.IS_EMBEDDED_APP && shop) {
      res.setHeader(
        "Content-Security-Policy",
        `frame-ancestors https://${shop} https://admin.shopify.com;`
      );
    } else {
      res.setHeader("Content-Security-Policy", `frame-ancestors 'none';`);
    }
    next();
  });

  if (isProd) {
    const compression = await import("compression").then(
      ({ default: fn }) => fn
    );
    const serveStatic = await import("serve-static").then(
      ({ default: fn }) => fn
    );
    app.use(compression());
    app.use(serveStatic(PROD_INDEX_PATH));
  }

  app.use("/*", async (req, res, next) => {
    const shop = req.query.shop;

    // Detect whether we need to reinstall the app, any request from Shopify will
    // include a shop in the query parameters.
    if (app.get("active-shopify-shops")[shop] === undefined && shop) {
      res.redirect(`/api/auth?shop=${shop}`);
    } else {
      // res.set('X-Shopify-App-Nothing-To-See-Here', '1');
      const fs = await import("fs");
      const fallbackFile = join(
        isProd ? PROD_INDEX_PATH : DEV_INDEX_PATH,
        "index.html"
      );
      res
        .status(200)
        .set("Content-Type", "text/html")
        .send(fs.readFileSync(fallbackFile));
    }
  });

  return { app };
}

if (!isTest) {
  createServer().then(({ app }) => app.listen(PORT));
}

PurplePineapple123 avatar Jul 11 '22 16:07 PurplePineapple123

@PurplePineapple123 Were you able to resolve this? I'm facing the same issue.

v-octal avatar Jul 13 '22 12:07 v-octal

@v-octal I did manage to fix it. So the branch the CLI automatically pulled from was cli_three. That has the above bug and doesn't work in production. I created a new project with this repos main branch and then rebuilt my project and deployed. That fixed the issue for me.

PurplePineapple123 avatar Jul 13 '22 14:07 PurplePineapple123

I don't really want to move to CLI 2.0 as it would require quite a lot of effort to rebuild and test the app again. I hope there's some other way around it.

v-octal avatar Jul 13 '22 15:07 v-octal

Honestly, I was the same way and then spent probably 20 hours on this bug. For me it got to a point where it made more sense to bite the bullet and rebuild. I believe the issue has something to do with app.use(serveStatic(PROD_INDEX_PATH));. Just not sure what

PurplePineapple123 avatar Jul 13 '22 16:07 PurplePineapple123

Hey folks, thanks for reporting this. I think you're right @PurplePineapple123 - the serveStatic call is intercepting the first request to / and defaulting it to /index.html, which causes it to skip OAuth even though the app isn't installed yet. You can fix that by changing that line to:

app.use(serveStatic(PROD_INDEX_PATH, { index: false }));

I'll put up a PR that does the same so future apps won't run into it. Great catch!

paulomarg avatar Jul 13 '22 16:07 paulomarg

Thank you @paulomarg. This worked!

v-octal avatar Jul 13 '22 17:07 v-octal

This issue is stale because it has been open for 90 days with no activity. It will be closed if no further action occurs in 14 days.

github-actions[bot] avatar Sep 28 '22 02:09 github-actions[bot]

Hey folks, thanks for reporting this. I think you're right @PurplePineapple123 - the serveStatic call is intercepting the first request to / and defaulting it to /index.html, which causes it to skip OAuth even though the app isn't installed yet. You can fix that by changing that line to:

app.use(serveStatic(PROD_INDEX_PATH, { index: false }));

I'll put up a PR that does the same so future apps won't run into it. Great catch!

Well it takes me days to find your solution, thank you so much!

thanhhd avatar Oct 10 '22 06:10 thanhhd

This issue is stale because it has been open for 60 days with no activity. It will be closed if no further action occurs in 14 days.

github-actions[bot] avatar Dec 11 '22 02:12 github-actions[bot]

Closing this one as it is now solved

cquemin avatar Dec 21 '22 21:12 cquemin

Sorry to dig this one from the archives but I wonder if the issue I'm having is related to this.

I've been working on app and was able to successfully install it on a development store, and then on a production store using the merchant install link. That link was still pointing to a development version of this app, served via ngrok. Once I created a new copy of the very same app intended to go into production, trying to run the corresponding merchant install link just gives me the error "You don't have this app installed": https://share.cleanshot.com/9cyPS8FZ

Clicking the "Install" button just pushes me back to the same page.

I can see the ensureInstalledOnShop middleware showing up on the logs, but nothing past that.

The very same app, when using the "Test your app" functionality (that only can run on a development store), works as expected and the app operates normally over the same server.

I've tried everything I could remember, still can get past this one. Any pointers would be much appreciated.

lmartins avatar Dec 29 '22 16:12 lmartins

@cquemin I've meant to follow up on my comment earlier and forgot, sorry! I was going to say that in my case the issue appear to be the same as outlined above but it actually was not. In my case it was due to an erroneous session being present in the database, causing the app to think it was already installed while it was not yet. This would halt the activation process and give that misleading message. Once I've removed the erroneous entry from the database things started working as expected.

lmartins avatar Jan 04 '23 08:01 lmartins

@lmartins Thanks for following up, I wanted to have a look today but I am happy to hear this was different. I will close that one then. Thanks a lot for taking the time to share and to follow up with a detailed explanation!

cquemin avatar Jan 04 '23 14:01 cquemin