├── 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 | 73 | 74 |
67 |

Hi

68 |

69 | Your friend {{emailSelf}} invites you to join {{siteName}}.
70 |

71 |

Accept invite

72 |
75 | 76 | 77 | -------------------------------------------------------------------------------- /backend/cms/mailTemplates/emailVerify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 61 | 62 | 63 | 64 | 65 | 66 | 74 | 75 |
67 |

Hi {{username}},

68 |

69 | Thanks for signing up!
70 | Please confirm your account. 71 |

72 |

Confirm Account

73 |
76 | 77 | 78 | -------------------------------------------------------------------------------- /backend/cms/mailTemplates/passwordReset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 61 | 62 | 63 | 64 | 65 | 66 | 75 | 76 |
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 |
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 |
63 | 64 | 65 |
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 |
43 |
44 |
45 | 51 |
52 | 55 |
56 |
57 |
58 |
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 |
    147 | 148 | 149 | New Password 150 |
    151 | 152 | Confirm New Password 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 |
    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 | ![CI/CD](https://github.com/hatemhosny/parse-starter/workflows/CI/CD/badge.svg) 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 | --------------------------------------------------------------------------------