├── frontend
├── dist
│ └── .gitkeep
├── .dockerignore
├── src
│ ├── parse.js
│ ├── index.html
│ └── app.js
├── Dockerfile
└── package.json
├── backend
├── .dockerignore
├── cms
│ ├── siteTemplates
│ │ ├── icons
│ │ │ ├── blog.png
│ │ │ ├── gallery.png
│ │ │ ├── products.png
│ │ │ └── knowledge.png
│ │ └── templates.json
│ ├── mailTemplates
│ │ ├── invite.txt
│ │ ├── emailVerify.txt
│ │ ├── passwordReset.txt
│ │ ├── styles.css
│ │ ├── invite.html
│ │ ├── emailVerify.html
│ │ └── passwordReset.html
│ ├── index.js
│ ├── cloud
│ │ ├── common.js
│ │ ├── payment.js
│ │ └── main.js
│ └── initialize-cms.js
├── server
│ ├── initialize-server.js
│ ├── dashboard.js
│ └── index.js
├── Dockerfile
├── package.json
├── entrypoint.sh
├── public
│ ├── password_reset_success.html
│ ├── verify_email_success.html
│ ├── link_send_fail.html
│ ├── link_send_success.html
│ ├── invalid_link.html
│ ├── invalid_verification_link.html
│ └── choose_password.html
├── cloud
│ └── main.js
└── config.js
├── .gitattributes
├── .vscode
└── settings.json
├── .gitignore
├── .github
├── actions
│ ├── build
│ │ └── action.yml
│ ├── push
│ │ └── action.yml
│ ├── test
│ │ └── action.yml
│ ├── deploy
│ │ └── action.yml
│ └── variables
│ │ └── action.yml
└── workflows
│ ├── delete-old-images.yml
│ └── CI-CD.yml
├── package.json
├── Caddyfile
├── LICENSE.md
├── sample.env
├── docker-compose.yml
└── README.md
/frontend/dist/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | logs
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .cache
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
3 | *.sh text eol=lf
4 |
--------------------------------------------------------------------------------
/frontend/src/parse.js:
--------------------------------------------------------------------------------
1 | export default window.Parse; // workaround for Parcel!
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "conventionalCommits.scopes": [
3 | "CI"
4 | ]
5 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | /data
3 | node_modules
4 | frontend/dist/*
5 | !frontend/dist/.gitkeep
6 | .cache
7 |
--------------------------------------------------------------------------------
/backend/cms/siteTemplates/icons/blog.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hatemhosny/parse-starter/HEAD/backend/cms/siteTemplates/icons/blog.png
--------------------------------------------------------------------------------
/backend/cms/siteTemplates/icons/gallery.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hatemhosny/parse-starter/HEAD/backend/cms/siteTemplates/icons/gallery.png
--------------------------------------------------------------------------------
/backend/cms/siteTemplates/icons/products.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hatemhosny/parse-starter/HEAD/backend/cms/siteTemplates/icons/products.png
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/invite.txt:
--------------------------------------------------------------------------------
1 | Hi,
2 |
3 | Your friend {{emailSelf}} invites you to join {{siteName}}.
4 |
5 | Accept invite:
6 | {{link}}
7 |
--------------------------------------------------------------------------------
/backend/cms/siteTemplates/icons/knowledge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hatemhosny/parse-starter/HEAD/backend/cms/siteTemplates/icons/knowledge.png
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/emailVerify.txt:
--------------------------------------------------------------------------------
1 | Hi {{username}},
2 |
3 | Thanks for signing up!
4 | Please confirm your account by clicking on the link below.
5 |
6 | {{link}}
7 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14.14.0-buster-slim
2 |
3 | RUN apt-get update && apt-get install -y git
4 |
5 | RUN mkdir /frontend
6 |
7 | WORKDIR /frontend
8 |
9 | COPY package*.json ./
10 |
11 | RUN npm i
12 |
13 | ADD . .
14 |
15 | CMD [ "npm", "run", "start" ]
16 |
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/passwordReset.txt:
--------------------------------------------------------------------------------
1 | Hi {{username}},
2 |
3 | Someone (hopefully you) has requested a password reset for your account.
4 | Click the link below to set a new password:
5 |
6 | {{link}}
7 |
8 | If you don't wish to reset your password, disregard this email and no action will be taken.
9 |
--------------------------------------------------------------------------------
/.github/actions/build/action.yml:
--------------------------------------------------------------------------------
1 | name: "Build"
2 | description: "Build docker images"
3 |
4 | runs:
5 | using: "composite"
6 | steps:
7 | - name: Build docker images
8 | run: docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1
9 | shell: bash
10 | env:
11 | DOCKER_BUILDKIT: "1"
12 | COMPOSE_DOCKER_CLI_BUILD: "1"
13 |
--------------------------------------------------------------------------------
/.github/actions/push/action.yml:
--------------------------------------------------------------------------------
1 | name: "Push"
2 | description: "Push docker images to registry"
3 |
4 | runs:
5 | using: "composite"
6 | steps:
7 | - name: Login to docker registry
8 | run: echo "${{ env.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin
9 | shell: bash
10 |
11 | - name: Push to docker registry
12 | run: docker-compose push
13 | shell: bash
14 |
--------------------------------------------------------------------------------
/backend/server/initialize-server.js:
--------------------------------------------------------------------------------
1 | const Parse = require("parse/node");
2 | //
3 | // This function runs after starting the server
4 | // You may add initialization code here
5 | // e.g. create classes, default users/roles, enforce security, etc.
6 | //
7 | const initializeServer = async (config) => {
8 | // Parse.initialize(config.appId, null, config.masterKey);
9 | // Parse.serverURL = config.serverURL;
10 | };
11 |
12 | module.exports = initializeServer;
13 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM parseplatform/parse-dashboard:latest as builder
2 |
3 | USER root
4 |
5 | RUN apk update && apk upgrade && \
6 | apk add --no-cache git
7 |
8 | RUN mkdir -p /src/parse-server
9 |
10 | WORKDIR /src/parse-server
11 |
12 | COPY package*.json ./
13 |
14 | RUN npm i
15 |
16 | FROM node:14.14.0-buster-slim
17 |
18 | COPY --from=builder /src /src
19 |
20 | WORKDIR /src/parse-server
21 |
22 | ADD . .
23 |
24 | CMD [ "npm", "run", "start" ]
25 |
--------------------------------------------------------------------------------
/backend/cms/index.js:
--------------------------------------------------------------------------------
1 | const config = require("../config");
2 |
3 | const parseConfig = {
4 | ...config,
5 | port: config.extraConfig.port,
6 | URLserver: config.publicServerURL,
7 | URLdb: config.databaseURI,
8 | URLsite: `https://${config.extraConfig.host}:${config.extraConfig.port}`,
9 | };
10 |
11 | module.exports.parseConfig = parseConfig;
12 | module.exports.URL_SITE = parseConfig.URLsite;
13 | module.exports.StripeConfig = config.extraConfig.StripeConfig;
14 |
--------------------------------------------------------------------------------
/.github/workflows/delete-old-images.yml:
--------------------------------------------------------------------------------
1 | name: Delete old docker images
2 | on:
3 | # push:
4 | schedule:
5 | - cron: "0 8 * * *"
6 |
7 | env:
8 | GITHUB_TOKEN: ${{ secrets.PROD_DOCKER_REGISTRY_TOKEN }}
9 |
10 | jobs:
11 | delete-old-images:
12 | name: Delete old docker images
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Delete old docker images
16 | if: env.GITHUB_TOKEN != ''
17 | uses: navikt/remove-package-versions@24c0c98
18 | with:
19 | keep_versions: 10
20 |
--------------------------------------------------------------------------------
/.github/actions/test/action.yml:
--------------------------------------------------------------------------------
1 | name: "Test"
2 | description: "Run tests"
3 |
4 | inputs:
5 | FRONTEND_NPM_SCRIPT:
6 | description: "npm script to run on frontend container"
7 | required: true
8 | default: "ci-test"
9 | BACKEND_NPM_SCRIPT:
10 | description: "npm script to run on backend container"
11 | required: true
12 | default: "ci-test"
13 |
14 | runs:
15 | using: "composite"
16 | steps:
17 | - name: Test frontend
18 | run: docker-compose run --no-deps frontend npm run ${{ inputs.FRONTEND_NPM_SCRIPT }}
19 | shell: bash
20 |
21 | - name: Test backend
22 | run: docker-compose run --no-deps backend npm run ${{ inputs.BACKEND_NPM_SCRIPT }}
23 | shell: bash
24 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "parse-backend",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "per-env",
8 | "start:development": "nodemon --legacy-watch --ignore node_modules/ ./server/index.js",
9 | "start:production": "pm2-runtime ./server/index.js",
10 | "test": "echo \"No test specified\"",
11 | "ci-test": "npm run test"
12 | },
13 | "dependencies": {
14 | "chisel-cms": "0.12.5",
15 | "cors": "2.8.5",
16 | "express": "4.17.1",
17 | "parse": "2.17.0",
18 | "parse-server": "4.3.0",
19 | "parse-smtp-template": "2.1.2",
20 | "per-env": "1.0.2",
21 | "pm2": "4.5.0"
22 | },
23 | "devDependencies": {
24 | "nodemon": "2.0.5"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/backend/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #! /bin/sh
2 |
3 | export BACKEND_HOST_NAME=${BACKEND_HOST_NAME:-${HOST_NAME}};
4 |
5 | if [ ${BACKEND_HOST_NAME} != ${HOST_NAME} ]; then
6 | export BACKEND_PORT=${BACKEND_PORT:-443}
7 | export PARSE_PUBLIC_SERVER_URL=https://${BACKEND_HOST_NAME}${PARSE_SERVER_MOUNT_PATH};
8 | else
9 | export BACKEND_PORT=${BACKEND_PORT:-1337}
10 | export PARSE_PUBLIC_SERVER_URL=https://${BACKEND_HOST_NAME}:${BACKEND_PORT}${PARSE_SERVER_MOUNT_PATH};
11 | fi;
12 | export PARSE_SERVER_URL=http://backend:${BACKEND_PORT}${PARSE_SERVER_MOUNT_PATH};
13 |
14 | # if IP address
15 | if expr "${HOST_NAME}" : '[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$' >/dev/null; then
16 | export DEFAULT_SNI="default_sni ${HOST_NAME}";
17 | fi;
18 |
19 | $@
20 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.0.0",
4 | "description": "",
5 | "scripts": {
6 | "start": "per-env",
7 | "start:development": "parcel src/index.html --global Parse --out-dir .cache/tmp --hmr-port=$HMR_PORT",
8 | "start:production": "npm run build",
9 | "build": "rimraf dist/* && parcel build src/index.html --global Parse",
10 | "test": "echo \"No test specified\"",
11 | "ci-test": "npm run test"
12 | },
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "bootstrap": "^4.5.3",
17 | "parse": "^2.17.0"
18 | },
19 | "devDependencies": {
20 | "nodemon": "^2.0.5",
21 | "parcel-bundler": "^1.12.4",
22 | "per-env": "^1.0.2",
23 | "rimraf": "^3.0.2"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "parse-starter",
3 | "version": "0.0.1",
4 | "description": "",
5 | "dependencies": {},
6 | "devDependencies": {},
7 | "scripts": {
8 | "start": "docker-compose up",
9 | "up": "docker-compose up",
10 | "up-detached": "docker-compose up -d",
11 | "down": "docker-compose down",
12 | "docker-build": "docker-compose build",
13 | "frontend-shell": "docker-compose up -d && docker-compose exec frontend bash",
14 | "backend-shell": "docker-compose up -d && docker-compose exec backend bash",
15 | "frontend-test": "docker-compose run --no-deps frontend npm run ci-test",
16 | "backend-test": "docker-compose run --no-deps backend npm run ci-test",
17 | "test": "npm run frontend-test && npm run backend-test",
18 | "clean": "docker system prune"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | {
2 | # Global options block. Entirely optional, https is on by default
3 | # Optional email key for lets encrypt
4 | # email youremail@domain.com
5 | # Optional staging lets encrypt for testing. Comment out for production.
6 | # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
7 |
8 | {$DEFAULT_SNI}
9 | }
10 | {$HOST_NAME} {
11 | encode zstd gzip
12 | root * /srv/{$FRONTEND_DIST_DIR}
13 | file_server
14 | }
15 | www.{$HOST_NAME} {
16 | redir https://{$HOST_NAME}{uri}
17 | }
18 | {$BACKEND_HOST_NAME}:{$BACKEND_PORT} {
19 | encode zstd gzip
20 | reverse_proxy backend:{$BACKEND_PORT}
21 | }
22 | {$HOST_NAME}:{$FRONTEND_DEV_SERVER_PORT} {
23 | encode zstd gzip
24 | reverse_proxy frontend:{$FRONTEND_DEV_SERVER_PORT}
25 | }
26 | {$HOST_NAME}:{$HMR_PORT} {
27 | encode zstd gzip
28 | reverse_proxy frontend:{$HMR_PORT}
29 | }
30 |
--------------------------------------------------------------------------------
/backend/public/password_reset_success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 | Password Reset
10 |
24 |
25 | Successfully updated your password!
26 |
27 |
28 |
--------------------------------------------------------------------------------
/backend/public/verify_email_success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 | Email Verification
10 |
24 |
25 | Successfully verified your email!
26 |
27 |
28 |
--------------------------------------------------------------------------------
/backend/server/dashboard.js:
--------------------------------------------------------------------------------
1 | const ParseDashboard = require("../../Parse-Dashboard/app");
2 |
3 | const createDashboard = (config) => {
4 | const dashboardConfig = {
5 | apps: [
6 | {
7 | serverURL: config.publicServerURL,
8 | appId: config.appId,
9 | masterKey: config.masterKey,
10 | appName: config.appName,
11 | graphQLServerURL: config.graphQLPath,
12 | },
13 | ],
14 | trustProxy: 1,
15 | allowInsecureHTTP: 1,
16 | };
17 |
18 | if (
19 | process.env.PARSE_DASHBOARD_USER_ID &&
20 | process.env.PARSE_DASHBOARD_USER_PASSWORD
21 | )
22 | dashboardConfig.users = [
23 | {
24 | user: process.env.PARSE_DASHBOARD_USER_ID,
25 | pass: process.env.PARSE_DASHBOARD_USER_PASSWORD,
26 | },
27 | ];
28 |
29 | return new ParseDashboard(dashboardConfig, {
30 | allowInsecureHTTP: true,
31 | });
32 | };
33 |
34 | module.exports = createDashboard;
35 |
--------------------------------------------------------------------------------
/backend/cms/cloud/common.js:
--------------------------------------------------------------------------------
1 | const configs = require('../index.js');
2 | module.exports.config = configs.parseConfig;
3 | module.exports.SITE = configs['URL_SITE'];
4 | module.exports.StripeConfig = configs.StripeConfig;
5 |
6 |
7 | module.exports.ROLE_ADMIN = "ADMIN";
8 | module.exports.ROLE_EDITOR = "EDITOR";
9 |
10 | module.exports.CLOUD_ERROR_CODE__STRIPE_INIT_ERROR = 701;
11 |
12 | module.exports.promisifyW = pp => {
13 | return new Promise((rs, rj) => pp.then(rs, rs));
14 | };
15 |
16 | module.exports.getAllObjects = query => {
17 | const MAX_COUNT = 90;
18 | let objects = [];
19 |
20 | const getObjects = async (offset = 0) => {
21 | const res = await query
22 | .limit(MAX_COUNT)
23 | .skip(offset)
24 | .find({useMasterKey: true});
25 |
26 | if (!res.length)
27 | return objects;
28 |
29 | objects = objects.concat(res);
30 | return getObjects(offset + MAX_COUNT);
31 | };
32 |
33 | return getObjects();
34 | };
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2020 Hatem Hosny
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | width: 100% !important;
3 | -webkit-text-size-adjust: none;
4 | margin: 0;
5 | padding: 20px;
6 | }
7 |
8 | table {
9 | border-spacing: 0;
10 | border-collapse: collapse;
11 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
12 | width: 100% !important;
13 | height: 100% !important;
14 | color: #4c4c4c;
15 | font-size: 15px;
16 | line-height: 150%;
17 | background: #ffffff;
18 | margin: 0;
19 | padding: 0;
20 | border: 0;
21 | }
22 |
23 | tr, td {
24 | vertical-align: top;
25 | padding: 0;
26 | }
27 |
28 | p {
29 | margin: 20px 0;
30 | }
31 |
32 | a {
33 | color: #9c27b0;
34 | }
35 |
36 | #link {
37 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
38 | box-sizing: border-box;
39 | font-size: 14px;
40 | color: #2e7d32;
41 | text-decoration: none;
42 | line-height: 2em;
43 | font-weight: bold;
44 | text-align: center;
45 | cursor: pointer;
46 | display: inline-block;
47 | border-radius: 5px;
48 | text-transform: capitalize;
49 | background-color: #69f0ae;
50 | margin: 0;
51 | border-color: #69f0ae;
52 | border-style: solid;
53 | border-width: 10px 20px;
54 | }
--------------------------------------------------------------------------------
/backend/cloud/main.js:
--------------------------------------------------------------------------------
1 | // The main entry to cloud code
2 |
3 | const config = require("../config");
4 |
5 | Parse.Cloud.define("hello", () => "hello world!");
6 | // you can call this function by running the following code (e.g. in the dashboard JS console)
7 | // Parse.Cloud.run('hello').then(result => {
8 | // console.log(result);
9 | // });
10 |
11 | Parse.Cloud.define("addAndGetAverage", async (req) => {
12 | const { name, value } = req.params;
13 | const obj = new Parse.Object("Demo");
14 | obj.set("name", name);
15 | obj.set("value", value);
16 | await obj.save();
17 |
18 | const query = new Parse.Query("Demo");
19 | const results = await query.find();
20 | const values = results
21 | .map((result) => result.get("value"))
22 | .filter((value) => value);
23 | const sum = values.reduce((a, b) => a + b);
24 | return sum / values.length;
25 | });
26 | // and call like this
27 | // (async () => {
28 | // const rnd = Math.floor(Math.random() * 10)
29 | // const params = { name: 'demo' + rnd, value: rnd };
30 | // const avg = await Parse.Cloud.run('addAndGetAverage', params);
31 | // console.log(avg);
32 | // })();
33 |
34 | // CMS Cloud Code
35 | if (config.extraConfig.cmsEnabled) {
36 | require("../cms/cloud/main");
37 | }
38 |
--------------------------------------------------------------------------------
/sample.env:
--------------------------------------------------------------------------------
1 | ### Environment ###
2 | # NODE_ENV=production # default: development
3 |
4 | ### Server Settings ###
5 | # HOST_NAME=localhost
6 | # BACKEND_HOST_NAME=api.localhost # default: $HOST_NAME
7 | # BACKEND_PORT=1337
8 | # PARSE_SERVER_PATH=/api
9 | # PARSE_SERVER_GRAPHQL_PATH=/graphql
10 | # PARSE_DASHBOARD_PATH=/dashboard
11 | # PARSE_DASHBOARD_ENABLED=yes
12 | # CMS_ENABLED=no
13 | # FRONTEND_DEV_SERVER_PORT=1234
14 | # HMR_PORT=1235
15 |
16 | ### Keys and Secrets ###
17 | # APP_ID=myappid
18 | # APP_NAME=myappname
19 | # MASTER_KEY=mymasterkey
20 | # PARSE_SERVER_DATABASE_URI=mongodb://mongo:27017/dev
21 | # PARSE_DASHBOARD_USER_ID=
22 | # PARSE_DASHBOARD_USER_PASSWORD=
23 | # CMS_USER_EMAIL=
24 | # CMS_USER_PASSWORD=
25 |
26 | ### App ###
27 | # BACKEND_SRC_DIR=backend
28 | # FRONTEND_SRC_DIR=frontend
29 | # FRONTEND_PUBLIC_DIR=dist
30 | # DATA_DIR=data
31 | # BACKEND_NPM_SCRIPT=start
32 | # FRONTEND_NPM_SCRIPT=start
33 |
34 | ### Mail Settings ###
35 | # MAIL_SMTP_HOST=
36 | # MAIL_SMTP_PORT=587
37 | # MAIL_SMTP_USERNAME=
38 | # MAIL_SMTP_PASSWORD=
39 | # MAIL_FROM_ADDRESS=
40 |
41 | ### DOCKER ###
42 | # BACKEND_IMAGE=docker.pkg.github.com/{owner}/{repo}/{repo}-backend
43 | # FRONTEND_IMAGE=docker.pkg.github.com/{owner}/{repo}/{repo}-frontend
44 | # IMAGE_TAG=latest
45 |
--------------------------------------------------------------------------------
/backend/public/link_send_fail.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | Invalid Link
10 |
38 |
39 |
40 |
41 |
42 |
No link sent. User not found or email already verified
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/backend/public/link_send_success.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | Invalid Link
10 |
38 |
39 |
40 |
41 |
42 |
Link Sent! Check your email.
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/backend/public/invalid_link.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | Invalid Link
10 |
38 |
39 |
40 |
41 |
42 |
Invalid Link
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/.github/actions/deploy/action.yml:
--------------------------------------------------------------------------------
1 | name: "Deploy"
2 | description: "Deploy via SSH"
3 |
4 | runs:
5 | using: "composite"
6 | steps:
7 | - name: SSH config
8 | run: |
9 | mkdir -p ~/.ssh
10 | echo "${SSH_KEY//'%0A'/$'\n'}" > ~/.ssh/id_rsa
11 | chmod 600 ~/.ssh/id_rsa
12 | eval "$(ssh-agent -s)"
13 | ssh-add ~/.ssh/id_rsa
14 | ssh-keyscan -H ${{ env.SSH_HOST }} >> ~/.ssh/known_hosts
15 | echo "Host deploy" >> ~/.ssh/config
16 | echo " HostName ${{ env.SSH_HOST }}" >> ~/.ssh/config
17 | echo " User ${{ env.SSH_USER }}" >> ~/.ssh/config
18 | echo " Port ${{ env.SSH_PORT }}" >> ~/.ssh/config
19 | chmod 600 ~/.ssh/config
20 | shell: bash
21 |
22 | - name: Copy files to remote server
23 | run: |
24 | ssh deploy mkdir -p "${{ env.SSH_PATH }}/.empty"
25 | rsync --exclude '.git' --exclude 'data' -azP ${PWD}/ deploy:${{ env.SSH_PATH }}
26 | shell: bash
27 |
28 | - name: Run docker-compose over SSH
29 | run: ssh deploy '
30 | echo "${{ env.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin &&
31 | cd ${{ env.SSH_PATH }} &&
32 | docker system prune --volumes --force &&
33 | docker-compose pull &&
34 | docker-compose down --remove-orphans &&
35 | docker-compose up --no-build -d ${{ env.DOCKER_COMPOSE_UP_ARGS }} &&
36 | rm -f .env'
37 | shell: bash
38 |
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/invite.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
61 |
62 |
63 |
64 |
65 |
66 | |
67 | Hi
68 |
69 | Your friend {{emailSelf}} invites you to join {{siteName}}.
70 |
71 | Accept invite
72 | |
73 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/emailVerify.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
61 |
62 |
63 |
64 |
65 |
66 | |
67 | Hi {{username}},
68 |
69 | Thanks for signing up!
70 | Please confirm your account.
71 |
72 | Confirm Account
73 | |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/backend/cms/mailTemplates/passwordReset.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
61 |
62 |
63 |
64 |
65 |
66 | |
67 | Hi {{username}},
68 | Click the button below to set a new password:
69 | Reset Password
70 |
71 | If you don't wish to reset your password, disregard this email and
72 | no action will be taken.
73 |
74 | |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/backend/public/invalid_verification_link.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | Invalid Link
10 |
38 |
39 |
58 |
59 |
60 |
61 |
Invalid Verification Link
62 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/backend/config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | const config = {
4 | // General Settings
5 | appId: process.env.PARSE_SERVER_APPLICATION_ID || "myAppId",
6 | masterKey: process.env.PARSE_SERVER_MASTER_KEY || "myMasterKey",
7 | appName: process.env.PARSE_SERVER_APP_NAME || "My App",
8 | serverURL: process.env.PARSE_SERVER_URL || "https://localhost:1337/api",
9 | publicServerURL:
10 | process.env.PARSE_PUBLIC_SERVER_URL || "https://localhost:1337/api",
11 | databaseURI:
12 | process.env.PARSE_SERVER_DATABASE_URI || "mongodb://mongo:27017/dev",
13 | mountPath: process.env.PARSE_SERVER_MOUNT_PATH || "/api",
14 | cloud: process.env.PARSE_SERVER_CLOUD || "/parse-server/cloud/main.js",
15 |
16 | // GraphQl
17 | mountGraphQL: true,
18 | graphQLPath: process.env.PARSE_SERVER_GRAPHQL_PATH || "/graphql",
19 |
20 | // LiveQuery
21 | startLiveQueryServer: true,
22 | liveQuery: { classNames: ["Todo"] },
23 |
24 | // Email
25 | verifyUserEmails: false,
26 | emailVerifyTokenValidityDuration: 2 * 60 * 60, // in seconds (2 hours = 7200 seconds)
27 | preventLoginWithUnverifiedEmail: false, // defaults to false
28 | emailAdapter: {
29 | module: "parse-smtp-template",
30 | options: {
31 | host: process.env.MAIL_SMTP_HOST || "smtp.sendgrid.net",
32 | port: process.env.MAIL_SMTP_PORT || 587,
33 | user: process.env.MAIL_SMTP_USERNAME || "user",
34 | password: process.env.MAIL_SMTP_PASSWORD || "pass",
35 | fromAddress:
36 | process.env.MAIL_FROM_ADDRESS || `admin@${process.env.HOST_NAME}`,
37 | },
38 | },
39 | customPages: {
40 | verifyEmailSuccess: "/public/verify_email_success.html",
41 | choosePassword: "/public/choose_password.html",
42 | passwordResetSuccess: "/public/password_reset_success.html",
43 | linkSendSuccess: "/public/link_send_success.html",
44 | linkSendFail: "/public/link_send_fail.html",
45 | invalidLink: "/public/invalid_link.html",
46 | invalidVerificationLink: "/public/invalid_verification_link.html",
47 | },
48 | extraConfig: {
49 | host: process.env.BACKEND_HOST_NAME || "localhost",
50 | port: Number(process.env.BACKEND_PORT) || 1337,
51 | dashboardEnabled: process.env.PARSE_DASHBOARD_ENABLED === "yes",
52 | dashboardPath: process.env.PARSE_DASHBOARD_PATH || "/dashboard",
53 | cmsEnabled: process.env.CMS_ENABLED === "yes",
54 | chiselCmsDistPath: path.resolve(__dirname, "node_modules/chisel-cms/dist"),
55 | cmsUserEmail: process.env.CMS_USER_EMAIL,
56 | cmsUserPassword: process.env.CMS_USER_PASSWORD,
57 | siteTemplates: true,
58 | // StripeConfig: {
59 | // keyPublic: "pk_test_sample",
60 | // keyPrivate: "sk_test_sample",
61 | // },
62 | },
63 | };
64 |
65 | module.exports = config;
66 |
--------------------------------------------------------------------------------
/backend/server/index.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const express = require("express");
3 | const cors = require("cors");
4 | const { default: ParseServer, ParseGraphQLServer } = require("parse-server");
5 |
6 | const config = require("../config");
7 | const initializeServer = require("./initialize-server");
8 | const initializeCMS = require("../cms/initialize-cms");
9 | const createDashboard = require("./dashboard");
10 |
11 | if (!config.databaseURI) {
12 | console.log("DATABASE_URI not specified, falling back to localhost.");
13 | }
14 |
15 | const {
16 | appId,
17 | mountPath,
18 | publicServerURL,
19 | startLiveQueryServer,
20 | mountGraphQL,
21 | graphQLPath,
22 | } = config;
23 |
24 | const {
25 | host,
26 | port,
27 | dashboardEnabled,
28 | dashboardPath,
29 | cmsEnabled,
30 | chiselCmsDistPath,
31 | } = config.extraConfig;
32 |
33 | const app = express();
34 |
35 | app.use(cors());
36 |
37 | const parseServer = new ParseServer(config);
38 |
39 | app.use("/public", express.static(path.join(__dirname, "../public")));
40 |
41 | app.use(mountPath, parseServer.app);
42 |
43 | if (mountGraphQL) {
44 | const parseGraphQLServer = new ParseGraphQLServer(parseServer, {
45 | graphQLPath,
46 | });
47 | parseGraphQLServer.applyGraphQL(app);
48 | }
49 |
50 | if (dashboardEnabled) {
51 | const dashboard = createDashboard(config);
52 | app.use(dashboardPath, dashboard);
53 | }
54 |
55 | if (cmsEnabled) {
56 | app.use("/", express.static(path.resolve(__dirname, chiselCmsDistPath)));
57 |
58 | app.get("/chisel-config.json", (req, res) => {
59 | const response = {
60 | configServerURL: publicServerURL,
61 | configAppId: appId,
62 | };
63 | return res.json(response);
64 | });
65 |
66 | app.use("/*", (req, res) =>
67 | res.sendFile(path.resolve(__dirname, chiselCmsDistPath, "index.html"))
68 | );
69 | }
70 |
71 | const httpServer = require("http").createServer(app);
72 |
73 | if (startLiveQueryServer) {
74 | ParseServer.createLiveQueryServer(httpServer);
75 | }
76 |
77 | httpServer.listen(port, async () => {
78 | await initializeServer(config);
79 | await initializeCMS(config);
80 | const serverUrl = port === 443 ? `${host}` : `${host}:${port}`;
81 | console.log(`Parse server is running on https://${serverUrl}${mountPath}`);
82 |
83 | if (startLiveQueryServer) {
84 | console.log(`Live Query server is enabled`);
85 | } else {
86 | console.log(`Live Query server is disabled`);
87 | }
88 | if (mountGraphQL) {
89 | console.log(
90 | `GraphQl server is running on https://${serverUrl}${graphQLPath}`
91 | );
92 | } else {
93 | console.log(`GraphQl server is disabled`);
94 | }
95 | if (dashboardEnabled) {
96 | console.log(`Dashboard is enabled on https://${serverUrl}${dashboardPath}`);
97 | } else {
98 | console.log(`Dashboard is disabled`);
99 | }
100 | if (cmsEnabled) {
101 | console.log(`CMS is enabled on https://${serverUrl}`);
102 | } else {
103 | console.log(`CMS is disabled`);
104 | }
105 | });
106 |
--------------------------------------------------------------------------------
/.github/workflows/CI-CD.yml:
--------------------------------------------------------------------------------
1 | name: CI/CD
2 |
3 | on:
4 | push:
5 | pull_request:
6 | workflow_dispatch:
7 | inputs:
8 | SHA:
9 | description: "Git Short SHA (Docker Image Tag)"
10 | required: true
11 |
12 | env:
13 | PROD_DOCKER_REGISTRY: ${{ secrets.PROD_DOCKER_REGISTRY }}
14 | PROD_DOCKER_REGISTRY_USER: ${{ secrets.PROD_DOCKER_REGISTRY_USER }}
15 | PROD_DOCKER_REGISTRY_TOKEN: ${{ secrets.PROD_DOCKER_REGISTRY_TOKEN }}
16 | PROD_SSH_HOST: ${{ secrets.PROD_SSH_HOST }}
17 | PROD_SSH_PORT: ${{ secrets.PROD_SSH_PORT }}
18 | PROD_SSH_USER: ${{ secrets.PROD_SSH_USER }}
19 | PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
20 | PROD_SSH_PATH: ${{ secrets.PROD_SSH_PATH }}
21 | PROD_ENV_VARS: ${{ secrets.PROD_ENV_VARS }}
22 | PROD_DOCKER_COMPOSE_UP_ARGS: ${{ secrets.PROD_DOCKER_COMPOSE_UP_ARGS }}
23 |
24 | STAG_DOCKER_REGISTRY: ${{ secrets.STAG_DOCKER_REGISTRY }}
25 | STAG_DOCKER_REGISTRY_USER: ${{ secrets.STAG_DOCKER_REGISTRY_USER }}
26 | STAG_DOCKER_REGISTRY_TOKEN: ${{ secrets.STAG_DOCKER_REGISTRY_TOKEN }}
27 | STAG_SSH_HOST: ${{ secrets.STAG_SSH_HOST }}
28 | STAG_SSH_PORT: ${{ secrets.STAG_SSH_PORT }}
29 | STAG_SSH_USER: ${{ secrets.STAG_SSH_USER }}
30 | STAG_SSH_KEY: ${{ secrets.STAG_SSH_KEY }}
31 | STAG_SSH_PATH: ${{ secrets.STAG_SSH_PATH }}
32 | STAG_ENV_VARS: ${{ secrets.STAG_ENV_VARS }}
33 | STAG_DOCKER_COMPOSE_UP_ARGS: ${{ secrets.STAG_DOCKER_COMPOSE_UP_ARGS }}
34 |
35 | DEV_DOCKER_REGISTRY: ${{ secrets.DEV_DOCKER_REGISTRY }}
36 | DEV_DOCKER_REGISTRY_USER: ${{ secrets.DEV_DOCKER_REGISTRY_USER }}
37 | DEV_DOCKER_REGISTRY_TOKEN: ${{ secrets.DEV_DOCKER_REGISTRY_TOKEN }}
38 | DEV_SSH_HOST: ${{ secrets.DEV_SSH_HOST }}
39 | DEV_SSH_PORT: ${{ secrets.DEV_SSH_PORT }}
40 | DEV_SSH_USER: ${{ secrets.DEV_SSH_USER }}
41 | DEV_SSH_KEY: ${{ secrets.DEV_SSH_KEY }}
42 | DEV_SSH_PATH: ${{ secrets.DEV_SSH_PATH }}
43 | DEV_ENV_VARS: ${{ secrets.DEV_ENV_VARS }}
44 | DEV_DOCKER_COMPOSE_UP_ARGS: ${{ secrets.DEV_DOCKER_COMPOSE_UP_ARGS }}
45 |
46 | jobs:
47 | CI-CD:
48 | name: CI/CD
49 | runs-on: ubuntu-latest
50 | if: "!contains(github.event.head_commit.message, '[skip ci]') &&
51 | !contains(github.event.head_commit.message, '[ci skip]')"
52 | steps:
53 | - name: Checkout the branch
54 | if: github.event.inputs.SHA == ''
55 | uses: actions/checkout@v2
56 |
57 | - name: Checkout specific git commit
58 | if: github.event.inputs.SHA != ''
59 | uses: actions/checkout@v1
60 | with:
61 | ref: ${{ github.event.inputs.SHA }}
62 |
63 | - name: Load environment variables
64 | id: vars
65 | uses: ./.github/actions/variables
66 | with:
67 | production: "main master"
68 | staging: "staging"
69 | development: "develop"
70 | SHA: ${{ github.event.inputs.SHA }}
71 |
72 | - name: Build
73 | if: github.event.inputs.SHA == ''
74 | uses: ./.github/actions/build
75 |
76 | - name: Test
77 | if: github.event.inputs.SHA == ''
78 | uses: ./.github/actions/test
79 |
80 | - name: Push
81 | if: github.event.inputs.SHA == '' &&
82 | contains('production staging development', env.ENVIRONMENT) &&
83 | env.REGISTRY_TOKEN != ''
84 | uses: ./.github/actions/push
85 |
86 | - name: Deploy
87 | if: contains('production staging development', env.ENVIRONMENT) &&
88 | env.REGISTRY_TOKEN != '' &&
89 | env.SSH_HOST != '' && env.SSH_KEY != ''
90 | uses: ./.github/actions/deploy
91 |
--------------------------------------------------------------------------------
/backend/cms/initialize-cms.js:
--------------------------------------------------------------------------------
1 | const Parse = require("parse/node");
2 |
3 | const initializeCMS = async (config) => {
4 | Parse.initialize(config.appId, null, config.masterKey);
5 | Parse.serverURL = config.serverURL;
6 |
7 | const {
8 | cmsUserEmail: username,
9 | cmsUserPassword: password,
10 | } = config.extraConfig;
11 | if (username && password) {
12 | const query = new Parse.Query(Parse.User);
13 | query.equalTo("username", username);
14 | const user = await query.first();
15 | if (user) {
16 | user.set("password", password);
17 | user.set("email", username);
18 | await user.save(null, { useMasterKey: true });
19 | } else {
20 | const newUser = new Parse.User();
21 | newUser.set("username", username);
22 | newUser.set("password", password);
23 | newUser.set("email", username);
24 | newUser.set("verifiedEmail", undefined);
25 | await newUser.signUp(null, { useMasterKey: true });
26 | }
27 | }
28 |
29 | if (config.StripeConfig) {
30 | try {
31 | await request({
32 | url: config.publicServerURL + "/config",
33 | method: "PUT",
34 | json: true,
35 | headers: {
36 | "X-Parse-Application-Id": config.appId,
37 | "X-Parse-Master-Key": config.masterKey,
38 | },
39 | body: { params: { StripeKeyPublic: config.StripeConfig.keyPublic } },
40 | });
41 | } catch (e) {
42 | console.error(e);
43 | }
44 | }
45 |
46 | // set templates
47 | if (config.siteTemplates) {
48 | const templates = require("./siteTemplates/templates.json");
49 | const fs = require("fs");
50 |
51 | const Template = Parse.Object.extend("Template");
52 | const Model = Parse.Object.extend("Model");
53 | const ModelField = Parse.Object.extend("ModelField");
54 |
55 | const ACL = new Parse.ACL();
56 | ACL.setPublicReadAccess(true);
57 | ACL.setPublicWriteAccess(false);
58 |
59 | for (let template of templates) {
60 | const res = await new Parse.Query("Template")
61 | .equalTo("name", template.name)
62 | .first();
63 | if (res) continue;
64 |
65 | const template_o = new Template();
66 |
67 | template_o.set("name", template.name);
68 | template_o.set("description", template.description);
69 | template_o.setACL(ACL);
70 |
71 | if (template.icon) {
72 | const iconData = fs.readFileSync(
73 | `./siteTemplates/icons/${template.icon}`
74 | );
75 | const iconFile = new Parse.File("icon.png", [...iconData]);
76 | await iconFile.save(null, { useMasterKey: true });
77 | template_o.set("icon", iconFile);
78 | }
79 |
80 | await template_o.save(null, { useMasterKey: true });
81 |
82 | for (let model of template.models) {
83 | const model_o = new Model();
84 |
85 | model_o.set("name", model.name);
86 | model_o.set("nameId", model.nameId);
87 | model_o.set("description", model.description);
88 | model_o.set("color", model.color);
89 | model_o.set("template", template_o);
90 | model_o.setACL(ACL);
91 |
92 | await model_o.save(null, { useMasterKey: true });
93 |
94 | for (let field of model.fields) {
95 | const field_o = new ModelField();
96 | field_o.set(field);
97 | field_o.set("model", model_o);
98 | field_o.setACL(ACL);
99 | field_o.save(null, { useMasterKey: true });
100 | }
101 | }
102 | }
103 | }
104 | };
105 |
106 | module.exports = initializeCMS;
107 |
--------------------------------------------------------------------------------
/frontend/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Todo List Sample App
10 |
17 |
21 |
32 |
33 |
34 |
35 |
36 |
Todo List
37 |
Realtime todo app using Parse JS SDK.
38 |
39 |
40 |
41 |
42 |
59 |
60 | Your Todos
61 |
62 |
68 | Loading...
69 |
70 |
71 |
72 |
73 |
74 | -
77 |
Loading...
78 |
79 |
80 |
81 |
82 |
83 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | mongo:
4 | image: mongo
5 | restart: unless-stopped
6 | volumes:
7 | - ./${DATA_DIR:-data}/mongodb:/data/db
8 |
9 | backend:
10 | build: ./${BACKEND_SRC_DIR:-backend}/
11 | image: ${BACKEND_IMAGE:-backend}:${IMAGE_TAG:-latest}
12 | entrypoint: ["/bin/sh", "./entrypoint.sh"]
13 | command: ["npm", "run", "${BACKEND_NPM_SCRIPT:-start}"]
14 | restart: unless-stopped
15 | volumes:
16 | - ./${BACKEND_SRC_DIR:-backend}:/src/parse-server
17 | - ./${DATA_DIR:-data}/parse-server/logs:/src/parse-server-logs
18 | - /src/parse-server/node_modules/
19 | environment:
20 | - NODE_ENV=${NODE_ENV:-development}
21 | - HOST_NAME=${HOST_NAME:-localhost}
22 | - BACKEND_HOST_NAME
23 | - BACKEND_PORT
24 | - PARSE_SERVER_APPLICATION_ID=${APP_ID:-myappid}
25 | - PARSE_SERVER_MASTER_KEY=${MASTER_KEY:-mymasterkey}
26 | - PARSE_SERVER_APP_NAME=${APP_NAME:-myappname}
27 | - PARSE_SERVER_MOUNT_PATH=${PARSE_SERVER_PATH:-/api}
28 | - PARSE_SERVER_DATABASE_URI=${PARSE_SERVER_DATABASE_URI:-mongodb://mongo:27017/dev}
29 | - PARSE_SERVER_GRAPHQL_PATH=${PARSE_SERVER_GRAPHQL_PATH:-/graphql}
30 | - PARSE_SERVER_CLOUD=/src/parse-server/cloud/main.js
31 | - PARSE_SERVER_LOGS_FOLDER=/src/parse-server-logs
32 | - MAIL_SMTP_HOST=${MAIL_SMTP_HOST:-smtp.sendgrid.net}
33 | - MAIL_SMTP_PORT=${MAIL_SMTP_PORT:-587}
34 | - MAIL_SMTP_USERNAME=${MAIL_SMTP_USERNAME:-}
35 | - MAIL_SMTP_PASSWORD=${MAIL_SMTP_PASSWORD:-}
36 | - MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS:-app@domain.com}
37 | - PARSE_DASHBOARD_ENABLED=${PARSE_DASHBOARD_ENABLED:-yes}
38 | - PARSE_DASHBOARD_USER_ID=${PARSE_DASHBOARD_USER_ID:-}
39 | - PARSE_DASHBOARD_USER_PASSWORD=${PARSE_DASHBOARD_USER_PASSWORD:-}
40 | - CMS_ENABLED=${CMS_ENABLED:-no}
41 | - CMS_USER_EMAIL=${CMS_USER_EMAIL:-}
42 | - CMS_USER_PASSWORD=${CMS_USER_PASSWORD:-}
43 | depends_on:
44 | - mongo
45 |
46 | frontend:
47 | build: ./${FRONTEND_SRC_DIR:-frontend}/
48 | image: ${FRONTEND_IMAGE:-frontend}:${IMAGE_TAG:-latest}
49 | command: ["npm", "run", "${FRONTEND_NPM_SCRIPT:-start}"]
50 | expose:
51 | - "${FRONTEND_DEV_SERVER_PORT:-1234}"
52 | - "${HMR_PORT:-1235}"
53 | environment:
54 | - NODE_ENV=${NODE_ENV:-development}
55 | - FRONTEND_DIST_DIR=${FRONTEND_DIST_DIR:-dist}
56 | - HMR_PORT=${HMR_PORT:-1235}
57 | - CHOKIDAR_USEPOLLING=1
58 | volumes:
59 | - ./${FRONTEND_SRC_DIR:-frontend}:/frontend
60 | - /frontend/node_modules/
61 | - /frontend/.cache/
62 |
63 | caddy:
64 | image: caddy:2-alpine
65 | entrypoint: ["/bin/sh", "./entrypoint.sh"]
66 | command:
67 | [
68 | "caddy",
69 | "run",
70 | "--config",
71 | "/etc/caddy/Caddyfile",
72 | "--adapter",
73 | "caddyfile",
74 | ]
75 | restart: unless-stopped
76 | ports:
77 | - "80:80"
78 | - "443:443"
79 | - "${BACKEND_PORT:-1337}:${BACKEND_PORT:-1337}"
80 | - "${FRONTEND_DEV_SERVER_PORT:-1234}:${FRONTEND_DEV_SERVER_PORT:-1234}"
81 | - "${HMR_PORT:-1235}:${HMR_PORT:-1235}"
82 | environment:
83 | - HOST_NAME=${HOST_NAME:-localhost}
84 | - BACKEND_HOST_NAME
85 | - BACKEND_PORT
86 | - FRONTEND_DIST_DIR=${FRONTEND_DIST_DIR:-dist}
87 | - FRONTEND_DEV_SERVER_PORT=${FRONTEND_DEV_SERVER_PORT:-1234}
88 | - HMR_PORT=${HMR_PORT:-1235}
89 | volumes:
90 | - ./backend/entrypoint.sh:/srv/entrypoint.sh
91 | - ./Caddyfile:/etc/caddy/Caddyfile
92 | - ./${DATA_DIR:-data}/caddy/data:/data
93 | - ./${DATA_DIR:-data}/caddy/config:/config
94 | - ./${FRONTEND_SRC_DIR:-frontend}/${FRONTEND_DIST_DIR:-dist}:/srv/${FRONTEND_DIST_DIR:-dist}
95 | depends_on:
96 | - backend
97 | - frontend
98 |
--------------------------------------------------------------------------------
/frontend/src/app.js:
--------------------------------------------------------------------------------
1 | import Parse from "./parse";
2 |
3 | Parse.initialize("myappid");
4 | Parse.serverURL = `https://${location.hostname}:1337/api`;
5 | // Parse.serverURL = `https://api.${location.hostname}/api`;
6 |
7 | const todoForm = document.querySelector("#todo-form");
8 | const todoInput = document.querySelector("#todo-input");
9 | const addBtn = document.querySelector("#add-btn");
10 | const todosContainer = document.querySelector("#todos-container");
11 | const todosCount = document.querySelector("#todos-count");
12 | const loadingIndicator = document.querySelector("#loading-indicator");
13 |
14 | const Todo = "Todo";
15 |
16 | const showLoading = (loading = true) => {
17 | if (loading) {
18 | loadingIndicator.style.display = "block";
19 | todosCount.style.display = "none";
20 | } else {
21 | loadingIndicator.style.display = "none";
22 | todosCount.style.display = "block";
23 | }
24 | };
25 |
26 | const subscribeToData = async () => {
27 | const query = new Parse.Query(Todo);
28 | query.descending("createdAt");
29 | let results = await query.find();
30 | updateDOM(results);
31 |
32 | const subscription = await query.subscribe();
33 |
34 | subscription.on("create", (object) => {
35 | results = [object, ...results];
36 | updateDOM(results);
37 | });
38 |
39 | subscription.on("update", (object) => {
40 | results = results.map((item) => (item.id === object.id ? object : item));
41 | updateDOM(results);
42 | });
43 |
44 | subscription.on("delete", (object) => {
45 | results = results.filter((item) => !(item.id === object.id));
46 | updateDOM(results);
47 | });
48 | };
49 |
50 | const updateDOM = (data) => {
51 | todosContainer.innerHTML = data.length === 0 ? emptyListHtml() : "";
52 | const todos = data.map((object) => ({
53 | id: object.id,
54 | text: object.get("text"),
55 | complete: object.get("complete"),
56 | }));
57 |
58 | todos.forEach((todo) => {
59 | const li = createTodoElement(todo);
60 | const closeButton = li.querySelector("button.close");
61 | todosContainer.appendChild(li);
62 | li.addEventListener("click", () => toggleTodo(todo.id));
63 | closeButton.addEventListener("click", () => deleteTodo(todo.id));
64 | });
65 |
66 | todosCount.innerHTML = todos.filter((todo) => !todo.complete).length;
67 |
68 | showLoading(false);
69 |
70 | function createTodoElement(todo) {
71 | const template = document.createElement("template");
72 | template.innerHTML = `
73 |
78 |
79 |
80 |
87 |
92 |
93 |
94 |
102 | `.trim();
103 | return template.content.firstChild;
104 | }
105 |
106 | function emptyListHtml() {
107 | return `
108 |
109 | No Todos!
110 |
111 | `;
112 | }
113 | };
114 |
115 | const addTodo = async () => {
116 | if (todoInput.value === "") return;
117 | showLoading();
118 | const obj = new Parse.Object(Todo);
119 | obj.set("text", todoInput.value);
120 | obj.set("complete", false);
121 | await obj.save();
122 | todoInput.value = "";
123 | todoInput.focus();
124 | };
125 |
126 | const getTodo = async (id) => {
127 | const query = new Parse.Query(Todo);
128 | query.equalTo("objectId", id);
129 | const todo = await query.first();
130 | return todo;
131 | };
132 |
133 | const toggleTodo = async (id) => {
134 | if (!id) return;
135 | showLoading();
136 | const todo = await getTodo(id);
137 | todo.set("complete", !todo.get("complete"));
138 | await todo.save();
139 | };
140 |
141 | const deleteTodo = async (id) => {
142 | if (!id) return;
143 | showLoading();
144 | const todo = await getTodo(id);
145 | await todo.destroy();
146 | };
147 |
148 | addBtn.addEventListener("click", addTodo);
149 | todoForm.addEventListener("submit", addTodo);
150 | todoInput.focus();
151 | subscribeToData();
152 |
--------------------------------------------------------------------------------
/.github/actions/variables/action.yml:
--------------------------------------------------------------------------------
1 | name: "Variables"
2 | description: "Set environment variables with defaults"
3 |
4 | inputs:
5 | production:
6 | description: "Production branches"
7 | required: false
8 | default: "main master"
9 |
10 | staging:
11 | description: "Staging branches"
12 | required: false
13 | default: "staging"
14 |
15 | development:
16 | description: "Development branches"
17 | required: false
18 | default: "develop"
19 |
20 | SHA:
21 | description: "Git short SHA for manually-triggered deploys"
22 | required: false
23 |
24 | runs:
25 | using: "composite"
26 | steps:
27 | - name: Set environment based on git branch
28 | run: |
29 | BRANCH=${GITHUB_REF##*/}
30 | PROD_BRANCHES=( "${{ inputs.production }}" )
31 | STAG_BRANCHES=( "${{ inputs.staging }}" )
32 | DEV_BRANCHES=( "${{ inputs.development }}" )
33 | echo "ENVIRONMENT=testing" >> $GITHUB_ENV
34 | if [[ " ${PROD_BRANCHES[@]} " =~ " ${BRANCH} " ]]; then echo "ENVIRONMENT=production" >> $GITHUB_ENV; fi;
35 | if [[ " ${STAG_BRANCHES[@]} " =~ " ${BRANCH} " ]]; then echo "ENVIRONMENT=staging" >> $GITHUB_ENV; fi;
36 | if [[ " ${DEV_BRANCHES[@]} " =~ " ${BRANCH} " ]]; then echo "ENVIRONMENT=development" >> $GITHUB_ENV; fi;
37 | shell: bash
38 |
39 | - name: Load secrets
40 | run: |
41 | if [[ ${ENVIRONMENT} == "staging" ]]; then
42 | echo "REGISTRY=${STAG_DOCKER_REGISTRY}" >> $GITHUB_ENV
43 | echo "REGISTRY_USER=${STAG_DOCKER_REGISTRY_USER}" >> $GITHUB_ENV
44 | echo "REGISTRY_TOKEN=${STAG_DOCKER_REGISTRY_TOKEN}" >> $GITHUB_ENV
45 | echo "SSH_HOST=${STAG_SSH_HOST}" >> $GITHUB_ENV
46 | echo "SSH_PORT=${STAG_SSH_PORT}" >> $GITHUB_ENV
47 | echo "SSH_USER=${STAG_SSH_USER}" >> $GITHUB_ENV
48 | echo "SSH_KEY=${STAG_SSH_KEY//$'\n'/'%0A'}" >> $GITHUB_ENV
49 | echo "SSH_PATH=${STAG_SSH_PATH}" >> $GITHUB_ENV
50 | echo "ENV_VARS=${STAG_ENV_VARS//$'\n'/'%0A'}" >> $GITHUB_ENV
51 | echo "DOCKER_COMPOSE_UP_ARGS=${STAG_DOCKER_COMPOSE_UP_ARGS}" >> $GITHUB_ENV
52 | elif [[ ${ENVIRONMENT} == "development" ]]; then
53 | echo "REGISTRY=${DEV_DOCKER_REGISTRY}" >> $GITHUB_ENV
54 | echo "REGISTRY_USER=${DEV_DOCKER_REGISTRY_USER}" >> $GITHUB_ENV
55 | echo "REGISTRY_TOKEN=${DEV_DOCKER_REGISTRY_TOKEN}" >> $GITHUB_ENV
56 | echo "SSH_HOST=${DEV_SSH_HOST}" >> $GITHUB_ENV
57 | echo "SSH_PORT=${DEV_SSH_PORT}" >> $GITHUB_ENV
58 | echo "SSH_USER=${DEV_SSH_USER}" >> $GITHUB_ENV
59 | echo "SSH_KEY=${DEV_SSH_KEY//$'\n'/'%0A'}" >> $GITHUB_ENV
60 | echo "SSH_PATH=${DEV_SSH_PATH}" >> $GITHUB_ENV
61 | echo "ENV_VARS=${DEV_ENV_VARS//$'\n'/'%0A'}" >> $GITHUB_ENV
62 | echo "DOCKER_COMPOSE_UP_ARGS=${DEV_DOCKER_COMPOSE_UP_ARGS}" >> $GITHUB_ENV
63 | else
64 | echo "REGISTRY=${PROD_DOCKER_REGISTRY}" >> $GITHUB_ENV
65 | echo "REGISTRY_USER=${PROD_DOCKER_REGISTRY_USER}" >> $GITHUB_ENV
66 | echo "REGISTRY_TOKEN=${PROD_DOCKER_REGISTRY_TOKEN}" >> $GITHUB_ENV
67 | echo "SSH_HOST=${PROD_SSH_HOST}" >> $GITHUB_ENV
68 | echo "SSH_PORT=${PROD_SSH_PORT}" >> $GITHUB_ENV
69 | echo "SSH_USER=${PROD_SSH_USER}" >> $GITHUB_ENV
70 | echo "SSH_KEY=${PROD_SSH_KEY//$'\n'/'%0A'}" >> $GITHUB_ENV
71 | echo "SSH_PATH=${PROD_SSH_PATH}" >> $GITHUB_ENV
72 | echo "ENV_VARS=${PROD_ENV_VARS//$'\n'/'%0A'}" >> $GITHUB_ENV
73 | echo "DOCKER_COMPOSE_UP_ARGS=${PROD_DOCKER_COMPOSE_UP_ARGS}" >> $GITHUB_ENV
74 | fi;
75 | shell: bash
76 |
77 | - id: vars
78 | name: Set variable defaults
79 | run: |
80 | echo "REGISTRY=${REGISTRY:-docker.pkg.github.com}" >> $GITHUB_ENV
81 | echo "REGISTRY_USER=${REGISTRY_USER:-${{github.actor}}}" >> $GITHUB_ENV
82 | echo "SSH_PORT=${SSH_PORT:-22}" >> $GITHUB_ENV
83 | echo "SSH_USER=${SSH_USER:-root}" >> $GITHUB_ENV
84 | echo "SSH_PATH=${SSH_PATH:-./deploy/}" >> $GITHUB_ENV
85 | shell: bash
86 |
87 | - name: Populate environment variables
88 | run: |
89 | REPO=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')
90 | IMAGE_PREFIX=$(echo "docker.pkg.github.com/${{ github.repository }}/${REPO}" | tr '[:upper:]' '[:lower:]')
91 | INPUT_SHA=${{ inputs.SHA }}
92 | IMAGE_TAG=${INPUT_SHA:-${SHA}}
93 | touch .env
94 | ORIGINAL_VARS=`cat .env`
95 | rm -f .env
96 | echo "### Defaults ###" >> .env
97 | echo "NODE_ENV=production" >> .env
98 | echo "HOST_NAME=${SSH_HOST}" >> .env
99 | echo "BACKEND_IMAGE=${IMAGE_PREFIX}-backend" >> .env
100 | echo "FRONTEND_IMAGE=${IMAGE_PREFIX}-frontend" >> .env
101 | echo "IMAGE_TAG=${IMAGE_TAG:0:7}" >> .env
102 | echo "" >> .env
103 | echo "${ORIGINAL_VARS}" >> .env
104 | echo "" >> .env
105 | echo "### Variables from Github Secrets ###" >> .env
106 | echo "${ENV_VARS//'%0A'/$'\n'}" >> .env
107 | echo "" >> .env
108 | shell: bash
109 | env:
110 | SHA: ${{ github.sha }}
111 |
--------------------------------------------------------------------------------
/backend/cms/cloud/payment.js:
--------------------------------------------------------------------------------
1 | const {StripeConfig, CLOUD_ERROR_CODE__STRIPE_INIT_ERROR} = require('./common');
2 |
3 | let stripe;
4 | if (StripeConfig && StripeConfig.keyPrivate)
5 | stripe = require("stripe")(StripeConfig.keyPrivate);
6 |
7 |
8 | let defaultPayPlan;
9 |
10 | //if there are no pay plans, return null
11 | const getDefaultPayPlan = async () => {
12 | if (!defaultPayPlan)
13 | defaultPayPlan = await new Parse.Query('PayPlan')
14 | .equalTo('priceMonthly', 0)
15 | .first();
16 |
17 | return defaultPayPlan;
18 | };
19 |
20 | const getPayPlan = async (user) => {
21 | if (!stripe) {
22 | //return null;
23 | //throw {errorMsg: 'Stripe is not initialized!', errorCode: 701};
24 |
25 | let payPlan = user.get('payPlan');
26 | if (!payPlan)
27 | return null;
28 |
29 | await payPlan.fetch();
30 | return payPlan;
31 | }
32 |
33 | const customerId = user.get('StripeId');
34 | if (!customerId)
35 | return getDefaultPayPlan();
36 |
37 | let customer;
38 | try {
39 | customer = await stripe.customers.retrieve(customerId);
40 | } catch (e) {}
41 | if (!customer || customer.deleted)
42 | return getDefaultPayPlan();
43 |
44 | const subscription = customer.subscriptions.data[0];
45 | if (!subscription || subscription.status == 'canceled' || !subscription.plan)
46 | return getDefaultPayPlan();
47 |
48 | const payPlan = await new Parse.Query('PayPlan')
49 | .equalTo('StripeId', subscription.plan.product)
50 | .first();
51 | if (!payPlan)
52 | return getDefaultPayPlan();
53 |
54 | return payPlan;
55 | };
56 | module.exports.getPayPlan = getPayPlan;
57 |
58 |
59 |
60 | Parse.Cloud.define("getStripeData", async request => {
61 | if (!stripe)
62 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
63 |
64 | const {user} = request;
65 | if (!user)
66 | throw 'Must be signed in to call this Cloud Function.';
67 |
68 | const customerId = user.get('StripeId');
69 | if (!customerId)
70 | return null;
71 |
72 | let customer;
73 | try {
74 | customer = await stripe.customers.retrieve(customerId);
75 | } catch (e) {}
76 | if (!customer || customer.deleted)
77 | return null;
78 |
79 | const sources = [];
80 | for await (const source of stripe.customers.listSources(customerId)) {
81 | sources.push(source);
82 | }
83 |
84 | let subscription = customer.subscriptions.data[0];
85 | if (subscription && subscription.status == 'canceled')
86 | subscription = null;
87 |
88 | return {
89 | defaultSource: customer.default_source,
90 | sources,
91 | subscription
92 | };
93 | });
94 |
95 | Parse.Cloud.define("savePaymentSource", async request => {
96 | if (!stripe)
97 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
98 |
99 | const {user} = request;
100 | if (!user)
101 | throw 'Must be signed in to call this Cloud Function.';
102 |
103 | const {tokenId, asDefault} = request.params;
104 | if (!tokenId)
105 | throw 'There is no token param!';
106 |
107 | const customerId = user.get('StripeId');
108 | let customer;
109 | try {
110 | customer = await stripe.customers.retrieve(customerId);
111 | } catch (e) {}
112 |
113 | if (customer && !customer.deleted) {
114 | const source = await stripe.customers.createSource(customerId, {source: tokenId});
115 | if (asDefault)
116 | await stripe.customers.update(customerId, {default_source: source.id});
117 |
118 | return null;
119 |
120 | } else {
121 | customer = await stripe.customers.create({
122 | source: tokenId,
123 | email: user.get('email')
124 | });
125 | user.set('StripeId', customer.id);
126 | await user.save(null, {useMasterKey: true});
127 |
128 | return customer.id;
129 | }
130 | });
131 |
132 | Parse.Cloud.define("setDefaultPaymentSource", async request => {
133 | if (!stripe)
134 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
135 |
136 | const {user} = request;
137 | if (!user)
138 | throw 'Must be signed in to call this Cloud Function.';
139 |
140 | const {sourceId} = request.params;
141 | if (!sourceId)
142 | throw 'There is no source param!';
143 |
144 | let customerId = user.get('StripeId');
145 | if (!customerId)
146 | throw 'There is no customer object yet!';
147 |
148 | await stripe.customers.update(customerId, {default_source: sourceId});
149 |
150 | return null;
151 | });
152 |
153 | Parse.Cloud.define("removePaymentSource", async request => {
154 | if (!stripe)
155 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
156 |
157 | const {user} = request;
158 | if (!user)
159 | throw 'Must be signed in to call this Cloud Function.';
160 |
161 | const {sourceId} = request.params;
162 | if (!sourceId)
163 | throw 'There is no sourceId param!';
164 |
165 | let customerId = user.get('StripeId');
166 | if (!customerId)
167 | throw 'There is no customer object yet!';
168 |
169 | await stripe.customers.deleteSource(customerId, sourceId);
170 |
171 | const customer = await stripe.customers.retrieve(customerId);
172 |
173 | return {defaultSource: customer.default_source};
174 | });
175 |
176 | Parse.Cloud.define('paySubscription', async request => {
177 | if (!stripe)
178 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
179 |
180 | const {user} = request;
181 | if (!user)
182 | throw 'Must be signed in to call this Cloud Function.';
183 |
184 | const customerId = user.get('StripeId');
185 | if (!customerId)
186 | throw 'There are no payment methods!';
187 |
188 | const {planId, isYearly} = request.params;
189 | if (!planId)
190 | throw 'There is no plan param!';
191 |
192 | const payPlan = await new Parse.Query("PayPlan").get(planId);
193 | if (!payPlan)
194 | throw 'There is no pay plan!';
195 |
196 | const StripePlanId = isYearly ? payPlan.get('StripeIdYearly') : payPlan.get('StripeIdMonthly');
197 | if (!StripePlanId)
198 | throw 'Wrong pay plan!';
199 |
200 | const customer = await stripe.customers.retrieve(customerId);
201 |
202 | let subscription = customer.subscriptions.data[0];
203 | if (subscription) {
204 | subscription = await stripe.subscriptions.update(subscription.id, {
205 | items: [{
206 | id: subscription.items.data[0].id,
207 | plan: StripePlanId
208 | }],
209 | cancel_at_period_end: false
210 | });
211 |
212 | } else {
213 | subscription = await stripe.subscriptions.create({
214 | customer: customerId,
215 | items: [{plan: StripePlanId}]
216 | });
217 | }
218 |
219 | user.set('payPlan', payPlan);
220 | await user.save(null, {useMasterKey: true});
221 |
222 | return subscription;
223 | });
224 |
225 | Parse.Cloud.define('cancelSubscription', async request => {
226 | if (!stripe)
227 | throw {errorMsg: 'Stripe is not initialized!', errorCode: CLOUD_ERROR_CODE__STRIPE_INIT_ERROR};
228 |
229 | const {user} = request;
230 | if (!user)
231 | throw 'Must be signed in to call this Cloud Function.';
232 |
233 | const customerId = user.get('StripeId');
234 | if (!customerId)
235 | throw 'There are no Stripe customer!';
236 |
237 | const customer = await stripe.customers.retrieve(customerId);
238 |
239 | const subscription = customer.subscriptions.data[0];
240 | if (!subscription)
241 | throw 'There are no subscription!';
242 |
243 | return await stripe.subscriptions.update(subscription.id, {cancel_at_period_end: true});
244 | });
245 |
--------------------------------------------------------------------------------
/backend/public/choose_password.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 | Password Reset
15 |
141 |
142 |
143 | Reset Your Password
144 |
145 |
146 |
163 |
164 |
215 |
216 |
--------------------------------------------------------------------------------
/backend/cms/cloud/main.js:
--------------------------------------------------------------------------------
1 | console.log('Cloud code connected');
2 |
3 | const {config, SITE, ROLE_ADMIN, ROLE_EDITOR, promisifyW, getAllObjects} = require('./common');
4 |
5 | const {getPayPlan} = require('./payment');
6 |
7 |
8 | const checkRights = (user, obj) => {
9 | const acl = obj.getACL();
10 | if (!acl)
11 | return true;
12 |
13 | const read = acl.getReadAccess(user.id);
14 | const write = acl.getWriteAccess(user.id);
15 |
16 | const pRead = acl.getPublicReadAccess();
17 | const pWrite = acl.getPublicWriteAccess();
18 |
19 | return read && write || pRead && pWrite;
20 | };
21 |
22 |
23 | const getTableData = async (table) => {
24 | const endpoint = '/schemas/' + table;
25 |
26 | try {
27 | const response = await Parse.Cloud.httpRequest({
28 | url: config.serverURL + endpoint,
29 | method: 'GET',
30 | mode: 'cors',
31 | cache: 'no-cache',
32 | headers: {
33 | 'Content-Type': 'application/json',
34 | 'X-Parse-Application-Id': config.appId,
35 | 'X-Parse-Master-Key': config.masterKey
36 | }
37 | });
38 |
39 | if (response.status == 200)
40 | return response.data;
41 |
42 | } catch (e) {}
43 |
44 | return null;
45 | };
46 |
47 | const setTableData = async (table, data, method = 'POST') => {
48 | const endpoint = '/schemas/' + table;
49 |
50 | const response = await Parse.Cloud.httpRequest({
51 | url: config.serverURL + endpoint,
52 | method,
53 | mode: 'cors',
54 | cache: 'no-cache',
55 | headers: {
56 | 'Content-Type': 'application/json',
57 | 'X-Parse-Application-Id': config.appId,
58 | 'X-Parse-Master-Key': config.masterKey
59 | },
60 | body: JSON.stringify(data)
61 | });
62 |
63 | if (response.status != 200)
64 | throw response.status;
65 | };
66 |
67 | const deleteTable = async (table) => {
68 | const endpoint = '/schemas/' + table;
69 |
70 | const response = await Parse.Cloud.httpRequest({
71 | url: config.serverURL + endpoint,
72 | method: 'DELETE',
73 | mode: 'cors',
74 | cache: 'no-cache',
75 | headers: {
76 | 'Content-Type': 'application/json',
77 | 'X-Parse-Application-Id': config.appId,
78 | 'X-Parse-Master-Key': config.masterKey
79 | }
80 | });
81 |
82 | if (response.status != 200)
83 | throw response.status;
84 | };
85 |
86 |
87 | const deleteContentItem = async (user, tableName, itemId) => {
88 | const item = await new Parse.Query(tableName)
89 | .get(itemId, {useMasterKey: true});
90 |
91 | if (!checkRights(user, item))
92 | throw "Access denied!";
93 |
94 |
95 | //removing MediaItem's belonging to content item
96 | const tableData = await getTableData(tableName);
97 |
98 | for (let field in tableData.fields) {
99 | const val = tableData.fields[field];
100 | if (val.type == 'Pointer' && val.targetClass == 'MediaItem') {
101 | const media = item.get(field);
102 | //!! uncontrolled async operation
103 | if (media)
104 | media.destroy({useMasterKey: true});
105 | }
106 | }
107 |
108 |
109 | //seeking draft version of content item
110 | const itemDraft = await new Parse.Query(tableName)
111 | .equalTo('t__owner', item)
112 | .first({useMasterKey: true});
113 |
114 | if (itemDraft) {
115 | if (!checkRights(user, itemDraft))
116 | throw "Access denied!";
117 |
118 | for (let field in tableData.fields) {
119 | const val = tableData.fields[field];
120 | if (val.type == 'Pointer' && val.targetClass == 'MediaItem') {
121 | const media = itemDraft.get(field);
122 | //!! uncontrolled async operation
123 | if (media)
124 | media.destroy({useMasterKey: true});
125 | }
126 | }
127 |
128 | await itemDraft.destroy({useMasterKey: true});
129 | }
130 |
131 | await item.destroy({useMasterKey: true});
132 | };
133 |
134 | const deleteModel = async (user, model, deleteRef = true, deleteModel = true) => {
135 | if (!checkRights(user, model))
136 | throw "Access denied!";
137 |
138 |
139 | //removing model fields
140 | let fields = await getAllObjects(
141 | new Parse.Query('ModelField')
142 | .equalTo('model', model)
143 | );
144 |
145 | let promises = [];
146 | for (let field of fields) {
147 | if (checkRights(user, field))
148 | promises.push(promisifyW(field.destroy({useMasterKey: true})));
149 | }
150 | await Promise.all(promises);
151 |
152 |
153 | //removing content items of model
154 | const tableName = model.get('tableName');
155 | const items = await getAllObjects(new Parse.Query(tableName));
156 | promises = [];
157 | for (let item of items) {
158 | promises.push(promisifyW(deleteContentItem(user, tableName, item.id)));
159 | }
160 | await Promise.all(promises);
161 |
162 | try {
163 | await deleteTable(tableName);
164 | } catch (e) {}
165 |
166 |
167 | //removing reference validation to model
168 | if (deleteRef) {
169 | const models = await getAllObjects(
170 | new Parse.Query('Model')
171 | .equalTo('site', model.get('site'))
172 | );
173 | fields = await getAllObjects(
174 | new Parse.Query('ModelField')
175 | .containedIn('model', models)
176 | .notEqualTo('model', model)
177 | .equalTo('type', 'Reference')
178 | );
179 |
180 | const promises = [];
181 | for (let field of fields) {
182 | const validations = field.get('validations');
183 | if (!validations || !validations.models || !validations.models.active || !validations.models.modelsList)
184 | continue;
185 |
186 | const i = validations.models.modelsList.indexOf(model.get('nameId'));
187 | if (i == -1)
188 | continue;
189 |
190 | validations.models.modelsList.splice(i, 1);
191 | field.set('validations', validations);
192 | promises.push(promisifyW(field.save(null, {useMasterKey: true})));
193 | }
194 | await Promise.all(promises);
195 | }
196 |
197 |
198 | //remove model
199 | if (deleteModel)
200 | await model.destroy({useMasterKey: true});
201 | };
202 |
203 |
204 | Parse.Cloud.define("deleteContentItem", async (request) => {
205 | if (!request.user)
206 | throw 'Must be signed in to call this Cloud Function.';
207 |
208 | const {tableName, itemId} = request.params;
209 | if (!tableName || !itemId)
210 | throw 'There is no tableName or itemId params!';
211 |
212 | try {
213 | await deleteContentItem(request.user, tableName, itemId);
214 | return "Successfully deleted content item.";
215 | } catch (error) {
216 | throw `Could not delete content item: ${error}`;
217 | }
218 | });
219 |
220 | Parse.Cloud.beforeDelete(`Model`, async request => {
221 | if (request.master)
222 | return;
223 |
224 | try {
225 | return await deleteModel(request.user, request.object, true, false);
226 | } catch (error) {
227 | throw `Could not delete model: ${JSON.stringify(error, null, 2)}`;
228 | }
229 | });
230 |
231 | Parse.Cloud.beforeDelete(`Site`, async request => {
232 | if (request.master)
233 | return;
234 |
235 | const site = request.object;
236 |
237 | if (!checkRights(request.user, site))
238 | throw "Access denied!";
239 |
240 | //removing site's models
241 | const models = await getAllObjects(
242 | new Parse.Query('Model')
243 | .equalTo('site', site));
244 |
245 | let promises = [];
246 | for (let model of models)
247 | promises.push(promisifyW(
248 | deleteModel(request.user, model, false)
249 | ));
250 | await Promise.all(promises);
251 |
252 |
253 | //removing site's collaborations
254 | const collabs = await getAllObjects(
255 | new Parse.Query('Collaboration')
256 | .equalTo('site', site));
257 |
258 | promises = [];
259 | for (let collab of collabs)
260 | promises.push(promisifyW(
261 | collab.destroy({useMasterKey: true})
262 | ));
263 | await Promise.all(promises);
264 | });
265 |
266 |
267 | const onCollaborationModify = async (collab, deleting = false) => {
268 | const site = collab.get('site');
269 | const user = collab.get('user');
270 | const role = collab.get('role');
271 |
272 | if (!user)
273 | return;
274 |
275 | await site.fetch({useMasterKey: true});
276 |
277 | //ACL for collaborations
278 | const owner = site.get('owner');
279 | let collabACL = collab.getACL();
280 | if (!collabACL)
281 | collabACL = new Parse.ACL(owner);
282 |
283 | //getting all site collabs
284 | const collabs = await getAllObjects(
285 | new Parse.Query('Collaboration')
286 | .equalTo('site', site)
287 | .notEqualTo('user', user));
288 |
289 | for (let tempCollab of collabs) {
290 | if (tempCollab.id == collab.id)
291 | continue;
292 |
293 | //set ACL for others collab
294 | let tempCollabACL = tempCollab.getACL();
295 | if (!tempCollabACL)
296 | tempCollabACL = new Parse.ACL(owner);
297 |
298 | tempCollabACL.setReadAccess(user, !deleting && role == ROLE_ADMIN);
299 | tempCollabACL.setWriteAccess(user, !deleting && role == ROLE_ADMIN);
300 |
301 | tempCollab.setACL(tempCollabACL);
302 | //!! uncontrolled async operation
303 | tempCollab.save(null, {useMasterKey: true});
304 |
305 | //set ACL for current collab
306 | if (!deleting) {
307 | const tempRole = tempCollab.get('role');
308 | const tempUser = tempCollab.get('user');
309 | collabACL.setReadAccess(tempUser, tempRole == ROLE_ADMIN);
310 | collabACL.setWriteAccess(tempUser, tempRole == ROLE_ADMIN);
311 | }
312 | }
313 |
314 | collabACL.setReadAccess(user, true);
315 | collabACL.setWriteAccess(user, true);
316 | collab.setACL(collabACL);
317 |
318 |
319 | //ACL for site
320 | let siteACL = site.getACL();
321 | if (!siteACL)
322 | siteACL = new Parse.ACL(owner);
323 |
324 | siteACL.setReadAccess(user, !deleting);
325 | siteACL.setWriteAccess(user, !deleting && role == ROLE_ADMIN);
326 | site.setACL(siteACL);
327 | //!! uncontrolled async operation
328 | site.save(null, {useMasterKey: true});
329 |
330 |
331 | //ACL for media items
332 | const mediaItems = await getAllObjects(
333 | new Parse.Query('MediaItem')
334 | .equalTo('site', site));
335 |
336 | for (let item of mediaItems) {
337 | let itemACL = item.getACL();
338 | if (!itemACL)
339 | itemACL = new Parse.ACL(owner);
340 |
341 | itemACL.setReadAccess(user, !deleting);
342 | itemACL.setWriteAccess(user, !deleting && role == ROLE_ADMIN);
343 | item.setACL(itemACL);
344 | //!! uncontrolled async operation
345 | item.save(null, {useMasterKey: true});
346 | }
347 |
348 |
349 | //ACL for models and content items
350 | const models = await getAllObjects(
351 | new Parse.Query('Model')
352 | .equalTo('site', site));
353 |
354 | for (let model of models) {
355 | let modelACL = model.getACL();
356 | if (!modelACL)
357 | modelACL = new Parse.ACL(owner);
358 |
359 | modelACL.setReadAccess(user, !deleting);
360 | modelACL.setWriteAccess(user, !deleting && role == ROLE_ADMIN);
361 | model.setACL(modelACL);
362 | //!! uncontrolled async operation
363 | model.save(null, {useMasterKey: true});
364 |
365 | const tableName = model.get('tableName');
366 | //!! uncontrolled async operation
367 | getTableData(tableName)
368 | .then(response => {
369 | let CLP = response ? response.classLevelPermissions : null;
370 | if (!CLP)
371 | CLP = {
372 | 'get': {},
373 | 'find': {},
374 | 'create': {},
375 | 'update': {},
376 | 'delete': {},
377 | 'addField': {}
378 | };
379 |
380 | if (!deleting) {
381 | CLP['get'][user.id] = true;
382 | CLP['find'][user.id] = true;
383 | } else {
384 | if (CLP['get'].hasOwnProperty(user.id))
385 | delete CLP['get'][user.id];
386 | if (CLP['find'].hasOwnProperty(user.id))
387 | delete CLP['find'][user.id];
388 | }
389 |
390 | if (!deleting && (role == ROLE_ADMIN || role == ROLE_EDITOR)) {
391 | CLP['create'][user.id] = true;
392 | CLP['update'][user.id] = true;
393 | CLP['delete'][user.id] = true;
394 | } else {
395 | if (CLP['create'].hasOwnProperty(user.id))
396 | delete CLP['create'][user.id];
397 | if (CLP['update'].hasOwnProperty(user.id))
398 | delete CLP['update'][user.id];
399 | if (CLP['delete'].hasOwnProperty(user.id))
400 | delete CLP['delete'][user.id];
401 | }
402 |
403 | if (!deleting && role == ROLE_ADMIN)
404 | CLP['addField'][user.id] = true;
405 | else if (CLP['addField'].hasOwnProperty(user.id))
406 | delete CLP['addField'][user.id];
407 |
408 | //!! uncontrolled async operation
409 | const data = {"classLevelPermissions": CLP};
410 | setTableData(tableName, data)
411 | .catch(() => setTableData(tableName, data, 'PUT'));
412 | });
413 | }
414 |
415 |
416 | //ACL for fields
417 | const fields = await getAllObjects(
418 | new Parse.Query('ModelField')
419 | .containedIn('model', models));
420 |
421 | for (let field of fields) {
422 | let fieldACL = field.getACL();
423 | if (!fieldACL)
424 | fieldACL = new Parse.ACL(owner);
425 |
426 | fieldACL.setReadAccess(user, !deleting);
427 | fieldACL.setWriteAccess(user, !deleting && role == ROLE_ADMIN);
428 | field.setACL(fieldACL);
429 | //!! uncontrolled async operation
430 | field.save(null, {useMasterKey: true});
431 | }
432 | };
433 |
434 |
435 | Parse.Cloud.beforeSave("Collaboration", async request => {
436 | if (request.master)
437 | return;
438 |
439 | const collab = request.object;
440 | if (!checkRights(request.user, collab))
441 | throw "Access denied!";
442 |
443 | return onCollaborationModify(collab);
444 | });
445 |
446 | Parse.Cloud.beforeDelete("Collaboration", async request => {
447 | if (request.master)
448 | return;
449 |
450 | const collab = request.object;
451 | if (!checkRights(request.user, collab))
452 | throw "Access denied!";
453 |
454 | return onCollaborationModify(collab, true);
455 | });
456 |
457 | Parse.Cloud.beforeSave(Parse.User, request => {
458 | const user = request.object;
459 | const email = user.get('email');
460 | if (user.get('username') != email)
461 | user.set('username', email);
462 | });
463 |
464 | Parse.Cloud.afterSave(Parse.User, async request => {
465 | const user = request.object;
466 |
467 | const collabs = await new Parse.Query('Collaboration')
468 | .equalTo('email', user.get('email'))
469 | .find({useMasterKey: true});
470 |
471 | const promises = [];
472 |
473 | for (let collab of collabs) {
474 | if (collab.get('user'))
475 | continue;
476 |
477 | collab.set('user', user);
478 | collab.set('email', '');
479 |
480 | promises.push(collab.save(null, {useMasterKey: true}));
481 | promises.push(promisifyW(onCollaborationModify(collab)));
482 | }
483 |
484 | await Promise.all(promises);
485 | });
486 |
487 | Parse.Cloud.beforeSave("Site", async request => {
488 | if (request.master)
489 | return;
490 |
491 | //updating an existing site
492 | if (request.object.id)
493 | return true;
494 |
495 | const user = request.user;
496 | if (!user)
497 | throw 'Must be signed in to save sites.';
498 |
499 | const payPlan = await getPayPlan(user);
500 | if (!payPlan)
501 | return true;
502 |
503 | const sitesLimit = payPlan.get('limitSites');
504 | if (!sitesLimit)
505 | return true;
506 |
507 | const sites = await new Parse.Query('Site')
508 | .equalTo('owner', user)
509 | .count({useMasterKey: true});
510 |
511 | if (sites >= sitesLimit)
512 | throw `The user has exhausted their sites' limit!`;
513 |
514 | return true;
515 | });
516 |
517 | Parse.Cloud.beforeSave(`Model`, async request => {
518 | if (request.master)
519 | return;
520 |
521 | const model = request.object;
522 | if (model.id)
523 | return;
524 |
525 | const site = model.get('site');
526 | await site.fetch({useMasterKey: true});
527 |
528 | //ACL for collaborations
529 | const owner = site.get('owner');
530 | const modelACL = new Parse.ACL(owner);
531 |
532 | const collabs = await getAllObjects(
533 | new Parse.Query('Collaboration')
534 | .equalTo('site', site));
535 |
536 | const admins = [owner.id];
537 | const writers = [owner.id];
538 | const all = [owner.id];
539 |
540 | for (let collab of collabs) {
541 | const user = collab.get('user');
542 | const role = collab.get('role');
543 |
544 | modelACL.setReadAccess(user, true);
545 | modelACL.setWriteAccess(user, role == ROLE_ADMIN);
546 |
547 | if (role == ROLE_ADMIN)
548 | admins.push(user.id);
549 | if (role == ROLE_ADMIN || role == ROLE_EDITOR)
550 | writers.push(user.id);
551 | all.push(user.id);
552 | }
553 |
554 | model.setACL(modelACL);
555 |
556 | //set CLP for content table
557 | const CLP = {
558 | 'get': {},
559 | 'find': {},
560 | 'create': {},
561 | 'update': {},
562 | 'delete': {},
563 | 'addField': {}
564 | };
565 |
566 | for (let user of all) {
567 | CLP['get'][user] = true;
568 | CLP['find'][user] = true;
569 | }
570 | for (let user of writers) {
571 | CLP['create'][user] = true;
572 | CLP['update'][user] = true;
573 | CLP['delete'][user] = true;
574 | }
575 | for (let user of admins) {
576 | CLP['addField'][user] = true;
577 | }
578 |
579 | const data = {"classLevelPermissions": CLP};
580 | await setTableData(model.get('tableName'), data);
581 | });
582 |
583 | Parse.Cloud.beforeSave(`ModelField`, async request => {
584 | if (request.master)
585 | return;
586 |
587 | const field = request.object;
588 | if (field.id)
589 | return;
590 |
591 | const model = field.get('model');
592 | await model.fetch({useMasterKey: true});
593 |
594 | const site = model.get('site');
595 | await site.fetch({useMasterKey: true});
596 |
597 | //ACL for collaborations
598 | const owner = site.get('owner');
599 | const fieldACL = new Parse.ACL(owner);
600 |
601 | const collabs = await getAllObjects(
602 | new Parse.Query('Collaboration')
603 | .equalTo('site', site));
604 |
605 | for (let collab of collabs) {
606 | const user = collab.get('user');
607 | const role = collab.get('role');
608 |
609 | fieldACL.setReadAccess(user, true);
610 | fieldACL.setWriteAccess(user, role == ROLE_ADMIN);
611 | }
612 |
613 | field.setACL(fieldACL);
614 | });
615 |
616 | Parse.Cloud.beforeSave(`MediaItem`, async request => {
617 | if (request.master)
618 | return;
619 |
620 | const item = request.object;
621 | if (item.id)
622 | return;
623 |
624 | const site = item.get('site');
625 | await site.fetch({useMasterKey: true});
626 |
627 | //ACL for collaborations
628 | const owner = site.get('owner');
629 | const itemACL = new Parse.ACL(owner);
630 |
631 | const collabs = await getAllObjects(
632 | new Parse.Query('Collaboration')
633 | .equalTo('site', site));
634 |
635 | for (let collab of collabs) {
636 | const user = collab.get('user');
637 | const role = collab.get('role');
638 |
639 | itemACL.setReadAccess(user, true);
640 | itemACL.setWriteAccess(user, role == ROLE_ADMIN);
641 | }
642 |
643 | item.setACL(itemACL);
644 | });
645 |
646 |
647 | Parse.Cloud.define("onContentModify", async request => {
648 | if (!request.user)
649 | throw 'Must be signed in to call this Cloud Function.';
650 |
651 | const {URL} = request.params;
652 | if (!URL)
653 | return 'Warning! There is no content hook!';
654 |
655 | const response = await Parse.Cloud.httpRequest({
656 | url: URL,
657 | method: 'GET'
658 | });
659 |
660 | if (response.status == 200)
661 | return response.data;
662 | else
663 | throw response.status;
664 | });
665 |
666 | Parse.Cloud.define("inviteUser", async request => {
667 | if (!request.user)
668 | throw 'Must be signed in to call this Cloud Function.';
669 |
670 | const {email, siteName} = request.params;
671 | if (!email || !siteName)
672 | throw 'Email or siteName is empty!';
673 |
674 | console.log(`Send invite to ${email} ${new Date()}`);
675 |
676 | const {AppCache} = require('parse-server/lib/cache');
677 | const emailAdapter = AppCache.get(config.appId)['userController']['adapter'];
678 |
679 | const emailSelf = request.user.get('email');
680 | const link = `${SITE}/sign?mode=register&email=${email}`;
681 |
682 | try {
683 | await emailAdapter.send({
684 | templateName: 'inviteEmail',
685 | recipient: email,
686 | variables: {siteName, emailSelf, link}
687 | });
688 | console.log(`Invite sent to ${email} ${new Date()}`);
689 | return "Invite email sent!";
690 |
691 | } catch (error) {
692 | console.log(`Got an error in inviteUser: ${error}`);
693 | throw error;
694 | }
695 | });
696 |
697 | Parse.Cloud.define("checkPassword", request => {
698 | if (!request.user)
699 | throw 'Must be signed in to call this Cloud Function.';
700 |
701 | const {password} = request.params;
702 | if (!password)
703 | throw 'There is no password param!';
704 |
705 | const username = request.user.get('username');
706 |
707 | return Parse.User.logIn(username, password);
708 | });
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A powerful backend ready for your next app 🚀
2 |
3 | 
4 |
5 | This is a starter template with a feature-rich ready-to-use backend that works out of the box for local development and can be easily deployed.
6 |
7 | You get a fully functional backend so that you can focus on what matters most: your app!
8 |
9 | ## Feature Summary
10 |
11 | - **[Parse server](https://github.com/parse-community/parse-server)**: Backend-as-a-Service (BaaS) that features:
12 | - [SDKs](https://parseplatform.org/#sdks) for popular platforms
13 | - [REST API](https://docs.parseplatform.org/rest/guide/)
14 | - [Graphql API](https://docs.parseplatform.org/graphql/guide/)
15 | - [LiveQuery](https://docs.parseplatform.org/parse-server/guide/#live-queries) for realtime apps
16 | - Security features including authentication, [users](https://docs.parseplatform.org/js/guide/#users), [roles](https://docs.parseplatform.org/js/guide/#roles), [access control lists (ACL)](https://docs.parseplatform.org/rest/guide/#object-level-access-control) and [class-level permissions (CLP)](https://docs.parseplatform.org/rest/guide/#class-level-permissions)
17 | - [3rd party authentication](https://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication)
18 | - [Push notifications](https://docs.parseplatform.org/parse-server/guide/#push-notifications)
19 | - Adapters for [file storage](https://docs.parseplatform.org/parse-server/guide/#configuring-file-adapters) and [caching](https://docs.parseplatform.org/parse-server/guide/#configuring-cache-adapters)
20 | - [Analytics](https://docs.parseplatform.org/rest/guide/#analytics)
21 | - [Cloud code](https://docs.parseplatform.org/rest/guide/#cloud-code) for custom server-side logic
22 | - [Web hooks](https://docs.parseplatform.org/rest/guide/#hooks)
23 | - Runs on top of [Express](https://expressjs.com) allowing the use of Express middleware
24 | - Comprehensive [documentation](https://docs.parseplatform.org/)
25 | - Large [community](https://github.com/parse-community)
26 | - **[MongoDB](https://www.mongodb.com)** database.
27 | - **[Parse dashboard](https://github.com/parse-community/parse-dashboard)** (optional): a powerful dashboard for managing the parse server.
28 | - **API-First Headless CMS** (optional): using [chisel-cms](https://chiselcms.com).
29 | - A sample realtime **frontend app**.
30 | - **Automatic HTTPS** for the frontend and backend using [Caddy server](https://caddyserver.com).
31 | - Reproducible setup using **[Docker](https://www.docker.com/)** containers managed by a single **[Docker Compose](https://docs.docker.com/compose)** file.
32 | - **Local development workflow** with hot reload for frontend and backend.
33 | - **Easy deployment**.
34 | - **CI/CD** ([continuous integration and deployment](https://www.atlassian.com/continuous-delivery/principles/continuous-integration-vs-delivery-vs-deployment)): using [github actions](https://docs.github.com/en/free-pro-team@latest/actions).
35 | - Optional **deployment to multiple environments** (e.g. development, staging and production).
36 | - The whole stack is **open source** with no vendor lock-in or pay-per-request restrictions.
37 |
38 | ## Table of Contents
39 |
40 | - [Requirements](#requirements)
41 | - [Getting Started](#getting-started)
42 | - [Configuration](#configuration)
43 | - [Environment Variables](#environment-variables)
44 | - [Environment](#environment)
45 | - [Server Settings](#server-settings)
46 | - [Keys and Secrets](#keys-and-secrets)
47 | - [App Structure](#app-structure)
48 | - [Mail Settings](#mail-settings)
49 | - [Docker Settings](#docker-settings)
50 | - [Parse Server Configuration](#parse-server-configuration)
51 | - [Data Persistance](#data-persistance)
52 | - [Cloud Code](#cloud-code)
53 | - [Static Files](#static-files)
54 | - [Express Middleware](#express-middleware)
55 | - [Parse Dashboard](#parse-dashboard)
56 | - [Headless CMS](#headless-cms)
57 | - [Automatic HTTPS](#automatic-https)
58 | - [Frontend App](#frontend-app)
59 | - [Local Development](#local-development)
60 | - [Running the Dev Server](#running-the-dev-server)
61 | - [Production Build](#production-build)
62 | - [Running Shell Commands on Containers](#running-shell-commands-on-containers)
63 | - [Installing Dependencies](#installing-dependencies)
64 | - [NPM Scripts](#npm-scripts)
65 | - [Deployment](#deployment)
66 | - [Quick Start](#quick-start)
67 | - [CI/CD using github actions](#cicd-using-github-actions)
68 | - [Server Setup](#server-setup)
69 | - [Multiple Deployment Environments](#multiple-deployment-environments)
70 | - [Container Registry](#container-registry)
71 | - [Github Secrets](#github-secrets)
72 | - [Running Tests](#running-tests)
73 | - [Manual Re-Deploys](#manual-re-deploys)
74 | - [Using a Different Host Name for Backend (e.g. Subdomain)](#using-a-different-host-name-for-backend-eg-subdomain)
75 | - [Using a Remote MongoDB Database](#using-a-remote-mongodb-database)
76 | - [Contributing](#contributing)
77 | - [License](#license)
78 |
79 | ## Requirements
80 |
81 | - [Docker](https://docs.docker.com/get-docker)
82 | - [Docker Compose](https://docs.docker.com/compose)
83 |
84 | ## Getting Started
85 |
86 | Run the shell command:
87 |
88 | ```sh
89 | docker-compose up
90 | ```
91 |
92 | By default, the following will be served:
93 |
94 | - parse server backend: https://localhost:1337/api
95 | - parse graphql API: https://localhost:1337/graphql
96 | - parse dashboard: https://localhost:1337/dashboard
97 | - frontend local dev server (with HMR): https://localhost:1234
98 |
99 | After [production build](#production-build):
100 |
101 | - frontend app: https://localhost
102 |
103 | When [CMS is enabled](#headless-cms):
104 |
105 | - chisel CMS: https://localhost:1337
106 |
107 | Now you can edit/replace the app in the `frontend` directory and start building your own app making use of the feature-rich backend.
108 |
109 | ## Configuration
110 |
111 | ### Environment Variables
112 |
113 | Several configuration options can be set using environments variables exposed while running the `docker-compose` command.
114 | Please refer to docs on [Environment variables in Compose](https://docs.docker.com/compose/environment-variables) for different ways to achieve that.
115 |
116 | Variables in `.env` file will be automatically used.
117 |
118 | Note: [DO NOT commit secrets to version control](https://github.com/eth0izzle/shhgit/).
119 |
120 | `.env` file is not tracked by git (added to `.gitignore`) to avoid accidentally committing secrets. A sample in provided in [sample.env](sample.env). You may rename it to `.env` for local development. For deployment, use [GitHub Secrets](#github-secrets) instead.
121 |
122 | #### Environment
123 |
124 | - **NODE_ENV**:
125 |
126 | If set to "development", running `docker-compose up` starts frontend local development server with hot module replracement (HMR) on port 1234, and watches the backend source code and reloads on changes (e.g. in cloud code).
127 |
128 | If set to "production", running `docker-compose up` triggers frontend build generating static files that will be served on the default port (443), and the backend server runs on `pm2` which restarts the server on crash.
129 |
130 | _valid values_: development, production
131 |
132 | _default_: development
133 |
134 | #### Server Settings
135 |
136 | - **HOST_NAME**:
137 |
138 | The DNS name for the server hosting the app. HTTPS is used automatically with TLS certificates issued and renewed from [Let's Encrypt](https://letsencrypt.org/). If "localhost" or an IP address is used, self-signed (locally trusted) certificates will be used.
139 |
140 | _examples_: mywebsite.com, blog.mywebsite.com
141 |
142 | _default_: localhost
143 |
144 | _Required for remote deployment_
145 |
146 | - **BACKEND_PORT**:
147 |
148 | The port for backend server.
149 |
150 | _default_: 1337
151 |
152 | - **PARSE_SERVER_PATH**:
153 |
154 | Route to parse-server REST API
155 |
156 | _default_: /api
157 |
158 | - **PARSE_SERVER_GRAPHQL_PATH**:
159 |
160 | Route to parse-server graphql API
161 |
162 | _default_: /graphql
163 |
164 | - **PARSE_DASHBOARD_PATH**:
165 |
166 | Route to parse dashboard
167 |
168 | _default_: /dashboard
169 |
170 | - **PARSE_DASHBOARD_ENABLED**:
171 |
172 | Enables parse dashboard if set to "yes"
173 |
174 | _default_: yes
175 |
176 | - **CMS_ENABLED**:
177 |
178 | Enables CMS if set to "yes"
179 |
180 | _default_: no
181 |
182 | - **FRONTEND_DEV_SERVER_PORT**:
183 |
184 | The port for frontend development server
185 |
186 | _default_: 1234
187 |
188 | - **HMR_PORT**:
189 |
190 | The port for hot module replacement (HMR) for frontend development server
191 |
192 | _default_: 1235
193 |
194 | #### Keys and Secrets
195 |
196 | - **APP_ID**:
197 |
198 | Parse server app id
199 |
200 | _default_: myappid
201 |
202 | - **APP_NAME**:
203 |
204 | _default_: myappname
205 |
206 | - **MASTER_KEY**:
207 |
208 | Parse server master key
209 |
210 | _default_: mymasterkey
211 |
212 | - **PARSE_SERVER_DATABASE_URI**:
213 |
214 | Parse server database URI.
215 |
216 | By default it uses the included MongoDB container. You may change this if you wish to use another MongoDB instance.
217 |
218 | _default_: mongodb://mongo:27017/dev
219 |
220 | - **PARSE_DASHBOARD_USER_ID**:
221 |
222 | Parse dashboard user ID
223 |
224 | - **PARSE_DASHBOARD_USER_PASSWORD**:
225 |
226 | Parse dashboard user password
227 |
228 | - **CMS_USER_EMAIL**:
229 |
230 | Chisel CMS user email
231 |
232 | - **CMS_USER_PASSWORD**:
233 |
234 | Chisel CMS user password
235 |
236 | #### App Structure
237 |
238 | - **BACKEND_SRC_DIR**:
239 |
240 | The backend source code directory.
241 |
242 | _default_: backend
243 |
244 | - **FRONTEND_SRC_DIR**:
245 |
246 | The frontend source code directory.
247 |
248 | _default_: frontend
249 |
250 | - **FRONTEND_DIST_DIR**:
251 |
252 | The directory containing the frontend production build inside the frontend source code directory.
253 |
254 | _default_: dist
255 |
256 | - **DATA_DIR**:
257 |
258 | The directory for data persistence (see [Data Persistance](#data-persistance)).
259 |
260 | _default_: data
261 |
262 | - **BACKEND_NPM_SCRIPT**:
263 |
264 | The npm script to run after starting the backend container.
265 |
266 | _default_: start
267 |
268 | - **FRONTEND_NPM_SCRIPT**:
269 |
270 | The npm script to run after starting the frontend container.
271 |
272 | _default_: start
273 |
274 | #### Mail Settings
275 |
276 | These settings are used to send mail from parse server (e.g. to verify user emails)
277 |
278 | - **MAIL_SMTP_HOST**:
279 |
280 | SMTP host server
281 |
282 | _example_: smtp.sendgrid.net
283 |
284 | - **MAIL_SMTP_PORT**:
285 |
286 | SMTP server port
287 |
288 | _default_: 587
289 |
290 | - **MAIL_SMTP_USERNAME**:
291 |
292 | SMTP username
293 |
294 | - **MAIL_SMTP_PASSWORD**:
295 |
296 | SMTP password
297 |
298 | - **MAIL_FROM_ADDRESS**:
299 |
300 | The send-from email address
301 |
302 | #### Docker Settings
303 |
304 | - **BACKEND_IMAGE**:
305 |
306 | The docker image for backend
307 |
308 | _default for local build_: backend
309 |
310 | _default for CI/CD_: docker.pkg.github.com/{owner}/{repo}/{repo}-backend
311 |
312 | - **FRONTEND_IMAGE**:
313 |
314 | The docker image for frontend
315 |
316 | _default for local build_: frontend
317 |
318 | _default for CI/CD_: docker.pkg.github.com/{owner}/{repo}/{repo}-frontend
319 |
320 | - **IMAGE_TAG**:
321 |
322 | The tag for docker images
323 |
324 | _default for local build_: latest
325 |
326 | _default for CI/CD_: git short SHA
327 |
328 | ### Parse Server Configuration
329 |
330 | Additional [parse server configurations](https://github.com/parse-community/parse-server#configuration) can be defined in [./backend/config.js](./backend/config.js)
331 |
332 | ## Data Persistance
333 |
334 | The data generated (e.g. MongoDB data, parse server logs and caddy certificates) are stored by default in the `./data` directory (can be configured by the `DATA_DIR` [environment variable](#app-structure)).
335 |
336 | ## Cloud Code
337 |
338 | [Cloud code](https://docs.parseplatform.org/cloudcode/guide/) can be used to run custom server-side logic. It is built on [Parse JavaScript SDK](http://parseplatform.org/Parse-SDK-JS/api).
339 |
340 | The main entry to cloud code is [./backend/cloud/main.js](backend/cloud/main.js)
341 |
342 | Any npm package can be installed in the backend and used (see [Installing Dependencies](#installing-dependencies)).
343 |
344 | To run code on server start (e.g. creating default users and roles, enforcing security rules, ...etc.), add your code to [./backend/server/initialize-server.js](backend/server/initialize-server.js)
345 |
346 | ## Static Files
347 |
348 | Files added to the directory [./backend/public](./backend/public) will be served by the backend server under the route `/public`.
349 |
350 | ## Express Middleware
351 |
352 | Express middleware can be added to [./backend/server/index.js](./backend/server/index.js)
353 |
354 | ## Parse Dashboard
355 |
356 | The [Parse dashboard](https://github.com/parse-community/parse-dashboard) is a powerful dashboard for managing the parse server. By default, it is enabled and accessible on the route `/dashboard` of the backend server (e.g. https://localhost:1337/dashboard)
357 |
358 | It can be configured by setting the [environment variables](#environment-variables) similar to this example:
359 |
360 | ```
361 | PARSE_DASHBOARD_ENABLED=yes
362 | PARSE_DASHBOARD_PATH=/dashboard
363 | PARSE_DASHBOARD_USER_ID=myuser
364 | PARSE_DASHBOARD_USER_PASSWORD=mypassword
365 | ```
366 |
367 | ## Headless CMS
368 |
369 | The API-first headless content management system, [chisel-cms](https://chiselcms.com), is included. However, it is disabled by default.
370 |
371 | To enable the CMS, set the [environment variable](#environment-variables):
372 |
373 | ```
374 | CMS_ENABLED=yes
375 | ```
376 |
377 | It is then accessible at the root of the backend server (by default: https://localhost:1337)
378 |
379 | Parse server users can then login by their user credentials. Users can be added using the [Parse SDK](http://parseplatform.org/Parse-SDK-JS/api), [REST API](https://docs.parseplatform.org/rest/guide/), [Graphql API](https://docs.parseplatform.org/graphql/guide/) or [Parse Dashboard](#parse-dashboard).
380 |
381 | Alternatively, a user can be added by setting the environment variables:
382 |
383 | ```
384 | CMS_USER_EMAIL=user@mywebsite.com
385 | CMS_USER_PASSWORD=password
386 | ```
387 |
388 | This creates a new user with these credentials in the parse server. If the user is already present, the password is reset to the supplied password.
389 |
390 | ## Automatic HTTPS
391 |
392 | The frontend and backend are served by [Caddy server](https://caddyserver.com) with [automatic HTTPS](https://caddyserver.com/docs/automatic-https).
393 |
394 | > Caddy serves all sites over HTTPS by default.
395 | >
396 | > - Caddy serves IP addresses and local/internal hostnames over HTTPS with locally-trusted certificates. Examples: localhost, 127.0.0.1.
397 | > - Caddy serves public DNS names over HTTPS with certificates from [Let's Encrypt](https://letsencrypt.org/). Examples: example.com, sub.example.com, \*.example.com.
398 | > - Caddy keeps all certificates renewed, and redirects HTTP (default port 80) to HTTPS (default port 443) automatically.
399 | >
400 | > from https://caddyserver.com/docs/automatic-https
401 |
402 | ## Frontend App
403 |
404 | The frontend web app can use vanilla javascript or any javascript/typescript framework or library (see [Installing Dependencies](#installing-dependencies)). The app can interact with the backend using the [Parse JS SDK](http://parseplatform.org/Parse-SDK-JS/api), [REST API](https://docs.parseplatform.org/rest/guide/) or [Graphql API](https://docs.parseplatform.org/graphql/guide/).
405 |
406 | A minimal vanilla javascript app is provided as a sample. It is a simple realtime todo app that uses the [Parse JS SDK](http://parseplatform.org/Parse-SDK-JS/api), [Bootstrap](https://getbootstrap.com/) and [Parcel bundler](https://parceljs.org/).
407 |
408 | Feel free to edit/replace the app with your own.
409 |
410 | ## Local Development
411 |
412 | ### Running the Dev Server
413 |
414 | With the environment variable `NODE_ENV` set to `development` (default), running `docker-compose up` starts the local development server on the front end with HMR. In addition, code in backend source directory is watched for changes which trigger backend server restart.
415 |
416 | By default, the local development server runs on https://localhost:1234, mapping the port 1234 on the frontend container. The port can be configured by setting the environment variable `FRONTEND_DEV_SERVER_PORT`.
417 |
418 | Hot Module Replacement (HMR) uses the port 1235. It can be configured using the environment variable `HMR_PORT`.
419 |
420 | Example: if you have an angular CLI app, you may want to set the following environment variables:
421 |
422 | ```
423 | FRONTEND_DEV_SERVER_PORT=4200
424 | HMR_PORT=49153
425 | ```
426 |
427 | ### Production Build
428 |
429 | When the environment variable `NODE_ENV` is set to `production`, `npm run build` is called in the frontend container to generate the production build in the folder `frontend/dist` (configurable by setting `FRONTEND_DIST_DIR` variable). The generated static files are served directly by caddy server.
430 |
431 | To trigger frontend production build while in development mode, run:
432 |
433 | ```
434 | docker-compose exec frontend npm run build
435 | ```
436 |
437 | See [Running Shell Commands on Containers](#running-shell-commands-on-containers)
438 |
439 | With `NODE_ENV=production`, the backend server process is managed by [PM2](https://pm2.keymetrics.io/) which restarts the server on crash.
440 |
441 | ### Running Shell Commands on Containers
442 |
443 | Make sure the containers are running
444 |
445 | ```
446 | docker-compose up -d
447 | ```
448 |
449 | To start an interactive shell session on the frontend docker container run:
450 |
451 | ```
452 | docker-compose exec frontend bash
453 | ```
454 |
455 | and for the backend
456 |
457 | ```
458 | docker-compose exec backend bash
459 | ```
460 |
461 | ### Installing Dependencies
462 |
463 | Dependencies for the backend (including cloud code) are defined in [./backend/package.json](./backend/package.json). While dependencies for the frontend app are defined in [./frontend/package.json](./frontend/package.json).
464 |
465 | Installing npm packages can be done like that:
466 |
467 | ```
468 | docker-compose up -d
469 | docker-compose exec frontend npm install lodash
470 | docker-compose exec backend npm install lodash
471 | ```
472 |
473 | Please note that adding dependencies have to be followed by docker image build to persist the changes in the docker images, by running:
474 |
475 | ```
476 | docker-compose build
477 | ```
478 |
479 | ### NPM Scripts
480 |
481 | For convenience, a [package.json](./package.json) file was added to the root of the repo, so that common commands can be saved as npm scripts for repeated tasks like starting the containers, starting shell sessions, running tests, ...etc.
482 |
483 | Examples:
484 |
485 | - ```
486 | npm start
487 | ```
488 |
489 | Runs the docker containers
490 |
491 | - ```
492 | npm run docker-build
493 | ```
494 |
495 | Builds the docker images
496 |
497 | - ```
498 | npm run frontend-shell
499 | ```
500 |
501 | starts intercative shell session in the frontend container
502 |
503 | - ```
504 | npm run test
505 | ```
506 |
507 | runs tests on the frontend and backend containers
508 |
509 | NPM needs to be installed on the host machine to be able to run npm scripts.
510 |
511 | ## Deployment
512 |
513 | Docker and Docker Compose significantly simplify deployment. All the setup and dependencies are already taken care of in the docker images.
514 |
515 | So, in principle, the steps required for deployment are:
516 |
517 | - Defining the variables for the deployment environment.
518 | - Building the docker images and verifying them.
519 | - Running the containers on the host server.
520 |
521 | Although this can be done manually, it is greatly simplified using the included automated CI/CD setup that uses github actions.
522 |
523 | ### Quick Start
524 |
525 | Assuming you can connect using SSH to your server which has Docker and Docker Compose installed (see [Server Setup](#server-setup)), and that you have a personal github access token (see [Container Registry](#container-registry)), add the following [Github Secrets](#github-secrets):
526 |
527 | - PROD_DOCKER_REGISTRY_TOKEN: your personal github access token
528 |
529 | - PROD_SSH_HOST: your server IP address
530 |
531 | - PROD_SSH_KEY: your server SSH private key
532 | - PROD_ENV_VARS: edit the following example with your values
533 |
534 | ```
535 | HOST_NAME=mywebsite.com
536 | APP_ID=myappid
537 | MASTER_KEY=mymasterkey
538 | PARSE_DASHBOARD_USER_ID=user
539 | PARSE_DASHBOARD_USER_PASSWORD=pass
540 | ```
541 |
542 | Note: The environment variable `HOST_NAME` is required for remote deployment.
543 |
544 | Now pushing code to main/master branch should trigger build and deploy to your server. Note that you can follow the progress and read logs of CI/CD workflows on the "Actions" tab in the gihub repo.
545 |
546 | Continue reading for details.
547 |
548 | ### CI/CD using github actions
549 |
550 | Pushing new code to the main branch (by default "main" or "master"), automatically triggers:
551 |
552 | - collecting configurations as environment variables
553 | - building docker images
554 | - running tests
555 | - pushing docker images to the container registry (by default, [Github Packages](https://github.com/features/packages))
556 | - the production server pulls the built docker images and code
557 | - docker-compose runs the containers on the deployment server
558 |
559 | The same can be done for different deployment environments like staging (by default, the branch "staging") and development (by default, the branch "develop"). See [Multiple Deployment Environments](#multiple-deployment-environments).
560 |
561 | Pull requests and code push to other branches trigger only building and testing the code.
562 |
563 | Pushing docker images to the container registry requires setting Github Secret `{env}_DOCKER_REGISTRY_TOKEN` (e.g. `PROD_DOCKER_REGISTRY_TOKEN` for production builds). Deployment requires setting Github Secrets `{env}_SSH_HOST` and `{env}_SSH_KEY` (e.g. `PROD_SSH_HOST` and `PROD_SSH_KEY` for production deployments). See [Github Secrets](#github-secrets) for details.
564 |
565 | Re-deploys can also be manually triggered (e.g. reverting to a previous build) by specifying the git SHA to revert to without having to re-build the images. See [Manual Re-Deploys](#manual-re-deploys)
566 |
567 | Please note that github actions and github packages have limits for free accounts and are paid services. Check [github pricing](https://github.com/pricing) for details.
568 |
569 | If you want to avoid running the CI/CD workflow on a commit (e.g. after updating a markdown file), include `[skip ci]` or `[ci skip]` to the commit message.
570 |
571 | To limit usage of github packages storage in private repos, only the latest 10 versions of each docker image are kept, and older ones are deleted. This behaviour [can be configured](./.github/workflows/delete-old-images.yml). Packages for public repos are free and [cannot be deleted](https://docs.github.com/en/free-pro-team@latest/packages/publishing-and-managing-packages/deleting-a-package#about-public-package-deletion).
572 |
573 | ### Server Setup
574 |
575 | Any cloud hosting service can be used for deployment. The server needs to have docker and docker-compose installed and you should be able to connect to it by SSH.
576 |
577 | I prefer Digital Ocean for the simplicity and ease of use. You can get your server up and running with minimal setup in a few minutes for as low as $5/month. If you do not have an account, this is a [referral link](https://m.do.co/c/fb8c00b45b91) to get started with $100 in credit over 60 days. I recommend using the [Docker One-Click Droplet](https://cloud.digitalocean.com/marketplace/5ba19751fc53b8179c7a0071?refcode=fb8c00b45b91&i=89fa97) where you get docker and docker-compose pre-installed. Make sure to [add SSH key to the created droplet](https://www.digitalocean.com/docs/droplets/how-to/add-ssh-keys/).
578 |
579 | [Configure the DNS records](https://www.digitalocean.com/community/tutorials/how-to-point-to-digitalocean-nameservers-from-common-domain-registrars#:~:text=DigitalOcean%20is%20not%20a%20domain,with%20Droplets%20and%20Load%20Balancers.), so that your [domain points to the deployment server](https://www.digitalocean.com/docs/networking/dns/how-to/manage-records/).
580 |
581 | ### Multiple Deployment Environments
582 |
583 | The CI/CD setup allows deployment to one or more of the following environments: Production, Staging and Development.
584 |
585 | The selection of environment is based on the git branch that triggered the deploy. Each environment has a prefix for the [Github Secrets](#github-secrets) used.
586 |
587 | The following table summarizes the environments, the associated git branch and the prefix used for Github Secrets:
588 |
589 | | Environment | Git branch | Prefix | Example |
590 | | ----------- | ------------ | ------ | ------------- |
591 | | Production | main, master | PROD | PROD_SSH_HOST |
592 | | Staging | staging | STAG | STAG_SSH_HOST |
593 | | Development | develop | DEV | DEV_SSH_HOST |
594 |
595 | For example, pushing code to the git branch "develop" triggers "Development" build and deploy which use Github secrets prefixed by "DEV" (e.g. DEV_DOCKER_REGISTRY, DEV_DOCKER_REGISTRY_USER, DEV_DOCKER_REGISTRY_TOKEN, DEV_SSH_HOST, DEV_SSH_KEY, DEV_ENV_VARS). This causes pushing the docker images to the "Development" container registry and deploying the app to the "Development" server with the specified environment variables.
596 |
597 | ### Container Registry
598 |
599 | By default, the included CI/CD setup uses [Github Packages](https://github.com/features/packages) as the container registry to host the built docker images, but it can be configured to use any other container registry.
600 |
601 | A [github personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) is required to allow for pushing to and pulling from the container registry. Note that the token needs to have permissions for `repo`, `write:packages` and `read:packages` (and `delete:packages` if you want to allow for automatic deletion of old images). This token should be saved as a value for the [Github Secret](#github-secrets): {env}\_DOCKER_REGISTRY_TOKEN (e.g. PROD_DOCKER_REGISTRY_TOKEN).
602 |
603 | To use a different container registry (other than Github Packages), all the following Github Secrets have to be set:
604 |
605 | - {env}\_DOCKER_REGISTRY
606 | - {env}\_DOCKER_REGISTRY_USER
607 | - {env}\_DOCKER_REGISTRY_TOKEN
608 |
609 | For example, to use Docker Hub for production images set the following Github Secrets:
610 |
611 | - PROD_DOCKER_REGISTRY: registry.hub.docker.com
612 | - PROD_DOCKER_REGISTRY_USER: < your docker hub username >
613 | - PROD_DOCKER_REGISTRY_TOKEN: < your docker hub password >
614 |
615 | ### Github Secrets
616 |
617 | Deployment settings should be stored in [GitHub Secrets](https://docs.github.com/en/free-pro-team@latest/actions/reference/encrypted-secrets).
618 |
619 | For each deployment environment, the following set of secrets can be used (prefixed by the environment prefix - see [Multiple Deployment Environments](#multiple-deployment-environments) for details):
620 |
621 | - **{env}\_DOCKER_REGISTRY**
622 |
623 | (e.g. PROD_DOCKER_REGISTRY)
624 |
625 | The docker container registry to host the built images.
626 |
627 | _default_: docker.pkg.github.com
628 |
629 | - **{env}\_DOCKER_REGISTRY_USER**
630 |
631 | (e.g. PROD_DOCKER_REGISTRY_USER)
632 |
633 | Container registry username.
634 |
635 | _default_: github user.
636 |
637 | - **{env}\_DOCKER_REGISTRY_TOKEN**
638 |
639 | (e.g. PROD_DOCKER_REGISTRY_TOKEN)
640 |
641 | Container registry password/token. When using Github Packages as registry, this should be the github personal access token ([see above](#container-registry)).
642 |
643 | _Required for pushing docker images._
644 |
645 | - **{env}\_SSH_HOST**
646 |
647 | (e.g. PROD_SSH_HOST)
648 |
649 | Depolyment server address (used in SSH connection).
650 |
651 | _Required for deployment_
652 |
653 | - **{env}\_SSH_PORT**
654 |
655 | (e.g. PROD_SSH_PORT)
656 |
657 | Depolyment server port (used in SSH connection).
658 |
659 | _default_: 22
660 |
661 | - **{env}\_SSH_USER**
662 |
663 | (e.g. PROD_SSH_USER)
664 |
665 | User on deployment server (used in SSH connection)
666 |
667 | _default_: root
668 |
669 | - **{env}\_SSH_KEY**
670 |
671 | (e.g. PROD_SSH_KEY)
672 |
673 | SSH key for deployment server (used in SSH connection)
674 |
675 | _Required for deployment_
676 |
677 | - **{env}\_SSH_PATH**
678 |
679 | (e.g. PROD_SSH_PATH)
680 |
681 | Path on deployment server (used in SSH connection)
682 |
683 | _default_: ./deploy/
684 |
685 | - **{env}\_ENV_VARS**
686 |
687 | (e.g. PROD_ENV_VARS)
688 |
689 | These are [environment variables](#environment-variables) that will be available when running `docker-compose up` on deployment server. Add each variable in a separate line.
690 |
691 | Example:
692 |
693 | ```
694 | HOST_NAME=mywebsite.com
695 | APP_ID=myappid
696 | MASTER_KEY=mymasterkey
697 | PARSE_DASHBOARD_USER_ID=user
698 | PARSE_DASHBOARD_USER_PASSWORD=pass
699 | ```
700 |
701 | - **{env}\_DOCKER_COMPOSE_UP_ARGS**
702 |
703 | (e.g. PROD_DOCKER_COMPOSE_UP_ARGS)
704 |
705 | When running `docker-compose up` on deployment server the following arguments are passed
706 |
707 | ```
708 | --no-build -d
709 | ```
710 |
711 | You may add here more arguments.
712 |
713 | This can be used to run specific services (see [Using a Remote MongoDB Database](#using-a-remote-mongodb-database)), scaling, etc. See [documentations](https://docs.docker.com/compose/reference/up/) for details.
714 |
715 | ### Running Tests
716 |
717 | During continuous integration, tests/checks are run by running `npm run ci-test` both on the frontend and backend containers. You may add all your tests/checks to this script.
718 |
719 | ### Manual Re-Deploys
720 |
721 | You can manually trigger a previous deploy, without having to re-build or test it (e.g. you find a bug in the current build and you want to revert to a previous deploy).
722 |
723 | The CI/CD workflow can be [triggered manually](https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/) (from ‘Run workflow’ button on the Actions tab)
724 |
725 | You need to supply the git short SHA for the commit you want to revert to. This is the same as the tag used for the docker images. Make sure that the docker images with that tag are available (check https://github.com/{owner}/{repo}/packages).
726 |
727 | When manual re-deploys are triggered, [Github Secrets](#github-secrets) used will be the ones present at the time of the new trigger not those that were present at the initial build.
728 |
729 | ### Using a Different Host Name for Backend (e.g. Subdomain)
730 |
731 | By default, the backend server runs on the same host name provided (configured by `HOST_NAME` environment variable) on a separate port (by default, 1337).
732 |
733 | For example by setting `HOST_NAME=mywebsite.com`, the backend is served on https://mywebsite.com:1337.
734 |
735 | You may set the environment variable `BACKEND_HOST_NAME` to a seperate host name. By default the backend port becomes 443 (unless the variable `BACKEND_PORT` is set to a different port).
736 |
737 | For example, to serve the backend on https://api.mywebsite.com, use this configuration:
738 |
739 | ```
740 | HOST_NAME=mywebsite.com
741 | BACKEND_HOST_NAME=api.mywebsite.com
742 | ```
743 |
744 | Do not forget to configure the DNS records to point the backend host name to the same server.
745 |
746 | Note that this can also be configured for localhost.
747 |
748 | Example:
749 |
750 | ```
751 | HOST_NAME=localhost
752 | BACKEND_HOST_NAME=api.localhost
753 | ```
754 |
755 | ### Using a Remote MongoDB Database
756 |
757 | To use a remote MongoDB instance (e.g. managed database on MongoDB Atlas) instead of the included container, follow the following steps:
758 |
759 | - Set `PARSE_SERVER_DATABASE_URI` [environment variable](#environment-variables) to point to your database, for example:
760 |
761 | ```
762 | PARSE_SERVER_DATABASE_URI=mongodb+srv://:@cluster0.abcde.mongodb.net/dev?retryWrites=true&w=majority
763 | ```
764 |
765 | - Start the stack using the command:
766 |
767 | ```
768 | docker-compose up --no-deps backend frontend caddy
769 | ```
770 |
771 | - To do the same for the CI/CD setup:
772 | - add the `PARSE_SERVER_DATABASE_URI` environment variable to `PROD_ENV_VARS` github secret.
773 | - add a github secret with the name `PROD_DOCKER_COMPOSE_UP_ARGS` and the value:
774 | ```
775 | --no-deps backend frontend caddy
776 | ```
777 |
778 | ## Contributing
779 |
780 | Pull requests are welcome!
781 |
782 | ## License
783 |
784 | [MIT](LICENSE.md)
785 |
--------------------------------------------------------------------------------
/backend/cms/siteTemplates/templates.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Blog",
4 | "description": "The template for blog.",
5 | "icon": "blog.png",
6 | "models": [
7 | {
8 | "name": "Author",
9 | "nameId": "Author",
10 | "description": "",
11 | "color": "rgba(187, 34, 230, 1)",
12 | "fields": [
13 | {
14 | "name": "Name",
15 | "nameId": "Name",
16 | "type": "Short Text",
17 | "appearance": "Single line",
18 | "color": "rgba(16, 178, 99, 1)",
19 | "boolTextYes": "",
20 | "boolTextNo": "",
21 | "validValues": [],
22 | "isRequired": true,
23 | "isTitle": true,
24 | "isList": false,
25 | "isDisabled": false,
26 | "order": 0,
27 | "validations": null
28 | },
29 | {
30 | "name": "Profile Photo",
31 | "nameId": "Profile_Photo",
32 | "type": "Media",
33 | "appearance": "Media",
34 | "color": "rgba(3, 22, 230, 1)",
35 | "boolTextYes": "",
36 | "boolTextNo": "",
37 | "validValues": [],
38 | "isRequired": false,
39 | "isTitle": false,
40 | "isList": false,
41 | "isDisabled": false,
42 | "order": 1,
43 | "validations": {
44 | "fileTypes": {
45 | "active": true,
46 | "types": [
47 | "Image"
48 | ],
49 | "errorMsg": "",
50 | "isError": false
51 | }
52 | }
53 | },
54 | {
55 | "name": "Biography",
56 | "nameId": "Biography",
57 | "type": "Long Text",
58 | "appearance": "Markdown",
59 | "color": "rgba(245, 200, 30, 1)",
60 | "boolTextYes": "",
61 | "boolTextNo": "",
62 | "validValues": [],
63 | "isRequired": false,
64 | "isTitle": false,
65 | "isList": false,
66 | "isDisabled": false,
67 | "order": 2,
68 | "validations": null
69 | },
70 | {
71 | "name": "Twitter Handle",
72 | "nameId": "Twitter_Handle",
73 | "type": "Short Text",
74 | "appearance": "Single line",
75 | "color": "rgba(64, 64, 230, 1)",
76 | "boolTextYes": "",
77 | "boolTextNo": "",
78 | "validValues": [],
79 | "isRequired": false,
80 | "isTitle": false,
81 | "isList": false,
82 | "isDisabled": false,
83 | "order": 3,
84 | "validations": null
85 | }
86 | ]
87 | },
88 | {
89 | "name": "Post",
90 | "nameId": "Post",
91 | "description": "",
92 | "color": "rgba(210, 230, 8, 1)",
93 | "fields": [
94 | {
95 | "name": "Title",
96 | "nameId": "Title",
97 | "type": "Short Text",
98 | "appearance": "Single line",
99 | "color": "rgba(255, 32, 89, 1)",
100 | "boolTextYes": "",
101 | "boolTextNo": "",
102 | "validValues": [],
103 | "isRequired": true,
104 | "isTitle": true,
105 | "isList": false,
106 | "isDisabled": false,
107 | "order": 0,
108 | "validations": null
109 | },
110 | {
111 | "name": "Slug",
112 | "nameId": "Slug",
113 | "type": "Short Text",
114 | "appearance": "Slug",
115 | "color": "rgba(76, 144, 8, 1)",
116 | "boolTextYes": "",
117 | "boolTextNo": "",
118 | "validValues": [],
119 | "isRequired": true,
120 | "isTitle": false,
121 | "isList": false,
122 | "isDisabled": false,
123 | "order": 1,
124 | "validations": null
125 | },
126 | {
127 | "name": "Author",
128 | "nameId": "Author",
129 | "type": "Reference",
130 | "appearance": "Reference",
131 | "color": "rgba(1, 34, 54, 1)",
132 | "boolTextYes": "",
133 | "boolTextNo": "",
134 | "validValues": [],
135 | "isRequired": false,
136 | "isTitle": false,
137 | "isList": false,
138 | "isDisabled": false,
139 | "order": 2,
140 | "validations": {
141 | "models": {
142 | "active": true,
143 | "modelsList": [
144 | "Author"
145 | ],
146 | "errorMsg": "",
147 | "isError": false
148 | }
149 | }
150 | },
151 | {
152 | "name": "Thumb Image",
153 | "nameId": "Thumb_Image",
154 | "type": "Media",
155 | "appearance": "Media",
156 | "color": "rgba(8, 233, 120, 1)",
157 | "boolTextYes": "",
158 | "boolTextNo": "",
159 | "validValues": [],
160 | "isRequired": true,
161 | "isTitle": false,
162 | "isList": false,
163 | "isDisabled": false,
164 | "order": 3,
165 | "validations": {
166 | "fileTypes": {
167 | "active": true,
168 | "types": [
169 | "Image"
170 | ],
171 | "errorMsg": "",
172 | "isError": false
173 | }
174 | }
175 | },
176 | {
177 | "name": "Banner Image",
178 | "nameId": "Banner_Image",
179 | "type": "Media",
180 | "appearance": "Media",
181 | "color": "rgba(58, 96, 145, 1)",
182 | "boolTextYes": "",
183 | "boolTextNo": "",
184 | "validValues": [],
185 | "isRequired": true,
186 | "isTitle": false,
187 | "isList": false,
188 | "isDisabled": false,
189 | "order": 4,
190 | "validations": {
191 | "fileTypes": {
192 | "active": true,
193 | "types": [
194 | "Image"
195 | ],
196 | "errorMsg": "",
197 | "isError": false
198 | }
199 | }
200 | },
201 | {
202 | "name": "Description",
203 | "nameId": "Description",
204 | "type": "Long Text",
205 | "appearance": "Markdown",
206 | "color": "rgba(189, 217, 94, 1)",
207 | "boolTextYes": "",
208 | "boolTextNo": "",
209 | "validValues": [],
210 | "isRequired": true,
211 | "isTitle": false,
212 | "isList": false,
213 | "isDisabled": false,
214 | "order": 5,
215 | "validations": null
216 | },
217 | {
218 | "name": "Body",
219 | "nameId": "Body",
220 | "type": "Long Text",
221 | "appearance": "Markdown",
222 | "color": "rgba(162, 174, 249, 1)",
223 | "boolTextYes": "",
224 | "boolTextNo": "",
225 | "validValues": [],
226 | "isRequired": true,
227 | "isTitle": false,
228 | "isList": false,
229 | "isDisabled": false,
230 | "order": 6,
231 | "validations": null
232 | },
233 | {
234 | "name": "Publish Date",
235 | "nameId": "Publish_Date",
236 | "type": "Date/time",
237 | "appearance": "Date & time",
238 | "color": "rgba(241, 11, 64, 1)",
239 | "boolTextYes": "",
240 | "boolTextNo": "",
241 | "validValues": [],
242 | "isRequired": true,
243 | "isTitle": false,
244 | "isList": false,
245 | "isDisabled": false,
246 | "order": 7,
247 | "validations": null
248 | },
249 | {
250 | "name": "Tags",
251 | "nameId": "Tags",
252 | "type": "Short Text",
253 | "appearance": "Single line",
254 | "color": "rgba(116, 114, 108, 1)",
255 | "boolTextYes": "",
256 | "boolTextNo": "",
257 | "validValues": [],
258 | "isRequired": false,
259 | "isTitle": false,
260 | "isList": true,
261 | "isDisabled": false,
262 | "order": 8,
263 | "validations": null
264 | }
265 | ]
266 | }
267 | ]
268 | },
269 | {
270 | "name": "Knowledge Base",
271 | "description": "The template for knowledge base.",
272 | "icon": "knowledge.png",
273 | "models": [
274 | {
275 | "name": "Author",
276 | "nameId": "Author",
277 | "description": "",
278 | "color": "rgba(244, 31, 177, 1)",
279 | "fields": [
280 | {
281 | "name": "Name",
282 | "nameId": "Name",
283 | "type": "Short Text",
284 | "appearance": "Single line",
285 | "color": "rgba(27, 80, 58, 1)",
286 | "boolTextYes": "",
287 | "boolTextNo": "",
288 | "validValues": [],
289 | "isRequired": false,
290 | "isTitle": true,
291 | "isList": false,
292 | "isDisabled": false,
293 | "order": 0,
294 | "validations": null
295 | },
296 | {
297 | "name": "Profile Photo",
298 | "nameId": "Profile_Photo",
299 | "type": "Media",
300 | "appearance": "Media",
301 | "color": "rgba(42, 202, 65, 1)",
302 | "boolTextYes": "",
303 | "boolTextNo": "",
304 | "validValues": [],
305 | "isRequired": false,
306 | "isTitle": false,
307 | "isList": false,
308 | "isDisabled": false,
309 | "order": 1,
310 | "validations": {
311 | "fileTypes": {
312 | "active": true,
313 | "types": [
314 | "Image"
315 | ],
316 | "errorMsg": "",
317 | "isError": false
318 | }
319 | }
320 | },
321 | {
322 | "name": "Biography",
323 | "nameId": "Biography",
324 | "type": "Long Text",
325 | "appearance": "Markdown",
326 | "color": "rgba(109, 77, 248, 1)",
327 | "boolTextYes": "",
328 | "boolTextNo": "",
329 | "validValues": [],
330 | "isRequired": false,
331 | "isTitle": false,
332 | "isList": false,
333 | "isDisabled": false,
334 | "order": 2,
335 | "validations": null
336 | },
337 | {
338 | "name": "Twitter Handle",
339 | "nameId": "Twitter_Handle",
340 | "type": "Short Text",
341 | "appearance": "Single line",
342 | "color": "rgba(108, 128, 3, 1)",
343 | "boolTextYes": "",
344 | "boolTextNo": "",
345 | "validValues": [],
346 | "isRequired": false,
347 | "isTitle": false,
348 | "isList": false,
349 | "isDisabled": false,
350 | "order": 3,
351 | "validations": null
352 | }
353 | ]
354 | },
355 | {
356 | "name": "Article",
357 | "nameId": "Article",
358 | "description": "",
359 | "color": "rgba(66, 134, 177, 1)",
360 | "fields": [
361 | {
362 | "name": "Title",
363 | "nameId": "Title",
364 | "type": "Short Text",
365 | "appearance": "Single line",
366 | "color": "rgba(212, 144, 167, 1)",
367 | "boolTextYes": "",
368 | "boolTextNo": "",
369 | "validValues": [],
370 | "isRequired": false,
371 | "isTitle": true,
372 | "isList": false,
373 | "isDisabled": false,
374 | "order": 0,
375 | "validations": null
376 | },
377 | {
378 | "name": "Slug",
379 | "nameId": "Slug",
380 | "type": "Short Text",
381 | "appearance": "Slug",
382 | "color": "rgba(143, 32, 133, 1)",
383 | "boolTextYes": "",
384 | "boolTextNo": "",
385 | "validValues": [],
386 | "isRequired": true,
387 | "isTitle": false,
388 | "isList": false,
389 | "isDisabled": false,
390 | "order": 1,
391 | "validations": null
392 | },
393 | {
394 | "name": "Author",
395 | "nameId": "Author",
396 | "type": "Reference",
397 | "appearance": "Reference",
398 | "color": "rgba(153, 60, 209, 1)",
399 | "boolTextYes": "",
400 | "boolTextNo": "",
401 | "validValues": [],
402 | "isRequired": false,
403 | "isTitle": false,
404 | "isList": false,
405 | "isDisabled": false,
406 | "order": 2,
407 | "validations": {
408 | "models": {
409 | "active": true,
410 | "modelsList": [
411 | "Author"
412 | ],
413 | "errorMsg": "",
414 | "isError": false
415 | }
416 | }
417 | },
418 | {
419 | "name": "Thumb Image",
420 | "nameId": "Thumb_Image",
421 | "type": "Media",
422 | "appearance": "Media",
423 | "color": "rgba(187, 244, 159, 1)",
424 | "boolTextYes": "",
425 | "boolTextNo": "",
426 | "validValues": [],
427 | "isRequired": true,
428 | "isTitle": false,
429 | "isList": false,
430 | "isDisabled": false,
431 | "order": 3,
432 | "validations": {
433 | "fileTypes": {
434 | "active": true,
435 | "types": [
436 | "Image"
437 | ],
438 | "errorMsg": "",
439 | "isError": false
440 | }
441 | }
442 | },
443 | {
444 | "name": "Banner Image",
445 | "nameId": "Banner_Image",
446 | "type": "Media",
447 | "appearance": "Media",
448 | "color": "rgba(221, 206, 126, 1)",
449 | "boolTextYes": "",
450 | "boolTextNo": "",
451 | "validValues": [],
452 | "isRequired": true,
453 | "isTitle": false,
454 | "isList": false,
455 | "isDisabled": false,
456 | "order": 4,
457 | "validations": {
458 | "fileTypes": {
459 | "active": true,
460 | "types": [
461 | "Image"
462 | ],
463 | "errorMsg": "",
464 | "isError": false
465 | }
466 | }
467 | },
468 | {
469 | "name": "Description",
470 | "nameId": "Description",
471 | "type": "Long Text",
472 | "appearance": "Markdown",
473 | "color": "rgba(248, 240, 138, 1)",
474 | "boolTextYes": "",
475 | "boolTextNo": "",
476 | "validValues": [],
477 | "isRequired": true,
478 | "isTitle": false,
479 | "isList": false,
480 | "isDisabled": false,
481 | "order": 5,
482 | "validations": null
483 | },
484 | {
485 | "name": "Body",
486 | "nameId": "Body",
487 | "type": "Long Text",
488 | "appearance": "Markdown",
489 | "color": "rgba(148, 57, 100, 1)",
490 | "boolTextYes": "",
491 | "boolTextNo": "",
492 | "validValues": [],
493 | "isRequired": true,
494 | "isTitle": false,
495 | "isList": false,
496 | "isDisabled": false,
497 | "order": 6,
498 | "validations": null
499 | },
500 | {
501 | "name": "Publish Date",
502 | "nameId": "Publish_Date",
503 | "type": "Date/time",
504 | "appearance": "Date & time",
505 | "color": "rgba(149, 203, 177, 1)",
506 | "boolTextYes": "",
507 | "boolTextNo": "",
508 | "validValues": [],
509 | "isRequired": true,
510 | "isTitle": false,
511 | "isList": false,
512 | "isDisabled": false,
513 | "order": 7,
514 | "validations": null
515 | },
516 | {
517 | "name": "Tags",
518 | "nameId": "Tags",
519 | "type": "Short Text",
520 | "appearance": "Single line",
521 | "color": "rgba(211, 169, 128, 1)",
522 | "boolTextYes": "",
523 | "boolTextNo": "",
524 | "validValues": [],
525 | "isRequired": false,
526 | "isTitle": false,
527 | "isList": true,
528 | "isDisabled": false,
529 | "order": 8,
530 | "validations": null
531 | },
532 | {
533 | "name": "Categories",
534 | "nameId": "Categories",
535 | "type": "Reference",
536 | "appearance": "Reference",
537 | "color": "rgba(228, 149, 237, 1)",
538 | "boolTextYes": "",
539 | "boolTextNo": "",
540 | "validValues": [],
541 | "isRequired": false,
542 | "isTitle": false,
543 | "isList": true,
544 | "isDisabled": false,
545 | "order": 9,
546 | "validations": {
547 | "models": {
548 | "active": true,
549 | "modelsList": [
550 | "Category"
551 | ],
552 | "errorMsg": "",
553 | "isError": false
554 | }
555 | }
556 | }
557 | ]
558 | },
559 | {
560 | "name": "Category",
561 | "nameId": "Category",
562 | "description": "",
563 | "color": "rgba(61, 166, 37, 1)",
564 | "fields": [
565 | {
566 | "name": "Title",
567 | "nameId": "Title",
568 | "type": "Short Text",
569 | "appearance": "Single line",
570 | "color": "rgba(93, 74, 112, 1)",
571 | "boolTextYes": "",
572 | "boolTextNo": "",
573 | "validValues": [],
574 | "isRequired": false,
575 | "isTitle": true,
576 | "isList": false,
577 | "isDisabled": false,
578 | "order": 0,
579 | "validations": null
580 | },
581 | {
582 | "name": "Icon",
583 | "nameId": "Icon",
584 | "type": "Media",
585 | "appearance": "Media",
586 | "color": "rgba(105, 88, 155, 1)",
587 | "boolTextYes": "",
588 | "boolTextNo": "",
589 | "validValues": [],
590 | "isRequired": true,
591 | "isTitle": false,
592 | "isList": false,
593 | "isDisabled": false,
594 | "order": 1,
595 | "validations": {
596 | "fileTypes": {
597 | "active": true,
598 | "types": [
599 | "Image"
600 | ],
601 | "errorMsg": "",
602 | "isError": false
603 | }
604 | }
605 | },
606 | {
607 | "name": "Description",
608 | "nameId": "Description",
609 | "type": "Long Text",
610 | "appearance": "Markdown",
611 | "color": "rgba(173, 51, 112, 1)",
612 | "boolTextYes": "",
613 | "boolTextNo": "",
614 | "validValues": [],
615 | "isRequired": false,
616 | "isTitle": false,
617 | "isList": false,
618 | "isDisabled": false,
619 | "order": 2,
620 | "validations": null
621 | }
622 | ]
623 | }
624 | ]
625 | },
626 | {
627 | "name": "Photo Gallery",
628 | "description": "The template for photo gallery.",
629 | "icon": "gallery.png",
630 | "models": [
631 | {
632 | "name": "Author",
633 | "nameId": "Author",
634 | "description": "",
635 | "color": "rgba(4, 204, 67, 1)",
636 | "fields": [
637 | {
638 | "name": "Name",
639 | "nameId": "Name",
640 | "type": "Short Text",
641 | "appearance": "Single line",
642 | "color": "rgba(254, 55, 203, 1)",
643 | "boolTextYes": "",
644 | "boolTextNo": "",
645 | "validValues": [],
646 | "isRequired": true,
647 | "isTitle": true,
648 | "isList": false,
649 | "isDisabled": false,
650 | "isUnique": false,
651 | "order": 0,
652 | "validations": null
653 | },
654 | {
655 | "name": "Progile Photo",
656 | "nameId": "Progile_Photo",
657 | "type": "Media",
658 | "appearance": "Media",
659 | "color": "rgba(100, 214, 132, 1)",
660 | "boolTextYes": "",
661 | "boolTextNo": "",
662 | "validValues": [],
663 | "isRequired": false,
664 | "isTitle": false,
665 | "isList": false,
666 | "isDisabled": false,
667 | "isUnique": false,
668 | "order": 1,
669 | "validations": {
670 | "fileTypes": {
671 | "active": true,
672 | "types": [
673 | "Image"
674 | ],
675 | "errorMsg": "",
676 | "isError": false
677 | }
678 | }
679 | },
680 | {
681 | "name": "Biography",
682 | "nameId": "Biography",
683 | "type": "Long Text",
684 | "appearance": "Markdown",
685 | "color": "rgba(242, 129, 214, 1)",
686 | "boolTextYes": "",
687 | "boolTextNo": "",
688 | "validValues": [],
689 | "isRequired": false,
690 | "isTitle": false,
691 | "isList": false,
692 | "isDisabled": false,
693 | "isUnique": false,
694 | "order": 2,
695 | "validations": null
696 | },
697 | {
698 | "name": "Twitter Handle",
699 | "nameId": "Twitter_Handle",
700 | "type": "Short Text",
701 | "appearance": "Single line",
702 | "color": "rgba(57, 216, 69, 1)",
703 | "boolTextYes": "",
704 | "boolTextNo": "",
705 | "validValues": [],
706 | "isRequired": false,
707 | "isTitle": false,
708 | "isList": false,
709 | "isDisabled": false,
710 | "isUnique": false,
711 | "order": 3,
712 | "validations": null
713 | }
714 | ]
715 | },
716 | {
717 | "name": "Image",
718 | "nameId": "Image",
719 | "description": "",
720 | "color": "rgba(37, 105, 84, 1)",
721 | "fields": [
722 | {
723 | "name": "Title",
724 | "nameId": "Title",
725 | "type": "Short Text",
726 | "appearance": "Single line",
727 | "color": "rgba(169, 184, 60, 1)",
728 | "boolTextYes": "",
729 | "boolTextNo": "",
730 | "validValues": [],
731 | "isRequired": true,
732 | "isTitle": true,
733 | "isList": false,
734 | "isDisabled": false,
735 | "isUnique": false,
736 | "order": 0,
737 | "validations": null
738 | },
739 | {
740 | "name": "Photo",
741 | "nameId": "Photo",
742 | "type": "Media",
743 | "appearance": "Media",
744 | "color": "rgba(237, 19, 114, 1)",
745 | "boolTextYes": "",
746 | "boolTextNo": "",
747 | "validValues": [],
748 | "isRequired": true,
749 | "isTitle": false,
750 | "isList": false,
751 | "isDisabled": false,
752 | "isUnique": false,
753 | "order": 1,
754 | "validations": {
755 | "fileTypes": {
756 | "active": true,
757 | "types": [
758 | "Image"
759 | ],
760 | "errorMsg": "",
761 | "isError": false
762 | }
763 | }
764 | },
765 | {
766 | "name": "Author",
767 | "nameId": "Author",
768 | "type": "Reference",
769 | "appearance": "Reference",
770 | "color": "rgba(94, 140, 238, 1)",
771 | "boolTextYes": "",
772 | "boolTextNo": "",
773 | "validValues": [],
774 | "isRequired": true,
775 | "isTitle": false,
776 | "isList": false,
777 | "isDisabled": false,
778 | "isUnique": false,
779 | "order": 2,
780 | "validations": {
781 | "models": {
782 | "active": true,
783 | "modelsList": [
784 | "Author"
785 | ],
786 | "errorMsg": "",
787 | "isError": false
788 | }
789 | }
790 | },
791 | {
792 | "name": "Description",
793 | "nameId": "Description",
794 | "type": "Long Text",
795 | "appearance": "Markdown",
796 | "color": "rgba(188, 92, 58, 1)",
797 | "boolTextYes": "",
798 | "boolTextNo": "",
799 | "validValues": [],
800 | "isRequired": false,
801 | "isTitle": false,
802 | "isList": false,
803 | "isDisabled": false,
804 | "isUnique": false,
805 | "order": 3,
806 | "validations": null
807 | },
808 | {
809 | "name": "Credits",
810 | "nameId": "Credits",
811 | "type": "Short Text",
812 | "appearance": "Single line",
813 | "color": "rgba(217, 126, 103, 1)",
814 | "boolTextYes": "",
815 | "boolTextNo": "",
816 | "validValues": [],
817 | "isRequired": false,
818 | "isTitle": false,
819 | "isList": false,
820 | "isDisabled": false,
821 | "isUnique": false,
822 | "order": 4,
823 | "validations": null
824 | }
825 | ]
826 | },
827 | {
828 | "name": "Gallery",
829 | "nameId": "Gallery",
830 | "description": "",
831 | "color": "rgba(236, 62, 101, 1)",
832 | "fields": [
833 | {
834 | "name": "Title",
835 | "nameId": "Title",
836 | "type": "Short Text",
837 | "appearance": "Single line",
838 | "color": "rgba(186, 151, 108, 1)",
839 | "boolTextYes": "",
840 | "boolTextNo": "",
841 | "validValues": [],
842 | "isRequired": true,
843 | "isTitle": true,
844 | "isList": false,
845 | "isDisabled": false,
846 | "isUnique": false,
847 | "order": 0,
848 | "validations": null
849 | },
850 | {
851 | "name": "Slug",
852 | "nameId": "Slug",
853 | "type": "Short Text",
854 | "appearance": "Slug",
855 | "color": "rgba(52, 93, 87, 1)",
856 | "boolTextYes": "",
857 | "boolTextNo": "",
858 | "validValues": [],
859 | "isRequired": true,
860 | "isTitle": false,
861 | "isList": false,
862 | "isDisabled": false,
863 | "isUnique": false,
864 | "order": 1,
865 | "validations": null
866 | },
867 | {
868 | "name": "Author",
869 | "nameId": "Author",
870 | "type": "Reference",
871 | "appearance": "Reference",
872 | "color": "rgba(21, 139, 191, 1)",
873 | "boolTextYes": "",
874 | "boolTextNo": "",
875 | "validValues": [],
876 | "isRequired": true,
877 | "isTitle": false,
878 | "isList": false,
879 | "isDisabled": false,
880 | "isUnique": false,
881 | "order": 2,
882 | "validations": {
883 | "models": {
884 | "active": true,
885 | "modelsList": [
886 | "Author"
887 | ],
888 | "errorMsg": "",
889 | "isError": false
890 | }
891 | }
892 | },
893 | {
894 | "name": "Cover Image",
895 | "nameId": "Cover_Image",
896 | "type": "Media",
897 | "appearance": "Media",
898 | "color": "rgba(179, 35, 128, 1)",
899 | "boolTextYes": "",
900 | "boolTextNo": "",
901 | "validValues": [],
902 | "isRequired": false,
903 | "isTitle": false,
904 | "isList": false,
905 | "isDisabled": false,
906 | "isUnique": false,
907 | "order": 3,
908 | "validations": {
909 | "fileTypes": {
910 | "active": true,
911 | "types": [
912 | "Image"
913 | ],
914 | "errorMsg": "",
915 | "isError": false
916 | }
917 | }
918 | },
919 | {
920 | "name": "Images",
921 | "nameId": "Images",
922 | "type": "Reference",
923 | "appearance": "Reference",
924 | "color": "rgba(123, 93, 49, 1)",
925 | "boolTextYes": "",
926 | "boolTextNo": "",
927 | "validValues": [],
928 | "isRequired": false,
929 | "isTitle": false,
930 | "isList": true,
931 | "isDisabled": false,
932 | "isUnique": false,
933 | "order": 4,
934 | "validations": {
935 | "models": {
936 | "active": true,
937 | "modelsList": [
938 | "Image"
939 | ],
940 | "errorMsg": "",
941 | "isError": false
942 | }
943 | }
944 | },
945 | {
946 | "name": "Description",
947 | "nameId": "Description",
948 | "type": "Long Text",
949 | "appearance": "Markdown",
950 | "color": "rgba(65, 62, 3, 1)",
951 | "boolTextYes": "",
952 | "boolTextNo": "",
953 | "validValues": [],
954 | "isRequired": false,
955 | "isTitle": false,
956 | "isList": false,
957 | "isDisabled": false,
958 | "isUnique": false,
959 | "order": 5,
960 | "validations": null
961 | },
962 | {
963 | "name": "Tags",
964 | "nameId": "Tags",
965 | "type": "Short Text",
966 | "appearance": "Single line",
967 | "color": "rgba(162, 6, 95, 1)",
968 | "boolTextYes": "",
969 | "boolTextNo": "",
970 | "validValues": [],
971 | "isRequired": false,
972 | "isTitle": false,
973 | "isList": true,
974 | "isDisabled": false,
975 | "isUnique": false,
976 | "order": 6,
977 | "validations": null
978 | },
979 | {
980 | "name": "Date",
981 | "nameId": "Date",
982 | "type": "Date/time",
983 | "appearance": "Date & time",
984 | "color": "rgba(206, 60, 35, 1)",
985 | "boolTextYes": "",
986 | "boolTextNo": "",
987 | "validValues": [],
988 | "isRequired": false,
989 | "isTitle": false,
990 | "isList": false,
991 | "isDisabled": false,
992 | "isUnique": false,
993 | "order": 7,
994 | "validations": null
995 | },
996 | {
997 | "name": "Location",
998 | "nameId": "Location",
999 | "type": "Short Text",
1000 | "appearance": "Single line",
1001 | "color": "rgba(74, 144, 26, 1)",
1002 | "boolTextYes": "",
1003 | "boolTextNo": "",
1004 | "validValues": [],
1005 | "isRequired": false,
1006 | "isTitle": false,
1007 | "isList": false,
1008 | "isDisabled": false,
1009 | "isUnique": false,
1010 | "order": 8,
1011 | "validations": null
1012 | }
1013 | ]
1014 | }
1015 | ]
1016 | },
1017 | {
1018 | "name": "Product Catalogue",
1019 | "description": "The template for product catalogue.",
1020 | "icon": "products.png",
1021 | "models": [
1022 | {
1023 | "name": "Brand",
1024 | "nameId": "Brand",
1025 | "description": "",
1026 | "color": "rgba(118, 64, 185, 1)",
1027 | "fields": [
1028 | {
1029 | "name": "Name",
1030 | "nameId": "Name",
1031 | "type": "Short Text",
1032 | "appearance": "Single line",
1033 | "color": "rgba(22, 135, 76, 1)",
1034 | "boolTextYes": "",
1035 | "boolTextNo": "",
1036 | "validValues": [],
1037 | "isRequired": true,
1038 | "isTitle": true,
1039 | "isList": false,
1040 | "isDisabled": false,
1041 | "isUnique": false,
1042 | "order": 0,
1043 | "validations": null
1044 | },
1045 | {
1046 | "name": "Logo",
1047 | "nameId": "Logo",
1048 | "type": "Media",
1049 | "appearance": "Media",
1050 | "color": "rgba(195, 204, 139, 1)",
1051 | "boolTextYes": "",
1052 | "boolTextNo": "",
1053 | "validValues": [],
1054 | "isRequired": false,
1055 | "isTitle": false,
1056 | "isList": false,
1057 | "isDisabled": false,
1058 | "isUnique": false,
1059 | "order": 1,
1060 | "validations": {
1061 | "fileTypes": {
1062 | "active": true,
1063 | "types": [
1064 | "Image"
1065 | ],
1066 | "errorMsg": "",
1067 | "isError": false
1068 | }
1069 | }
1070 | },
1071 | {
1072 | "name": "Description",
1073 | "nameId": "Description",
1074 | "type": "Long Text",
1075 | "appearance": "Markdown",
1076 | "color": "rgba(140, 79, 152, 1)",
1077 | "boolTextYes": "",
1078 | "boolTextNo": "",
1079 | "validValues": [],
1080 | "isRequired": false,
1081 | "isTitle": false,
1082 | "isList": false,
1083 | "isDisabled": false,
1084 | "isUnique": false,
1085 | "order": 2,
1086 | "validations": null
1087 | },
1088 | {
1089 | "name": "Website",
1090 | "nameId": "Website",
1091 | "type": "Short Text",
1092 | "appearance": "URL",
1093 | "color": "rgba(13, 90, 220, 1)",
1094 | "boolTextYes": "",
1095 | "boolTextNo": "",
1096 | "validValues": [],
1097 | "isRequired": false,
1098 | "isTitle": false,
1099 | "isList": false,
1100 | "isDisabled": false,
1101 | "isUnique": false,
1102 | "order": 3,
1103 | "validations": null
1104 | },
1105 | {
1106 | "name": "Email",
1107 | "nameId": "Email",
1108 | "type": "Short Text",
1109 | "appearance": "Single line",
1110 | "color": "rgba(255, 173, 143, 1)",
1111 | "boolTextYes": "",
1112 | "boolTextNo": "",
1113 | "validValues": [],
1114 | "isRequired": false,
1115 | "isTitle": false,
1116 | "isList": false,
1117 | "isDisabled": false,
1118 | "isUnique": false,
1119 | "order": 4,
1120 | "validations": null
1121 | },
1122 | {
1123 | "name": "Phone Number",
1124 | "nameId": "Phone_Number",
1125 | "type": "Short Text",
1126 | "appearance": "Single line",
1127 | "color": "rgba(235, 45, 248, 1)",
1128 | "boolTextYes": "",
1129 | "boolTextNo": "",
1130 | "validValues": [],
1131 | "isRequired": false,
1132 | "isTitle": false,
1133 | "isList": false,
1134 | "isDisabled": false,
1135 | "isUnique": false,
1136 | "order": 5,
1137 | "validations": null
1138 | },
1139 | {
1140 | "name": "Twitter Handle",
1141 | "nameId": "Twitter_Handle",
1142 | "type": "Short Text",
1143 | "appearance": "Single line",
1144 | "color": "rgba(100, 129, 165, 1)",
1145 | "boolTextYes": "",
1146 | "boolTextNo": "",
1147 | "validValues": [],
1148 | "isRequired": false,
1149 | "isTitle": false,
1150 | "isList": false,
1151 | "isDisabled": false,
1152 | "isUnique": false,
1153 | "order": 6,
1154 | "validations": null
1155 | }
1156 | ]
1157 | },
1158 | {
1159 | "name": "Category",
1160 | "nameId": "Category",
1161 | "description": "",
1162 | "color": "rgba(219, 247, 152, 1)",
1163 | "fields": [
1164 | {
1165 | "name": "Title",
1166 | "nameId": "Title",
1167 | "type": "Short Text",
1168 | "appearance": "Single line",
1169 | "color": "rgba(128, 242, 36, 1)",
1170 | "boolTextYes": "",
1171 | "boolTextNo": "",
1172 | "validValues": [],
1173 | "isRequired": true,
1174 | "isTitle": true,
1175 | "isList": false,
1176 | "isDisabled": false,
1177 | "isUnique": false,
1178 | "order": 0,
1179 | "validations": null
1180 | },
1181 | {
1182 | "name": "Icon",
1183 | "nameId": "Icon",
1184 | "type": "Media",
1185 | "appearance": "Media",
1186 | "color": "rgba(134, 131, 232, 1)",
1187 | "boolTextYes": "",
1188 | "boolTextNo": "",
1189 | "validValues": [],
1190 | "isRequired": true,
1191 | "isTitle": false,
1192 | "isList": false,
1193 | "isDisabled": false,
1194 | "isUnique": false,
1195 | "order": 1,
1196 | "validations": {
1197 | "fileTypes": {
1198 | "active": true,
1199 | "types": [
1200 | "Image"
1201 | ],
1202 | "errorMsg": "",
1203 | "isError": false
1204 | }
1205 | }
1206 | },
1207 | {
1208 | "name": "Description",
1209 | "nameId": "Description",
1210 | "type": "Long Text",
1211 | "appearance": "Markdown",
1212 | "color": "rgba(242, 209, 218, 1)",
1213 | "boolTextYes": "",
1214 | "boolTextNo": "",
1215 | "validValues": [],
1216 | "isRequired": false,
1217 | "isTitle": false,
1218 | "isList": false,
1219 | "isDisabled": false,
1220 | "isUnique": false,
1221 | "order": 2,
1222 | "validations": null
1223 | }
1224 | ]
1225 | },
1226 | {
1227 | "name": "Product",
1228 | "nameId": "Product",
1229 | "description": "",
1230 | "color": "rgba(185, 56, 212, 1)",
1231 | "fields": [
1232 | {
1233 | "name": "Name",
1234 | "nameId": "Name",
1235 | "type": "Short Text",
1236 | "appearance": "Single line",
1237 | "color": "rgba(13, 141, 142, 1)",
1238 | "boolTextYes": "",
1239 | "boolTextNo": "",
1240 | "validValues": [],
1241 | "isRequired": true,
1242 | "isTitle": true,
1243 | "isList": false,
1244 | "isDisabled": false,
1245 | "isUnique": false,
1246 | "order": 0,
1247 | "validations": null
1248 | },
1249 | {
1250 | "name": "Slug",
1251 | "nameId": "Slug",
1252 | "type": "Short Text",
1253 | "appearance": "Slug",
1254 | "color": "rgba(125, 42, 81, 1)",
1255 | "boolTextYes": "",
1256 | "boolTextNo": "",
1257 | "validValues": [],
1258 | "isRequired": true,
1259 | "isTitle": false,
1260 | "isList": false,
1261 | "isDisabled": false,
1262 | "isUnique": false,
1263 | "order": 1,
1264 | "validations": null
1265 | },
1266 | {
1267 | "name": "Brand",
1268 | "nameId": "Brand",
1269 | "type": "Reference",
1270 | "appearance": "Reference",
1271 | "color": "rgba(214, 38, 88, 1)",
1272 | "boolTextYes": "",
1273 | "boolTextNo": "",
1274 | "validValues": [],
1275 | "isRequired": false,
1276 | "isTitle": false,
1277 | "isList": false,
1278 | "isDisabled": false,
1279 | "isUnique": false,
1280 | "order": 2,
1281 | "validations": {
1282 | "models": {
1283 | "active": true,
1284 | "modelsList": [
1285 | "Brand"
1286 | ],
1287 | "errorMsg": "",
1288 | "isError": false
1289 | }
1290 | }
1291 | },
1292 | {
1293 | "name": "Description",
1294 | "nameId": "Description",
1295 | "type": "Long Text",
1296 | "appearance": "Markdown",
1297 | "color": "rgba(3, 33, 249, 1)",
1298 | "boolTextYes": "",
1299 | "boolTextNo": "",
1300 | "validValues": [],
1301 | "isRequired": true,
1302 | "isTitle": false,
1303 | "isList": false,
1304 | "isDisabled": false,
1305 | "isUnique": false,
1306 | "order": 3,
1307 | "validations": null
1308 | },
1309 | {
1310 | "name": "Categories",
1311 | "nameId": "Categories",
1312 | "type": "Reference",
1313 | "appearance": "Reference",
1314 | "color": "rgba(50, 140, 103, 1)",
1315 | "boolTextYes": "",
1316 | "boolTextNo": "",
1317 | "validValues": [],
1318 | "isRequired": false,
1319 | "isTitle": false,
1320 | "isList": true,
1321 | "isDisabled": false,
1322 | "isUnique": false,
1323 | "order": 4,
1324 | "validations": {
1325 | "models": {
1326 | "active": true,
1327 | "modelsList": [
1328 | "Category"
1329 | ],
1330 | "errorMsg": "",
1331 | "isError": false
1332 | }
1333 | }
1334 | },
1335 | {
1336 | "name": "Images",
1337 | "nameId": "Images",
1338 | "type": "Media",
1339 | "appearance": "Media",
1340 | "color": "rgba(255, 230, 50, 1)",
1341 | "boolTextYes": "",
1342 | "boolTextNo": "",
1343 | "validValues": [],
1344 | "isRequired": false,
1345 | "isTitle": false,
1346 | "isList": true,
1347 | "isDisabled": false,
1348 | "isUnique": false,
1349 | "order": 5,
1350 | "validations": {
1351 | "fileTypes": {
1352 | "active": true,
1353 | "types": [
1354 | "Image"
1355 | ],
1356 | "errorMsg": "",
1357 | "isError": false
1358 | }
1359 | }
1360 | },
1361 | {
1362 | "name": "Tags",
1363 | "nameId": "Tags",
1364 | "type": "Short Text",
1365 | "appearance": "Single line",
1366 | "color": "rgba(224, 124, 237, 1)",
1367 | "boolTextYes": "",
1368 | "boolTextNo": "",
1369 | "validValues": [],
1370 | "isRequired": false,
1371 | "isTitle": false,
1372 | "isList": true,
1373 | "isDisabled": false,
1374 | "isUnique": false,
1375 | "order": 6,
1376 | "validations": null
1377 | },
1378 | {
1379 | "name": "Price",
1380 | "nameId": "Price",
1381 | "type": "Number",
1382 | "appearance": "Decimal",
1383 | "color": "rgba(98, 140, 144, 1)",
1384 | "boolTextYes": "",
1385 | "boolTextNo": "",
1386 | "validValues": [],
1387 | "isRequired": false,
1388 | "isTitle": false,
1389 | "isList": false,
1390 | "isDisabled": false,
1391 | "isUnique": false,
1392 | "order": 7,
1393 | "validations": null
1394 | },
1395 | {
1396 | "name": "Quantity Options",
1397 | "nameId": "Quantity_Options",
1398 | "type": "Number Int",
1399 | "appearance": "Decimal",
1400 | "color": "rgba(87, 61, 120, 1)",
1401 | "boolTextYes": "",
1402 | "boolTextNo": "",
1403 | "validValues": [],
1404 | "isRequired": false,
1405 | "isTitle": false,
1406 | "isList": true,
1407 | "isDisabled": false,
1408 | "isUnique": false,
1409 | "order": 8,
1410 | "validations": null
1411 | },
1412 | {
1413 | "name": "SKU",
1414 | "nameId": "SKU",
1415 | "type": "Short Text",
1416 | "appearance": "Single line",
1417 | "color": "rgba(35, 94, 57, 1)",
1418 | "boolTextYes": "",
1419 | "boolTextNo": "",
1420 | "validValues": [],
1421 | "isRequired": false,
1422 | "isTitle": false,
1423 | "isList": false,
1424 | "isDisabled": false,
1425 | "isUnique": false,
1426 | "order": 9,
1427 | "validations": null
1428 | },
1429 | {
1430 | "name": "Current Stock",
1431 | "nameId": "Current_Stock",
1432 | "type": "Number Int",
1433 | "appearance": "Decimal",
1434 | "color": "rgba(185, 194, 50, 1)",
1435 | "boolTextYes": "",
1436 | "boolTextNo": "",
1437 | "validValues": [],
1438 | "isRequired": false,
1439 | "isTitle": false,
1440 | "isList": false,
1441 | "isDisabled": false,
1442 | "isUnique": false,
1443 | "order": 10,
1444 | "validations": null
1445 | }
1446 | ]
1447 | }
1448 | ]
1449 | }
1450 | ]
1451 |
--------------------------------------------------------------------------------