diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..55f79c9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,3 @@
+web/vendor
+web/frontend/node_modules
+web/frontend/dist
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..6537ca4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..967315d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+* text=auto
+*.css linguist-vendored
+*.scss linguist-vendored
+*.js linguist-vendored
+CHANGELOG.md export-ignore
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..ae7f54e
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @Shopify/learn-libs-superteam
diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
new file mode 100644
index 0000000..15ee3e2
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
@@ -0,0 +1,39 @@
+---
+name: '🐛 Bug Report'
+about: Something isn't working
+labels: "Type: Bug 🐛"
+---
+
+# Issue summary
+
+Write a short description of the issue here ↓
+
+
+## Expected behavior
+
+What do you think should happen?
+
+
+## Actual behavior
+
+What actually happens?
+
+Tip: include an error message (in a ` ` tag) if your issue is related to an error
+
+
+## Steps to reproduce the problem
+
+1.
+1.
+1.
+
+## Reduced test case
+
+The best way to get your bug fixed is to provide a [reduced test case](https://developer.mozilla.org/en-US/docs/Mozilla/QA/Reducing_testcases).
+
+
+---
+
+## Checklist
+
+- [ ] I have described this issue in a way that is actionable (if possible)
diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.md b/.github/ISSUE_TEMPLATE/ENHANCEMENT.md
new file mode 100644
index 0000000..b8c48be
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.md
@@ -0,0 +1,26 @@
+---
+name: '📈 Enhancement'
+about: Enhancement to our codebase that isn't a adding or changing a feature
+labels: "Type: Enhancement 📈"
+---
+
+## Overview/summary
+
+...
+
+## Motivation
+
+> What inspired this enhancement?
+
+...
+
+### Area
+
+- [ ] Add any relevant `Area: ` labels to this issue
+
+
+---
+
+## Checklist
+
+- [ ] I have described this enhancement in a way that is actionable (if possible)
diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
new file mode 100644
index 0000000..3ec6555
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
@@ -0,0 +1,30 @@
+---
+name: '🙌 Feature Request'
+about: Suggest a new feature, or changes to an existing one
+labels: "Type: Feature Request :raised_hands:"
+---
+
+## Overview
+
+...
+
+## Type
+
+- [ ] New feature
+- [ ] Changes to existing features
+
+## Motivation
+
+> What inspired this feature request? What problems were you facing?
+
+...
+
+### Area
+
+- [ ] Add any relevant `Area: ` labels to this issue
+
+---
+
+## Checklist
+
+- [ ] I have described this feature request in a way that is actionable (if possible)
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..a5e842b
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,29 @@
+
+
+### WHY are these changes introduced?
+
+Fixes #0000
+
+
+
+### WHAT is this pull request doing?
+
+
+
+## Checklist
+
+**Note**: once this PR is merged, it becomes a new release for this template.
+
+- [ ] I have added/updated tests for this change
+- [ ] I have made changes to the `README.md` file and other related documentation, if applicable
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..6957370
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,34 @@
+version: 2
+updates:
+ # Enable version updates for composer - main/web (new template)
+ - package-ecosystem: composer
+ directory: "/web"
+ schedule:
+ interval: "daily"
+ time: "00:00"
+ timezone: "UTC"
+ # Raise PRs for composer updates against the `main` branch
+ target-branch: "main"
+ reviewers:
+ - Shopify/client-libraries-atc
+ labels:
+ - "Composer upgrades"
+ open-pull-requests-limit: 100
+
+ # Enable version updates for npm - main/ (new template)
+ - package-ecosystem: "npm"
+ # Look for `package.json` and `lock` files in the `root` directory
+ # This will be CLI dependencies only
+ directory: "/"
+ # Check the npm registry for updates every day (weekdays)
+ schedule:
+ interval: "daily"
+ time: "00:00"
+ timezone: "UTC"
+ # Raise PRs for version updates to npm against the `main` branch
+ target-branch: "main"
+ reviewers:
+ - Shopify/client-libraries-atc
+ # Labels on pull requests for version updates only
+ labels:
+ - "npm dependencies"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2040b3f
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,42 @@
+on: [push, pull_request]
+name: CI
+jobs:
+ CI:
+ runs-on: ubuntu-latest
+ env:
+ PHP_INI_VALUES: assert.exception=1, zend.assertions=1
+ SHOPIFY_API_KEY: test-api-key
+ SHOPIFY_API_SECRET: test-secret-key
+ SCOPES: read_products,write_products
+ HOST: app-host-name.com
+ APP_ENV: local
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "8.0"
+ - "8.1"
+ - "8.2"
+ defaults:
+ run:
+ working-directory: ./web
+ steps:
+ - uses: actions/checkout@v3
+ with:
+ submodules: recursive
+
+ - name: Find any lock file
+ run: if test -f ../yarn.lock || test -f ../pnpm-lock.yaml || test -f ../package-lock.json; then echo "Please don't commit lock files" && exit 1; fi
+
+ - name: Install PHP with extensions
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer:v2, phpcs
+ ini-values: ${{ env.PHP_INI_VALUES }}
+
+ - name: Install dependencies with composer
+ run: composer update --no-ansi --no-interaction --no-progress
+
+ - name: Run linter
+ run: composer lint
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
new file mode 100644
index 0000000..2c3a404
--- /dev/null
+++ b/.github/workflows/cla.yml
@@ -0,0 +1,22 @@
+name: Contributor License Agreement (CLA)
+
+on:
+ pull_request_target:
+ types: [opened, synchronize]
+ issue_comment:
+ types: [created]
+
+jobs:
+ cla:
+ runs-on: ubuntu-latest
+ if: |
+ (github.event.issue.pull_request
+ && !github.event.issue.pull_request.merged_at
+ && contains(github.event.comment.body, 'signed')
+ )
+ || (github.event.pull_request && !github.event.pull_request.merged)
+ steps:
+ - uses: Shopify/shopify-cla-action@v1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ cla-token: ${{ secrets.CLA_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..718b63a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+/web/frontend/node_modules
+/web/public/hot
+/web/public/storage
+/web/storage/*.key
+/web/storage/db.sqlite
+/web/vendor
+/web/composer.lock
+.env
+.env.*
+!.env.example
+!.env.testing
+web/.phpunit.result.cache
+docker-compose.override.yml
+Homestead.json
+Homestead.yaml
+npm-debug.log
+yarn-error.log
+
+**/node_modules
+
+.idea
+.vscode
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..e094332
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "web/frontend"]
+ path = web/frontend
+ url = https://github.com/Shopify/shopify-frontend-template-react
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..cfa0686
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,3 @@
+engine-strict=true
+auto-install-peers=true
+shamefully-hoist=true
diff --git a/.styleci.yml b/.styleci.yml
new file mode 100644
index 0000000..f3830ad
--- /dev/null
+++ b/.styleci.yml
@@ -0,0 +1,9 @@
+php:
+ preset: laravel
+ disabled:
+ - no_unused_imports
+ finder:
+ not-name:
+ - index.php
+ - server.php
+css: true
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..29b4982
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "shopify.polaris-for-vscode"
+ ]
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bfc9895
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+FROM php:8.1-fpm-alpine
+
+ARG SHOPIFY_API_KEY
+ENV SHOPIFY_API_KEY=$SHOPIFY_API_KEY
+
+RUN apk update && apk add --update nodejs npm \
+ composer php-pdo_sqlite php-pdo_mysql php-pdo_pgsql php-simplexml php-fileinfo php-dom php-tokenizer php-xml php-xmlwriter php-session \
+ openrc bash nginx
+
+RUN docker-php-ext-install pdo
+
+COPY --chown=www-data:www-data web /app
+WORKDIR /app
+
+# Overwrite default nginx config
+COPY web/nginx.conf /etc/nginx/nginx.conf
+
+# Use the default production configuration
+RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
+
+RUN composer install
+RUN touch /app/storage/db.sqlite
+RUN chown www-data:www-data /app/storage/db.sqlite
+
+RUN cd frontend && npm install && npm run build
+RUN composer build
+
+ENTRYPOINT [ "/app/entrypoint.sh" ]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9b9f866
--- /dev/null
+++ b/README.md
@@ -0,0 +1,306 @@
+# Shopify App Template - PHP
+
+This is a template for building a [Shopify app](https://shopify.dev/docs/apps/getting-started) using PHP and React. It contains the basics for building a Shopify app.
+
+Rather than cloning this repo, you can use your preferred package manager and the Shopify CLI with [these steps](#installing-the-template).
+
+## Benefits
+
+Shopify apps are built on a variety of Shopify tools to create a great merchant experience. The [create an app](https://shopify.dev/docs/apps/getting-started/create) tutorial in our developer documentation will guide you through creating a Shopify app using this template.
+
+The PHP app template comes with the following out-of-the-box functionality:
+
+- OAuth: Installing the app and granting permissions
+- GraphQL Admin API: Querying or mutating Shopify admin data
+- REST Admin API: Resource classes to interact with the API
+- Shopify-specific tooling:
+ - AppBridge
+ - Polaris
+ - Webhooks
+
+## Tech Stack
+
+This template combines a number of third party open source tools:
+
+- [Laravel](https://laravel.com/) builds and tests the backend.
+- [Vite](https://vitejs.dev/) builds the [React](https://reactjs.org/) frontend.
+- [React Router](https://reactrouter.com/) is used for routing. We wrap this with file-based routing.
+- [React Query](https://react-query.tanstack.com/) queries the Admin API.
+- [`i18next`](https://www.i18next.com/) and related libraries are used to internationalize the frontend.
+ - [`react-i18next`](https://react.i18next.com/) is used for React-specific i18n functionality.
+ - [`i18next-resources-to-backend`](https://github.com/i18next/i18next-resources-to-backend) is used to dynamically load app translations.
+ - [`@formatjs/intl-localematcher`](https://formatjs.io/docs/polyfills/intl-localematcher/) is used to match the user locale with supported app locales.
+ - [`@formatjs/intl-locale`](https://formatjs.io/docs/polyfills/intl-locale) is used as a polyfill for [`Intl.Locale`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) if necessary.
+ - [`@formatjs/intl-pluralrules`](https://formatjs.io/docs/polyfills/intl-pluralrules) is used as a polyfill for [`Intl.PluralRules`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules) if necessary.
+
+These third party tools are complemented by Shopify specific tools to ease app development:
+
+- [Shopify API library](https://github.com/Shopify/shopify-api-php) adds OAuth to the Laravel backend. This lets users install the app and grant scope permissions.
+- [App Bridge React](https://shopify.dev/docs/tools/app-bridge/react-components) adds authentication to API requests in the frontend and renders components outside of the embedded App’s iFrame.
+- [Polaris React](https://polaris.shopify.com/) is a powerful design system and component library that helps developers build high quality, consistent experiences for Shopify merchants.
+- [Custom hooks](https://github.com/Shopify/shopify-frontend-template-react/tree/main/hooks) make authenticated requests to the GraphQL Admin API.
+- [File-based routing](https://github.com/Shopify/shopify-frontend-template-react/blob/main/Routes.jsx) makes creating new pages easier.
+- [`@shopify/i18next-shopify`](https://github.com/Shopify/i18next-shopify) is a plugin for [`i18next`](https://www.i18next.com/) that allows translation files to follow the same JSON schema used by Shopify [app extensions](https://shopify.dev/docs/apps/checkout/best-practices/localizing-ui-extensions#how-it-works) and [themes](https://shopify.dev/docs/themes/architecture/locales/storefront-locale-files#usage).
+
+## Getting started
+
+### Requirements
+
+1. You must [create a Shopify partner account](https://partners.shopify.com/signup) if you don’t have one.
+1. You must create a store for testing if you don't have one, either a [development store](https://help.shopify.com/en/partners/dashboard/development-stores#create-a-development-store) or a [Shopify Plus sandbox store](https://help.shopify.com/en/partners/dashboard/managing-stores/plus-sandbox-store).
+1. You must have [PHP](https://www.php.net/) installed.
+1. You must have [Composer](https://getcomposer.org/) installed.
+1. You must have [Node.js](https://nodejs.org/) installed.
+
+### Installing the template
+
+This template runs on Shopify CLI 3.0, which is a node package that can be included in projects. You can install it using your preferred Node.js package manager:
+
+Using yarn:
+
+```shell
+yarn create @shopify/app --template php
+```
+
+Using npx:
+
+```shell
+npm init @shopify/app@latest -- --template php
+```
+
+Using pnpm:
+
+```shell
+pnpm create @shopify/app@latest --template php
+```
+
+This will clone the template and install the CLI in that project.
+
+### Setting up your Laravel app
+
+Once the Shopify CLI clones the repo, you will be able to run commands on your app.
+However, the CLI will not manage your PHP dependencies automatically, so you will need to go through some steps to be able to run your app.
+These are the typical steps needed to set up a Laravel app once it's cloned:
+
+1. Start off by switching to the `web` folder:
+
+ ```shell
+ cd web
+ ```
+
+1. Install your composer dependencies:
+
+ ```shell
+ composer install
+ ```
+
+1. Create the `.env` file:
+
+ ```shell
+ cp .env.example .env
+ ```
+
+1. Bootstrap the default [SQLite](https://www.sqlite.org/index.html) database and add it to your `.env` file:
+
+ ```shell
+ touch storage/db.sqlite
+ ```
+
+ **NOTE**: Once you create the database file, make sure to update your `DB_DATABASE` variable in `.env` since Laravel requires a full path to the file.
+
+1. Generate an `APP_KEY` for your app:
+
+ ```shell
+ php artisan key:generate
+ ```
+
+1. Create the necessary Shopify tables in your database:
+
+ ```shell
+ php artisan migrate
+ ```
+
+And your Laravel app is ready to run! You can now switch back to your app's root folder to continue:
+
+```shell
+cd ..
+```
+
+### Local Development
+
+[The Shopify CLI](https://shopify.dev/docs/apps/tools/cli) connects to an app in your Partners dashboard.
+It provides environment variables, runs commands in parallel, and updates application URLs for easier development.
+
+You can develop locally using your preferred Node.js package manager.
+Run one of the following commands from the root of your app:
+
+Using yarn:
+
+```shell
+yarn dev
+```
+
+Using npm:
+
+```shell
+npm run dev
+```
+
+Using pnpm:
+
+```shell
+pnpm run dev
+```
+
+Open the URL generated in your console. Once you grant permission to the app, you can start development.
+
+## Deployment
+
+### Application Storage
+
+This template uses [Laravel's Eloquent framework](https://laravel.com/docs/9.x/eloquent) to store Shopify session data.
+It provides migrations to create the necessary tables in your database, and it stores and loads session data from them.
+
+The database that works best for you depends on the data your app needs and how it is queried.
+You can run your database of choice on a server yourself or host it with a SaaS company.
+Once you decide which database to use, you can update your Laravel app's `DB_*` environment variables to connect to it, and this template will start using that database for session storage.
+
+### Build
+
+The frontend is a single page React app. It requires the `SHOPIFY_API_KEY` environment variable, which you can find on the page for your app in your partners dashboard.
+The CLI will set up the necessary environment variables for the build if you run its `build` command from your app's root:
+
+Using yarn:
+
+```shell
+yarn build --api-key=REPLACE_ME
+```
+
+Using npm:
+
+```shell
+npm run build --api-key=REPLACE_ME
+```
+
+Using pnpm:
+
+```shell
+pnpm run build --api-key=REPLACE_ME
+```
+
+The app build command will build both the frontend and backend when running as above.
+If you're manually building (for instance when deploying the `web` folder to production), you'll need to build both of them:
+
+```shell
+cd web/frontend
+SHOPIFY_API_KEY=REPLACE_ME yarn build
+cd ..
+composer build
+```
+
+## Hosting
+
+When you're ready to set up your app in production, you can follow [our deployment documentation](https://shopify.dev/docs/apps/deployment/web) to host your app on a cloud provider like [Heroku](https://www.heroku.com/) or [Fly.io](https://fly.io/).
+
+When you reach the step for [setting up environment variables](https://shopify.dev/docs/apps/deployment/web#set-env-vars), you also need to set the following variables:
+
+| Variable | Secret? | Required | Value | Description |
+| ----------------- | :-----: | :------: | :------------: | ----------------------------------------------------------------------------------- |
+| `APP_KEY` | Yes | Yes | string | Run `php web/artisan key:generate --show` to generate one. |
+| `APP_NAME` | | Yes | string | App name for Laravel. |
+| `APP_ENV` | | Yes | `"production"` | |
+| `DB_CONNECTION` | | Yes | string | Set this to the database you want to use, e.g. `"sqlite"`. |
+| `DB_DATABASE` | | Yes | string | Set this to the connection string to your database, e.g. `"/app/storage/db.sqlite"` |
+| `DB_FOREIGN_KEYS` | | | `true` | If your app is using foreign keys. |
+
+## Known issues
+
+### Hot module replacement and Firefox
+
+When running the app with the CLI in development mode on Firefox, you might see your app constantly reloading when you access it.
+That happened in previous versions of the CLI, because of the way HMR websocket requests work.
+
+We fixed this issue with v3.4.0 of the CLI, so after updating it, you can make the following changes to your app's `web/frontend/vite.config.js` file:
+
+1. Change the definition `hmrConfig` object to be:
+
+ ```js
+ const host = process.env.HOST
+ ? process.env.HOST.replace(/https?:\/\//, "")
+ : "localhost";
+
+ let hmrConfig;
+ if (host === "localhost") {
+ hmrConfig = {
+ protocol: "ws",
+ host: "localhost",
+ port: 64999,
+ clientPort: 64999,
+ };
+ } else {
+ hmrConfig = {
+ protocol: "wss",
+ host: host,
+ port: process.env.FRONTEND_PORT,
+ clientPort: 443,
+ };
+ }
+ ```
+
+1. Change the `server.host` setting in the configs to `"localhost"`:
+
+ ```js
+ server: {
+ host: "localhost",
+ ...
+ ```
+
+### I can't get past the ngrok "Visit site" page
+
+When you’re previewing your app or extension, you might see an ngrok interstitial page with a warning:
+
+```
+You are about to visit .ngrok.io: Visit Site
+```
+
+If you click the `Visit Site` button, but continue to see this page, then you should run dev using an alternate tunnel URL that you run using tunneling software.
+We've validated that [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/trycloudflare/) works with this template.
+
+To do that, you can [install the `cloudflared` CLI tool](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/), and run:
+
+```shell
+# Note that you can also use a different port
+cloudflared tunnel --url http://localhost:3000
+```
+In the output produced by `cloudflared tunnel` command, you will notice a https URL where the domain ends with `trycloudflare.com`. This is your tunnel URL. You need to copy this URL as you will need it in the next step.
+```shell
+2022-11-11T19:57:55Z INF Requesting new quick Tunnel on trycloudflare.com...
+2022-11-11T19:57:58Z INF +--------------------------------------------------------------------------------------------+
+2022-11-11T19:57:58Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
+2022-11-11T19:57:58Z INF | https://randomly-generated-hostname.trycloudflare.com |
+2022-11-11T19:57:58Z INF +--------------------------------------------------------------------------------------------+
+```
+
+In a different terminal window, navigate to your app's root and run one of the following commands (replacing `randomly-generated-hostname` with the Cloudflare tunnel URL copied from the output of `cloudflared` command):
+
+```shell
+# Using yarn
+yarn dev --tunnel-url https://randomly-generated-hostname.trycloudflare.com:3000
+# or using npm
+npm run dev --tunnel-url https://randomly-generated-hostname.trycloudflare.com:3000
+# or using pnpm
+pnpm dev --tunnel-url https://randomly-generated-hostname.trycloudflare.com:3000
+```
+
+## Developer resources
+
+- [Introduction to Shopify apps](https://shopify.dev/docs/apps/getting-started)
+- [App authentication](https://shopify.dev/docs/apps/auth)
+- [Shopify CLI](https://shopify.dev/docs/apps/tools/cli)
+- [Shopify API Library documentation](https://github.com/Shopify/shopify-api-php/tree/main/docs)
+- [Getting started with internationalizing your app](https://shopify.dev/docs/apps/best-practices/internationalization/getting-started)
+ - [i18next](https://www.i18next.com/)
+ - [Configuration options](https://www.i18next.com/overview/configuration-options)
+ - [react-i18next](https://react.i18next.com/)
+ - [`useTranslation` hook](https://react.i18next.com/latest/usetranslation-hook)
+ - [`Trans` component usage with components array](https://react.i18next.com/latest/trans-component#alternative-usage-components-array)
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..596079f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "shopify-app-template-php",
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "scripts": {
+ "shopify": "shopify",
+ "build": "shopify app build",
+ "dev": "shopify app dev",
+ "push": "shopify app push",
+ "generate": "shopify app generate",
+ "deploy": "shopify app deploy",
+ "info": "shopify app info"
+ },
+ "dependencies": {
+ "@shopify/app": "^3.0.0",
+ "@shopify/cli": "^3.0.0"
+ }
+}
diff --git a/shopify.app.toml b/shopify.app.toml
new file mode 100644
index 0000000..7659888
--- /dev/null
+++ b/shopify.app.toml
@@ -0,0 +1,3 @@
+# This file stores configurations for your Shopify app.
+
+scopes = "write_products"
diff --git a/web/.env.example b/web/.env.example
new file mode 100644
index 0000000..f7ed9ea
--- /dev/null
+++ b/web/.env.example
@@ -0,0 +1,12 @@
+APP_NAME="Shopify PHP App"
+APP_ENV=local
+APP_KEY=
+APP_DEBUG=true
+
+LOG_CHANNEL=stack
+LOG_LEVEL=debug
+
+DB_CONNECTION=sqlite
+# The path to the database file should be absolute, make sure to update it!
+DB_DATABASE=storage/db.sqlite
+DB_FOREIGN_KEYS=true
diff --git a/web/.env.testing b/web/.env.testing
new file mode 100644
index 0000000..003afb9
--- /dev/null
+++ b/web/.env.testing
@@ -0,0 +1,16 @@
+APP_NAME="Shopify App PHP"
+APP_ENV=testing
+APP_KEY=base64:1A/PQiiIkwNm04ftgLPzG20N//X6XUgoZI25wWYe/bU=
+APP_DEBUG=true
+APP_URL=https://app-host-name.com
+
+SHOPIFY_API_KEY=test-api-key
+SHOPIFY_API_SECRET=test-secret-key
+SCOPES=read_products,write_products
+HOST=app-host-name.com
+
+LOG_CHANNEL=stack
+LOG_LEVEL=debug
+
+DB_CONNECTION=sqlite
+DB_DATABASE=:memory:
diff --git a/web/app/Console/Kernel.php b/web/app/Console/Kernel.php
new file mode 100644
index 0000000..31f4b24
--- /dev/null
+++ b/web/app/Console/Kernel.php
@@ -0,0 +1,41 @@
+command('inspire')->hourly();
+ }
+
+ /**
+ * Register the commands for the application.
+ *
+ * @return void
+ */
+ protected function commands()
+ {
+ $this->load(__DIR__ . '/Commands');
+
+ require base_path('routes/console.php');
+ }
+}
diff --git a/web/app/Exceptions/Handler.php b/web/app/Exceptions/Handler.php
new file mode 100644
index 0000000..c18c43c
--- /dev/null
+++ b/web/app/Exceptions/Handler.php
@@ -0,0 +1,41 @@
+reportable(function (Throwable $e) {
+ //
+ });
+ }
+}
diff --git a/web/app/Exceptions/ShopifyBillingException.php b/web/app/Exceptions/ShopifyBillingException.php
new file mode 100644
index 0000000..6c1502d
--- /dev/null
+++ b/web/app/Exceptions/ShopifyBillingException.php
@@ -0,0 +1,17 @@
+errorData = $errorData;
+ }
+}
diff --git a/web/app/Exceptions/ShopifyProductCreatorException.php b/web/app/Exceptions/ShopifyProductCreatorException.php
new file mode 100644
index 0000000..7894389
--- /dev/null
+++ b/web/app/Exceptions/ShopifyProductCreatorException.php
@@ -0,0 +1,18 @@
+response = $response;
+ }
+}
diff --git a/web/app/Http/Controllers/Controller.php b/web/app/Http/Controllers/Controller.php
new file mode 100644
index 0000000..ce1176d
--- /dev/null
+++ b/web/app/Http/Controllers/Controller.php
@@ -0,0 +1,15 @@
+ [
+ \App\Http\Middleware\EncryptCookies::class,
+ \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
+ \Illuminate\Session\Middleware\StartSession::class,
+ // \Illuminate\Session\Middleware\AuthenticateSession::class,
+ \Illuminate\View\Middleware\ShareErrorsFromSession::class,
+ \App\Http\Middleware\VerifyCsrfToken::class,
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ \App\Http\Middleware\CspHeader::class,
+ ],
+
+ 'api' => [
+ 'throttle:api',
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ],
+ ];
+
+ /**
+ * The application's route middleware.
+ *
+ * These middleware may be assigned to groups or used individually.
+ *
+ * @var array
+ */
+ protected $routeMiddleware = [
+ 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
+ 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
+ 'can' => \Illuminate\Auth\Middleware\Authorize::class,
+ 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
+ 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
+ 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
+ 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
+ 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
+
+ 'shopify.auth' => \App\Http\Middleware\EnsureShopifySession::class,
+ 'shopify.installed' => \App\Http\Middleware\EnsureShopifyInstalled::class,
+ ];
+}
diff --git a/web/app/Http/Middleware/Authenticate.php b/web/app/Http/Middleware/Authenticate.php
new file mode 100644
index 0000000..f09d427
--- /dev/null
+++ b/web/app/Http/Middleware/Authenticate.php
@@ -0,0 +1,9 @@
+query('shop', ''));
+
+ if (Context::$IS_EMBEDDED_APP) {
+ $domainHost = $shop ? "https://$shop" : "*.myshopify.com";
+ $allowedDomains = "$domainHost https://admin.shopify.com";
+ } else {
+ $allowedDomains = "'none'";
+ }
+
+ /** @var Response $response */
+ $response = $next($request);
+
+ $currentHeader = $response->headers->get('Content-Security-Policy');
+ if ($currentHeader) {
+ $values = preg_split("/;\s*/", $currentHeader);
+
+ // Replace or add the URLs the frame-ancestors directive
+ $found = false;
+ foreach ($values as $index => $value) {
+ if (mb_strpos($value, "frame-ancestors") === 0) {
+ $values[$index] = preg_replace("/^(frame-ancestors)/", "$1 $allowedDomains", $value);
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $values[] = "frame-ancestors $allowedDomains";
+ }
+
+ $headerValue = implode("; ", $values);
+ } else {
+ $headerValue = "frame-ancestors $allowedDomains;";
+ }
+
+
+ $response->headers->set('Content-Security-Policy', $headerValue);
+
+ return $response;
+ }
+}
diff --git a/web/app/Http/Middleware/EncryptCookies.php b/web/app/Http/Middleware/EncryptCookies.php
new file mode 100644
index 0000000..033136a
--- /dev/null
+++ b/web/app/Http/Middleware/EncryptCookies.php
@@ -0,0 +1,17 @@
+query('shop') ? Utils::sanitizeShopDomain($request->query('shop')) : null;
+
+ $appInstalled = $shop && Session::where('shop', $shop)->where('access_token', '<>', null)->exists();
+ $isExitingIframe = preg_match("/^ExitIframe/i", $request->path());
+
+ return ($appInstalled || $isExitingIframe) ? $next($request) : AuthRedirection::redirect($request);
+ }
+}
diff --git a/web/app/Http/Middleware/EnsureShopifySession.php b/web/app/Http/Middleware/EnsureShopifySession.php
new file mode 100644
index 0000000..6ee6fea
--- /dev/null
+++ b/web/app/Http/Middleware/EnsureShopifySession.php
@@ -0,0 +1,103 @@
+query('shop', ''));
+ $session = Utils::loadCurrentSession($request->header(), $request->cookie(), $isOnline);
+
+ if ($session && $shop && $session->getShop() !== $shop) {
+ // This request is for a different shop. Go straight to login
+ return AuthRedirection::redirect($request);
+ }
+
+ if ($session && $session->isValid()) {
+ if (Config::get('shopify.billing.required')) {
+ // The request to check billing status serves to validate that the access token is still valid.
+ try {
+ list($hasPayment, $confirmationUrl) =
+ EnsureBilling::check($session, Config::get('shopify.billing'));
+ $proceed = true;
+
+ if (!$hasPayment) {
+ return TopLevelRedirection::redirect($request, $confirmationUrl);
+ }
+ } catch (ShopifyBillingException $e) {
+ $proceed = false;
+ }
+ } else {
+ // Make a request to ensure the access token is still valid. Otherwise, re-authenticate the user.
+ $client = new Graphql($session->getShop(), $session->getAccessToken());
+ $response = $client->query(self::TEST_GRAPHQL_QUERY);
+
+ $proceed = $response->getStatusCode() === 200;
+ }
+
+ if ($proceed) {
+ $request->attributes->set('shopifySession', $session);
+ return $next($request);
+ }
+ }
+
+ $bearerPresent = preg_match("/Bearer (.*)/", $request->header('Authorization', ''), $bearerMatches);
+ if (!$shop) {
+ if ($session) {
+ $shop = $session->getShop();
+ } elseif (Context::$IS_EMBEDDED_APP) {
+ if ($bearerPresent !== false) {
+ $payload = Utils::decodeSessionToken($bearerMatches[1]);
+ $shop = parse_url($payload['dest'], PHP_URL_HOST);
+ }
+ }
+ }
+
+ return TopLevelRedirection::redirect($request, "/api/auth?shop=$shop");
+ }
+}
diff --git a/web/app/Http/Middleware/PreventRequestsDuringMaintenance.php b/web/app/Http/Middleware/PreventRequestsDuringMaintenance.php
new file mode 100644
index 0000000..e4956d0
--- /dev/null
+++ b/web/app/Http/Middleware/PreventRequestsDuringMaintenance.php
@@ -0,0 +1,17 @@
+check()) {
+ return redirect(RouteServiceProvider::HOME);
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/web/app/Http/Middleware/TrimStrings.php b/web/app/Http/Middleware/TrimStrings.php
new file mode 100644
index 0000000..a8a252d
--- /dev/null
+++ b/web/app/Http/Middleware/TrimStrings.php
@@ -0,0 +1,19 @@
+allSubdomainsOfApplicationUrl(),
+ ];
+ }
+}
diff --git a/web/app/Http/Middleware/TrustProxies.php b/web/app/Http/Middleware/TrustProxies.php
new file mode 100644
index 0000000..a297111
--- /dev/null
+++ b/web/app/Http/Middleware/TrustProxies.php
@@ -0,0 +1,28 @@
+query("shop"));
+
+ if (Context::$IS_EMBEDDED_APP && $request->query("embedded", false) === "1") {
+ $redirectUrl = self::clientSideRedirectUrl($shop, $request->query());
+ } else {
+ $redirectUrl = self::serverSideRedirectUrl($shop, $isOnline);
+ }
+
+ return redirect($redirectUrl);
+ }
+
+ private static function serverSideRedirectUrl(string $shop, bool $isOnline): string
+ {
+ return OAuth::begin(
+ $shop,
+ '/api/auth/callback',
+ $isOnline,
+ ['App\Lib\CookieHandler', 'saveShopifyCookie'],
+ );
+ }
+
+ private static function clientSideRedirectUrl($shop, array $query): string
+ {
+ $appHost = Context::$HOST_NAME;
+ $redirectUri = urlencode("https://$appHost/api/auth?shop=$shop");
+
+ $queryString = http_build_query(array_merge($query, ["redirectUri" => $redirectUri]));
+ return "/ExitIframe?$queryString";
+ }
+}
diff --git a/web/app/Lib/CookieHandler.php b/web/app/Lib/CookieHandler.php
new file mode 100644
index 0000000..654fbd0
--- /dev/null
+++ b/web/app/Lib/CookieHandler.php
@@ -0,0 +1,29 @@
+getName(),
+ $cookie->getValue(),
+ $cookie->getExpire() ? ceil(($cookie->getExpire() - time()) / 60) : null,
+ '/',
+ parse_url(Context::$HOST_SCHEME . "://" . Context::$HOST_NAME, PHP_URL_HOST),
+ $cookie->isSecure(),
+ $cookie->isHttpOnly(),
+ false,
+ 'Lax'
+ );
+
+ return true;
+ }
+}
diff --git a/web/app/Lib/DbSessionStorage.php b/web/app/Lib/DbSessionStorage.php
new file mode 100644
index 0000000..ac4ded6
--- /dev/null
+++ b/web/app/Lib/DbSessionStorage.php
@@ -0,0 +1,88 @@
+first();
+
+ if ($dbSession) {
+ $session = new Session(
+ $dbSession->session_id,
+ $dbSession->shop,
+ $dbSession->is_online == 1,
+ $dbSession->state
+ );
+ if ($dbSession->expires_at) {
+ $session->setExpires($dbSession->expires_at);
+ }
+ if ($dbSession->access_token) {
+ $session->setAccessToken($dbSession->access_token);
+ }
+ if ($dbSession->scope) {
+ $session->setScope($dbSession->scope);
+ }
+ if ($dbSession->user_id) {
+ $onlineAccessInfo = new AccessTokenOnlineUserInfo(
+ (int)$dbSession->user_id,
+ $dbSession->user_first_name,
+ $dbSession->user_last_name,
+ $dbSession->user_email,
+ $dbSession->user_email_verified == 1,
+ $dbSession->account_owner == 1,
+ $dbSession->locale,
+ $dbSession->collaborator == 1
+ );
+ $session->setOnlineAccessInfo($onlineAccessInfo);
+ }
+ return $session;
+ }
+ return null;
+ }
+
+ public function storeSession(Session $session): bool
+ {
+ $dbSession = \App\Models\Session::where('session_id', $session->getId())->first();
+ if (!$dbSession) {
+ $dbSession = new \App\Models\Session();
+ }
+ $dbSession->session_id = $session->getId();
+ $dbSession->shop = $session->getShop();
+ $dbSession->state = $session->getState();
+ $dbSession->is_online = $session->isOnline();
+ $dbSession->access_token = $session->getAccessToken();
+ $dbSession->expires_at = $session->getExpires();
+ $dbSession->scope = $session->getScope();
+ if (!empty($session->getOnlineAccessInfo())) {
+ $dbSession->user_id = $session->getOnlineAccessInfo()->getId();
+ $dbSession->user_first_name = $session->getOnlineAccessInfo()->getFirstName();
+ $dbSession->user_last_name = $session->getOnlineAccessInfo()->getLastName();
+ $dbSession->user_email = $session->getOnlineAccessInfo()->getEmail();
+ $dbSession->user_email_verified = $session->getOnlineAccessInfo()->isEmailVerified();
+ $dbSession->account_owner = $session->getOnlineAccessInfo()->isAccountOwner();
+ $dbSession->locale = $session->getOnlineAccessInfo()->getLocale();
+ $dbSession->collaborator = $session->getOnlineAccessInfo()->isCollaborator();
+ }
+ try {
+ return $dbSession->save();
+ } catch (Exception $err) {
+ Log::error("Failed to save session to database: " . $err->getMessage());
+ return false;
+ }
+ }
+
+ public function deleteSession(string $sessionId): bool
+ {
+ return \App\Models\Session::where('session_id', $sessionId)->delete() === 1;
+ }
+}
diff --git a/web/app/Lib/EnsureBilling.php b/web/app/Lib/EnsureBilling.php
new file mode 100644
index 0000000..3211326
--- /dev/null
+++ b/web/app/Lib/EnsureBilling.php
@@ -0,0 +1,267 @@
+ self::ONE_TIME_PURCHASES_QUERY,
+ "variables" => ["endCursor" => $endCursor]
+ ]
+ );
+ $purchases = $responseBody["data"]["currentAppInstallation"]["oneTimePurchases"];
+
+ foreach ($purchases["edges"] as $purchase) {
+ $node = $purchase["node"];
+ if (
+ $node["name"] === $config["chargeName"] &&
+ (!self::isProd() || !$node["test"]) &&
+ $node["status"] === "ACTIVE"
+ ) {
+ return true;
+ }
+ }
+
+ $endCursor = $purchases["pageInfo"]["endCursor"];
+ } while ($purchases["pageInfo"]["hasNextPage"]);
+
+ return false;
+ }
+
+ /**
+ * @return string|null
+ */
+ private static function requestPayment(Session $session, array $config)
+ {
+ $hostName = Context::$HOST_NAME;
+ $shop = $session->getShop();
+ $host = base64_encode("$shop/admin");
+ $returnUrl = "https://$hostName?shop={$shop}&host=$host";
+
+ if (self::isRecurring($config)) {
+ $data = self::requestRecurringPayment($session, $config, $returnUrl);
+ $data = $data["data"]["appSubscriptionCreate"];
+ } else {
+ $data = self::requestOneTimePayment($session, $config, $returnUrl);
+ $data = $data["data"]["appPurchaseOneTimeCreate"];
+ }
+
+ if (!empty($data["userErrors"])) {
+ throw new ShopifyBillingException("Error while billing the store", $data["userErrors"]);
+ }
+
+ return $data["confirmationUrl"];
+ }
+
+ private static function requestRecurringPayment(Session $session, array $config, string $returnUrl): array
+ {
+ return self::queryOrException(
+ $session,
+ [
+ "query" => self::RECURRING_PURCHASE_MUTATION,
+ "variables" => [
+ "name" => $config["chargeName"],
+ "lineItems" => [
+ "plan" => [
+ "appRecurringPricingDetails" => [
+ "interval" => $config["interval"],
+ "price" => ["amount" => $config["amount"], "currencyCode" => $config["currencyCode"]],
+ ],
+ ],
+ ],
+ "returnUrl" => $returnUrl,
+ "test" => !self::isProd(),
+ ],
+ ]
+ );
+ }
+
+ private static function requestOneTimePayment(Session $session, array $config, string $returnUrl): array
+ {
+ return self::queryOrException(
+ $session,
+ [
+ "query" => self::ONE_TIME_PURCHASE_MUTATION,
+ "variables" => [
+ "name" => $config["chargeName"],
+ "price" => ["amount" => $config["amount"], "currencyCode" => $config["currencyCode"]],
+ "returnUrl" => $returnUrl,
+ "test" => !self::isProd(),
+ ],
+ ]
+ );
+ }
+
+ private static function isProd()
+ {
+ return app()->environment() === 'production';
+ }
+
+ private static function isRecurring(array $config): bool
+ {
+ return in_array($config["interval"], self::$RECURRING_INTERVALS);
+ }
+
+ /**
+ * @param string|array $query
+ */
+ private static function queryOrException(Session $session, $query): array
+ {
+ $client = new Graphql($session->getShop(), $session->getAccessToken());
+
+ $response = $client->query($query);
+ $responseBody = $response->getDecodedBody();
+
+ if (!empty($responseBody["errors"])) {
+ throw new ShopifyBillingException("Error while billing the store", (array)$responseBody["errors"]);
+ }
+
+ return $responseBody;
+ }
+
+ private const RECURRING_PURCHASES_QUERY = <<<'QUERY'
+ query appSubscription {
+ currentAppInstallation {
+ activeSubscriptions {
+ name, test
+ }
+ }
+ }
+ QUERY;
+
+ private const ONE_TIME_PURCHASES_QUERY = <<<'QUERY'
+ query appPurchases($endCursor: String) {
+ currentAppInstallation {
+ oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) {
+ edges {
+ node {
+ name, test, status
+ }
+ }
+ pageInfo {
+ hasNextPage, endCursor
+ }
+ }
+ }
+ }
+ QUERY;
+
+ private const RECURRING_PURCHASE_MUTATION = <<<'QUERY'
+ mutation createPaymentMutation(
+ $name: String!
+ $lineItems: [AppSubscriptionLineItemInput!]!
+ $returnUrl: URL!
+ $test: Boolean
+ ) {
+ appSubscriptionCreate(
+ name: $name
+ lineItems: $lineItems
+ returnUrl: $returnUrl
+ test: $test
+ ) {
+ confirmationUrl
+ userErrors {
+ field, message
+ }
+ }
+ }
+ QUERY;
+
+ private const ONE_TIME_PURCHASE_MUTATION = <<<'QUERY'
+ mutation createPaymentMutation(
+ $name: String!
+ $price: MoneyInput!
+ $returnUrl: URL!
+ $test: Boolean
+ ) {
+ appPurchaseOneTimeCreate(
+ name: $name
+ price: $price
+ returnUrl: $returnUrl
+ test: $test
+ ) {
+ confirmationUrl
+ userErrors {
+ field, message
+ }
+ }
+ }
+ QUERY;
+}
diff --git a/web/app/Lib/Handlers/AppUninstalled.php b/web/app/Lib/Handlers/AppUninstalled.php
new file mode 100644
index 0000000..c04e636
--- /dev/null
+++ b/web/app/Lib/Handlers/AppUninstalled.php
@@ -0,0 +1,18 @@
+delete();
+ }
+}
diff --git a/web/app/Lib/Handlers/Gdpr/CustomersDataRequest.php b/web/app/Lib/Handlers/Gdpr/CustomersDataRequest.php
new file mode 100644
index 0000000..c83b05c
--- /dev/null
+++ b/web/app/Lib/Handlers/Gdpr/CustomersDataRequest.php
@@ -0,0 +1,40 @@
+getShop(), $session->getAccessToken());
+
+ for ($i = 0; $i < $count; $i++) {
+ $response = $client->query(
+ [
+ "query" => self::CREATE_PRODUCTS_MUTATION,
+ "variables" => [
+ "input" => [
+ "title" => self::randomTitle(),
+ "variants" => [["price" => self::randomPrice()]],
+ ]
+ ]
+ ],
+ );
+
+ if ($response->getStatusCode() !== 200) {
+ throw new ShopifyProductCreatorException($response->getBody()->__toString(), $response);
+ }
+ }
+ }
+
+ private static function randomTitle()
+ {
+ $adjective = self::ADJECTIVES[mt_rand(0, count(self::ADJECTIVES) - 1)];
+ $noun = self::NOUNS[mt_rand(0, count(self::NOUNS) - 1)];
+
+ return "$adjective $noun";
+ }
+
+ private static function randomPrice()
+ {
+
+ return (100.0 + mt_rand(0, 1000)) / 100;
+ }
+
+ private const ADJECTIVES = [
+ "autumn",
+ "hidden",
+ "bitter",
+ "misty",
+ "silent",
+ "empty",
+ "dry",
+ "dark",
+ "summer",
+ "icy",
+ "delicate",
+ "quiet",
+ "white",
+ "cool",
+ "spring",
+ "winter",
+ "patient",
+ "twilight",
+ "dawn",
+ "crimson",
+ "wispy",
+ "weathered",
+ "blue",
+ "billowing",
+ "broken",
+ "cold",
+ "damp",
+ "falling",
+ "frosty",
+ "green",
+ "long",
+ ];
+
+ private const NOUNS = [
+ "waterfall",
+ "river",
+ "breeze",
+ "moon",
+ "rain",
+ "wind",
+ "sea",
+ "morning",
+ "snow",
+ "lake",
+ "sunset",
+ "pine",
+ "shadow",
+ "leaf",
+ "dawn",
+ "glitter",
+ "forest",
+ "hill",
+ "cloud",
+ "meadow",
+ "sun",
+ "glade",
+ "bird",
+ "brook",
+ "butterfly",
+ "bush",
+ "dew",
+ "dust",
+ "field",
+ "fire",
+ "flower",
+ ];
+}
diff --git a/web/app/Lib/TopLevelRedirection.php b/web/app/Lib/TopLevelRedirection.php
new file mode 100644
index 0000000..651bd29
--- /dev/null
+++ b/web/app/Lib/TopLevelRedirection.php
@@ -0,0 +1,26 @@
+header('Authorization', ''));
+ if ($bearerPresent !== false) {
+ return response('', 401, [
+ self::REDIRECT_HEADER => '1',
+ self::REDIRECT_URL_HEADER => $redirectUrl,
+ ]);
+ } else {
+ return redirect($redirectUrl);
+ }
+ }
+}
diff --git a/web/app/Models/Session.php b/web/app/Models/Session.php
new file mode 100644
index 0000000..4962fc8
--- /dev/null
+++ b/web/app/Models/Session.php
@@ -0,0 +1,11 @@
+ 'App\Policies\ModelPolicy',
+ ];
+
+ /**
+ * Register any authentication / authorization services.
+ *
+ * @return void
+ */
+ public function boot()
+ {
+ $this->registerPolicies();
+
+ //
+ }
+}
diff --git a/web/app/Providers/BroadcastServiceProvider.php b/web/app/Providers/BroadcastServiceProvider.php
new file mode 100644
index 0000000..395c518
--- /dev/null
+++ b/web/app/Providers/BroadcastServiceProvider.php
@@ -0,0 +1,21 @@
+ [
+ SendEmailVerificationNotification::class,
+ ],
+ ];
+
+ /**
+ * Register any events for your application.
+ *
+ * @return void
+ */
+ public function boot()
+ {
+ //
+ }
+}
diff --git a/web/app/Providers/RouteServiceProvider.php b/web/app/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c14ae3a
--- /dev/null
+++ b/web/app/Providers/RouteServiceProvider.php
@@ -0,0 +1,63 @@
+configureRateLimiting();
+
+ $this->routes(function () {
+ Route::prefix('api')
+ ->middleware('api')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/api.php'));
+
+ Route::middleware('web')
+ ->namespace($this->namespace)
+ ->group(base_path('routes/web.php'));
+ });
+ }
+
+ /**
+ * Configure the rate limiters for the application.
+ *
+ * @return void
+ */
+ protected function configureRateLimiting()
+ {
+ RateLimiter::for('api', function (Request $request) {
+ return Limit::perMinute(60)->by($request->ip());
+ });
+ }
+}
diff --git a/web/artisan b/web/artisan
new file mode 100755
index 0000000..f82e421
--- /dev/null
+++ b/web/artisan
@@ -0,0 +1,53 @@
+#!/usr/bin/env php
+make(Illuminate\Contracts\Console\Kernel::class);
+
+$status = $kernel->handle(
+ $input = new Symfony\Component\Console\Input\ArgvInput,
+ new Symfony\Component\Console\Output\ConsoleOutput
+);
+
+/*
+|--------------------------------------------------------------------------
+| Shutdown The Application
+|--------------------------------------------------------------------------
+|
+| Once Artisan has finished running, we will fire off the shutdown events
+| so that any final work may be done by the application before we shut
+| down the process. This is the last thing to happen to the request.
+|
+*/
+
+$kernel->terminate($input, $status);
+
+exit($status);
diff --git a/web/bootstrap/app.php b/web/bootstrap/app.php
new file mode 100644
index 0000000..037e17d
--- /dev/null
+++ b/web/bootstrap/app.php
@@ -0,0 +1,55 @@
+singleton(
+ Illuminate\Contracts\Http\Kernel::class,
+ App\Http\Kernel::class
+);
+
+$app->singleton(
+ Illuminate\Contracts\Console\Kernel::class,
+ App\Console\Kernel::class
+);
+
+$app->singleton(
+ Illuminate\Contracts\Debug\ExceptionHandler::class,
+ App\Exceptions\Handler::class
+);
+
+/*
+|--------------------------------------------------------------------------
+| Return The Application
+|--------------------------------------------------------------------------
+|
+| This script returns the application instance. The instance is given to
+| the calling script so we can separate the building of the instances
+| from the actual running of the application and sending responses.
+|
+*/
+
+return $app;
diff --git a/web/bootstrap/cache/.gitignore b/web/bootstrap/cache/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/bootstrap/cache/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/composer.json b/web/composer.json
new file mode 100644
index 0000000..dc6382c
--- /dev/null
+++ b/web/composer.json
@@ -0,0 +1,75 @@
+{
+ "name": "laravel/laravel",
+ "type": "project",
+ "description": "The Laravel Framework.",
+ "keywords": [
+ "framework",
+ "laravel"
+ ],
+ "license": "UNLICENSED",
+ "require": {
+ "php": "~8.0.0 || ~8.1.0 || ~8.2.0",
+ "ext-xml": "*",
+ "ext-zip": "*",
+ "doctrine/dbal": "^3.1",
+ "fideloper/proxy": "^4.4",
+ "fruitcake/laravel-cors": "^3.0",
+ "guzzlehttp/guzzle": "^7.0.1",
+ "laravel/framework": "^8.12",
+ "laravel/tinker": "^2.5",
+ "shopify/shopify-api": "^5.0",
+ "squizlabs/php_codesniffer": "^3.6"
+ },
+ "require-dev": {
+ "facade/ignition": "^2.5",
+ "fakerphp/faker": "^1.9.1",
+ "laravel/sail": "^1.0.1",
+ "mockery/mockery": "^1.4.2",
+ "nunomaduro/collision": "^5.0",
+ "phpunit/phpunit": "^9.3.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Factories\\": "database/factories/",
+ "Database\\Seeders\\": "database/seeders/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "post-autoload-dump": [
+ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
+ "@php artisan package:discover --ansi"
+ ],
+ "post-root-package-install": [
+ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
+ ],
+ "post-create-project-cmd": [
+ "@php artisan key:generate --ansi"
+ ],
+ "serve": [
+ "Composer\\Config::disableProcessTimeout",
+ "php artisan serve"
+ ],
+ "react": "npm run watch",
+ "lint": "./vendor/bin/phpcs --standard=PSR12 app routes",
+ "build": "composer build-frontend-links",
+ "build-frontend-links": "ln -sf ../frontend/dist/assets public/assets && ln -sf ../frontend/dist/index.html public/index.html"
+ },
+ "extra": {
+ "laravel": {
+ "dont-discover": []
+ }
+ },
+ "config": {
+ "optimize-autoloader": true,
+ "preferred-install": "dist",
+ "sort-packages": true
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
+}
diff --git a/web/config/app.php b/web/config/app.php
new file mode 100644
index 0000000..2dc6c8f
--- /dev/null
+++ b/web/config/app.php
@@ -0,0 +1,233 @@
+ env('APP_NAME', 'Laravel'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Environment
+ |--------------------------------------------------------------------------
+ |
+ | This value determines the "environment" your application is currently
+ | running in. This may determine how you prefer to configure various
+ | services the application utilizes. Set this in your ".env" file.
+ |
+ */
+
+ 'env' => env('APP_ENV', 'production'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Debug Mode
+ |--------------------------------------------------------------------------
+ |
+ | When your application is in debug mode, detailed error messages with
+ | stack traces will be shown on every error that occurs within your
+ | application. If disabled, a simple generic error page is shown.
+ |
+ */
+
+ 'debug' => (bool) env('APP_DEBUG', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application URL
+ |--------------------------------------------------------------------------
+ |
+ | This URL is used by the console to properly generate URLs when using
+ | the Artisan command line tool. You should set this to the root of
+ | your application so that it is used when running Artisan tasks.
+ |
+ */
+
+ 'url' => env('HOST', 'http://localhost'),
+
+ 'asset_url' => env('ASSET_URL', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Timezone
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the default timezone for your application, which
+ | will be used by the PHP date and date-time functions. We have gone
+ | ahead and set this to a sensible default for you out of the box.
+ |
+ */
+
+ 'timezone' => 'UTC',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Locale Configuration
+ |--------------------------------------------------------------------------
+ |
+ | The application locale determines the default locale that will be used
+ | by the translation service provider. You are free to set this value
+ | to any of the locales which will be supported by the application.
+ |
+ */
+
+ 'locale' => 'en',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Application Fallback Locale
+ |--------------------------------------------------------------------------
+ |
+ | The fallback locale determines the locale to use when the current one
+ | is not available. You may change the value to correspond to any of
+ | the language folders that are provided through your application.
+ |
+ */
+
+ 'fallback_locale' => 'en',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Faker Locale
+ |--------------------------------------------------------------------------
+ |
+ | This locale will be used by the Faker PHP library when generating fake
+ | data for your database seeds. For example, this will be used to get
+ | localized telephone numbers, street address information and more.
+ |
+ */
+
+ 'faker_locale' => 'en_US',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Encryption Key
+ |--------------------------------------------------------------------------
+ |
+ | This key is used by the Illuminate encrypter service and should be set
+ | to a random, 32 character string, otherwise these encrypted strings
+ | will not be safe. Please do this before deploying an application!
+ |
+ */
+
+ 'key' => env('APP_KEY'),
+
+ 'cipher' => 'AES-256-CBC',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Autoloaded Service Providers
+ |--------------------------------------------------------------------------
+ |
+ | The service providers listed here will be automatically loaded on the
+ | request to your application. Feel free to add your own services to
+ | this array to grant expanded functionality to your applications.
+ |
+ */
+
+ 'providers' => [
+
+ /*
+ * Laravel Framework Service Providers...
+ */
+ Illuminate\Auth\AuthServiceProvider::class,
+ Illuminate\Broadcasting\BroadcastServiceProvider::class,
+ Illuminate\Bus\BusServiceProvider::class,
+ Illuminate\Cache\CacheServiceProvider::class,
+ Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
+ Illuminate\Cookie\CookieServiceProvider::class,
+ Illuminate\Database\DatabaseServiceProvider::class,
+ Illuminate\Encryption\EncryptionServiceProvider::class,
+ Illuminate\Filesystem\FilesystemServiceProvider::class,
+ Illuminate\Foundation\Providers\FoundationServiceProvider::class,
+ Illuminate\Hashing\HashServiceProvider::class,
+ Illuminate\Mail\MailServiceProvider::class,
+ Illuminate\Notifications\NotificationServiceProvider::class,
+ Illuminate\Pagination\PaginationServiceProvider::class,
+ Illuminate\Pipeline\PipelineServiceProvider::class,
+ Illuminate\Queue\QueueServiceProvider::class,
+ Illuminate\Redis\RedisServiceProvider::class,
+ Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
+ Illuminate\Session\SessionServiceProvider::class,
+ Illuminate\Translation\TranslationServiceProvider::class,
+ Illuminate\Validation\ValidationServiceProvider::class,
+ Illuminate\View\ViewServiceProvider::class,
+
+ /*
+ * Package Service Providers...
+ */
+
+ /*
+ * Application Service Providers...
+ */
+ App\Providers\AppServiceProvider::class,
+ App\Providers\AuthServiceProvider::class,
+ // App\Providers\BroadcastServiceProvider::class,
+ App\Providers\EventServiceProvider::class,
+ App\Providers\RouteServiceProvider::class,
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Class Aliases
+ |--------------------------------------------------------------------------
+ |
+ | This array of class aliases will be registered when this application
+ | is started. However, feel free to register as many as you wish as
+ | the aliases are "lazy" loaded so they don't hinder performance.
+ |
+ */
+
+ 'aliases' => [
+
+ 'App' => Illuminate\Support\Facades\App::class,
+ 'Arr' => Illuminate\Support\Arr::class,
+ 'Artisan' => Illuminate\Support\Facades\Artisan::class,
+ 'Auth' => Illuminate\Support\Facades\Auth::class,
+ 'Blade' => Illuminate\Support\Facades\Blade::class,
+ 'Broadcast' => Illuminate\Support\Facades\Broadcast::class,
+ 'Bus' => Illuminate\Support\Facades\Bus::class,
+ 'Cache' => Illuminate\Support\Facades\Cache::class,
+ 'Config' => Illuminate\Support\Facades\Config::class,
+ 'Cookie' => Illuminate\Support\Facades\Cookie::class,
+ 'Crypt' => Illuminate\Support\Facades\Crypt::class,
+ 'Date' => Illuminate\Support\Facades\Date::class,
+ 'DB' => Illuminate\Support\Facades\DB::class,
+ 'Eloquent' => Illuminate\Database\Eloquent\Model::class,
+ 'Event' => Illuminate\Support\Facades\Event::class,
+ 'File' => Illuminate\Support\Facades\File::class,
+ 'Gate' => Illuminate\Support\Facades\Gate::class,
+ 'Hash' => Illuminate\Support\Facades\Hash::class,
+ 'Http' => Illuminate\Support\Facades\Http::class,
+ 'Lang' => Illuminate\Support\Facades\Lang::class,
+ 'Log' => Illuminate\Support\Facades\Log::class,
+ 'Mail' => Illuminate\Support\Facades\Mail::class,
+ 'Notification' => Illuminate\Support\Facades\Notification::class,
+ 'Password' => Illuminate\Support\Facades\Password::class,
+ 'Queue' => Illuminate\Support\Facades\Queue::class,
+ 'Redirect' => Illuminate\Support\Facades\Redirect::class,
+ // 'Redis' => Illuminate\Support\Facades\Redis::class,
+ 'Request' => Illuminate\Support\Facades\Request::class,
+ 'Response' => Illuminate\Support\Facades\Response::class,
+ 'Route' => Illuminate\Support\Facades\Route::class,
+ 'Schema' => Illuminate\Support\Facades\Schema::class,
+ 'Session' => Illuminate\Support\Facades\Session::class,
+ 'Storage' => Illuminate\Support\Facades\Storage::class,
+ 'Str' => Illuminate\Support\Str::class,
+ 'URL' => Illuminate\Support\Facades\URL::class,
+ 'Validator' => Illuminate\Support\Facades\Validator::class,
+ 'View' => Illuminate\Support\Facades\View::class,
+
+ ],
+
+];
diff --git a/web/config/auth.php b/web/config/auth.php
new file mode 100644
index 0000000..365d479
--- /dev/null
+++ b/web/config/auth.php
@@ -0,0 +1,112 @@
+ [
+// 'guard' => 'web',
+// 'passwords' => 'users',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Authentication Guards
+ |--------------------------------------------------------------------------
+ |
+ | Next, you may define every authentication guard for your application.
+ | Of course, a great default configuration has been defined for you
+ | here which uses session storage and the Eloquent user provider.
+ |
+ | All authentication drivers have a user provider. This defines how the
+ | users are actually retrieved out of your database or other storage
+ | mechanisms used by this application to persist your user's data.
+ |
+ | Supported: "session", "token"
+ |
+ */
+
+ 'guards' => [
+// 'web' => [
+// 'driver' => 'session',
+// 'provider' => 'users',
+// ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | User Providers
+ |--------------------------------------------------------------------------
+ |
+ | All authentication drivers have a user provider. This defines how the
+ | users are actually retrieved out of your database or other storage
+ | mechanisms used by this application to persist your user's data.
+ |
+ | If you have multiple user tables or models you may configure multiple
+ | sources which represent each model / table. These sources may then
+ | be assigned to any extra authentication guards you have defined.
+ |
+ | Supported: "database", "eloquent"
+ |
+ */
+
+ 'providers' => [
+// 'users' => [
+// 'driver' => 'eloquent',
+// 'model' => App\Models\User::class,
+// ],
+
+ // 'users' => [
+ // 'driver' => 'database',
+ // 'table' => 'users',
+ // ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Resetting Passwords
+ |--------------------------------------------------------------------------
+ |
+ | You may specify multiple password reset configurations if you have more
+ | than one user table or model in the application and you want to have
+ | separate password reset settings based on the specific user types.
+ |
+ | The expire time is the number of minutes that the reset token should be
+ | considered valid. This security feature keeps tokens short-lived so
+ | they have less time to be guessed. You may change this as needed.
+ |
+ */
+
+// 'passwords' => [
+// 'users' => [
+// 'provider' => 'users',
+// 'table' => 'password_resets',
+// 'expire' => 60,
+// 'throttle' => 60,
+// ],
+// ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Password Confirmation Timeout
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define the amount of seconds before a password confirmation
+ | times out and the user is prompted to re-enter their password via the
+ | confirmation screen. By default, the timeout lasts for three hours.
+ |
+ */
+
+// 'password_timeout' => 10800,
+
+];
diff --git a/web/config/broadcasting.php b/web/config/broadcasting.php
new file mode 100644
index 0000000..2d52982
--- /dev/null
+++ b/web/config/broadcasting.php
@@ -0,0 +1,64 @@
+ env('BROADCAST_DRIVER', 'null'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Broadcast Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define all of the broadcast connections that will be used
+ | to broadcast events to other systems or over websockets. Samples of
+ | each available type of connection are provided inside this array.
+ |
+ */
+
+ 'connections' => [
+
+ 'pusher' => [
+ 'driver' => 'pusher',
+ 'key' => env('PUSHER_APP_KEY'),
+ 'secret' => env('PUSHER_APP_SECRET'),
+ 'app_id' => env('PUSHER_APP_ID'),
+ 'options' => [
+ 'cluster' => env('PUSHER_APP_CLUSTER'),
+ 'useTLS' => true,
+ ],
+ ],
+
+ 'ably' => [
+ 'driver' => 'ably',
+ 'key' => env('ABLY_KEY'),
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'default',
+ ],
+
+ 'log' => [
+ 'driver' => 'log',
+ ],
+
+ 'null' => [
+ 'driver' => 'null',
+ ],
+
+ ],
+
+];
diff --git a/web/config/cache.php b/web/config/cache.php
new file mode 100644
index 0000000..e32a2fd
--- /dev/null
+++ b/web/config/cache.php
@@ -0,0 +1,106 @@
+ env('CACHE_DRIVER', 'file'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Stores
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define all of the cache "stores" for your application as
+ | well as their drivers. You may even define multiple stores for the
+ | same cache driver to group types of items stored in your caches.
+ |
+ | Supported drivers: "apc", "array", "database", "file",
+ | "memcached", "redis", "dynamodb", "null"
+ |
+ */
+
+ 'stores' => [
+
+ 'apc' => [
+ 'driver' => 'apc',
+ ],
+
+ 'array' => [
+ 'driver' => 'array',
+ 'serialize' => false,
+ ],
+
+ 'database' => [
+ 'driver' => 'database',
+ 'table' => 'cache',
+ 'connection' => null,
+ 'lock_connection' => null,
+ ],
+
+ 'file' => [
+ 'driver' => 'file',
+ 'path' => storage_path('framework/cache/data'),
+ ],
+
+ 'memcached' => [
+ 'driver' => 'memcached',
+ 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
+ 'sasl' => [
+ env('MEMCACHED_USERNAME'),
+ env('MEMCACHED_PASSWORD'),
+ ],
+ 'options' => [
+ // Memcached::OPT_CONNECT_TIMEOUT => 2000,
+ ],
+ 'servers' => [
+ [
+ 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
+ 'port' => env('MEMCACHED_PORT', 11211),
+ 'weight' => 100,
+ ],
+ ],
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'cache',
+ 'lock_connection' => 'default',
+ ],
+
+ 'dynamodb' => [
+ 'driver' => 'dynamodb',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
+ 'endpoint' => env('DYNAMODB_ENDPOINT'),
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Key Prefix
+ |--------------------------------------------------------------------------
+ |
+ | When utilizing a RAM based store such as APC or Memcached, there might
+ | be other applications utilizing the same cache. So, we'll specify a
+ | value to get prefixed to all our keys so we can avoid collisions.
+ |
+ */
+
+ 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'),
+
+];
diff --git a/web/config/cors.php b/web/config/cors.php
new file mode 100644
index 0000000..8a39e6d
--- /dev/null
+++ b/web/config/cors.php
@@ -0,0 +1,34 @@
+ ['api/*', 'sanctum/csrf-cookie'],
+
+ 'allowed_methods' => ['*'],
+
+ 'allowed_origins' => ['*'],
+
+ 'allowed_origins_patterns' => [],
+
+ 'allowed_headers' => ['*'],
+
+ 'exposed_headers' => [],
+
+ 'max_age' => 0,
+
+ 'supports_credentials' => false,
+
+];
diff --git a/web/config/database.php b/web/config/database.php
new file mode 100644
index 0000000..b42d9b3
--- /dev/null
+++ b/web/config/database.php
@@ -0,0 +1,147 @@
+ env('DB_CONNECTION', 'mysql'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Database Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here are each of the database connections setup for your application.
+ | Of course, examples of configuring each database platform that is
+ | supported by Laravel is shown below to make development simple.
+ |
+ |
+ | All database work in Laravel is done through the PHP PDO facilities
+ | so make sure you have the driver for your particular database of
+ | choice installed on your machine before you begin development.
+ |
+ */
+
+ 'connections' => [
+
+ 'sqlite' => [
+ 'driver' => 'sqlite',
+ 'url' => env('DATABASE_URL'),
+ 'database' => env('DB_DATABASE', database_path('database.sqlite')),
+ 'prefix' => '',
+ 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
+ ],
+
+ 'mysql' => [
+ 'driver' => 'mysql',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '3306'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'unix_socket' => env('DB_SOCKET', ''),
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
+ 'pgsql' => [
+ 'driver' => 'pgsql',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', '127.0.0.1'),
+ 'port' => env('DB_PORT', '5432'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => 'utf8',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'schema' => 'public',
+ 'sslmode' => 'prefer',
+ ],
+
+ 'sqlsrv' => [
+ 'driver' => 'sqlsrv',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', 'localhost'),
+ 'port' => env('DB_PORT', '1433'),
+ 'database' => env('DB_DATABASE', 'forge'),
+ 'username' => env('DB_USERNAME', 'forge'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => 'utf8',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Migration Repository Table
+ |--------------------------------------------------------------------------
+ |
+ | This table keeps track of all the migrations that have already run for
+ | your application. Using this information, we can determine which of
+ | the migrations on disk haven't actually been run in the database.
+ |
+ */
+
+ 'migrations' => 'migrations',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Redis Databases
+ |--------------------------------------------------------------------------
+ |
+ | Redis is an open source, fast, and advanced key-value store that also
+ | provides a richer body of commands than a typical key-value system
+ | such as APC or Memcached. Laravel makes it easy to dig right in.
+ |
+ */
+
+ 'redis' => [
+
+ 'client' => env('REDIS_CLIENT', 'phpredis'),
+
+ 'options' => [
+ 'cluster' => env('REDIS_CLUSTER', 'redis'),
+ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
+ ],
+
+ 'default' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'password' => env('REDIS_PASSWORD', null),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_DB', '0'),
+ ],
+
+ 'cache' => [
+ 'url' => env('REDIS_URL'),
+ 'host' => env('REDIS_HOST', '127.0.0.1'),
+ 'password' => env('REDIS_PASSWORD', null),
+ 'port' => env('REDIS_PORT', '6379'),
+ 'database' => env('REDIS_CACHE_DB', '1'),
+ ],
+
+ ],
+
+];
diff --git a/web/config/filesystems.php b/web/config/filesystems.php
new file mode 100644
index 0000000..10c9d9b
--- /dev/null
+++ b/web/config/filesystems.php
@@ -0,0 +1,72 @@
+ env('FILESYSTEM_DRIVER', 'local'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Filesystem Disks
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure as many filesystem "disks" as you wish, and you
+ | may even configure multiple disks of the same driver. Defaults have
+ | been setup for each driver as an example of the required options.
+ |
+ | Supported Drivers: "local", "ftp", "sftp", "s3"
+ |
+ */
+
+ 'disks' => [
+
+ 'local' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app'),
+ ],
+
+ 'public' => [
+ 'driver' => 'local',
+ 'root' => storage_path('app/public'),
+ 'url' => env('APP_URL').'/storage',
+ 'visibility' => 'public',
+ ],
+
+ 's3' => [
+ 'driver' => 's3',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION'),
+ 'bucket' => env('AWS_BUCKET'),
+ 'url' => env('AWS_URL'),
+ 'endpoint' => env('AWS_ENDPOINT'),
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Symbolic Links
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the symbolic links that will be created when the
+ | `storage:link` Artisan command is executed. The array keys should be
+ | the locations of the links and the values should be their targets.
+ |
+ */
+
+ 'links' => [
+ public_path('storage') => storage_path('app/public'),
+ ],
+
+];
diff --git a/web/config/hashing.php b/web/config/hashing.php
new file mode 100644
index 0000000..8425770
--- /dev/null
+++ b/web/config/hashing.php
@@ -0,0 +1,52 @@
+ 'bcrypt',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Bcrypt Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Bcrypt algorithm. This will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'bcrypt' => [
+ 'rounds' => env('BCRYPT_ROUNDS', 10),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Argon Options
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the configuration options that should be used when
+ | passwords are hashed using the Argon algorithm. These will allow you
+ | to control the amount of time it takes to hash the given password.
+ |
+ */
+
+ 'argon' => [
+ 'memory' => 1024,
+ 'threads' => 2,
+ 'time' => 2,
+ ],
+
+];
diff --git a/web/config/logging.php b/web/config/logging.php
new file mode 100644
index 0000000..1aa06aa
--- /dev/null
+++ b/web/config/logging.php
@@ -0,0 +1,105 @@
+ env('LOG_CHANNEL', 'stack'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Log Channels
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the log channels for your application. Out of
+ | the box, Laravel uses the Monolog PHP logging library. This gives
+ | you a variety of powerful log handlers / formatters to utilize.
+ |
+ | Available Drivers: "single", "daily", "slack", "syslog",
+ | "errorlog", "monolog",
+ | "custom", "stack"
+ |
+ */
+
+ 'channels' => [
+ 'stack' => [
+ 'driver' => 'stack',
+ 'channels' => ['single'],
+ 'ignore_exceptions' => false,
+ ],
+
+ 'single' => [
+ 'driver' => 'single',
+ 'path' => storage_path('logs/laravel.log'),
+ 'level' => env('LOG_LEVEL', 'debug'),
+ ],
+
+ 'daily' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/laravel.log'),
+ 'level' => env('LOG_LEVEL', 'debug'),
+ 'days' => 14,
+ ],
+
+ 'slack' => [
+ 'driver' => 'slack',
+ 'url' => env('LOG_SLACK_WEBHOOK_URL'),
+ 'username' => 'Laravel Log',
+ 'emoji' => ':boom:',
+ 'level' => env('LOG_LEVEL', 'critical'),
+ ],
+
+ 'papertrail' => [
+ 'driver' => 'monolog',
+ 'level' => env('LOG_LEVEL', 'debug'),
+ 'handler' => SyslogUdpHandler::class,
+ 'handler_with' => [
+ 'host' => env('PAPERTRAIL_URL'),
+ 'port' => env('PAPERTRAIL_PORT'),
+ ],
+ ],
+
+ 'stderr' => [
+ 'driver' => 'monolog',
+ 'level' => env('LOG_LEVEL', 'debug'),
+ 'handler' => StreamHandler::class,
+ 'formatter' => env('LOG_STDERR_FORMATTER'),
+ 'with' => [
+ 'stream' => 'php://stderr',
+ ],
+ ],
+
+ 'syslog' => [
+ 'driver' => 'syslog',
+ 'level' => env('LOG_LEVEL', 'debug'),
+ ],
+
+ 'errorlog' => [
+ 'driver' => 'errorlog',
+ 'level' => env('LOG_LEVEL', 'debug'),
+ ],
+
+ 'null' => [
+ 'driver' => 'monolog',
+ 'handler' => NullHandler::class,
+ ],
+
+ 'emergency' => [
+ 'path' => storage_path('logs/laravel.log'),
+ ],
+ ],
+
+];
diff --git a/web/config/mail.php b/web/config/mail.php
new file mode 100644
index 0000000..54299aa
--- /dev/null
+++ b/web/config/mail.php
@@ -0,0 +1,110 @@
+ env('MAIL_MAILER', 'smtp'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Mailer Configurations
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure all of the mailers used by your application plus
+ | their respective settings. Several examples have been configured for
+ | you and you are free to add your own as your application requires.
+ |
+ | Laravel supports a variety of mail "transport" drivers to be used while
+ | sending an e-mail. You will specify which one you are using for your
+ | mailers below. You are free to add additional mailers as required.
+ |
+ | Supported: "smtp", "sendmail", "mailgun", "ses",
+ | "postmark", "log", "array"
+ |
+ */
+
+ 'mailers' => [
+ 'smtp' => [
+ 'transport' => 'smtp',
+ 'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
+ 'port' => env('MAIL_PORT', 587),
+ 'encryption' => env('MAIL_ENCRYPTION', 'tls'),
+ 'username' => env('MAIL_USERNAME'),
+ 'password' => env('MAIL_PASSWORD'),
+ 'timeout' => null,
+ 'auth_mode' => null,
+ ],
+
+ 'ses' => [
+ 'transport' => 'ses',
+ ],
+
+ 'mailgun' => [
+ 'transport' => 'mailgun',
+ ],
+
+ 'postmark' => [
+ 'transport' => 'postmark',
+ ],
+
+ 'sendmail' => [
+ 'transport' => 'sendmail',
+ 'path' => '/usr/sbin/sendmail -bs',
+ ],
+
+ 'log' => [
+ 'transport' => 'log',
+ 'channel' => env('MAIL_LOG_CHANNEL'),
+ ],
+
+ 'array' => [
+ 'transport' => 'array',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Global "From" Address
+ |--------------------------------------------------------------------------
+ |
+ | You may wish for all e-mails sent by your application to be sent from
+ | the same address. Here, you may specify a name and address that is
+ | used globally for all e-mails that are sent by your application.
+ |
+ */
+
+ 'from' => [
+ 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
+ 'name' => env('MAIL_FROM_NAME', 'Example'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Markdown Mail Settings
+ |--------------------------------------------------------------------------
+ |
+ | If you are using Markdown based email rendering, you may configure your
+ | theme and component paths here, allowing you to customize the design
+ | of the emails. Or, you may simply stick with the Laravel defaults!
+ |
+ */
+
+ 'markdown' => [
+ 'theme' => 'default',
+
+ 'paths' => [
+ resource_path('views/vendor/mail'),
+ ],
+ ],
+
+];
diff --git a/web/config/queue.php b/web/config/queue.php
new file mode 100644
index 0000000..25ea5a8
--- /dev/null
+++ b/web/config/queue.php
@@ -0,0 +1,93 @@
+ env('QUEUE_CONNECTION', 'sync'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Connections
+ |--------------------------------------------------------------------------
+ |
+ | Here you may configure the connection information for each server that
+ | is used by your application. A default configuration has been added
+ | for each back-end shipped with Laravel. You are free to add more.
+ |
+ | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
+ |
+ */
+
+ 'connections' => [
+
+ 'sync' => [
+ 'driver' => 'sync',
+ ],
+
+ 'database' => [
+ 'driver' => 'database',
+ 'table' => 'jobs',
+ 'queue' => 'default',
+ 'retry_after' => 90,
+ 'after_commit' => false,
+ ],
+
+ 'beanstalkd' => [
+ 'driver' => 'beanstalkd',
+ 'host' => 'localhost',
+ 'queue' => 'default',
+ 'retry_after' => 90,
+ 'block_for' => 0,
+ 'after_commit' => false,
+ ],
+
+ 'sqs' => [
+ 'driver' => 'sqs',
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
+ 'queue' => env('SQS_QUEUE', 'default'),
+ 'suffix' => env('SQS_SUFFIX'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ 'after_commit' => false,
+ ],
+
+ 'redis' => [
+ 'driver' => 'redis',
+ 'connection' => 'default',
+ 'queue' => env('REDIS_QUEUE', 'default'),
+ 'retry_after' => 90,
+ 'block_for' => null,
+ 'after_commit' => false,
+ ],
+
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Failed Queue Jobs
+ |--------------------------------------------------------------------------
+ |
+ | These options configure the behavior of failed queue job logging so you
+ | can control which database and table are used to store the jobs that
+ | have failed. You may change them to any database / table you wish.
+ |
+ */
+
+ 'failed' => [
+ 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
+ 'database' => env('DB_CONNECTION', 'mysql'),
+ 'table' => 'failed_jobs',
+ ],
+
+];
diff --git a/web/config/services.php b/web/config/services.php
new file mode 100644
index 0000000..2a1d616
--- /dev/null
+++ b/web/config/services.php
@@ -0,0 +1,33 @@
+ [
+ 'domain' => env('MAILGUN_DOMAIN'),
+ 'secret' => env('MAILGUN_SECRET'),
+ 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
+ ],
+
+ 'postmark' => [
+ 'token' => env('POSTMARK_TOKEN'),
+ ],
+
+ 'ses' => [
+ 'key' => env('AWS_ACCESS_KEY_ID'),
+ 'secret' => env('AWS_SECRET_ACCESS_KEY'),
+ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
+ ],
+
+];
diff --git a/web/config/session.php b/web/config/session.php
new file mode 100644
index 0000000..4e0f66c
--- /dev/null
+++ b/web/config/session.php
@@ -0,0 +1,201 @@
+ env('SESSION_DRIVER', 'file'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Lifetime
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify the number of minutes that you wish the session
+ | to be allowed to remain idle before it expires. If you want them
+ | to immediately expire on the browser closing, set that option.
+ |
+ */
+
+ 'lifetime' => env('SESSION_LIFETIME', 120),
+
+ 'expire_on_close' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Encryption
+ |--------------------------------------------------------------------------
+ |
+ | This option allows you to easily specify that all of your session data
+ | should be encrypted before it is stored. All encryption will be run
+ | automatically by Laravel and you can use the Session like normal.
+ |
+ */
+
+ 'encrypt' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session File Location
+ |--------------------------------------------------------------------------
+ |
+ | When using the native session driver, we need a location where session
+ | files may be stored. A default has been set for you but a different
+ | location may be specified. This is only needed for file sessions.
+ |
+ */
+
+ 'files' => storage_path('framework/sessions'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Database Connection
+ |--------------------------------------------------------------------------
+ |
+ | When using the "database" or "redis" session drivers, you may specify a
+ | connection that should be used to manage these sessions. This should
+ | correspond to a connection in your database configuration options.
+ |
+ */
+
+ 'connection' => env('SESSION_CONNECTION', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Database Table
+ |--------------------------------------------------------------------------
+ |
+ | When using the "database" session driver, you may specify the table we
+ | should use to manage the sessions. Of course, a sensible default is
+ | provided for you; however, you are free to change this as needed.
+ |
+ */
+
+ 'table' => 'sessions',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cache Store
+ |--------------------------------------------------------------------------
+ |
+ | While using one of the framework's cache driven session backends you may
+ | list a cache store that should be used for these sessions. This value
+ | must match with one of the application's configured cache "stores".
+ |
+ | Affects: "apc", "dynamodb", "memcached", "redis"
+ |
+ */
+
+ 'store' => env('SESSION_STORE', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Sweeping Lottery
+ |--------------------------------------------------------------------------
+ |
+ | Some session drivers must manually sweep their storage location to get
+ | rid of old sessions from storage. Here are the chances that it will
+ | happen on a given request. By default, the odds are 2 out of 100.
+ |
+ */
+
+ 'lottery' => [2, 100],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Name
+ |--------------------------------------------------------------------------
+ |
+ | Here you may change the name of the cookie used to identify a session
+ | instance by ID. The name specified here will get used every time a
+ | new session cookie is created by the framework for every driver.
+ |
+ */
+
+ 'cookie' => env(
+ 'SESSION_COOKIE',
+ Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
+ ),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Path
+ |--------------------------------------------------------------------------
+ |
+ | The session cookie path determines the path for which the cookie will
+ | be regarded as available. Typically, this will be the root path of
+ | your application but you are free to change this when necessary.
+ |
+ */
+
+ 'path' => '/',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Session Cookie Domain
+ |--------------------------------------------------------------------------
+ |
+ | Here you may change the domain of the cookie used to identify a session
+ | in your application. This will determine which domains the cookie is
+ | available to in your application. A sensible default has been set.
+ |
+ */
+
+ 'domain' => env('SESSION_DOMAIN', null),
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTTPS Only Cookies
+ |--------------------------------------------------------------------------
+ |
+ | By setting this option to true, session cookies will only be sent back
+ | to the server if the browser has a HTTPS connection. This will keep
+ | the cookie from being sent to you if it can not be done securely.
+ |
+ */
+
+ 'secure' => env('SESSION_SECURE_COOKIE'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | HTTP Access Only
+ |--------------------------------------------------------------------------
+ |
+ | Setting this value to true will prevent JavaScript from accessing the
+ | value of the cookie and the cookie will only be accessible through
+ | the HTTP protocol. You are free to modify this option if needed.
+ |
+ */
+
+ 'http_only' => true,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Same-Site Cookies
+ |--------------------------------------------------------------------------
+ |
+ | This option determines how your cookies behave when cross-site requests
+ | take place, and can be used to mitigate CSRF attacks. By default, we
+ | will set this value to "lax" since this is a secure default value.
+ |
+ | Supported: "lax", "strict", "none", null
+ |
+ */
+
+ 'same_site' => 'lax',
+
+];
diff --git a/web/config/shopify.php b/web/config/shopify.php
new file mode 100644
index 0000000..60631da
--- /dev/null
+++ b/web/config/shopify.php
@@ -0,0 +1,30 @@
+ [
+ "required" => false,
+
+ // Example set of values to create a charge for $5 one time
+ "chargeName" => "My Shopify App One-Time Billing",
+ "amount" => 5.0,
+ "currencyCode" => "USD", // Currently only supports USD
+ "interval" => EnsureBilling::INTERVAL_ONE_TIME,
+ ],
+
+];
diff --git a/web/config/view.php b/web/config/view.php
new file mode 100644
index 0000000..22b8a18
--- /dev/null
+++ b/web/config/view.php
@@ -0,0 +1,36 @@
+ [
+ resource_path('views'),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Compiled View Path
+ |--------------------------------------------------------------------------
+ |
+ | This option determines where all the compiled Blade templates will be
+ | stored for your application. Typically, this is within the storage
+ | directory. However, as usual, you are free to change this value.
+ |
+ */
+
+ 'compiled' => env(
+ 'VIEW_COMPILED_PATH',
+ realpath(storage_path('framework/views'))
+ ),
+
+];
diff --git a/web/database/.gitignore b/web/database/.gitignore
new file mode 100644
index 0000000..9b19b93
--- /dev/null
+++ b/web/database/.gitignore
@@ -0,0 +1 @@
+*.sqlite*
diff --git a/web/database/migrations/2019_08_19_000000_create_failed_jobs_table.php b/web/database/migrations/2019_08_19_000000_create_failed_jobs_table.php
new file mode 100644
index 0000000..6aa6d74
--- /dev/null
+++ b/web/database/migrations/2019_08_19_000000_create_failed_jobs_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->string('uuid')->unique();
+ $table->text('connection');
+ $table->text('queue');
+ $table->longText('payload');
+ $table->longText('exception');
+ $table->timestamp('failed_at')->useCurrent();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('failed_jobs');
+ }
+}
diff --git a/web/database/migrations/2021_05_03_050717_create_sessions_table.php b/web/database/migrations/2021_05_03_050717_create_sessions_table.php
new file mode 100644
index 0000000..1d046b5
--- /dev/null
+++ b/web/database/migrations/2021_05_03_050717_create_sessions_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->string('session_id')->nullable(false)->unique();
+ $table->string('shop')->nullable(false);
+ $table->boolean('is_online')->nullable(false);
+ $table->string('state')->nullable(false);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('sessions');
+ }
+}
diff --git a/web/database/migrations/2021_05_05_071311_add_scope_expires_access_token_to_sessions.php b/web/database/migrations/2021_05_05_071311_add_scope_expires_access_token_to_sessions.php
new file mode 100644
index 0000000..3f86c29
--- /dev/null
+++ b/web/database/migrations/2021_05_05_071311_add_scope_expires_access_token_to_sessions.php
@@ -0,0 +1,36 @@
+string('scope')->nullable();
+ $table->string('access_token')->nullable();
+ $table->dateTime('expires_at')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('sessions', function (Blueprint $table) {
+ $table->dropColumn('scope');
+ $table->dropColumn('access_token');
+ $table->dropColumn('expires_at');
+ });
+ }
+}
diff --git a/web/database/migrations/2021_05_11_151158_add_online_access_info_to_sessions.php b/web/database/migrations/2021_05_11_151158_add_online_access_info_to_sessions.php
new file mode 100644
index 0000000..34d36b9
--- /dev/null
+++ b/web/database/migrations/2021_05_11_151158_add_online_access_info_to_sessions.php
@@ -0,0 +1,46 @@
+integer('user_id')->nullable();
+ $table->string('user_first_name')->nullable();
+ $table->string('user_last_name')->nullable();
+ $table->string('user_email')->nullable();
+ $table->boolean('user_email_verified')->nullable();
+ $table->boolean('account_owner')->nullable();
+ $table->string('locale')->nullable();
+ $table->boolean('collaborator')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('sessions', function (Blueprint $table) {
+ $table->dropColumn('user_id');
+ $table->dropColumn('user_first_name');
+ $table->dropColumn('user_last_name');
+ $table->dropColumn('user_email');
+ $table->dropColumn('user_email_verified');
+ $table->dropColumn('account_owner');
+ $table->dropColumn('locale');
+ $table->dropColumn('collaborator');
+ });
+ }
+}
diff --git a/web/database/migrations/2021_05_17_152611_change_sessions_user_id_type.php b/web/database/migrations/2021_05_17_152611_change_sessions_user_id_type.php
new file mode 100644
index 0000000..6e83b4d
--- /dev/null
+++ b/web/database/migrations/2021_05_17_152611_change_sessions_user_id_type.php
@@ -0,0 +1,30 @@
+bigInteger('user_id')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ //
+ }
+}
diff --git a/web/database/seeders/DatabaseSeeder.php b/web/database/seeders/DatabaseSeeder.php
new file mode 100644
index 0000000..57b73b5
--- /dev/null
+++ b/web/database/seeders/DatabaseSeeder.php
@@ -0,0 +1,18 @@
+create();
+ }
+}
diff --git a/web/entrypoint.sh b/web/entrypoint.sh
new file mode 100755
index 0000000..7177e6b
--- /dev/null
+++ b/web/entrypoint.sh
@@ -0,0 +1,19 @@
+#! /usr/bin/env bash
+
+# Clean up openrc and nginx configurations
+sed -i 's/hostname $opts/# hostname $opts/g' /etc/init.d/hostname
+sed -i 's/#rc_sys=""/rc_sys="docker"/g' /etc/rc.conf
+sed -i "s/listen PORT/listen $PORT/g" /etc/nginx/nginx.conf
+
+cd /app
+
+echo "Running database migrations..."
+php artisan migrate --force
+
+echo "Starting nginx server..."
+openrc
+touch /run/openrc/softlevel
+rc-service nginx start
+
+echo "Starting PHP server..."
+php-fpm
diff --git a/web/frontend b/web/frontend
new file mode 160000
index 0000000..6d7567b
--- /dev/null
+++ b/web/frontend
@@ -0,0 +1 @@
+Subproject commit 6d7567ba545a5aed33bd8983896b369f9c84ad4b
diff --git a/web/nginx.conf b/web/nginx.conf
new file mode 100644
index 0000000..fb26ed9
--- /dev/null
+++ b/web/nginx.conf
@@ -0,0 +1,39 @@
+user www-data www-data;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ index index.php index.html;
+
+ upstream php {
+ server 127.0.0.1:9000;
+ }
+
+ server {
+ include /etc/nginx/mime.types;
+ include /etc/nginx/default.d/*.conf;
+
+ listen PORT;
+ server_name 0.0.0.0;
+ root /app/public;
+
+ location / {
+ try_files $uri $uri/ /index.php?$args;
+ }
+
+ location ~ [^/]\.php(/|$) {
+ include /etc/nginx/fastcgi_params;
+
+ try_files $uri $uri/ /index.php?$uri;
+
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_param PATH_INFO $fastcgi_path_info;
+ fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
+
+ fastcgi_pass php;
+ fastcgi_index index.php;
+ }
+ }
+}
\ No newline at end of file
diff --git a/web/phpunit.xml b/web/phpunit.xml
new file mode 100644
index 0000000..4ae4d97
--- /dev/null
+++ b/web/phpunit.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ ./tests/Unit
+
+
+ ./tests/Feature
+
+
+
+
+ ./app
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/public/assets b/web/public/assets
new file mode 120000
index 0000000..da56533
--- /dev/null
+++ b/web/public/assets
@@ -0,0 +1 @@
+../frontend/dist/assets
\ No newline at end of file
diff --git a/web/public/favicon.ico b/web/public/favicon.ico
new file mode 100644
index 0000000..e69de29
diff --git a/web/public/index.html b/web/public/index.html
new file mode 120000
index 0000000..c867cf2
--- /dev/null
+++ b/web/public/index.html
@@ -0,0 +1 @@
+../frontend/dist/index.html
\ No newline at end of file
diff --git a/web/public/index.php b/web/public/index.php
new file mode 100644
index 0000000..79f02c5
--- /dev/null
+++ b/web/public/index.php
@@ -0,0 +1,55 @@
+make(Kernel::class);
+
+$response = tap($kernel->handle(
+ $request = Request::capture()
+))->send();
+
+$kernel->terminate($request, $response);
diff --git a/web/public/robots.txt b/web/public/robots.txt
new file mode 100644
index 0000000..eb05362
--- /dev/null
+++ b/web/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/web/resources/css/app.css b/web/resources/css/app.css
new file mode 100644
index 0000000..e69de29
diff --git a/web/resources/lang/en/auth.php b/web/resources/lang/en/auth.php
new file mode 100644
index 0000000..6598e2c
--- /dev/null
+++ b/web/resources/lang/en/auth.php
@@ -0,0 +1,20 @@
+ 'These credentials do not match our records.',
+ 'password' => 'The provided password is incorrect.',
+ 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+
+];
diff --git a/web/resources/lang/en/pagination.php b/web/resources/lang/en/pagination.php
new file mode 100644
index 0000000..d481411
--- /dev/null
+++ b/web/resources/lang/en/pagination.php
@@ -0,0 +1,19 @@
+ '« Previous',
+ 'next' => 'Next »',
+
+];
diff --git a/web/resources/lang/en/passwords.php b/web/resources/lang/en/passwords.php
new file mode 100644
index 0000000..2345a56
--- /dev/null
+++ b/web/resources/lang/en/passwords.php
@@ -0,0 +1,22 @@
+ 'Your password has been reset!',
+ 'sent' => 'We have emailed your password reset link!',
+ 'throttled' => 'Please wait before retrying.',
+ 'token' => 'This password reset token is invalid.',
+ 'user' => "We can't find a user with that email address.",
+
+];
diff --git a/web/resources/lang/en/validation.php b/web/resources/lang/en/validation.php
new file mode 100644
index 0000000..49e3388
--- /dev/null
+++ b/web/resources/lang/en/validation.php
@@ -0,0 +1,155 @@
+ 'The :attribute must be accepted.',
+ 'active_url' => 'The :attribute is not a valid URL.',
+ 'after' => 'The :attribute must be a date after :date.',
+ 'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
+ 'alpha' => 'The :attribute must only contain letters.',
+ 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.',
+ 'alpha_num' => 'The :attribute must only contain letters and numbers.',
+ 'array' => 'The :attribute must be an array.',
+ 'before' => 'The :attribute must be a date before :date.',
+ 'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
+ 'between' => [
+ 'numeric' => 'The :attribute must be between :min and :max.',
+ 'file' => 'The :attribute must be between :min and :max kilobytes.',
+ 'string' => 'The :attribute must be between :min and :max characters.',
+ 'array' => 'The :attribute must have between :min and :max items.',
+ ],
+ 'boolean' => 'The :attribute field must be true or false.',
+ 'confirmed' => 'The :attribute confirmation does not match.',
+ 'date' => 'The :attribute is not a valid date.',
+ 'date_equals' => 'The :attribute must be a date equal to :date.',
+ 'date_format' => 'The :attribute does not match the format :format.',
+ 'different' => 'The :attribute and :other must be different.',
+ 'digits' => 'The :attribute must be :digits digits.',
+ 'digits_between' => 'The :attribute must be between :min and :max digits.',
+ 'dimensions' => 'The :attribute has invalid image dimensions.',
+ 'distinct' => 'The :attribute field has a duplicate value.',
+ 'email' => 'The :attribute must be a valid email address.',
+ 'ends_with' => 'The :attribute must end with one of the following: :values.',
+ 'exists' => 'The selected :attribute is invalid.',
+ 'file' => 'The :attribute must be a file.',
+ 'filled' => 'The :attribute field must have a value.',
+ 'gt' => [
+ 'numeric' => 'The :attribute must be greater than :value.',
+ 'file' => 'The :attribute must be greater than :value kilobytes.',
+ 'string' => 'The :attribute must be greater than :value characters.',
+ 'array' => 'The :attribute must have more than :value items.',
+ ],
+ 'gte' => [
+ 'numeric' => 'The :attribute must be greater than or equal :value.',
+ 'file' => 'The :attribute must be greater than or equal :value kilobytes.',
+ 'string' => 'The :attribute must be greater than or equal :value characters.',
+ 'array' => 'The :attribute must have :value items or more.',
+ ],
+ 'image' => 'The :attribute must be an image.',
+ 'in' => 'The selected :attribute is invalid.',
+ 'in_array' => 'The :attribute field does not exist in :other.',
+ 'integer' => 'The :attribute must be an integer.',
+ 'ip' => 'The :attribute must be a valid IP address.',
+ 'ipv4' => 'The :attribute must be a valid IPv4 address.',
+ 'ipv6' => 'The :attribute must be a valid IPv6 address.',
+ 'json' => 'The :attribute must be a valid JSON string.',
+ 'lt' => [
+ 'numeric' => 'The :attribute must be less than :value.',
+ 'file' => 'The :attribute must be less than :value kilobytes.',
+ 'string' => 'The :attribute must be less than :value characters.',
+ 'array' => 'The :attribute must have less than :value items.',
+ ],
+ 'lte' => [
+ 'numeric' => 'The :attribute must be less than or equal :value.',
+ 'file' => 'The :attribute must be less than or equal :value kilobytes.',
+ 'string' => 'The :attribute must be less than or equal :value characters.',
+ 'array' => 'The :attribute must not have more than :value items.',
+ ],
+ 'max' => [
+ 'numeric' => 'The :attribute must not be greater than :max.',
+ 'file' => 'The :attribute must not be greater than :max kilobytes.',
+ 'string' => 'The :attribute must not be greater than :max characters.',
+ 'array' => 'The :attribute must not have more than :max items.',
+ ],
+ 'mimes' => 'The :attribute must be a file of type: :values.',
+ 'mimetypes' => 'The :attribute must be a file of type: :values.',
+ 'min' => [
+ 'numeric' => 'The :attribute must be at least :min.',
+ 'file' => 'The :attribute must be at least :min kilobytes.',
+ 'string' => 'The :attribute must be at least :min characters.',
+ 'array' => 'The :attribute must have at least :min items.',
+ ],
+ 'multiple_of' => 'The :attribute must be a multiple of :value.',
+ 'not_in' => 'The selected :attribute is invalid.',
+ 'not_regex' => 'The :attribute format is invalid.',
+ 'numeric' => 'The :attribute must be a number.',
+ 'password' => 'The password is incorrect.',
+ 'present' => 'The :attribute field must be present.',
+ 'regex' => 'The :attribute format is invalid.',
+ 'required' => 'The :attribute field is required.',
+ 'required_if' => 'The :attribute field is required when :other is :value.',
+ 'required_unless' => 'The :attribute field is required unless :other is in :values.',
+ 'required_with' => 'The :attribute field is required when :values is present.',
+ 'required_with_all' => 'The :attribute field is required when :values are present.',
+ 'required_without' => 'The :attribute field is required when :values is not present.',
+ 'required_without_all' => 'The :attribute field is required when none of :values are present.',
+ 'prohibited' => 'The :attribute field is prohibited.',
+ 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
+ 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
+ 'same' => 'The :attribute and :other must match.',
+ 'size' => [
+ 'numeric' => 'The :attribute must be :size.',
+ 'file' => 'The :attribute must be :size kilobytes.',
+ 'string' => 'The :attribute must be :size characters.',
+ 'array' => 'The :attribute must contain :size items.',
+ ],
+ 'starts_with' => 'The :attribute must start with one of the following: :values.',
+ 'string' => 'The :attribute must be a string.',
+ 'timezone' => 'The :attribute must be a valid zone.',
+ 'unique' => 'The :attribute has already been taken.',
+ 'uploaded' => 'The :attribute failed to upload.',
+ 'url' => 'The :attribute format is invalid.',
+ 'uuid' => 'The :attribute must be a valid UUID.',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Validation Language Lines
+ |--------------------------------------------------------------------------
+ |
+ | Here you may specify custom validation messages for attributes using the
+ | convention "attribute.rule" to name the lines. This makes it quick to
+ | specify a specific custom language line for a given attribute rule.
+ |
+ */
+
+ 'custom' => [
+ 'attribute-name' => [
+ 'rule-name' => 'custom-message',
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Validation Attributes
+ |--------------------------------------------------------------------------
+ |
+ | The following language lines are used to swap our attribute placeholder
+ | with something more reader friendly such as "E-Mail Address" instead
+ | of "email". This simply helps us make our message more expressive.
+ |
+ */
+
+ 'attributes' => [],
+
+];
diff --git a/web/routes/api.php b/web/routes/api.php
new file mode 100644
index 0000000..de20d06
--- /dev/null
+++ b/web/routes/api.php
@@ -0,0 +1,19 @@
+id === (int) $id;
+});
diff --git a/web/routes/console.php b/web/routes/console.php
new file mode 100644
index 0000000..e05f4c9
--- /dev/null
+++ b/web/routes/console.php
@@ -0,0 +1,19 @@
+comment(Inspiring::quote());
+})->purpose('Display an inspiring quote');
diff --git a/web/routes/web.php b/web/routes/web.php
new file mode 100644
index 0000000..b882edc
--- /dev/null
+++ b/web/routes/web.php
@@ -0,0 +1,145 @@
+query("embedded", false) === "1") {
+ if (env('APP_ENV') === 'production') {
+ return file_get_contents(public_path('index.html'));
+ } else {
+ return file_get_contents(base_path('frontend/index.html'));
+ }
+ } else {
+ return redirect(Utils::getEmbeddedAppUrl($request->query("host", null)) . "/" . $request->path());
+ }
+})->middleware('shopify.installed');
+
+Route::get('/api/auth', function (Request $request) {
+ $shop = Utils::sanitizeShopDomain($request->query('shop'));
+
+ // Delete any previously created OAuth sessions that were not completed (don't have an access token)
+ Session::where('shop', $shop)->where('access_token', null)->delete();
+
+ return AuthRedirection::redirect($request);
+});
+
+Route::get('/api/auth/callback', function (Request $request) {
+ $session = OAuth::callback(
+ $request->cookie(),
+ $request->query(),
+ ['App\Lib\CookieHandler', 'saveShopifyCookie'],
+ );
+
+ $host = $request->query('host');
+ $shop = Utils::sanitizeShopDomain($request->query('shop'));
+
+ $response = Registry::register('/api/webhooks', Topics::APP_UNINSTALLED, $shop, $session->getAccessToken());
+ if ($response->isSuccess()) {
+ Log::debug("Registered APP_UNINSTALLED webhook for shop $shop");
+ } else {
+ Log::error(
+ "Failed to register APP_UNINSTALLED webhook for shop $shop with response body: " .
+ print_r($response->getBody(), true)
+ );
+ }
+
+ $redirectUrl = Utils::getEmbeddedAppUrl($host);
+ if (Config::get('shopify.billing.required')) {
+ list($hasPayment, $confirmationUrl) = EnsureBilling::check($session, Config::get('shopify.billing'));
+
+ if (!$hasPayment) {
+ $redirectUrl = $confirmationUrl;
+ }
+ }
+
+ return redirect($redirectUrl);
+});
+
+Route::get('/api/products/count', function (Request $request) {
+ /** @var AuthSession */
+ $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active
+
+ $client = new Rest($session->getShop(), $session->getAccessToken());
+ $result = $client->get('products/count');
+
+ return response($result->getDecodedBody());
+})->middleware('shopify.auth');
+
+Route::get('/api/products/create', function (Request $request) {
+ /** @var AuthSession */
+ $session = $request->get('shopifySession'); // Provided by the shopify.auth middleware, guaranteed to be active
+
+ $success = $code = $error = null;
+ try {
+ ProductCreator::call($session, 5);
+ $success = true;
+ $code = 200;
+ $error = null;
+ } catch (\Exception $e) {
+ $success = false;
+
+ if ($e instanceof ShopifyProductCreatorException) {
+ $code = $e->response->getStatusCode();
+ $error = $e->response->getDecodedBody();
+ if (array_key_exists("errors", $error)) {
+ $error = $error["errors"];
+ }
+ } else {
+ $code = 500;
+ $error = $e->getMessage();
+ }
+
+ Log::error("Failed to create products: $error");
+ } finally {
+ return response()->json(["success" => $success, "error" => $error], $code);
+ }
+})->middleware('shopify.auth');
+
+Route::post('/api/webhooks', function (Request $request) {
+ try {
+ $topic = $request->header(HttpHeaders::X_SHOPIFY_TOPIC, '');
+
+ $response = Registry::process($request->header(), $request->getContent());
+ if (!$response->isSuccess()) {
+ Log::error("Failed to process '$topic' webhook: {$response->getErrorMessage()}");
+ return response()->json(['message' => "Failed to process '$topic' webhook"], 500);
+ }
+ } catch (InvalidWebhookException $e) {
+ Log::error("Got invalid webhook request for topic '$topic': {$e->getMessage()}");
+ return response()->json(['message' => "Got invalid webhook request for topic '$topic'"], 401);
+ } catch (\Exception $e) {
+ Log::error("Got an exception when handling '$topic' webhook: {$e->getMessage()}");
+ return response()->json(['message' => "Got an exception when handling '$topic' webhook"], 500);
+ }
+});
diff --git a/web/server.php b/web/server.php
new file mode 100644
index 0000000..5fb6379
--- /dev/null
+++ b/web/server.php
@@ -0,0 +1,21 @@
+
+ */
+
+$uri = urldecode(
+ parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)
+);
+
+// This file allows us to emulate Apache's "mod_rewrite" functionality from the
+// built-in PHP web server. This provides a convenient way to test a Laravel
+// application without having installed a "real" web server software here.
+if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) {
+ return false;
+}
+
+require_once __DIR__.'/public/index.php';
diff --git a/web/shopify.web.toml b/web/shopify.web.toml
new file mode 100644
index 0000000..447f0c2
--- /dev/null
+++ b/web/shopify.web.toml
@@ -0,0 +1,5 @@
+type="backend"
+
+[commands]
+dev = "composer serve"
+build = "composer build"
diff --git a/web/storage/app/.gitignore b/web/storage/app/.gitignore
new file mode 100644
index 0000000..8f4803c
--- /dev/null
+++ b/web/storage/app/.gitignore
@@ -0,0 +1,3 @@
+*
+!public/
+!.gitignore
diff --git a/web/storage/app/public/.gitignore b/web/storage/app/public/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/app/public/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/storage/framework/.gitignore b/web/storage/framework/.gitignore
new file mode 100644
index 0000000..05c4471
--- /dev/null
+++ b/web/storage/framework/.gitignore
@@ -0,0 +1,9 @@
+compiled.php
+config.php
+down
+events.scanned.php
+maintenance.php
+routes.php
+routes.scanned.php
+schedule-*
+services.json
diff --git a/web/storage/framework/cache/.gitignore b/web/storage/framework/cache/.gitignore
new file mode 100644
index 0000000..01e4a6c
--- /dev/null
+++ b/web/storage/framework/cache/.gitignore
@@ -0,0 +1,3 @@
+*
+!data/
+!.gitignore
diff --git a/web/storage/framework/cache/data/.gitignore b/web/storage/framework/cache/data/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/framework/cache/data/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/storage/framework/sessions/.gitignore b/web/storage/framework/sessions/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/framework/sessions/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/storage/framework/testing/.gitignore b/web/storage/framework/testing/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/framework/testing/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/storage/framework/views/.gitignore b/web/storage/framework/views/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/framework/views/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/web/storage/logs/.gitignore b/web/storage/logs/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/web/storage/logs/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore