From cd95f0e1515a23211af22cab38ccd4c0a930ceff Mon Sep 17 00:00:00 2001 From: folexbrits Date: Sun, 2 Jul 2023 00:51:46 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 3 + .editorconfig | 15 + .gitattributes | 5 + .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/BUG_REPORT.md | 39 +++ .github/ISSUE_TEMPLATE/ENHANCEMENT.md | 26 ++ .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md | 30 ++ .github/PULL_REQUEST_TEMPLATE.md | 29 ++ .github/dependabot.yml | 34 ++ .github/workflows/ci.yml | 42 +++ .github/workflows/cla.yml | 22 ++ .gitignore | 22 ++ .gitmodules | 3 + .npmrc | 3 + .styleci.yml | 9 + .vscode/extensions.json | 5 + Dockerfile | 28 ++ README.md | 306 ++++++++++++++++++ package.json | 18 ++ shopify.app.toml | 3 + web/.env.example | 12 + web/.env.testing | 16 + web/app/Console/Kernel.php | 41 +++ web/app/Exceptions/Handler.php | 41 +++ .../Exceptions/ShopifyBillingException.php | 17 + .../ShopifyProductCreatorException.php | 18 ++ web/app/Http/Controllers/Controller.php | 15 + web/app/Http/Kernel.php | 69 ++++ web/app/Http/Middleware/Authenticate.php | 9 + web/app/Http/Middleware/CspHeader.php | 64 ++++ web/app/Http/Middleware/EncryptCookies.php | 17 + .../Middleware/EnsureShopifyInstalled.php | 29 ++ .../Http/Middleware/EnsureShopifySession.php | 103 ++++++ .../PreventRequestsDuringMaintenance.php | 17 + .../Middleware/RedirectIfAuthenticated.php | 32 ++ web/app/Http/Middleware/TrimStrings.php | 19 ++ web/app/Http/Middleware/TrustHosts.php | 20 ++ web/app/Http/Middleware/TrustProxies.php | 28 ++ web/app/Http/Middleware/VerifyCsrfToken.php | 18 ++ web/app/Lib/AuthRedirection.php | 46 +++ web/app/Lib/CookieHandler.php | 29 ++ web/app/Lib/DbSessionStorage.php | 88 +++++ web/app/Lib/EnsureBilling.php | 267 +++++++++++++++ web/app/Lib/Handlers/AppUninstalled.php | 18 ++ .../Handlers/Gdpr/CustomersDataRequest.php | 40 +++ web/app/Lib/Handlers/Gdpr/CustomersRedact.php | 37 +++ web/app/Lib/Handlers/Gdpr/ShopRedact.php | 27 ++ web/app/Lib/ProductCreator.php | 127 ++++++++ web/app/Lib/TopLevelRedirection.php | 26 ++ web/app/Models/Session.php | 11 + web/app/Providers/AppServiceProvider.php | 74 +++++ web/app/Providers/AuthServiceProvider.php | 30 ++ .../Providers/BroadcastServiceProvider.php | 21 ++ web/app/Providers/EventServiceProvider.php | 32 ++ web/app/Providers/RouteServiceProvider.php | 63 ++++ web/artisan | 53 +++ web/bootstrap/app.php | 55 ++++ web/bootstrap/cache/.gitignore | 2 + web/composer.json | 75 +++++ web/config/app.php | 233 +++++++++++++ web/config/auth.php | 112 +++++++ web/config/broadcasting.php | 64 ++++ web/config/cache.php | 106 ++++++ web/config/cors.php | 34 ++ web/config/database.php | 147 +++++++++ web/config/filesystems.php | 72 +++++ web/config/hashing.php | 52 +++ web/config/logging.php | 105 ++++++ web/config/mail.php | 110 +++++++ web/config/queue.php | 93 ++++++ web/config/services.php | 33 ++ web/config/session.php | 201 ++++++++++++ web/config/shopify.php | 30 ++ web/config/view.php | 36 +++ web/database/.gitignore | 1 + ..._08_19_000000_create_failed_jobs_table.php | 36 +++ ...021_05_03_050717_create_sessions_table.php | 35 ++ ...scope_expires_access_token_to_sessions.php | 36 +++ ...158_add_online_access_info_to_sessions.php | 46 +++ ...17_152611_change_sessions_user_id_type.php | 30 ++ web/database/seeders/DatabaseSeeder.php | 18 ++ web/entrypoint.sh | 19 ++ web/frontend | 1 + web/nginx.conf | 39 +++ web/phpunit.xml | 31 ++ web/public/assets | 1 + web/public/favicon.ico | 0 web/public/index.html | 1 + web/public/index.php | 55 ++++ web/public/robots.txt | 2 + web/resources/css/app.css | 0 web/resources/lang/en/auth.php | 20 ++ web/resources/lang/en/pagination.php | 19 ++ web/resources/lang/en/passwords.php | 22 ++ web/resources/lang/en/validation.php | 155 +++++++++ web/routes/api.php | 19 ++ web/routes/channels.php | 18 ++ web/routes/console.php | 19 ++ web/routes/web.php | 145 +++++++++ web/server.php | 21 ++ web/shopify.web.toml | 5 + web/storage/app/.gitignore | 3 + web/storage/app/public/.gitignore | 2 + web/storage/framework/.gitignore | 9 + web/storage/framework/cache/.gitignore | 3 + web/storage/framework/cache/data/.gitignore | 2 + web/storage/framework/sessions/.gitignore | 2 + web/storage/framework/testing/.gitignore | 2 + web/storage/framework/views/.gitignore | 2 + web/storage/logs/.gitignore | 2 + 110 files changed, 4598 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/BUG_REPORT.md create mode 100644 .github/ISSUE_TEMPLATE/ENHANCEMENT.md create mode 100644 .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/cla.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .npmrc create mode 100644 .styleci.yml create mode 100644 .vscode/extensions.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 package.json create mode 100644 shopify.app.toml create mode 100644 web/.env.example create mode 100644 web/.env.testing create mode 100644 web/app/Console/Kernel.php create mode 100644 web/app/Exceptions/Handler.php create mode 100644 web/app/Exceptions/ShopifyBillingException.php create mode 100644 web/app/Exceptions/ShopifyProductCreatorException.php create mode 100644 web/app/Http/Controllers/Controller.php create mode 100644 web/app/Http/Kernel.php create mode 100644 web/app/Http/Middleware/Authenticate.php create mode 100644 web/app/Http/Middleware/CspHeader.php create mode 100644 web/app/Http/Middleware/EncryptCookies.php create mode 100644 web/app/Http/Middleware/EnsureShopifyInstalled.php create mode 100644 web/app/Http/Middleware/EnsureShopifySession.php create mode 100644 web/app/Http/Middleware/PreventRequestsDuringMaintenance.php create mode 100644 web/app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 web/app/Http/Middleware/TrimStrings.php create mode 100644 web/app/Http/Middleware/TrustHosts.php create mode 100644 web/app/Http/Middleware/TrustProxies.php create mode 100644 web/app/Http/Middleware/VerifyCsrfToken.php create mode 100644 web/app/Lib/AuthRedirection.php create mode 100644 web/app/Lib/CookieHandler.php create mode 100644 web/app/Lib/DbSessionStorage.php create mode 100644 web/app/Lib/EnsureBilling.php create mode 100644 web/app/Lib/Handlers/AppUninstalled.php create mode 100644 web/app/Lib/Handlers/Gdpr/CustomersDataRequest.php create mode 100644 web/app/Lib/Handlers/Gdpr/CustomersRedact.php create mode 100644 web/app/Lib/Handlers/Gdpr/ShopRedact.php create mode 100644 web/app/Lib/ProductCreator.php create mode 100644 web/app/Lib/TopLevelRedirection.php create mode 100644 web/app/Models/Session.php create mode 100644 web/app/Providers/AppServiceProvider.php create mode 100644 web/app/Providers/AuthServiceProvider.php create mode 100644 web/app/Providers/BroadcastServiceProvider.php create mode 100644 web/app/Providers/EventServiceProvider.php create mode 100644 web/app/Providers/RouteServiceProvider.php create mode 100755 web/artisan create mode 100644 web/bootstrap/app.php create mode 100644 web/bootstrap/cache/.gitignore create mode 100644 web/composer.json create mode 100644 web/config/app.php create mode 100644 web/config/auth.php create mode 100644 web/config/broadcasting.php create mode 100644 web/config/cache.php create mode 100644 web/config/cors.php create mode 100644 web/config/database.php create mode 100644 web/config/filesystems.php create mode 100644 web/config/hashing.php create mode 100644 web/config/logging.php create mode 100644 web/config/mail.php create mode 100644 web/config/queue.php create mode 100644 web/config/services.php create mode 100644 web/config/session.php create mode 100644 web/config/shopify.php create mode 100644 web/config/view.php create mode 100644 web/database/.gitignore create mode 100644 web/database/migrations/2019_08_19_000000_create_failed_jobs_table.php create mode 100644 web/database/migrations/2021_05_03_050717_create_sessions_table.php create mode 100644 web/database/migrations/2021_05_05_071311_add_scope_expires_access_token_to_sessions.php create mode 100644 web/database/migrations/2021_05_11_151158_add_online_access_info_to_sessions.php create mode 100644 web/database/migrations/2021_05_17_152611_change_sessions_user_id_type.php create mode 100644 web/database/seeders/DatabaseSeeder.php create mode 100755 web/entrypoint.sh create mode 160000 web/frontend create mode 100644 web/nginx.conf create mode 100644 web/phpunit.xml create mode 120000 web/public/assets create mode 100644 web/public/favicon.ico create mode 120000 web/public/index.html create mode 100644 web/public/index.php create mode 100644 web/public/robots.txt create mode 100644 web/resources/css/app.css create mode 100644 web/resources/lang/en/auth.php create mode 100644 web/resources/lang/en/pagination.php create mode 100644 web/resources/lang/en/passwords.php create mode 100644 web/resources/lang/en/validation.php create mode 100644 web/routes/api.php create mode 100644 web/routes/channels.php create mode 100644 web/routes/console.php create mode 100644 web/routes/web.php create mode 100644 web/server.php create mode 100644 web/shopify.web.toml create mode 100644 web/storage/app/.gitignore create mode 100644 web/storage/app/public/.gitignore create mode 100644 web/storage/framework/.gitignore create mode 100644 web/storage/framework/cache/.gitignore create mode 100644 web/storage/framework/cache/data/.gitignore create mode 100644 web/storage/framework/sessions/.gitignore create mode 100644 web/storage/framework/testing/.gitignore create mode 100644 web/storage/framework/views/.gitignore create mode 100644 web/storage/logs/.gitignore 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