├── .browserslistrc ├── .editorconfig ├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .lintstagedrc.js ├── .nojekyll ├── .npmignore ├── .npmrc ├── .remarkignore ├── .travis.yml ├── CNAME ├── LICENSE ├── README.md ├── cli.js ├── config.js ├── favicon.ico ├── index.html ├── media ├── lad-120x120.png ├── lad-footer.png └── lad.png ├── package.json ├── sao.js ├── template ├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .env.defaults ├── .env.schema ├── .gitattributes ├── .gitignore ├── .lintstagedrc.js ├── .pug-lintrc.js ├── .remarkignore ├── .stylelintrc ├── .travis.yml ├── LICENSE ├── README ├── ansible-playbook.js ├── ansible.cfg ├── ansible │ ├── playbooks │ │ ├── aws-credentials.yml │ │ ├── bree.yml │ │ ├── certificates.yml │ │ ├── deployment-keys.yml │ │ ├── ecosystem.yml │ │ ├── env.yml │ │ ├── gapp-creds.yml │ │ ├── http.yml │ │ ├── mongo.yml │ │ ├── node.yml │ │ ├── python.yml │ │ ├── redis.yml │ │ ├── security.yml │ │ ├── ssh-keys.yml │ │ └── templates │ │ │ ├── aws-credentials.j2 │ │ │ ├── before.rules.j2 │ │ │ ├── ecosystem-api.json.j2 │ │ │ ├── ecosystem-bree.json.j2 │ │ │ ├── ecosystem-web.json.j2 │ │ │ ├── env │ │ │ ├── hosts.yml │ │ │ └── security-limits.d-mongod.conf │ └── requirements.yml ├── api.js ├── app │ ├── controllers │ │ ├── api │ │ │ ├── index.js │ │ │ └── v1 │ │ │ │ ├── index.js │ │ │ │ ├── log.js │ │ │ │ └── users.js │ │ ├── index.js │ │ └── web │ │ │ ├── admin │ │ │ ├── index.js │ │ │ └── users.js │ │ │ ├── auth.js │ │ │ ├── index.js │ │ │ ├── my-account.js │ │ │ ├── otp │ │ │ ├── disable.js │ │ │ ├── index.js │ │ │ ├── keys.js │ │ │ ├── recovery.js │ │ │ └── setup.js │ │ │ ├── report.js │ │ │ └── support.js │ ├── models │ │ ├── index.js │ │ ├── inquiry.js │ │ └── user.js │ └── views │ │ ├── 404.pug │ │ ├── 500.pug │ │ ├── _breadcrumbs.pug │ │ ├── _footer.pug │ │ ├── _nav.pug │ │ ├── _pagination.pug │ │ ├── _register-or-login.pug │ │ ├── about.pug │ │ ├── admin │ │ ├── index.pug │ │ └── users │ │ │ ├── index.pug │ │ │ └── retrieve.pug │ │ ├── change-email.pug │ │ ├── dashboard │ │ └── index.pug │ │ ├── donate.pug │ │ ├── forgot-password.pug │ │ ├── home.pug │ │ ├── layout.pug │ │ ├── my-account │ │ ├── index.pug │ │ ├── profile.pug │ │ └── security.pug │ │ ├── otp │ │ ├── enable.pug │ │ ├── keys.pug │ │ ├── login.pug │ │ └── setup.pug │ │ ├── privacy.pug │ │ ├── register-or-login.pug │ │ ├── reset-password.pug │ │ ├── spinner │ │ ├── 1.pug │ │ ├── 10.pug │ │ ├── 11.pug │ │ ├── 2.pug │ │ ├── 3.pug │ │ ├── 4.pug │ │ ├── 5.pug │ │ ├── 6.pug │ │ ├── 7.pug │ │ ├── 8.pug │ │ ├── 9.pug │ │ └── spinner.pug │ │ ├── support.pug │ │ ├── terms.pug │ │ └── verify.pug ├── assets │ ├── browserconfig.xml │ ├── css │ │ ├── _btn-auth.scss │ │ ├── _custom.scss │ │ ├── _email.scss │ │ ├── _markdown.scss │ │ ├── _responsive-backgrounds.scss │ │ ├── _responsive-borders.scss │ │ ├── _responsive-rounded.scss │ │ ├── _swal2.scss │ │ ├── _variables.scss │ │ └── app.scss │ ├── img │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-384x384.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── github-logo.svg │ │ ├── google-logo.svg │ │ ├── logo-square.svg │ │ ├── mstile-150x150.png │ │ ├── social.png │ │ └── twitter.png │ ├── js │ │ ├── core.js │ │ ├── logger.js │ │ └── uncaught.js │ ├── robots.txt │ └── site.webmanifest ├── bree.js ├── config │ ├── api.js │ ├── bree.js │ ├── cookies.js │ ├── env.js │ ├── filters.js │ ├── i18n.js │ ├── index.js │ ├── koa-cash.js │ ├── locales.js │ ├── logger.js │ ├── meta.js │ ├── phrases.js │ ├── utilities.js │ └── web.js ├── emails │ ├── _content.pug │ ├── _footer.pug │ ├── _nav.pug │ ├── account-update │ │ ├── html.pug │ │ └── subject.pug │ ├── change-email │ │ ├── html.pug │ │ └── subject.pug │ ├── inquiry │ │ ├── html.pug │ │ └── subject.pug │ ├── layout.pug │ ├── recovery │ │ ├── html.pug │ │ └── subject.pug │ ├── reset-password │ │ ├── html.pug │ │ └── subject.pug │ ├── two-factor-reminder │ │ ├── html.pug │ │ └── subject.pug │ ├── verify │ │ ├── html.pug │ │ └── subject.pug │ └── welcome │ │ ├── html.pug │ │ └── subject.pug ├── env ├── gitignore ├── gulpfile.js ├── helpers │ ├── email.js │ ├── get-email-locals.js │ ├── i18n.js │ ├── logger.js │ ├── markdown.js │ ├── passport.js │ ├── policies.js │ ├── send-verification-email.js │ └── to-object.js ├── index.js ├── jobs │ ├── account-updates.js │ ├── index.js │ ├── translate-markdown.js │ ├── translate-phrases.js │ ├── two-factor-reminder.js │ └── welcome-email.js ├── nodemon.json ├── package-scripts.js ├── package.json ├── proxy.js ├── routes │ ├── api │ │ ├── index.js │ │ └── v1 │ │ │ └── index.js │ ├── index.js │ └── web │ │ ├── admin.js │ │ ├── auth.js │ │ ├── index.js │ │ ├── my-account.js │ │ └── otp.js ├── test │ ├── _utils.js │ ├── api │ │ └── v1.js │ ├── config │ │ ├── snapshots │ │ │ ├── utilities.js.md │ │ │ └── utilities.js.snap │ │ └── utilities.js │ ├── utils.js │ └── web │ │ ├── auth.js │ │ ├── index.js │ │ ├── otp.js │ │ ├── snapshots │ │ ├── index.js.md │ │ ├── index.js.snap │ │ ├── otp.js.md │ │ ├── otp.js.snap │ │ ├── support.js.md │ │ └── support.js.snap │ │ └── support.js ├── web.js └── yarn.lock ├── test ├── snapshots │ ├── test.js.md │ └── test.js.snap └── test.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | # browsers that we support 2 | # 3 | 4 | > 1% 5 | last 2 versions 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: niftylettuce 4 | patreon: niftylettuce 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | template/build 8 | template/temp.md 9 | *.lcov 10 | .base64-cache 11 | template/locales 12 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.md,!test/snapshots/**/*.md,!test/**/snapshots/**/*.md,!locales/README.md": [ 3 | filenames => filenames.map(filename => `remark ${filename} -qfo`), 4 | 'git add' 5 | ], 6 | 'package.json': ['fixpack', 'git add'], 7 | '*.js': ['xo --fix', 'git add '] 8 | }; 9 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/.nojekyll -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | media 2 | .nyc_output 3 | coverage 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | test/snapshots/**/*.md 2 | template/test/snapshots/**/*.md 3 | template/test/**/snapshots/**/*.md 4 | template/README.md 5 | template/locales/README.md 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | services: 5 | - mongodb 6 | - redis-server 7 | script: 8 | npm run test-coverage 9 | after_success: 10 | npm run coverage 11 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | lad.js.org 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nick Baugh (http://niftylettuce.com/) 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Inspired by: 4 | // 5 | // 6 | 7 | const path = require('path'); 8 | const cac = require('cac'); 9 | const sao = require('sao'); 10 | const update = require('update-notifier'); 11 | 12 | const pkg = require('./package'); 13 | 14 | const cli = cac(); 15 | 16 | cli 17 | .command('', 'Generate a new project') 18 | .action((name) => { 19 | const folderName = name; 20 | const targetPath = path.resolve(folderName); 21 | console.log(`> Generating project in ${targetPath}`); 22 | 23 | const templatePath = path.dirname(require.resolve('./package')); 24 | 25 | return sao({ 26 | template: templatePath, 27 | targetPath 28 | }); 29 | }) 30 | .example('lad my-new-project'); 31 | 32 | cli.version(pkg.version); 33 | cli.help(); 34 | cli.parse(); 35 | 36 | update({ pkg }).notify(); 37 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | docute.init({ 2 | debug: true, 3 | title: 'Lad', 4 | repo: 'ladjs/lad', 5 | 'edit-link': 'https://github.com/ladjs/lad/tree/master/', 6 | twitter: 'niftylettuce', 7 | nav: { 8 | default: [ 9 | { 10 | title: 'Lad is the best Node.js framework', 11 | path: '/' 12 | } 13 | ] 14 | }, 15 | plugins: [ 16 | docuteEmojify() 17 | ] 18 | }); 19 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Lad is the best Node.js framework 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | 25 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /media/lad-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/media/lad-120x120.png -------------------------------------------------------------------------------- /media/lad-footer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/media/lad-footer.png -------------------------------------------------------------------------------- /media/lad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/media/lad.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lad", 3 | "description": "Lad is the best Node.js framework. Made by a former Express TC and Koa team member.", 4 | "version": "1.3.2", 5 | "author": "Nick Baugh (http://niftylettuce.com)", 6 | "ava": { 7 | "verbose": true, 8 | "timeout": "30s", 9 | "files": [ 10 | "test/**/*.js" 11 | ] 12 | }, 13 | "bin": "cli.js", 14 | "bugs": { 15 | "url": "https://github.com/ladjs/lad/issues", 16 | "email": "niftylettuce@gmail.com" 17 | }, 18 | "commitlint": { 19 | "extends": [ 20 | "@commitlint/config-conventional" 21 | ] 22 | }, 23 | "contributors": [ 24 | "Nick Baugh (http://niftylettuce.com)", 25 | "Shaun Warman (https://shaunwarman.com/)" 26 | ], 27 | "dependencies": { 28 | "@ladjs/browserslist-config": "^0.0.1", 29 | "cac": "^6.7.1", 30 | "camelcase": "^6.2.0", 31 | "github-username-regex": "^1.0.0", 32 | "is-email": "^1.0.0", 33 | "is-url": "^1.2.4", 34 | "is-valid-npm-name": "^0.0.5", 35 | "npm-conf": "^1.1.3", 36 | "sao": "0.x", 37 | "semver": "^7.3.4", 38 | "speakingurl": "^14.0.1", 39 | "superb": "^4.0.0", 40 | "update-notifier": "^5.1.0", 41 | "uppercamelcase": "^3.0.0" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "^11.0.0", 45 | "@commitlint/config-conventional": "^11.0.0", 46 | "ava": "^3.15.0", 47 | "codecov": "^3.8.1", 48 | "cross-env": "^7.0.3", 49 | "eslint": "^7.19.0", 50 | "eslint-config-xo-lass": "^1.0.5", 51 | "eslint-plugin-compat": "^3.9.0", 52 | "eslint-plugin-no-smart-quotes": "^1.1.0", 53 | "husky": "^5.0.9", 54 | "lint-staged": "^10.5.4", 55 | "nyc": "^15.1.0", 56 | "remark-cli": "^9.0.0", 57 | "remark-preset-github": "^4.0.1", 58 | "xo": "^0.37.1" 59 | }, 60 | "engines": { 61 | "node": ">=12.11.0" 62 | }, 63 | "homepage": "https://github.com/ladjs/lad", 64 | "husky": { 65 | "hooks": { 66 | "pre-commit": "lint-staged", 67 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 68 | } 69 | }, 70 | "keywords": [ 71 | "ava", 72 | "boilerplate", 73 | "codecov", 74 | "es6", 75 | "es7", 76 | "flavored", 77 | "generator", 78 | "gfm", 79 | "github", 80 | "lad", 81 | "license", 82 | "license-generator", 83 | "markdown", 84 | "module", 85 | "np", 86 | "npm", 87 | "nyc", 88 | "package", 89 | "prettier", 90 | "project", 91 | "remark", 92 | "sao", 93 | "scaffold", 94 | "spdx", 95 | "starter", 96 | "xo", 97 | "yeoman" 98 | ], 99 | "license": "MIT", 100 | "main": "sao.js", 101 | "nyc": { 102 | "reporter": [ 103 | "lcov", 104 | "html", 105 | "text" 106 | ] 107 | }, 108 | "prettier": { 109 | "singleQuote": true, 110 | "bracketSpacing": true, 111 | "trailingComma": "none" 112 | }, 113 | "remarkConfig": { 114 | "plugins": [ 115 | "preset-github" 116 | ] 117 | }, 118 | "repository": { 119 | "type": "git", 120 | "url": "https://github.com/ladjs/lad" 121 | }, 122 | "scripts": { 123 | "ava": "cross-env NODE_ENV=test ava", 124 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", 125 | "lint": "xo && remark . -qfo", 126 | "nyc": "cross-env NODE_ENV=test nyc ava", 127 | "pretest": "npm run lint", 128 | "test": "npm run ava && cd template && yarn && npm run test", 129 | "test-coverage": "npm run lint && npm run nyc && cd template && yarn && npm run test-coverage" 130 | }, 131 | "xo": { 132 | "prettier": true, 133 | "space": true, 134 | "extends": [ 135 | "xo-lass" 136 | ], 137 | "ignores": [ 138 | "config.js", 139 | "template/**/*", 140 | "template/**/**/*" 141 | ] 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /template/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "compact": false, 3 | "presets": [ 4 | [ 5 | "@babel/env", { 6 | "debug": true, 7 | "forceAllTransforms": true, 8 | "modules": false, 9 | "targets": { 10 | "browsers": [ "extends @ladjs/browserslist-config" ] 11 | } 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /template/.browserslistrc: -------------------------------------------------------------------------------- 1 | extends @ladjs/browserslist-config 2 | -------------------------------------------------------------------------------- /template/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /template/.env.schema: -------------------------------------------------------------------------------- 1 | ################# 2 | ## environment ## 3 | ################# 4 | NODE_ENV= 5 | 6 | ########### 7 | ## proxy ## 8 | ########### 9 | PROXY_PORT= 10 | 11 | ########## 12 | ## http ## 13 | ########## 14 | HTTP_PROTOCOL= 15 | HTTP_PORT= 16 | 17 | ################ 18 | ## web server ## 19 | ################ 20 | WEB_HOST= 21 | WEB_PORT= 22 | WEB_PROTOCOL= 23 | WEB_URL= 24 | WEB_SSL_KEY_PATH= 25 | WEB_SSL_CERT_PATH= 26 | WEB_SSL_CA_PATH= 27 | 28 | ################ 29 | ## api server ## 30 | ################ 31 | API_HOST= 32 | API_PORT= 33 | API_PROTOCOL= 34 | API_URL= 35 | API_SSL_KEY_PATH= 36 | API_SSL_CERT_PATH= 37 | API_SSL_CA_PATH= 38 | API_RATELIMIT_WHITELIST= 39 | 40 | ######### 41 | ## app ## 42 | ######### 43 | APP_NAME= 44 | APP_COLOR= 45 | TWITTER= 46 | SEND_EMAIL= 47 | TRANSPORT_DEBUG= 48 | EMAIL_DEFAULT_FROM= 49 | SHOW_STACK= 50 | SHOW_META= 51 | SUPPORT_REQUEST_MAX_LENGTH= 52 | # koa-better-error-handler 53 | ERROR_HANDLER_BASE_URL= 54 | # @ladjs/i18n 55 | I18N_SYNC_FILES= 56 | I18N_AUTO_RELOAD= 57 | I18N_UPDATE_FILES= 58 | # @ladjs/auth 59 | AUTH_LOCAL_ENABLED= 60 | AUTH_FACEBOOK_ENABLED= 61 | AUTH_TWITTER_ENABLED= 62 | AUTH_GOOGLE_ENABLED= 63 | AUTH_GITHUB_ENABLED= 64 | AUTH_LINKEDIN_ENABLED= 65 | AUTH_INSTAGRAM_ENABLED= 66 | AUTH_OTP_ENABLED= 67 | AUTH_STRIPE_ENABLED= 68 | # your google client ID and secret from: 69 | # https://console.developers.google.com 70 | GOOGLE_CLIENT_ID= 71 | GOOGLE_CLIENT_SECRET= 72 | GOOGLE_CALLBACK_URL= 73 | GOOGLE_APPLICATION_CREDENTIALS= 74 | # your github client ID and secret from: 75 | # https://github.com/settings/applications 76 | GITHUB_CLIENT_ID= 77 | GITHUB_CLIENT_SECRET= 78 | GITHUB_CALLBACK_URL= 79 | # your Postmark token from: 80 | # https//postmarkapp.com 81 | POSTMARK_API_TOKEN= 82 | # your CodeCov token from: 83 | # https://codecov.io 84 | CODECOV_TOKEN= 85 | # aws credentials 86 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html 87 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html 88 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-json-file.html 89 | AWS_PROFILE= 90 | AWS_S3_BUCKET= 91 | AWS_CLOUDFRONT_DOMAIN= 92 | 93 | ############# 94 | ## mongodb ## 95 | ############# 96 | MONGO_USER= 97 | MONGO_PASS= 98 | MONGO_HOST= 99 | MONGO_PORT= 100 | MONGO_NAME= 101 | MONGO_URI= 102 | 103 | WEB_MONGO_USER= 104 | WEB_MONGO_PASS= 105 | WEB_MONGO_HOST= 106 | WEB_MONGO_NAME= 107 | WEB_MONGO_PORT= 108 | WEB_MONGO_URI= 109 | 110 | API_MONGO_PASS= 111 | API_MONGO_USER= 112 | API_MONGO_HOST= 113 | API_MONGO_NAME= 114 | API_MONGO_PORT= 115 | API_MONGO_URI= 116 | 117 | BREE_MONGO_USER= 118 | BREE_MONGO_PASS= 119 | BREE_MONGO_HOST= 120 | BREE_MONGO_NAME= 121 | BREE_MONGO_PORT= 122 | BREE_MONGO_URI= 123 | 124 | ########### 125 | ## redis ## 126 | ########### 127 | REDIS_PORT= 128 | REDIS_HOST= 129 | REDIS_PASSWORD= 130 | WEB_REDIS_PORT= 131 | WEB_REDIS_HOST= 132 | WEB_REDIS_PASSWORD= 133 | API_REDIS_PORT= 134 | API_REDIS_HOST= 135 | API_REDIS_PASSWORD= 136 | BREE_REDIS_PORT= 137 | BREE_REDIS_HOST= 138 | BREE_REDIS_PASSWORD= 139 | MANDARIN_REDIS_PORT= 140 | MANDARIN_REDIS_HOST= 141 | MANDARIN_REDIS_PASSWORD= 142 | 143 | ############# 144 | ## certbot ## 145 | ############# 146 | CERTBOT_WELL_KNOWN_NAME= 147 | CERTBOT_WELL_KNOWN_CONTENTS= 148 | 149 | ###################### 150 | ## verification pin ## 151 | ###################### 152 | VERIFICATION_PIN_TIMEOUT_MS= 153 | VERIFICATION_PIN_EMAIL_INTERVAL_MS= 154 | 155 | ################# 156 | ## reset token ## 157 | ################# 158 | RESET_TOKEN_TIMEOUT_MS= 159 | 160 | ######################## 161 | ## change email token ## 162 | ######################## 163 | CHANGE_EMAIL_TOKEN_TIMEOUT_MS= 164 | CHANGE_EMAIL_LIMIT_MS= 165 | 166 | ################# 167 | ## api secrets ## 168 | ################# 169 | API_SECRETS= 170 | 171 | ##################### 172 | ## cache responses ## 173 | ##################### 174 | CACHE_RESPONSES= 175 | 176 | ##################### 177 | ## slack api token ## 178 | ##################### 179 | SLACK_API_TOKEN= 180 | -------------------------------------------------------------------------------- /template/.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /template/.gitignore: -------------------------------------------------------------------------------- 1 | gitignore -------------------------------------------------------------------------------- /template/.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.md,!test/snapshots/**/*.md,!test/**/snapshots/**/*.md,!locales/README.md": [ 3 | filenames => filenames.map(filename => `remark ${filename} -qfo`), 4 | 'git add' 5 | ], 6 | 'package.json': ['fixpack', 'git add'], 7 | '*.js': ['xo --fix', 'git add '] 8 | }; 9 | -------------------------------------------------------------------------------- /template/.pug-lintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 3 | disallowAttributeConcatentation: true, 4 | disallowAttributeInterpolation: true, 5 | disallowClassAttributeWithStaticValue: true, 6 | disallowDuplicateAttributes: true, 7 | disallowHtmlText: true, 8 | disallowIdAttributeWithStaticValue: true, 9 | disallowLegacyMixinCall: true, 10 | disallowMultipleLineBreaks: true, 11 | disallowStringConcatenation: 'aggressive', 12 | disallowTrailingSpaces: true, 13 | requireLineFeedAtFileEnd: true, 14 | requireLowerCaseTags: true, 15 | requireSpaceAfterCodeOperator: true, 16 | requireStrictEqualityOperators: true, 17 | validateDivTags: true, 18 | validateIndentation: 2, 19 | validateLineBreaks: 'LF', 20 | validateSelfClosingTags: true, 21 | validateTemplateString: true 22 | }; 23 | -------------------------------------------------------------------------------- /template/.remarkignore: -------------------------------------------------------------------------------- 1 | test/snapshots/**/*.md 2 | test/**/snapshots/**/*.md 3 | locales/README.md 4 | *-*.md 5 | -------------------------------------------------------------------------------- /template/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-recommended-scss"] 3 | } 4 | -------------------------------------------------------------------------------- /template/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | services: 5 | - mongodb 6 | - redis-server 7 | script: 8 | npm run test-coverage 9 | after_success: 10 | npm run coverage 11 | -------------------------------------------------------------------------------- /template/LICENSE: -------------------------------------------------------------------------------- 1 | All Rights Reserved. 2 | 3 | Copyright (c) <%= new Date().getUTCFullYear() %> <%= author %> <<%= email %>><% if (website) { %> (<%= website %>)<% } %> 4 | -------------------------------------------------------------------------------- /template/README: -------------------------------------------------------------------------------- 1 | # <%= name %> 2 | 3 | [![build status](https://img.shields.io/travis/com/<%- repo.replace('https://github.com/', '') %>.svg)](https://travis-ci.org/<%- repo.replace('https://github.com/', '') %>) 4 | [![code coverage](https://img.shields.io/codecov/c/github/<%= repo.replace('https://github.com/', '') %>.svg)](https://codecov.io/gh/<%= repo.replace('https://github.com/', '') %>) 5 | [![code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | [![made with lad](https://img.shields.io/badge/made_with-lad-95CC28.svg)](https://lad.js.org) 8 | 9 | > <%= description %> 10 | 11 | ## Table of Contents 12 | 13 | 14 | ## Install 15 | 16 | 17 | ## Usage 18 | 19 | 20 | ## Contributors 21 | 22 | 23 | ## License 24 | 25 | 26 | ## 27 | -------------------------------------------------------------------------------- /template/ansible-playbook.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { execSync } = require('child_process'); 4 | 5 | const parse = require('parse-git-config'); 6 | 7 | const env = path.join(__dirname, '.env.production'); 8 | 9 | if (!fs.existsSync(env)) 10 | throw new Error(`.env.production file missing: ${env}`); 11 | 12 | if (!fs.statSync(env).isFile()) 13 | throw new Error(`.env.production file missing: ${env}`); 14 | 15 | // this will populate process.env with 16 | // environment variables from dot env file 17 | require('@ladjs/env')({ 18 | path: env, 19 | defaults: path.join(__dirname, '.env.defaults'), 20 | schema: path.join(__dirname, '.env.schema') 21 | }); 22 | 23 | // set git config url 24 | process.env.GITHUB_REPO = parse.sync({ 25 | path: path.join(__dirname, '.git', 'config') 26 | })['remote "origin"'].url; 27 | 28 | if (!execSync('which ansible-playbook')) 29 | throw new Error('ansible-playbook is required to be installed on this os'); 30 | 31 | execSync(`ansible-playbook ${process.argv.slice(2).join(' ')}`); 32 | -------------------------------------------------------------------------------- /template/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory = hosts.yml 3 | [ssh_connection] 4 | pipelining = true 5 | -------------------------------------------------------------------------------- /template/ansible/playbooks/aws-credentials.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: "{{ hostlist }}" 3 | become: true 4 | become_user: root 5 | tasks: 6 | - name: 'create ~/.aws directory' 7 | file: 8 | path: '/home/deploy/.aws' 9 | state: directory 10 | mode: 0755 11 | owner: www-data 12 | group: www-data 13 | - name: 'copy aws credentials to ~/.aws/credentials on server' 14 | template: 15 | src: '{{ playbook_dir }}/templates/aws-credentials.j2' 16 | dest: /home/deploy/.aws/credentials 17 | owner: www-data 18 | group: www-data 19 | mode: 0644 20 | -------------------------------------------------------------------------------- /template/ansible/playbooks/bree.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - import_playbook: security.yml hostlist=bree 3 | - import_playbook: python.yml hostlist=bree 4 | - import_playbook: node.yml hostlist=bree 5 | - import_playbook: ssh-keys.yml hostlist=bree 6 | - hosts: bree 7 | become: true 8 | become_user: root 9 | roles: 10 | # https://github.com/holms/ansible-fqdn 11 | - role: fqdn 12 | tasks: 13 | # ufw 14 | - name: enable ufw 15 | ufw: 16 | state: enabled 17 | policy: deny 18 | direction: incoming 19 | - name: limit ufw ssh 20 | ufw: 21 | rule: limit 22 | port: 22 23 | proto: tcp 24 | - name: allow ssh 25 | ufw: 26 | rule: allow 27 | port: 22 28 | proto: tcp 29 | - name: reload ufw 30 | ufw: 31 | state: reloaded 32 | -------------------------------------------------------------------------------- /template/ansible/playbooks/certificates.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: http 3 | become: true 4 | become_user: root 5 | vars_prompt: 6 | - name: input_key 7 | prompt: 'Enter path to certificate private key file (e.g. /path/to/example.key)' 8 | private: false 9 | - name: input_cert 10 | prompt: 'Enter path to certificate full chain/certificate file (e.g. /path/to/example.crt)' 11 | private: false 12 | - name: input_bundle 13 | prompt: 'Optional: Leave blank or enter path to certificate CA bundle file (e.g. /path/to/example.ca-bundle)' 14 | private: false 15 | 16 | tasks: 17 | # key file 18 | - name: check if key file exists 19 | local_action: stat path={{ input_key }} 20 | become: false 21 | register: local_key_file 22 | - name: fail when local key file does not exist 23 | fail: 24 | msg: 'key file does not exist: {{ input_key }}' 25 | when: not local_key_file.stat.exists 26 | 27 | # cert file 28 | - name: check if cert file exists 29 | local_action: stat path={{ input_cert }} 30 | become: false 31 | register: local_cert_file 32 | 33 | - name: fail when local cert file does not exist 34 | fail: 35 | msg: 'cert file does not exist: {{ input_cert }}' 36 | when: not local_cert_file.stat.exists 37 | 38 | # bundle file 39 | - name: check if bundle file exists 40 | local_action: stat path={{ input_bundle }} 41 | register: local_bundle_file 42 | become: false 43 | when: (input_bundle is defined) and (input_bundle|length > 0) 44 | 45 | - name: fail when local bundle file does not exist 46 | fail: 47 | msg: 'bundle file does not exist: {{ input_bundle }}' 48 | when: (input_bundle is defined) and (input_bundle|length > 0) and (not local_bundle_file.stat.exists) 49 | 50 | # remote dir 51 | - name: check if remote dir exists 52 | stat: 53 | path: '/var/www/production' 54 | register: remote_dir 55 | 56 | - name: fail when remote dir does not exist 57 | fail: 58 | msg: pm2 dir not yet created 59 | when: not remote_dir.stat.exists or not remote_dir.stat.isdir 60 | 61 | # copy local key 62 | - name: copy local key file to server 63 | copy: 64 | src: '{{ input_key }}' 65 | dest: /var/www/production/.ssl-key 66 | owner: www-data 67 | group: www-data 68 | # https://chmodcommand.com/chmod-660/ 69 | mode: 0660 70 | 71 | # copy local cert 72 | - name: copy local cert file to server 73 | copy: 74 | src: '{{ input_cert }}' 75 | dest: /var/www/production/.ssl-cert 76 | owner: www-data 77 | group: www-data 78 | # https://chmodcommand.com/chmod-660/ 79 | mode: 0660 80 | 81 | # copy local bundle 82 | - name: copy local bundle file to server 83 | copy: 84 | src: '{{ input_bundle }}' 85 | dest: /var/www/production/.ssl-ca 86 | owner: www-data 87 | group: www-data 88 | # https://chmodcommand.com/chmod-660/ 89 | mode: 0660 90 | when: (input_bundle is defined) and (input_bundle|length > 0) 91 | -------------------------------------------------------------------------------- /template/ansible/playbooks/deployment-keys.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: http:bree 3 | become: true 4 | become_user: root 5 | tasks: 6 | - name: check if key exists 7 | stat: 8 | path: '/home/deploy/.ssh/id_rsa.pub' 9 | register: key_file 10 | - name: fetch key file to local dir 11 | fetch: 12 | src: '/home/deploy/.ssh/id_rsa.pub' 13 | dest: '{{ inventory_dir }}/deployment-keys/{{ inventory_hostname }}.pub' 14 | flat: true 15 | when: key_file.stat.exists 16 | -------------------------------------------------------------------------------- /template/ansible/playbooks/ecosystem.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | tasks: 4 | # 5 | # https://stackoverflow.com/a/24959173 6 | # 7 | - name: create ecosystem-web.json 8 | template: 9 | src: '{{ playbook_dir }}/templates/ecosystem-web.json.j2' 10 | dest: "{{ lookup('env', 'PWD')}}/ecosystem-web.json" 11 | delegate_to: localhost 12 | - name: create ecosystem-api.json 13 | template: 14 | src: '{{ playbook_dir }}/templates/ecosystem-api.json.j2' 15 | dest: "{{ lookup('env', 'PWD')}}/ecosystem-api.json" 16 | delegate_to: localhost 17 | - name: create ecosystem-bree.json 18 | template: 19 | src: '{{ playbook_dir }}/templates/ecosystem-bree.json.j2' 20 | dest: "{{ lookup('env', 'PWD')}}/ecosystem-bree.json" 21 | delegate_to: localhost 22 | -------------------------------------------------------------------------------- /template/ansible/playbooks/env.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - import_playbook: aws-credentials.yml hostlist=http:bree 3 | - hosts: http:bree 4 | become: true 5 | become_user: root 6 | vars: 7 | env_path: "{{ inventory_dir }}/.env.production" 8 | tasks: 9 | # check env file 10 | - name: check if env exists 11 | local_action: stat path={{ env_path }} 12 | become: false 13 | register: env_file 14 | - name: fail when env file does not exist 15 | fail: 16 | msg: .env.production does not exist 17 | when: not env_file.stat.exists 18 | 19 | # remote dir 20 | - name: check if remote dir exists 21 | stat: 22 | path: '/var/www/production' 23 | register: remote_dir 24 | 25 | - name: fail when remote dir does not exist 26 | fail: 27 | msg: pm2 dir not yet created 28 | when: not remote_dir.stat.exists or not remote_dir.stat.isdir 29 | 30 | # copy env file to server 31 | - name: copy env file to server 32 | copy: 33 | src: '{{ env_path }}' 34 | dest: /var/www/production/current/.env 35 | owner: www-data 36 | group: www-data 37 | # https://chmodcommand.com/chmod-660/ 38 | mode: 0660 39 | -------------------------------------------------------------------------------- /template/ansible/playbooks/gapp-creds.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: http:bree 3 | become: true 4 | become_user: root 5 | vars_prompt: 6 | - name: input_profile 7 | prompt: 'Enter path to Google application credentials profile file (used for translation with `mandarin`) (e.g. /path/to/client-profile.json)' 8 | private: false 9 | 10 | tasks: 11 | # profile file 12 | - name: check if profile file exists 13 | local_action: stat path={{ input_profile }} 14 | become: false 15 | register: local_profile_file 16 | - name: fail when local profile file does not exist 17 | fail: 18 | msg: 'profile file does not exist: {{ input_profile }}' 19 | when: not local_profile_file.stat.exists 20 | 21 | # remote dir 22 | - name: check if remote dir exists 23 | stat: 24 | path: '/var/www/production' 25 | register: remote_dir 26 | 27 | - name: fail when remote dir does not exist 28 | fail: 29 | msg: pm2 dir not yet created 30 | when: not remote_dir.stat.exists or not remote_dir.stat.isdir 31 | 32 | # copy local profile 33 | - name: copy local profile file to server 34 | copy: 35 | src: '{{ input_profile }}' 36 | dest: /var/www/production/.gapps-creds.json 37 | owner: www-data 38 | group: www-data 39 | # https://chmodcommand.com/chmod-660/ 40 | mode: 0660 41 | -------------------------------------------------------------------------------- /template/ansible/playbooks/http.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: web 3 | become: true 4 | become_user: root 5 | tasks: 6 | - name: set hostname 7 | hostname: 8 | name: "{{ lookup('env', 'WEB_HOST') }}" 9 | # TODO: deprecate this in Jan 1st 2021 10 | - name: allow legacy api 11 | ufw: 12 | rule: allow 13 | port: 4000 14 | proto: tcp 15 | roles: 16 | # https://github.com/holms/ansible-fqdn 17 | - role: fqdn 18 | fqdn: "{{ lookup('env', 'WEB_HOST') }}" 19 | - hosts: api 20 | become: true 21 | become_user: root 22 | tasks: 23 | - name: set hostname 24 | hostname: 25 | name: "{{ lookup('env', 'API_HOST') }}" 26 | roles: 27 | # https://github.com/holms/ansible-fqdn 28 | - role: fqdn 29 | fqdn: "{{ lookup('env', 'API_HOST') }}" 30 | - import_playbook: security.yml hostlist="http" 31 | - import_playbook: python.yml hostlist="http" 32 | - import_playbook: node.yml hostlist="http" 33 | - import_playbook: ssh-keys.yml hostlist="http" 34 | - hosts: web:api 35 | become: true 36 | become_user: root 37 | # this was already defined in the ufw role 38 | # https://github.com/Oefenweb/ansible-ufw/blob/master/handlers/main.yml 39 | handlers: 40 | - name: reload ufw 41 | ufw: 42 | state: reloaded 43 | tasks: 44 | # ufw 45 | - name: enable ufw 46 | ufw: 47 | state: enabled 48 | policy: deny 49 | direction: incoming 50 | - name: limit ufw ssh 51 | ufw: 52 | rule: limit 53 | port: 22 54 | proto: tcp 55 | - name: set UFW default forward policy to ACCEPT 56 | lineinfile: 57 | dest: /etc/default/ufw 58 | line: DEFAULT_FORWARD_POLICY="ACCEPT" 59 | regexp: "^DEFAULT_FORWARD_POLICY\\=" 60 | - name: allow ssh 61 | ufw: 62 | rule: allow 63 | port: 22 64 | proto: tcp 65 | - name: allow http 66 | ufw: 67 | rule: allow 68 | port: 80 69 | proto: tcp 70 | - name: allow https 71 | ufw: 72 | rule: allow 73 | port: 443 74 | proto: tcp 75 | - name: allow http forwarder 76 | ufw: 77 | rule: allow 78 | port: "{{ lookup('env', 'PROXY_PORT') }}" 79 | proto: tcp 80 | - name: allow https forwarder 81 | ufw: 82 | rule: allow 83 | port: "{{ lookup('env', 'HTTP_PORT') }}" 84 | proto: tcp 85 | - name: reload ufw 86 | ufw: 87 | state: reloaded 88 | # 89 | # modify ufw setup 90 | # https://github.com/Oefenweb/ansible-ufw/issues/21 91 | # 92 | - name: 'update ufw before.rules until #21 is resolved' 93 | template: 94 | src: '{{ playbook_dir }}/templates/before.rules.j2' 95 | dest: /etc/ufw/before.rules 96 | owner: root 97 | group: root 98 | mode: 0644 99 | notify: reload ufw 100 | -------------------------------------------------------------------------------- /template/ansible/playbooks/mongo.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - import_playbook: security.yml hostlist=mongo 3 | - hosts: mongo 4 | become: true 5 | become_user: root 6 | vars: 7 | # https://github.com/UnderGreen/ansible-role-mongodb 8 | mongodb_version: "4.2" 9 | mongodb_pymongo_from_pip: true 10 | mongodb_daemon_name: mongod 11 | mongodb_net_port: "{{ lookup('env', 'MONGO_PORT') }}" 12 | mongodb_security_authorization: 'enabled' 13 | mongodb_user_admin_name: admin_user 14 | mongodb_user_admin_password: "{{ lookup('env', 'MONGO_PASS') }}" 15 | mongodb_root_admin_name: "{{ lookup('env', 'MONGO_USER') }}" 16 | mongodb_root_admin_password: "{{ lookup('env', 'MONGO_PASS') }}" 17 | mongodb_root_backup_name: admin_backup 18 | mongodb_root_backup_password: "{{ lookup('env', 'MONGO_PASS') }}" 19 | mongodb_net_bindip: "127.0.0.1,{{ lookup('env', 'MONGO_HOST') }}" 20 | mongodb_security_javascript_enabled: true 21 | mongodb_manage_service: true 22 | # this was already defined in the mongo role 23 | # https://github.com/UnderGreen/ansible-role-mongodb/blob/master/handlers/main.yml 24 | handlers: 25 | - name: mongodb restart 26 | service: 27 | name: "{{ mongodb_daemon_name }}" 28 | state: restarted 29 | when: mongodb_manage_service | bool 30 | roles: 31 | # https://github.com/holms/ansible-fqdn 32 | - role: fqdn 33 | # https://github.com/UnderGreen/ansible-role-mongodb 34 | - role: mongo 35 | tasks: 36 | # security 37 | - name: increase nproc and nofile for mongodb 38 | copy: 39 | src: '{{ playbook_dir }}/templates/security-limits.d-mongod.conf' 40 | dest: /etc/security/limits.d/mongod.conf 41 | owner: root 42 | group: root 43 | mode: 0644 44 | notify: mongodb restart 45 | # ufw 46 | - name: enable ufw 47 | ufw: 48 | state: enabled 49 | policy: deny 50 | direction: incoming 51 | - name: limit ufw ssh 52 | ufw: 53 | rule: limit 54 | port: 22 55 | proto: tcp 56 | - name: allow ssh 57 | ufw: 58 | rule: allow 59 | port: 22 60 | proto: tcp 61 | - name: allow server access 62 | ufw: 63 | rule: allow 64 | port: "{{ lookup('env', 'MONGO_PORT') }}" 65 | src: "{{ hostvars[item].ansible_host }}" 66 | proto: tcp 67 | with_items: "{{ groups['web'] + groups['api'] + groups['bree'] }}" 68 | - name: reload ufw 69 | ufw: 70 | state: reloaded 71 | -------------------------------------------------------------------------------- /template/ansible/playbooks/node.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: "{{ hostlist }}" 3 | become: true 4 | become_user: root 5 | vars: 6 | # node.js 7 | nodejs_version: 'nodejs-v12x' 8 | nodejs_npm_packages: 9 | - name: pm2 10 | roles: 11 | # https://github.com/Oefenweb/ansible-nodejs 12 | - role: nodejs 13 | # https://github.com/Oefenweb/ansible-yarn 14 | - role: yarn 15 | tasks: 16 | # install fonts 17 | - name: accept fonts license 18 | shell: 19 | cmd: 'echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | sudo debconf-set-selections' 20 | - name: install fonts 21 | apt: 22 | name: 23 | - ttf-mscorefonts-installer 24 | - libfontconfig 25 | update_cache: true 26 | # configure pm2 27 | - name: create pm2 directory 28 | file: 29 | path: /var/www 30 | state: directory 31 | owner: www-data 32 | group: www-data 33 | # https://chmodcommand.com/chmod-770/ 34 | mode: 0770 35 | recurse: true 36 | - name: install pm2-logrotate 37 | shell: 38 | cmd: 'pm2 install pm2-logrotate' 39 | - name: check that pm2 startup script exists 40 | stat: 41 | path: /etc/systemd/system/pm2-deploy.service 42 | register: pm2_startup_result 43 | - name: install pm2 startup script 44 | command: 'pm2 startup ubuntu -u deploy --hp /home/deploy' 45 | when: not pm2_startup_result.stat.exists 46 | -------------------------------------------------------------------------------- /template/ansible/playbooks/python.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: "{{ hostlist }}" 3 | become: true 4 | become_user: root 5 | vars: 6 | # python 3 7 | pip_python_version: 3 8 | roles: 9 | # https://github.com/Oefenweb/ansible-pip 10 | - role: pip 11 | tasks: 12 | # 13 | # install pip3 deps 14 | # https://github.com/Oefenweb/ansible-pip/issues/10 15 | # 16 | - name: install pyspf 17 | pip: 18 | name: pyspf 19 | - name: install dnspython 20 | pip: 21 | name: dnspython 22 | - name: install dkimpy 23 | pip: 24 | name: dkimpy 25 | -------------------------------------------------------------------------------- /template/ansible/playbooks/redis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - import_playbook: security.yml hostlist=redis 3 | - hosts: redis 4 | become: true 5 | become_user: root 6 | roles: 7 | # https://github.com/Oefenweb/ansible-redis 8 | - role: redis 9 | redis_port: "{{ lookup('env', 'REDIS_PORT') }}" 10 | redis_tcp_keepalive: 60 11 | redis_maxmemory: '2gb' 12 | redis_maxmemory_policy: 'allkeys-lru' 13 | redis_requirepass: "{{ lookup('env', 'REDIS_PASSWORD') }}" 14 | redis_bind: 15 | - 127.0.0.1 16 | - "{{ lookup('env', 'REDIS_HOST') }}" 17 | # https://github.com/holms/ansible-fqdn 18 | - role: fqdn 19 | tasks: 20 | # ufw 21 | - name: enable ufw 22 | ufw: 23 | state: enabled 24 | policy: deny 25 | direction: incoming 26 | - name: limit ufw ssh 27 | ufw: 28 | rule: limit 29 | port: 22 30 | proto: tcp 31 | - name: allow ssh 32 | ufw: 33 | rule: allow 34 | port: 22 35 | proto: tcp 36 | - name: allow server access 37 | ufw: 38 | rule: allow 39 | port: "{{ lookup('env', 'REDIS_PORT') }}" 40 | src: "{{ hostvars[item].ansible_host }}" 41 | proto: tcp 42 | with_items: "{{ groups['web'] + groups['api'] + groups['bree'] }}" 43 | - name: reload ufw 44 | ufw: 45 | state: reloaded 46 | -------------------------------------------------------------------------------- /template/ansible/playbooks/ssh-keys.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: "{{ hostlist }}" 3 | become: true 4 | become_user: root 5 | vars: 6 | # ssh-keys 7 | ssh_keys_known_hosts: 8 | - hostname: github.com 9 | enctype: ssh-rsa 10 | fingerprint: 'AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==' 11 | roles: 12 | # https://github.com/Oefenweb/ansible-ssh-keys 13 | - role: ssh-keys 14 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/aws-credentials.j2: -------------------------------------------------------------------------------- 1 | [default] 2 | aws_access_key_id = {{ lookup('env', 'AWS_ACCESS_KEY_ID') }} 3 | aws_secret_access_key = {{ lookup('env', 'AWS_SECRET_ACCESS_KEY') }} 4 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/before.rules.j2: -------------------------------------------------------------------------------- 1 | # 2 | # rules.before 3 | # 4 | # Rules that should be run before the ufw command line added rules. Custom 5 | # rules should be added to one of these chains: 6 | # ufw-before-input 7 | # ufw-before-output 8 | # ufw-before-forward 9 | # 10 | 11 | *nat 12 | :PREROUTING ACCEPT [0:0] 13 | -A PREROUTING -p tcp -m tcp --dport 80 -j REDIRECT --to-port {{ lookup('env', 'PROXY_PORT') }} 14 | -A PREROUTING -p tcp -m tcp --dport 443 -j REDIRECT --to-port {{ lookup('env', 'HTTP_PORT') }} 15 | COMMIT 16 | 17 | # Don't delete these required lines, otherwise there will be errors 18 | *filter 19 | :ufw-before-input - [0:0] 20 | :ufw-before-output - [0:0] 21 | :ufw-before-forward - [0:0] 22 | :ufw-not-local - [0:0] 23 | # End required lines 24 | 25 | 26 | # allow all on loopback 27 | -A ufw-before-input -i lo -j ACCEPT 28 | -A ufw-before-output -o lo -j ACCEPT 29 | 30 | # quickly process packets for which we already have a connection 31 | -A ufw-before-input -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 32 | -A ufw-before-output -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 33 | -A ufw-before-forward -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 34 | 35 | # drop INVALID packets (logs these in loglevel medium and higher) 36 | -A ufw-before-input -m conntrack --ctstate INVALID -j ufw-logging-deny 37 | -A ufw-before-input -m conntrack --ctstate INVALID -j DROP 38 | 39 | # ok icmp codes for INPUT 40 | -A ufw-before-input -p icmp --icmp-type destination-unreachable -j ACCEPT 41 | -A ufw-before-input -p icmp --icmp-type time-exceeded -j ACCEPT 42 | -A ufw-before-input -p icmp --icmp-type parameter-problem -j ACCEPT 43 | -A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT 44 | 45 | # ok icmp code for FORWARD 46 | -A ufw-before-forward -p icmp --icmp-type destination-unreachable -j ACCEPT 47 | -A ufw-before-forward -p icmp --icmp-type time-exceeded -j ACCEPT 48 | -A ufw-before-forward -p icmp --icmp-type parameter-problem -j ACCEPT 49 | -A ufw-before-forward -p icmp --icmp-type echo-request -j ACCEPT 50 | 51 | # allow dhcp client to work 52 | -A ufw-before-input -p udp --sport 67 --dport 68 -j ACCEPT 53 | 54 | # 55 | # ufw-not-local 56 | # 57 | -A ufw-before-input -j ufw-not-local 58 | 59 | # if LOCAL, RETURN 60 | -A ufw-not-local -m addrtype --dst-type LOCAL -j RETURN 61 | 62 | # if MULTICAST, RETURN 63 | -A ufw-not-local -m addrtype --dst-type MULTICAST -j RETURN 64 | 65 | # if BROADCAST, RETURN 66 | -A ufw-not-local -m addrtype --dst-type BROADCAST -j RETURN 67 | 68 | # all other non-local packets are dropped 69 | -A ufw-not-local -m limit --limit 3/min --limit-burst 10 -j ufw-logging-deny 70 | -A ufw-not-local -j DROP 71 | 72 | # allow MULTICAST mDNS for service discovery (be sure the MULTICAST line above 73 | # is uncommented) 74 | -A ufw-before-input -p udp -d 224.0.0.251 --dport 5353 -j ACCEPT 75 | 76 | # allow MULTICAST UPnP for service discovery (be sure the MULTICAST line above 77 | # is uncommented) 78 | -A ufw-before-input -p udp -d 239.255.255.250 --dport 1900 -j ACCEPT 79 | 80 | # don't delete the 'COMMIT' line or these rules won't be processed 81 | COMMIT 82 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/ecosystem-api.json.j2: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "api", 5 | "script": "api.js", 6 | "exec_mode": "cluster", 7 | "wait_ready": true, 8 | "instances": "max", 9 | "env_production": { 10 | "NODE_ENV": "production" 11 | } 12 | }, 13 | { 14 | "name": "proxy", 15 | "script": "proxy.js", 16 | "exec_mode": "cluster", 17 | "wait_ready": true, 18 | "instances": "max", 19 | "env_production": { 20 | "NODE_ENV": "production" 21 | } 22 | } 23 | ], 24 | "deploy": { 25 | "production": { 26 | "user": "deploy", 27 | "host": [{% for host in groups['api'] %}"{{ hostvars[host].ansible_host }}"{% if not loop.last %}, {% endif %}{% endfor %}], 28 | "ref": "origin/master", 29 | "repo": "{{ lookup('env', 'GITHUB_REPO') }}", 30 | "path": "/var/www/production", 31 | "pre-deploy": "git reset --hard", 32 | "post-deploy": "npm install && NODE_ENV=production npm start build && pm2 startOrGracefulReload ecosystem-api.json --env production --update-env" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/ecosystem-bree.json.j2: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "bree", 5 | "script": "bree.js", 6 | "exec_mode": "fork", 7 | "wait_ready": true, 8 | "instances": "1", 9 | "env_production": { 10 | "NODE_ENV": "production" 11 | } 12 | } 13 | ], 14 | "deploy": { 15 | "production": { 16 | "user": "deploy", 17 | "host": [{% for host in groups['bree'] %}"{{ hostvars[host].ansible_host }}"{% if not loop.last %}, {% endif %}{% endfor %}], 18 | "ref": "origin/master", 19 | "repo": "{{ lookup('env', 'GITHUB_REPO') }}", 20 | "path": "/var/www/production", 21 | "pre-deploy": "git reset --hard", 22 | "post-deploy": "npm install && NODE_ENV=production npm start build && pm2 startOrGracefulReload ecosystem-bree.json --env production --update-env" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/ecosystem-web.json.j2: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "web", 5 | "script": "web.js", 6 | "exec_mode": "cluster", 7 | "wait_ready": true, 8 | "instances": "max", 9 | "env_production": { 10 | "NODE_ENV": "production" 11 | } 12 | }, 13 | { 14 | "name": "proxy", 15 | "script": "proxy.js", 16 | "exec_mode": "cluster", 17 | "wait_ready": true, 18 | "instances": "max", 19 | "env_production": { 20 | "NODE_ENV": "production" 21 | } 22 | }, 23 | { 24 | "name": "api", 25 | "script": "api.js", 26 | "exec_mode": "fork", 27 | "wait_ready": true, 28 | "instances": "1", 29 | "env_production": { 30 | "NODE_ENV": "production", 31 | "API_PORT": "4000" 32 | } 33 | } 34 | ], 35 | "deploy": { 36 | "production": { 37 | "user": "deploy", 38 | "host": [{% for host in groups['web'] %}"{{ hostvars[host].ansible_host }}"{% if not loop.last %}, {% endif %}{% endfor %}], 39 | "ref": "origin/master", 40 | "repo": "{{ lookup('env', 'GITHUB_REPO') }}", 41 | "path": "/var/www/production", 42 | "pre-deploy": "git reset --hard", 43 | "post-deploy": "npm install && NODE_ENV=production npm start build && pm2 startOrGracefulReload ecosystem-web.json --env production --update-env" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/env: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | ## Configure the section below replacing "TODO" values ## 3 | ######################################################### 4 | 5 | # 6 | # sign up and set up Postmark and add your API token here 7 | # https://postmarkapp.com 8 | # 9 | POSTMARK_API_TOKEN="TODO" 10 | 11 | # set web server hostname 12 | # (e.g. example.com) 13 | WEB_HOST=TODO 14 | 15 | # set api server hostname 16 | # (e.g. api.example.com) 17 | API_HOST=TODO 18 | 19 | # 20 | # this is the IP address of your redis server 21 | # (you defined this above in `ansible/hosts` file) 22 | # 23 | REDIS_HOST=TODO 24 | 25 | # 26 | # generate a password: 27 | # openssl rand 60 | openssl base64 -A | pbcopy 28 | # 29 | REDIS_PASSWORD="TODO" 30 | 31 | # set mongo server hostname 32 | # (e.g. 1.2.3.4) 33 | MONGO_HOST=TODO 34 | 35 | # 36 | # generate password for mongo 37 | # 38 | # https://docs.mongodb.com/manual/reference/connection-string/ 39 | # https://github.com/ladjs/mongoose/issues/10 40 | # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-linux-openssl.html 41 | # 42 | # openssl rand 60 | openssl base64 -A | tr -- '@:/%' '-_~$' | pbcopy 43 | # 44 | MONGO_PASS="TODO" 45 | 46 | # 47 | # aws credentials 48 | # 49 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html 50 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html 51 | # https://docs.aws.amazon.com/en_pv/sdk-for-javascript/v2/developer-guide/loading-node-credentials-json-file.html 52 | # 53 | # 54 | AWS_ACCESS_KEY_ID= 55 | AWS_SECRET_ACCESS_KEY= 56 | AWS_S3_BUCKET=TODO 57 | AWS_CLOUDFRONT_DOMAIN=TODO 58 | AWS_CLOUDFRONT_DISTRIBUTION_ID=TODO 59 | 60 | ######################################################################## 61 | ## Leave the section below alone unless you know what you're doing :) ## 62 | ######################################################################## 63 | NODE_ENV=production 64 | 65 | SEND_EMAIL=true 66 | 67 | AWS_PROFILE=default 68 | 69 | CACHE_RESPONSES=true 70 | 71 | PROXY_PORT=8080 72 | 73 | HTTP_PORT=8443 74 | HTTP_PROTOCOL=https 75 | 76 | MONGO_USER=admin_root 77 | MONGO_NAME=production 78 | MONGO_PORT=27017 79 | MONGO_URI="mongodb://{{MONGO_USER}}:{{MONGO_PASS}}@{{MONGO_HOST}}:{{MONGO_PORT}}/{{MONGO_NAME}}?authSource=admin" 80 | 81 | #GOOGLE_APPLICATION_CREDENTIALS="/var/www/production/.gapp-creds.json" 82 | 83 | SSL_KEY_PATH="/var/www/production/.ssl-key" 84 | SSL_CERT_PATH="/var/www/production/.ssl-cert" 85 | SSL_CA_PATH="/var/www/production/.ssl-ca" 86 | 87 | WEB_PROTOCOL={{HTTP_PROTOCOL}} 88 | WEB_PORT={{HTTP_PORT}} 89 | WEB_URL={{WEB_PROTOCOL}}://{{WEB_HOST}} 90 | WEB_MONGO_URI={{{MONGO_URI}}} 91 | 92 | WEB_REDIS_HOST={{REDIS_HOST}} 93 | WEB_REDIS_PASSWORD={{REDIS_PASSWORD}} 94 | 95 | WEB_SSL_KEY_PATH={{{SSL_KEY_PATH}}} 96 | WEB_SSL_CERT_PATH={{{SSL_CERT_PATH}}} 97 | WEB_SSL_CA_PATH={{{SSL_CA_PATH}}} 98 | 99 | API_PROTOCOL={{HTTP_PROTOCOL}} 100 | API_PORT={{HTTP_PORT}} 101 | API_URL={{API_PROTOCOL}}://{{API_HOST}} 102 | API_MONGO_URI={{{MONGO_URI}}} 103 | 104 | API_REDIS_HOST={{REDIS_HOST}} 105 | API_REDIS_PASSWORD={{REDIS_PASSWORD}} 106 | 107 | API_SSL_KEY_PATH={{{SSL_KEY_PATH}}} 108 | API_SSL_CERT_PATH={{{SSL_CERT_PATH}}} 109 | API_SSL_CA_PATH={{{SSL_CA_PATH}}} 110 | 111 | BREE_MONGO_URI={{{MONGO_URI}}} 112 | BREE_REDIS_HOST={{REDIS_HOST}} 113 | BREE_REDIS_PASSWORD={{REDIS_PASSWORD}} 114 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/hosts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | all: 3 | vars: 4 | # host_key_checking: false 5 | ansible_python_interpreter: /usr/bin/python3 6 | ansible_user: devops 7 | children: 8 | http: 9 | children: 10 | web: 11 | hosts: 12 | web-1-do-nyc3-us.lad.sh: 13 | ansible_host: 0.0.0.0 14 | api: 15 | hosts: 16 | api-1-do-nyc3-us.api.lad.sh: 17 | ansible_host: 0.0.0.0 18 | bree: 19 | hosts: 20 | bree-1-do-nyc3-us.lad.sh: 21 | ansible_host: 0.0.0.0 22 | redis: 23 | hosts: 24 | redis-master-do-nyc3.lad.sh: 25 | ansible_host: 0.0.0.0 26 | mongo: 27 | hosts: 28 | mongo-primary-do-nyc3-us.lad.sh: 29 | ansible_host: 0.0.0.0 30 | -------------------------------------------------------------------------------- /template/ansible/playbooks/templates/security-limits.d-mongod.conf: -------------------------------------------------------------------------------- 1 | mongod soft nproc 64000 2 | mongod hard nproc 64000 3 | mongod soft nofile 64000 4 | mongod hard nofile 64000 5 | -------------------------------------------------------------------------------- /template/ansible/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: swapfile 3 | src: oefenweb.swapfile 4 | version: v2.0.28 5 | - name: dns 6 | src: oefenweb.dns 7 | version: v1.1.26 8 | - name: ntp 9 | src: oefenweb.ntp 10 | version: v1.1.28 11 | - name: fail2ban 12 | src: oefenweb.fail2ban 13 | version: v3.3.13 14 | - name: timezone 15 | src: oefenweb.timezone 16 | version: v1.0.42 17 | - name: unattended-upgrades 18 | src: jnv.unattended-upgrades 19 | version: v1.8.0 20 | - name: nodejs 21 | src: oefenweb.nodejs 22 | version: v6.0.3 23 | - name: yarn 24 | src: oefenweb.yarn 25 | version: v1.0.42 26 | - name: pip 27 | src: oefenweb.pip 28 | version: v2.1.1 29 | - name: sysctl 30 | src: oefenweb.sysctl 31 | version: v1.0.36 32 | - name: redis 33 | src: oefenweb.redis 34 | version: v3.0.29 35 | - name: mongo 36 | src: https://github.com/niftylettuce/ansible-role-mongodb.git 37 | version: master 38 | # src: undergreen.mongodb 39 | # version: v2.6.1 40 | - name: ssh-keys 41 | src: oefenweb.ssh_keys 42 | version: v2.1.4 43 | - name: fqdn 44 | src: holms.fqdn 45 | version: '1.1' 46 | - name: mongo-shell 47 | src: enix.mongodb 48 | version: '1.1.1' 49 | -------------------------------------------------------------------------------- /template/api.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('./config/env'); 3 | 4 | const API = require('@ladjs/api'); 5 | const Graceful = require('@ladjs/graceful'); 6 | const Mongoose = require('@ladjs/mongoose'); 7 | const ip = require('ip'); 8 | 9 | const logger = require('./helpers/logger'); 10 | const apiConfig = require('./config/api'); 11 | 12 | const api = new API(apiConfig); 13 | 14 | if (!module.parent) { 15 | const mongoose = new Mongoose({ ...api.config.mongoose, logger }); 16 | 17 | const graceful = new Graceful({ 18 | mongooses: [mongoose], 19 | servers: [api], 20 | redisClients: [api.client], 21 | logger 22 | }); 23 | graceful.listen(); 24 | 25 | (async () => { 26 | try { 27 | await api.listen(api.config.port); 28 | if (process.send) process.send('ready'); 29 | const { port } = api.server.address(); 30 | logger.info( 31 | `Lad API server listening on ${port} (LAN: ${ip.address()}:${port})` 32 | ); 33 | await mongoose.connect(); 34 | } catch (err) { 35 | logger.error(err); 36 | // eslint-disable-next-line unicorn/no-process-exit 37 | process.exit(1); 38 | } 39 | })(); 40 | } 41 | 42 | module.exports = api; 43 | -------------------------------------------------------------------------------- /template/app/controllers/api/index.js: -------------------------------------------------------------------------------- 1 | const v1 = require('./v1'); 2 | 3 | module.exports = { v1 }; 4 | -------------------------------------------------------------------------------- /template/app/controllers/api/v1/index.js: -------------------------------------------------------------------------------- 1 | const log = require('./log'); 2 | const users = require('./users'); 3 | 4 | module.exports = { log, users }; 5 | -------------------------------------------------------------------------------- /template/app/controllers/api/v1/log.js: -------------------------------------------------------------------------------- 1 | const auth = require('basic-auth'); 2 | const parseLogs = require('parse-logs'); 3 | 4 | const policies = require('../../../../helpers/policies'); 5 | 6 | async function parseLog(ctx) { 7 | ctx.body = 'OK'; 8 | try { 9 | const log = parseLogs(ctx.request); 10 | ctx.logger[log.meta.level](log.message, log.meta); 11 | } catch (err) { 12 | ctx.logger.error(err); 13 | } 14 | } 15 | 16 | async function checkToken(ctx, next) { 17 | try { 18 | await policies.ensureApiToken(ctx, next); 19 | } catch (err) { 20 | const credentials = auth(ctx.req); 21 | if (typeof credentials !== 'undefined') ctx.logger.error(err); 22 | return next(); 23 | } 24 | } 25 | 26 | module.exports = { parseLog, checkToken }; 27 | -------------------------------------------------------------------------------- /template/app/controllers/api/v1/users.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const _ = require('lodash'); 3 | const isSANB = require('is-string-and-not-blank'); 4 | 5 | const sendVerificationEmail = require('../../../../helpers/send-verification-email'); 6 | const config = require('../../../../config'); 7 | const { Users } = require('../../../models'); 8 | 9 | async function create(ctx) { 10 | const { body } = ctx.request; 11 | 12 | if (!isSANB(body.password)) 13 | return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_PASSWORD'))); 14 | 15 | // register the user 16 | const query = { email: body.email, locale: ctx.locale }; 17 | query[config.userFields.hasVerifiedEmail] = false; 18 | query[config.userFields.hasSetPassword] = true; 19 | query[config.userFields.pendingRecovery] = false; 20 | query[config.lastLocaleField] = ctx.locale; 21 | 22 | ctx.state.user = await Users.register(query, body.password); 23 | 24 | // send a verification email 25 | ctx.state.user = await sendVerificationEmail(ctx); 26 | 27 | // send the response 28 | const object = ctx.state.user.toObject(); 29 | object[config.userFields.apiToken] = 30 | ctx.state.user[config.userFields.apiToken]; 31 | ctx.body = object; 32 | } 33 | 34 | async function retrieve(ctx) { 35 | // since we already have the user object 36 | // just send it over as a response 37 | ctx.body = ctx.state.user.toObject(); 38 | } 39 | 40 | async function update(ctx) { 41 | const { body } = ctx.request; 42 | 43 | if (_.isString(body.email)) ctx.state.user.email = body.email; 44 | 45 | if (_.isString(body[config.passport.fields.givenName])) 46 | ctx.state.user[config.passport.fields.givenName] = 47 | body[config.passport.fields.givenName]; 48 | 49 | if (_.isString(body[config.passport.fields.familyName])) 50 | ctx.state.user[config.passport.fields.familyName] = 51 | body[config.passport.fields.familyName]; 52 | 53 | if (_.isString(body.avatar_url)) ctx.state.user.avatar_url = body.avatar_url; 54 | 55 | ctx.state.user = await ctx.state.user.save(); 56 | ctx.body = ctx.state.user.toObject(); 57 | } 58 | 59 | module.exports = { create, retrieve, update }; 60 | -------------------------------------------------------------------------------- /template/app/controllers/index.js: -------------------------------------------------------------------------------- 1 | const web = require('./web'); 2 | const api = require('./api'); 3 | 4 | module.exports = { web, api }; 5 | -------------------------------------------------------------------------------- /template/app/controllers/web/admin/index.js: -------------------------------------------------------------------------------- 1 | const users = require('./users'); 2 | 3 | module.exports = { users }; 4 | -------------------------------------------------------------------------------- /template/app/controllers/web/admin/users.js: -------------------------------------------------------------------------------- 1 | const paginate = require('koa-ctx-paginate'); 2 | const { boolean } = require('boolean'); 3 | 4 | const { Users } = require('../../../models'); 5 | const config = require('../../../../config'); 6 | 7 | async function list(ctx) { 8 | const [users, itemCount] = await Promise.all([ 9 | Users.find({}) 10 | .limit(ctx.query.limit) 11 | .skip(ctx.paginate.skip) 12 | .lean() 13 | .sort('-created_at') 14 | .exec(), 15 | Users.countDocuments({}) 16 | ]); 17 | 18 | const pageCount = Math.ceil(itemCount / ctx.query.limit); 19 | 20 | return ctx.render('admin/users', { 21 | users, 22 | pageCount, 23 | itemCount, 24 | pages: paginate.getArrayPages(ctx)(3, pageCount, ctx.query.page) 25 | }); 26 | } 27 | 28 | async function retrieve(ctx) { 29 | ctx.state.result = await Users.findById(ctx.params.id); 30 | if (!ctx.state.result) throw ctx.translateError('INVALID_USER'); 31 | return ctx.render('admin/users/retrieve'); 32 | } 33 | 34 | async function update(ctx) { 35 | const user = await Users.findById(ctx.params.id); 36 | if (!user) throw ctx.translateError('INVALID_USER'); 37 | const { body } = ctx.request; 38 | 39 | user[config.passport.fields.givenName] = 40 | body[config.passport.fields.givenName]; 41 | user[config.passport.fields.familyName] = 42 | body[config.passport.fields.familyName]; 43 | user[config.passport.fields.otpEnabled] = 44 | body[config.passport.fields.otpEnabled]; 45 | user.email = body.email; 46 | user.group = body.group; 47 | 48 | if (boolean(!body[config.passport.fields.otpEnabled])) 49 | user[config.userFields.pendingRecovery] = false; 50 | 51 | await user.save(); 52 | 53 | if (user.id === ctx.state.user.id) await ctx.login(user); 54 | 55 | ctx.flash('custom', { 56 | title: ctx.request.t('Success'), 57 | text: ctx.translate('REQUEST_OK'), 58 | type: 'success', 59 | toast: true, 60 | showConfirmButton: false, 61 | timer: 3000, 62 | position: 'top' 63 | }); 64 | 65 | if (ctx.accepts('html')) ctx.redirect('back'); 66 | else ctx.body = { reloadPage: true }; 67 | } 68 | 69 | async function remove(ctx) { 70 | const user = await Users.findById(ctx.params.id); 71 | if (!user) throw ctx.translateError('INVALID_USER'); 72 | await user.remove(); 73 | ctx.flash('custom', { 74 | title: ctx.request.t('Success'), 75 | text: ctx.translate('REQUEST_OK'), 76 | type: 'success', 77 | toast: true, 78 | showConfirmButton: false, 79 | timer: 3000, 80 | position: 'top' 81 | }); 82 | 83 | if (ctx.accepts('html')) ctx.redirect('back'); 84 | else ctx.body = { reloadPage: true }; 85 | } 86 | 87 | async function login(ctx) { 88 | const user = await Users.findById(ctx.params.id); 89 | if (!user) throw ctx.translateError('INVALID_USER'); 90 | 91 | ctx.logout(); 92 | 93 | await ctx.login(user); 94 | 95 | ctx.flash('custom', { 96 | title: ctx.request.t('Success'), 97 | text: ctx.translate('REQUEST_OK'), 98 | type: 'success', 99 | toast: true, 100 | showConfirmButton: false, 101 | timer: 3000, 102 | position: 'top' 103 | }); 104 | 105 | if (ctx.accepts('html')) ctx.redirect('/'); 106 | else ctx.body = { redirectTo: '/' }; 107 | } 108 | 109 | module.exports = { list, retrieve, update, remove, login }; 110 | -------------------------------------------------------------------------------- /template/app/controllers/web/index.js: -------------------------------------------------------------------------------- 1 | const { extname } = require('path'); 2 | 3 | const _ = require('lodash'); 4 | const humanize = require('humanize-string'); 5 | const titleize = require('titleize'); 6 | 7 | const config = require('../../../config'); 8 | 9 | const admin = require('./admin'); 10 | const auth = require('./auth'); 11 | const myAccount = require('./my-account'); 12 | const support = require('./support'); 13 | const otp = require('./otp'); 14 | const report = require('./report'); 15 | 16 | function breadcrumbs(ctx, next) { 17 | // return early if its not a pure path (e.g. ignore static assets) 18 | // and also return early if it's not a GET request 19 | // and also return early if it's an XHR request 20 | if (ctx.method !== 'GET' || extname(ctx.path) !== '') return next(); 21 | 22 | const breadcrumbs = _.compact(ctx.path.split('/')).slice(1); 23 | ctx.state.breadcrumbs = breadcrumbs; 24 | 25 | // only override the title if the match was not accurate 26 | if (!config.meta[ctx.pathWithoutLocale]) 27 | ctx.state.meta.title = ctx.request.t( 28 | breadcrumbs.length === 1 29 | ? titleize(humanize(breadcrumbs[0])) 30 | : `${titleize(humanize(breadcrumbs[0]))} - ${titleize( 31 | humanize(breadcrumbs[1]) 32 | )}` 33 | ); 34 | 35 | return next(); 36 | } 37 | 38 | module.exports = { support, auth, admin, myAccount, breadcrumbs, otp, report }; 39 | -------------------------------------------------------------------------------- /template/app/controllers/web/otp/disable.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const isSANB = require('is-string-and-not-blank'); 3 | 4 | const config = require('../../../../config'); 5 | 6 | async function disable(ctx) { 7 | const { body } = ctx.request; 8 | 9 | const redirectTo = ctx.state.l('/my-account/security'); 10 | 11 | if (!ctx.state.user[config.passport.fields.otpEnabled]) 12 | throw Boom.badRequest(ctx.translateError('TWO_FACTOR_REQUIRED')); 13 | 14 | if (ctx.state.user[config.userFields.hasSetPassword]) { 15 | if (!isSANB(body.password)) 16 | throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); 17 | 18 | const { user } = await ctx.state.user.authenticate(body.password); 19 | if (!user) throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); 20 | } 21 | 22 | ctx.state.user[config.passport.fields.otpEnabled] = false; 23 | ctx.state.user[config.passport.fields.otpToken] = null; 24 | ctx.state.user[config.userFields.otpRecoveryKeys] = null; 25 | await ctx.state.user.save(); 26 | 27 | ctx.flash('custom', { 28 | title: ctx.request.t('Success'), 29 | text: ctx.translate('REQUEST_OK'), 30 | type: 'success', 31 | toast: true, 32 | showConfirmButton: false, 33 | timer: 3000, 34 | position: 'top' 35 | }); 36 | 37 | if (ctx.accepts('html')) ctx.redirect(redirectTo); 38 | else ctx.body = { redirectTo }; 39 | } 40 | 41 | module.exports = disable; 42 | -------------------------------------------------------------------------------- /template/app/controllers/web/otp/index.js: -------------------------------------------------------------------------------- 1 | const disable = require('./disable'); 2 | const recovery = require('./recovery'); 3 | const setup = require('./setup'); 4 | const keys = require('./keys'); 5 | 6 | module.exports = { disable, recovery, keys, setup }; 7 | -------------------------------------------------------------------------------- /template/app/controllers/web/otp/keys.js: -------------------------------------------------------------------------------- 1 | async function keys(ctx) { 2 | // this is like a migration, it will automatically add token + keys if needed 3 | await ctx.state.user.save(); 4 | return ctx.render('otp/setup'); 5 | } 6 | 7 | module.exports = keys; 8 | -------------------------------------------------------------------------------- /template/app/controllers/web/otp/recovery.js: -------------------------------------------------------------------------------- 1 | const config = require('../../../../config'); 2 | const sendVerificationEmail = require('../../../../helpers/send-verification-email'); 3 | 4 | async function recovery(ctx) { 5 | const redirectTo = ctx.state.l(config.verifyRoute); 6 | 7 | ctx.state.redirectTo = redirectTo; 8 | 9 | ctx.state.user[config.userFields.pendingRecovery] = true; 10 | await ctx.state.user.save(); 11 | 12 | try { 13 | ctx.state.user = await sendVerificationEmail(ctx); 14 | } catch (err) { 15 | // wrap with try/catch to prevent redirect looping 16 | // (even though the koa redirect loop package will help here) 17 | if (!err.isBoom) return ctx.throw(err); 18 | ctx.logger.warn(err); 19 | if (ctx.accepts('html')) { 20 | ctx.flash('warning', err.message); 21 | ctx.redirect(ctx.state.l(config.loginRoute)); 22 | } else { 23 | ctx.body = { message: err.message }; 24 | } 25 | 26 | return; 27 | } 28 | 29 | if (ctx.accepts('html')) { 30 | ctx.redirect(redirectTo); 31 | } else { 32 | ctx.body = { redirectTo }; 33 | } 34 | } 35 | 36 | module.exports = recovery; 37 | -------------------------------------------------------------------------------- /template/app/controllers/web/otp/setup.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const isSANB = require('is-string-and-not-blank'); 3 | const qrcode = require('qrcode'); 4 | const { authenticator } = require('otplib'); 5 | 6 | const config = require('../../../../config'); 7 | 8 | const options = { width: 500, margin: 0 }; 9 | 10 | async function setup(ctx) { 11 | const { body } = ctx.request; 12 | 13 | if (ctx.method === 'DELETE') { 14 | ctx.state.user[config.passport.fields.otpEnabled] = false; 15 | await ctx.state.user.save(); 16 | ctx.flash('custom', { 17 | title: ctx.request.t('Success'), 18 | text: ctx.translate('REQUEST_OK'), 19 | type: 'success', 20 | toast: true, 21 | showConfirmButton: false, 22 | timer: 3000, 23 | position: 'top' 24 | }); 25 | ctx.redirect(ctx.state.l('/my-account/security')); 26 | return; 27 | } 28 | 29 | if (isSANB(body.token)) { 30 | const isValid = authenticator.verify({ 31 | token: ctx.request.body.token, 32 | secret: ctx.state.user[config.passport.fields.otpToken] 33 | }); 34 | 35 | if (!isValid) { 36 | ctx.flash('error', ctx.translate('INVALID_OTP_PASSCODE')); 37 | ctx.state.otpTokenURI = authenticator.keyuri( 38 | ctx.state.user.email, 39 | process.env.WEB_HOST, 40 | ctx.state.user[config.passport.fields.otpToken] 41 | ); 42 | ctx.state.qrcode = await qrcode.toDataURL(ctx.state.otpTokenURI, options); 43 | return ctx.render('otp/enable'); 44 | } 45 | 46 | ctx.state.user[config.passport.fields.otpEnabled] = true; 47 | await ctx.state.user.save(); 48 | ctx.session.otp = 'totp-setup'; 49 | ctx.flash('custom', { 50 | title: ctx.request.t('Success'), 51 | text: ctx.translate('REQUEST_OK'), 52 | type: 'success', 53 | toast: true, 54 | showConfirmButton: false, 55 | timer: 3000, 56 | position: 'top' 57 | }); 58 | ctx.redirect(ctx.state.l('/my-account/security')); 59 | return; 60 | } 61 | 62 | if (ctx.state.user[config.userFields.hasSetPassword]) { 63 | if (!isSANB(body.password)) 64 | throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); 65 | 66 | const { user } = await ctx.state.user.authenticate(body.password); 67 | if (!user) throw Boom.badRequest(ctx.translateError('INVALID_PASSWORD')); 68 | } 69 | 70 | ctx.state.otpTokenURI = authenticator.keyuri( 71 | ctx.state.user.email, 72 | process.env.WEB_HOST, 73 | ctx.state.user[config.passport.fields.otpToken] 74 | ); 75 | ctx.state.qrcode = await qrcode.toDataURL(ctx.state.otpTokenURI, options); 76 | return ctx.render('otp/enable'); 77 | } 78 | 79 | module.exports = setup; 80 | -------------------------------------------------------------------------------- /template/app/controllers/web/report.js: -------------------------------------------------------------------------------- 1 | async function report(ctx) { 2 | ctx.body = 'OK'; 3 | } 4 | 5 | module.exports = report; 6 | -------------------------------------------------------------------------------- /template/app/controllers/web/support.js: -------------------------------------------------------------------------------- 1 | const sanitize = require('sanitize-html'); 2 | const dayjs = require('dayjs'); 3 | const isSANB = require('is-string-and-not-blank'); 4 | const Boom = require('@hapi/boom'); 5 | const _ = require('lodash'); 6 | const validator = require('validator'); 7 | 8 | const email = require('../../../helpers/email'); 9 | const { Inquiries } = require('../../models'); 10 | const config = require('../../../config'); 11 | 12 | async function help(ctx) { 13 | let { body } = ctx.request; 14 | 15 | if (config.env === 'test') ctx.ip = ctx.ip || '127.0.0.1'; 16 | 17 | body = _.pick(body, ['email', 'message']); 18 | 19 | if (!_.isString(body.email) || !validator.isEmail(body.email)) 20 | throw Boom.badRequest(ctx.translateError('INVALID_EMAIL')); 21 | 22 | if (!_.isUndefined(body.message) && !_.isString(body.message)) 23 | delete body.message; 24 | 25 | if (_.isString(body.message)) 26 | body.message = sanitize(body.message, { 27 | allowedTags: [], 28 | allowedAttributes: [] 29 | }); 30 | 31 | if (_.isString(body.message)) { 32 | if (!isSANB(body.message)) 33 | throw Boom.badRequest(ctx.translateError('INVALID_MESSAGE')); 34 | if (body.message.length > config.supportRequestMaxLength) 35 | throw Boom.badRequest(ctx.translateError('INVALID_MESSAGE')); 36 | } else { 37 | body.message = ctx.translate('SUPPORT_REQUEST_MESSAGE'); 38 | body.is_email_only = true; 39 | } 40 | 41 | // check if we already sent a support request in the past day 42 | // with this given ip address or email, otherwise create and email 43 | const count = await Inquiries.countDocuments({ 44 | $or: [ 45 | { 46 | ip: ctx.ip 47 | }, 48 | { 49 | email: body.email 50 | } 51 | ], 52 | created_at: { 53 | $gte: dayjs().subtract(1, 'day').toDate() 54 | } 55 | }); 56 | 57 | if (count > 0 && config.env !== 'development') 58 | throw Boom.badRequest(ctx.translateError('SUPPORT_REQUEST_LIMIT')); 59 | 60 | try { 61 | const inquiry = await Inquiries.create({ 62 | ...body, 63 | ip: ctx.ip, 64 | locale: ctx.locale 65 | }); 66 | 67 | ctx.logger.debug('created inquiry', inquiry); 68 | 69 | await email({ 70 | template: 'inquiry', 71 | message: { 72 | to: body.email, 73 | cc: config.email.message.from 74 | }, 75 | locals: { 76 | locale: ctx.locale, 77 | inquiry 78 | } 79 | }); 80 | 81 | const message = ctx.translate('SUPPORT_REQUEST_SENT'); 82 | if (ctx.accepts('html')) { 83 | ctx.flash('success', message); 84 | ctx.redirect('back'); 85 | } else { 86 | ctx.body = { message, resetForm: true, hideModal: true }; 87 | } 88 | } catch (err) { 89 | ctx.logger.error(err, { body }); 90 | throw Boom.badRequest(ctx.translateError('SUPPORT_REQUEST_ERROR')); 91 | } 92 | } 93 | 94 | module.exports = help; 95 | -------------------------------------------------------------------------------- /template/app/models/index.js: -------------------------------------------------------------------------------- 1 | const Users = require('./user'); 2 | const Inquiries = require('./inquiry'); 3 | 4 | module.exports = { Users, Inquiries }; 5 | -------------------------------------------------------------------------------- /template/app/models/inquiry.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const validator = require('validator'); 3 | const _ = require('lodash'); 4 | const mongooseCommonPlugin = require('mongoose-common-plugin'); 5 | 6 | // 7 | mongoose.Error.messages = require('@ladjs/mongoose-error-messages'); 8 | 9 | const config = require('../../config'); 10 | 11 | const Inquiry = new mongoose.Schema({ 12 | ip: { 13 | type: String, 14 | required: true, 15 | index: true, 16 | validate: (value) => validator.isIP(value) 17 | }, 18 | email: { 19 | type: String, 20 | required: true, 21 | index: true, 22 | validate: (value) => validator.isEmail(value) 23 | }, 24 | message: { 25 | type: String, 26 | required: true, 27 | validate: (value) => 28 | _.isString(value) && value.length <= config.supportRequestMaxLength 29 | }, 30 | is_email_only: { 31 | type: Boolean, 32 | default: false 33 | } 34 | }); 35 | 36 | Inquiry.plugin(mongooseCommonPlugin, { object: 'inquiry' }); 37 | 38 | module.exports = mongoose.model('Inquiry', Inquiry); 39 | -------------------------------------------------------------------------------- /template/app/views/404.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.text-center.py-5 6 | h1= t('Page not found') 7 | p.lead.text-muted= t("We're sorry, but the page you requested could not be found.") 8 | -------------------------------------------------------------------------------- /template/app/views/500.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.text-center.py-5 6 | h1= t('Server Error') 7 | p.lead.text-muted!= t('A server error has unfortunately occurred.') 8 | -------------------------------------------------------------------------------- /template/app/views/_breadcrumbs.pug: -------------------------------------------------------------------------------- 1 | nav(aria-label='breadcrumb') 2 | ol.breadcrumb.justify-content-center.justify-content-md-start 3 | each breadcrumb, i in breadcrumbs 4 | if i === breadcrumbs.length - 1 5 | li.breadcrumb-item.active(aria-current='page')= t(titleize(humanize(breadcrumb))) 6 | else 7 | li.breadcrumb-item: a(href=`/${breadcrumbs.slice(0, i + 1).join('/')}/`)= t(titleize(humanize(breadcrumb))) 8 | .text-center.text-md-left 9 | h1.text-muted= t(titleize(humanize(breadcrumbs[1] ? breadcrumbs[1] : breadcrumbs[0]))) 10 | hr 11 | -------------------------------------------------------------------------------- /template/app/views/_footer.pug: -------------------------------------------------------------------------------- 1 | 2 | footer.mt-auto 3 | .bg-dark.text-white-50 4 | .container.py-5.text-center.text-md-left 5 | .d-flex.flex-column.flex-md-row 6 | .flex-nowrap.order-3.order-md-0.d-flex.flex-column.flex-grow-1 7 | hr.d-md-none 8 | .dropdown.dropup 9 | a(href=ctx.url, role="button", data-toggle="dropdown", aria-haspopup="true", aria-expanded="false")#navbar-dropdown-language-btn.btn.btn-outline-light.dropdown-toggle 10 | = `${titleize(t(currentLanguage))} ${ctx.locale !== 'en' && currentLanguage !== titleize(currentLanguage) ? `(${titleize(currentLanguage)})` : ''}` 11 | ul#navbar-dropdown-language-ul.dropdown-menu(role="menu", aria-expanded='false', aria-hidden='true', aria-labelledby="navbar-dropdown-language-btn") 12 | each language in availableLanguages 13 | if language.locale !== locale 14 | li: a.dropdown-item(href=language.url)= `${titleize(t(language.name))} ${ctx.locale !== 'en' ? `(${titleize(language.name)})` : ''}` 15 | hr.d-md-none 16 | .mt-auto 17 | img(src=manifest('img/logo-square.svg'), width=50, height=50, alt='').d-inline-block.mr-2 18 | .d-inline-block.text-muted!= `© ${config.appName}` 19 | .flex-wrap.flex-fill 20 | h5.mb-2= t('Developers') 21 | ul.list-unstyled 22 | li: a.text-light(href=config.pkg.homepage)= t('Documentation') 23 | hr.d-md-none 24 | .flex-wrap.flex-fill 25 | h5.mb-2= t('Resources') 26 | ul.list-unstyled 27 | li: a.text-light(href=l('/support'))= t('Support') 28 | li: a.text-light(href=l('/privacy'))= t('Privacy') 29 | li: a.text-light(href=l('/terms'))= t('Terms') 30 | hr.d-md-none 31 | .flex-wrap.flex-fill.d-flex.flex-column 32 | div 33 | h5.mb-2= t('Company') 34 | ul.list-unstyled 35 | li: a.text-light(href=l('/about'))= t('About') 36 | .mt-auto 37 | ul.list-inline.mb-0 38 | if config.twitter 39 | li.list-inline-item: a(href=`https://twitter.com/${config.twitter.replace('@', '')}`, target='_blank').text-light: i.fa.fa-2x.fa-twitter 40 | if config.pkg.homepage && config.pkg.homepage.startsWith('https://github.com') 41 | li.list-inline-item: a(href=config.pkg.homepage, target='_blank').text-light: i.fa.fa-2x.fa-github 42 | -------------------------------------------------------------------------------- /template/app/views/_pagination.pug: -------------------------------------------------------------------------------- 1 | if pageCount && pageCount > 0 2 | nav(aria-label="Page navigation").d-flex.justify-content-center 3 | ul.pagination 4 | if paginate.hasPreviousPages 5 | li.page-item 6 | a.page-link(href=paginate.href(true), aria-label=t('Previous')) 7 | span(aria-hidden="true") 8 | i.fa.fa-angle-double-left 9 | span.sr-only= t('Previous') 10 | else 11 | li.page-item.disabled 12 | span.page-link(aria-label=t('Previous')) 13 | span(aria-hidden="true") 14 | i.fa.fa-angle-double-left 15 | span.sr-only= t('Previous') 16 | if pages 17 | each page in pages 18 | if page.number === 1 && pageCount === 1 19 | li.page-item.disabled 20 | span.page-link= page.number 21 | else 22 | if page.number === ctx.query.page 23 | li.page-item.active 24 | a.page-link(href=page.url)= page.number 25 | else 26 | li.page-item 27 | a.page-link(href=page.url)= page.number 28 | if paginate.hasNextPages(pageCount) 29 | li.page-item 30 | a.page-link(href=paginate.href({ page: ctx.query.page + 1 }), aria-label=t('Next')) 31 | span(aria-hidden="true") 32 | i.fa.fa-angle-double-right 33 | span.sr-only= t('Next') 34 | else 35 | li.page-item.disabled 36 | span.page-link(aria-label=t('Next')) 37 | span(aria-hidden="true") 38 | i.fa.fa-angle-double-right 39 | span.sr-only= t('Next') 40 | -------------------------------------------------------------------------------- /template/app/views/_register-or-login.pug: -------------------------------------------------------------------------------- 1 | mixin registerOrLogin(verb, isModal = false) 2 | .container(class=isModal ? '' : 'py-3') 3 | if !isModal 4 | .text-center 5 | h1.my-3.py-3 6 | = t(`${humanize(verb)} now`) 7 | = ' ' 8 | if verb === 'sign up' 9 | = emoji('sparkles') 10 | else 11 | = emoji('wave') 12 | div(class=isModal ? '' : 'col-sm-12 col-md-8 offset-md-2 col-lg-6 offset-lg-3') 13 | if boolean(process.env.AUTH_GOOGLE_ENABLED) 14 | a.btn-auth.btn-auth-google.btn-auth-google-dark.my-3(href='/auth/google', role="button") 15 | .btn-auth-wrapper 16 | span.btn-auth-icon 17 | span.btn-auth-text= t(`${humanize(verb)} with Google`) 18 | //-. 19 | a.btn-auth.btn-auth-google.btn-auth-google-light.my-3(href='/auth/google', role="button") 20 | .btn-auth-wrapper 21 | span.btn-auth-icon 22 | span.btn-auth-text= t(`${humanize(verb)} with Google`) 23 | if boolean(process.env.AUTH_GITHUB_ENABLED) 24 | a.btn-auth.btn-auth-github.btn-auth-github-dark.my-3(href='/auth/github', role="button") 25 | .btn-auth-wrapper 26 | span.btn-auth-icon 27 | span.btn-auth-text= t(`${humanize(verb)} with GitHub`) 28 | //-. 29 | a.btn-auth.btn-auth-github.btn-auth-github-light.my-3(href='/auth/github', role="button") 30 | .btn-auth-wrapper 31 | span.btn-auth-icon 32 | span.btn-auth-text= t(`${humanize(verb)} with GitHub`) 33 | if boolean(process.env.AUTH_GOOGLE_ENABLED) || boolean(process.env.AUTH_GITHUB_ENABLED) 34 | .hr-text.d-flex.text-secondary.align-items-center= t('or') 35 | - const action = verb === 'sign up' ? '/register' : config.loginRoute 36 | form.ajax-form(action=l(action), method='POST') 37 | input(type="hidden", name="_csrf", value=ctx.csrf) 38 | .form-group.floating-label 39 | input.form-control.form-control-lg(id=`input-email-${dashify(verb)}`, type="email", required, name="email", placeholder="name@example.com", autocomplete='email') 40 | label(for=`input-email-${dashify(verb)}`)= t('Email address') 41 | .form-group.floating-label 42 | input.form-control.form-control-lg(id=`input-password-${dashify(verb)}`, type="password", required, name="password", placeholder=" ", autocomplete=verb === 'sign up' ? 'off' : 'current-password') 43 | label(for=`input-password-${dashify(verb)}`)= t('Password') 44 | if verb === 'sign in' 45 | .form-group 46 | small.form-text.text-right: a(href=l('/forgot-password')).text-secondary= t('Forget your password?') 47 | button.btn.btn-success.btn-lg.btn-block(type="submit")= t(titleize(verb)) 48 | .alert.alert-warning.mt-3.text-center(class=isModal ? 'mb-0' : '') 49 | - const isRegisterOrLogin = ['/register', config.loginRoute].includes(ctx.pathWithoutLocale) 50 | ul.list-inline.mb-0 51 | if verb === 'sign up' 52 | li.list-inline-item!= t('Have an account?') 53 | li.list-inline-item 54 | a(href=l(config.loginRoute), data-dismiss-modal=isRegisterOrLogin ? false : 'true', data-toggle=isRegisterOrLogin ? '' : 'modal-anchor', data-target=isRegisterOrLogin ? '' : '#modal-sign-in').alert-link= t('Sign in') 55 | else 56 | li.list-inline-item!= t("Don't have an account?") 57 | li.list-inline-item 58 | a(href=l('/register'), data-dismiss-modal=isRegisterOrLogin ? false : 'true', data-toggle=isRegisterOrLogin ? '' : 'modal-anchor', data-target=isRegisterOrLogin ? '' : '#modal-sign-up').alert-link= t('Sign up') 59 | if verb === 'sign up' 60 | p.mt-3.mb-1.text-center: small.text-black-50!= t('Read our Privacy Policy and Terms', l('/privacy'), l('/terms')) 61 | -------------------------------------------------------------------------------- /template/app/views/about.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .jumbotron.mb-0 6 | .container 7 | h1= `${t('About')} ${config.appName}` 8 | p= t("Dependencies and development dependencies are listed below.") 9 | .container.py-3 10 | .row 11 | .col-12 12 | ul.list-group 13 | h2.mb-4= t('Dependencies') 14 | each dep in Object.keys(config.pkg.dependencies) 15 | a(href=`https://www.npmjs.com/package/${dep}`, target="_blank").list-group-item.list-group-item-action.d-flex.justify-content-between.align-items-center 16 | = dep 17 | span.badge.badge-secondary.badge-pill= config.pkg.dependencies[dep] 18 | hr 19 | .row 20 | .col-12 21 | ul.list-group 22 | h2.mb-4= t('Development Dependencies') 23 | each dep in Object.keys(config.pkg.devDependencies) 24 | a(href=`https://www.npmjs.com/package/${dep}`, target="_blank").list-group-item.list-group-item-action.d-flex.justify-content-between.align-items-center 25 | = dep 26 | span.badge.badge-secondary.badge-pill= config.pkg.devDependencies[dep] 27 | -------------------------------------------------------------------------------- /template/app/views/admin/index.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | .container-fluid.py-3 6 | .row 7 | .col 8 | include ../_breadcrumbs 9 | p.lead 10 | if (dayjs().format('HH') >= 12 && dayjs().format('HH') <= 17) 11 | = t('Good afternoon') 12 | else if (dayjs().format('HH') >= 17) 13 | = t('Good evening') 14 | else 15 | = t('Good morning') 16 | -------------------------------------------------------------------------------- /template/app/views/admin/users/index.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../../layout 3 | 4 | block body 5 | .container-fluid.py-3 6 | .row.mt-1 7 | .col 8 | include ../../_breadcrumbs 9 | if users.length === 0 10 | .alert.alert-info= t('No users exist yet') 11 | else 12 | .table-responsive 13 | table.table.table-hover.table-bordered 14 | thead.thead-dark 15 | tr 16 | th(scope='col')= t('First Name') 17 | th(scope='col')= t('Last Name') 18 | th(scope='col')= t('Email') 19 | th(scope='col')= t('Group') 20 | th(scope='col')= t('Created') 21 | th(scope='col')= t('Updated') 22 | th(scope='col')= t('Last Login') 23 | th(scope='col')= t('Last IP') 24 | th(scope='col')= t('Last Locale') 25 | if boolean(process.env.AUTH_OTP_ENABLED) 26 | th(scope='col')= t('OTP Enabled') 27 | th(scope='col')= t('Actions') 28 | tbody 29 | each user in users 30 | tr 31 | td.align-middle= user[config.passport.fields.givenName] 32 | td.align-middle= user[config.passport.fields.familyName] 33 | td.align-middle: a(href=`mailto:${user.email}`, target='_blank')= user.email 34 | td.align-middle= titleize(user.group) 35 | td.align-middle= dayjs(user.created_at).format('M/D/YY h:mm A') 36 | td.align-middle= dayjs(user.updated_at).format('M/D/YY h:mm A') 37 | td.align-middle= dayjs(user.last_login_at).format('M/D/YY h:mm A') 38 | td.align-middle: code= user[config.storeIPAddress.ip] 39 | td.align-middle: code= user[config.lastLocaleField] 40 | if boolean(process.env.AUTH_OTP_ENABLED) 41 | td.align-middle= user[config.passport.fields.otpEnabled] 42 | td.align-middle 43 | .btn-group(role='group', aria-label=t('Actions')) 44 | a(href=l(`/admin/users/${user.id}`), data-toggle='tooltip', data-title=t('Edit')).btn.btn-secondary: i.fa.fa-fw.fa-edit 45 | form.ajax-form.confirm-prompt.btn-group(action=l(`/admin/users/${user.id}/login`), method="POST", autocomplete="off") 46 | input(type="hidden", name="_csrf", value=ctx.csrf) 47 | button(type='submit', data-toggle='tooltip', data-title=t('Log in as user')).btn.btn-secondary: i.fa.fa-fw.fa-user-secret 48 | form.ajax-form.confirm-prompt.btn-group(action=l(`/admin/users/${user.id}`), method="POST", autocomplete="off") 49 | input(type="hidden", name="_csrf", value=ctx.csrf) 50 | input(type="hidden", name="_method", value="DELETE") 51 | button(type='submit', data-toggle='tooltip', data-title=t('Remove')).btn.btn-secondary: i.fa.fa-fw.fa-remove 52 | include ../../_pagination 53 | -------------------------------------------------------------------------------- /template/app/views/admin/users/retrieve.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../../layout 3 | 4 | block body 5 | .container-fluid.py-3 6 | .row.mt-1 7 | .col 8 | include ../../_breadcrumbs 9 | form(action=ctx.path, method='POST').ajax-form.confirm-prompt 10 | input(type='hidden', name='_method', value='PUT') 11 | input(type="hidden", name="_csrf", value=ctx.csrf) 12 | .card.card-bg-light 13 | h4.card-header= result.id 14 | .card-body 15 | .form-group.floating-label 16 | input#input-first-name(type='text', name=config.passport.fields.givenName, value=result[config.passport.fields.givenName], placeholder=t('First name')).form-control 17 | label(for='input-first-name')= t('First name') 18 | .form-group.floating-label 19 | input#input-last-name(type='text', name=config.passport.fields.familyName, value=result[config.passport.fields.familyName], placeholder=t('Last name')).form-control 20 | label(for='input-last-name')= t('Last name') 21 | .form-group.floating-label 22 | input#input-email(type='email', required, name='email', value=result.email, placeholder='name@example.com').form-control 23 | label(for='input-email')= t('Email address') 24 | .form-group.floating-label 25 | select#input-group(name='group', required).form-control 26 | option(value='user', selected=result.group === 'user') User 27 | option(value='admin', selected=result.group === 'admin') Admin 28 | label(for='input-group')= t('Group') 29 | if boolean(process.env.AUTH_OTP_ENABLED) && result[config.passport.fields.otpEnabled] 30 | .form-check 31 | input#otp-enabled(type='checkbox', name=config.passport.fields.otpEnabled, value='true' checked) 32 | label(for='input-otp-enabled')= t('OTP Enabled') 33 | .card-footer.text-right 34 | button(type='reset').btn.btn-secondary= t('Reset') 35 | button(type='submit').btn.btn-primary= t('Save') 36 | -------------------------------------------------------------------------------- /template/app/views/change-email.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | .text-center: h1.my-3.py-3= t('Change Your Email') 7 | .row 8 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 9 | .alert.alert-warning.text-center.mb-4!= t('From %s to %s.', user[config.passportLocalMongoose.usernameField], user[config.userFields.changeEmailNewAddress]) 10 | form.ajax-form.confirm-prompt(action=ctx.path, method="POST") 11 | input#input-email(type="hidden", name="email", value=user[config.passportLocalMongoose.usernameField]) 12 | .form-group.floating-label 13 | input#input-password.form-control.form-control-lg(type="password", autocomplete="confirm-password", name="password", placeholder=" ") 14 | label(for="input-password")= t('Confirm password') 15 | button.btn.btn-success.btn-block.btn-lg(type="submit")= t('Continue') 16 | -------------------------------------------------------------------------------- /template/app/views/dashboard/index.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | .container-fluid.py-3 6 | .row.mt-1 7 | .col 8 | include ../_breadcrumbs 9 | p.lead 10 | if (dayjs().format('HH') >= 12 && dayjs().format('HH') <= 17) 11 | = t('Good afternoon') 12 | else if (dayjs().format('HH') >= 17) 13 | = t('Good evening') 14 | else 15 | = t('Good morning') 16 | -------------------------------------------------------------------------------- /template/app/views/donate.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container-fluid.bg-dark.py-3.px-5.py-md-5.d-block.text-center 6 | .row 7 | .col-md-12 8 | p.lead.mb-0.text-white.font-weight-bold 9 | = t('We thank you for your generosity and time in considering a donation.') 10 | .py-5 11 | .text-center.mb-5 12 | .h2.display-3.mb-1= emoji('open_hands') 13 | h2.display-5.font-weight-light.mb-0.text-uppercase= t('Our Methods') 14 | small.text-black-50.text-uppercase= t('How can I donate?') 15 | .row.mt-3 16 | .col-md-6.offset-md-3 17 | ul.list-unstyled.text-left 18 | li 19 | a(href='https://github.com/sponsors/niftylettuce', target='_blank', rel='noopener noreferrer').btn.btn-lg.btn-link.btn-block 20 | i.fa.fa-github 21 | = ' GitHub Sponsorship' 22 | li 23 | a(href='https://paypal.me/niftylettuce', target='_blank', rel='noopener noreferrer').btn.btn-lg.btn-link.btn-block 24 | i.fa.fa-paypal 25 | = ' PayPal' 26 | li 27 | a(href='https://www.patreon.com/niftylettuce', target='blank', rel='noopener noreferrer').btn.btn-lg.btn-link.btn-block 28 | i.fa.fa-patreon 29 | = 'Patreon' 30 | p.text-muted= t('We would also appreciate your donation to:') 31 | ul.list-unstyled 32 | li: a(href="https://duckduckgo.com/donations", target="_blank", rel="noopener noreferrer").btn.btn-sm.btn-link.btn-block DuckDuckGo 33 | li: a(href="https://www.eff.org/pages/donate-eff", target="_blank", rel="noopener noreferrer").btn.btn-sm.btn-link.btn-block Electronic Frontier Foundation 34 | -------------------------------------------------------------------------------- /template/app/views/forgot-password.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | .text-center: h1.my-3.py-3= t('Forgot Password') 7 | .row 8 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 9 | .alert.alert-primary.text-center.mb-4= t('Enter your email address to continue.') 10 | form.ajax-form(action=ctx.path, method="POST") 11 | input(type="hidden", name="_csrf", value=ctx.csrf) 12 | .form-group.floating-label 13 | input#input-email.form-control.form-control-lg(type="email", autofocus, autocomplete="email", name="email", placeholder='name@example.com') 14 | label(for="input-email")= t('Email address') 15 | button.btn.btn-success.btn-block.btn-lg(type="submit")= t('Continue') 16 | .alert.alert-warning.mt-3.text-center 17 | ul.list-inline.mb-0 18 | li.list-inline-item= t('Remember your password?') 19 | li.list-inline-item 20 | a.alert-link(href=l(config.loginRoute))= t('Log in') 21 | -------------------------------------------------------------------------------- /template/app/views/home.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | //- Main jumbotron for a primary marketing message or call to action 6 | .jumbotron.mb-0.text-center 7 | .container 8 | h1= t('The Best Node.js Framework') 9 | p.lead= t('Lad scaffolds a Koa webapp and API framework for Node.js') 10 | a.btn.btn-success.btn-lg(href=l('/register'), role="button", data-toggle='modal-anchor', data-target='#modal-sign-up')= t('Try the demo') 11 | .bg-dark.py-5.text-center 12 | .d-flex.justify-content-center 13 | .input-group.input-group-lg.w-auto 14 | .input-group-prepend 15 | span.input-group-text.border.border-light.bg-transparent.text-white: i.fa.fa-terminal 16 | input(type='text', name='bash', readonly, value='npm install -g lad').border.border-light.bg-transparent.text-white.form-control#npm-install 17 | .input-group-append.d-none.d-md-inline-block 18 | button(type='button', data-toggle="clipboard", data-clipboard-target="#npm-install").btn.btn-light 19 | i.fa.fa-clipboard 20 | = ' ' 21 | = t('Copy') 22 | .container.py-5 23 | .row 24 | //- Example row of columns 25 | .col-md-3 26 | h2= t('Web Server') 27 | p= t('Built on top of Koa, the successor to Express. This full-stack web server uses the latest versions of Pug, Gulp, Sass, PostCSS, Bootstrap, and more.') 28 | p 29 | a.btn.btn-secondary(href="https://lad.js.org/#/?id=front-end", target="_blank", role="button")= `${t('Front-end')} »` 30 | .col-md-3 31 | h2= t('API Server') 32 | p= t('Inspired by Stripe, the RESTful API server also uses Koa and has a complete stack with error handling, authentication, and tests.') 33 | p 34 | a.btn.btn-secondary(href="https://lad.js.org/#/?id=back-end", target="_blank", role="button")= `${t('Back-end')} »` 35 | .col-md-3 36 | h2= t('Job Queue') 37 | p= t('Layered on top of Bree, the job scheduler supports cron and human-readable syntax, child processes, and more.') 38 | p 39 | a.btn.btn-secondary(href="https://lad.js.org/#/?id=back-end", target="_blank", role="button")= `${t('Learn more')} »` 40 | .col-md-3 41 | h2= t('Proxy Server') 42 | p= t("Redirect HTTP to HTTPS traffic with support for Let's Encrypt Free SSL Certbot validation.") 43 | p 44 | a.btn.btn-secondary(href="https://lad.js.org/#/", target="_blank", role="button")= `${t('View docs')} »` 45 | -------------------------------------------------------------------------------- /template/app/views/my-account/index.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block body 4 | .container-fluid.pt-3 5 | .row 6 | .col 7 | include ../_breadcrumbs 8 | .container.pb-3 9 | h1 Profile 10 | h1 Security 11 | -------------------------------------------------------------------------------- /template/app/views/my-account/profile.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block body 4 | #modal-change-password(tabindex='-1', role='dialog').modal.fade 5 | .modal-dialog(role='document') 6 | .modal-content 7 | .modal-header.d-block.text-center 8 | h5.modal-title= t('Change password') 9 | form(action=ctx.path, method='POST').ajax-form.confirm-prompt.mt-3 10 | input(type='hidden', name='_method', value='PUT') 11 | input(type="hidden", name="_csrf", value=ctx.csrf) 12 | input(type='hidden', name='change_password', value='true') 13 | .modal-body 14 | if user[config.userFields.hasSetPassword] 15 | .form-group.floating-label 16 | input#input-old-password(type='password', name='old_password', required, autocomplete='off').form-control 17 | label(for='input-old-password')= t('Confirm old password') 18 | .form-group.floating-label 19 | input#input-password(type='password', name='password', required, autocomplete='off').form-control 20 | label(for='input-password')= t('Set new password') 21 | .form-group.floating-label 22 | input#input-confirm-password(type='password', name='confirm_password', required, autocomplete='off').form-control 23 | label(for='input-confirm-password')= t('Confirm new password') 24 | .modal-footer.text-right 25 | button(type='button', data-dismiss='modal', aria-label=t('Cancel')).btn.btn-secondary= t('Cancel') 26 | button(type='submit').btn.btn-primary= user[config.userFields.hasSetPassword] ? t('Change password') : t('Set an account password') 27 | .container-fluid.py-3 28 | .row 29 | .col 30 | include ../_breadcrumbs 31 | form(action=ctx.path, method='POST').ajax-form.confirm-prompt 32 | input(type='hidden', name='_method', value='PUT') 33 | input(type="hidden", name="_csrf", value=ctx.csrf) 34 | .card 35 | h4.card-header= t('Update Profile') 36 | .card-body 37 | .form-group.floating-label 38 | input#input-email(type='email', required, name='email', value=user.email, placeholder='name@example.com').form-control 39 | label(for='input-email')= t('Email address') 40 | .form-group.floating-label 41 | input#input-given-name(type='text', name=config.passport.fields.givenName, value=user[config.passport.fields.givenName], placeholder=t('First name')).form-control 42 | label(for="input-given-name")= t('First name') 43 | .form-group.floating-label 44 | input#input-family-name(type='text', name=config.passport.fields.familyName, value=user[config.passport.fields.familyName], placeholder=t('Last name')).form-control 45 | label(for="input-family-name")= t('Last name') 46 | .form-group.floating-label 47 | if user[config.userFields.hasSetPassword] 48 | button(data-toggle='modal', data-target='#modal-change-password', type='button').btn.btn-secondary.btn-sm.my-auto= t('Change password') 49 | else 50 | button(data-toggle='modal', data-target='#modal-change-password', type='button').btn.btn-primary.btn-sm.my-auto= t('Set an account password') 51 | .card-footer.text-right 52 | button(type='reset').btn.btn-secondary= t('Reset') 53 | button(type='submit').btn.btn-primary= t('Save') 54 | -------------------------------------------------------------------------------- /template/app/views/my-account/security.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | #modal-disable-otp.modal.fade(tabindex='-1', role='dialog', aria-labelledby='modal-disable-otp-title', aria-hidden='true') 6 | .modal-dialog(role='document') 7 | .modal-content 8 | .modal-header.d-block.text-center 9 | h4.modal-title.d-inline-block.ml-4#modal-disable-otp-title= t('Disable OTP') 10 | button(type='button', data-dismiss='modal', aria-label='Close').close 11 | span(aria-hidden='true') × 12 | .modal-body.text-center 13 | form(action=l(`${config.otpRoutePrefix}/disable`), method='POST').ajax-form.confirm-prompt.mb-0 14 | input(type="hidden", name="_csrf", value=ctx.csrf) 15 | if user[config.userFields.hasSetPassword] 16 | .form-group.floating-label 17 | input#input-password.form-control.form-control-lg(type="password", autocomplete="off", name="password" required) 18 | label(for="input-password")= t('Confirm Password') 19 | button.btn.btn-danger.btn-lg.btn-block(type="submit")= t('Disable OTP') 20 | .container-fluid.py-3 21 | .row 22 | .col 23 | include ../_breadcrumbs 24 | if boolean(process.env.AUTH_OTP_ENABLED) 25 | .card.card-bg-light.mb-3 26 | h4.card-header= t('Two-Factor Authentication') 27 | .card-body 28 | if user[config.passport.fields.otpEnabled] 29 | h5= t('Recovery keys') 30 | p= t('Recovery keys allow you to login to your account when you have lost access to your Two-Factor Authentication device or authenticator app. Download your recovery keys and put them in a safe place to use as a last resort.') 31 | form(action=l('/my-account/recovery-keys'), method='POST') 32 | input(type="hidden", name="_csrf", value=ctx.csrf) 33 | button(type='submit').btn.btn-secondary.btn-lg= t('Download recovery keys') 34 | button(data-toggle='modal', data-target='#modal-disable-otp', type='button').btn.btn-danger.btn-lg.mt-3= t('Disable OTP') 35 | else 36 | h5= t('Configure One-time Password') 37 | p= t('One-time passwords ("OTP") allow you to add a layer of Two-Factor Authentication to your account using a device or authenticator app. If you lose access to your device or authenticator app, then you can use a recovery key provided to you during configuration.') 38 | a.btn.btn-primary.btn-lg(href=l(`${config.otpRoutePrefix}/setup`), role="button", data-toggle='modal-anchor', data-target='#modal-sign-up')= t('Enable OTP') 39 | .card-footer.text-right 40 | a(href='https://en.wikipedia.org/wiki/One-time_password', rel='nofollow', target='_blank').btn.btn-dark= t('Learn more') 41 | .card.mb-3 42 | h4.card-header= t('Developer Access') 43 | .card-body 44 | .form-group.row 45 | label.control-label.col-md-4.col-form-label.text-md-right= t('API token') 46 | .col-md-8 47 | .input-group 48 | input(type='text', readonly, value=user[config.userFields.apiToken]).form-control#api-token 49 | .input-group-append 50 | button(type='button', data-toggle="clipboard", data-clipboard-target="#api-token").btn.btn-primary 51 | i.fa.fa-clipboard 52 | = ' ' 53 | = t('Copy') 54 | small.form-text.text-muted= t('Keep your token secure and never share it publicly') 55 | -------------------------------------------------------------------------------- /template/app/views/otp/keys.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | .container.py-3 6 | .row 7 | .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3.text-center 8 | h1.my-3.py-3= t('Recovery Key') 9 | form(action=ctx.path, method="POST", autocomplete="off").ajax-form 10 | input(type="hidden", name="_csrf", value=ctx.csrf) 11 | .form-group.floating-label 12 | input#input-text.form-control.form-control-lg(type="text", autofocus, required, autocomplete="off", name="recovery_key", placeholder=' ') 13 | label(for="recovery_key")= t('Recovery Key') 14 | button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Continue') 15 | -------------------------------------------------------------------------------- /template/app/views/otp/login.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | #modal-recovery.modal.fade(tabindex='-1', role='dialog', aria-labelledby='modal-domain-title', aria-hidden='true') 6 | .modal-dialog.modal-lg(role='document') 7 | .modal-content 8 | .modal-header.text-center.d-block 9 | h4.modal-title.d-inline-block.ml-4#modal-recovery-title= t('Account Recovery') 10 | button(type='button', data-dismiss='modal', aria-label='Close').close 11 | span(aria-hidden='true') × 12 | .modal-body 13 | form.ajax-form(action=l(`${config.otpRoutePrefix}/recovery`), method="POST").confirm-prompt 14 | input(type="hidden", name="_csrf", value=ctx.csrf) 15 | p= t("If you can't access your authenticator app or lose your recovery keys, then you can submit a request for your account to be unlocked.") 16 | ol 17 | li= t('Verify access to your email address with a code emailed to you.') 18 | li= t('Wait 3-5 business days for an administrative follow-up email.') 19 | li= t('Access to your account will be unlocked for you.') 20 | button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Continue') 21 | .container.py-3 22 | h1.my-3.py-3.text-center= t('Two-Factor Check') 23 | .row 24 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 25 | form(action=ctx.path, method="POST", autocomplete="off").ajax-form 26 | input(type="hidden", name="_csrf", value=ctx.csrf) 27 | .form-group.floating-label 28 | input#input-text.form-control.form-control-lg(type="text", autofocus, autocomplete="off", name="passcode", placeholder=' ') 29 | label(for="input-passcode")= t('Passcode') 30 | .form-group.form-check 31 | input.form-check-input(type='checkbox', name='otp_remember_me', value='true', checked=ctx.session.otp_remember_me)#otp-remember-me 32 | label.form-check-label(for='otp-remember-me')= t("Don't ask me again in this browser") 33 | button.btn.btn-primary.btn-block.btn-lg(type="submit")= t('Continue') 34 | .alert.alert-warning.mt-3.text-center 35 | ul.list-inline.mb-0 36 | li.list-inline-item= t('Having trouble?') 37 | li.list-inline-item 38 | a(href=l(`${config.otpRoutePrefix}/keys`)).alert-link= t('Use a recovery key') 39 | ul.list-inline.text-center.mb-0 40 | li.list-inline-item 41 | small.text-muted= t('Lose your recovery keys?') 42 | li.list-inline-item 43 | small: a(href='#', data-toggle='modal-anchor', data-target='#modal-recovery').text-danger= t('Request account recovery') 44 | -------------------------------------------------------------------------------- /template/app/views/otp/setup.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block body 5 | .container.py-3 6 | .row 7 | .col-xs-12.col-sm-12.col-md-6.offset-md-3.col-lg-6.offset-lg-3.text-center 8 | h1.my-3.py-3= t('Setup OTP') 9 | .alert.alert-warning(role='alert') 10 | i.fa.fa-exclamation-triangle 11 | = ' ' 12 | = t('Download your emergency recovery keys below.') 13 | textarea(rows='5').form-control.text-monospace.text-center.rounded-bottom-0.border-dark#otp-recovery-keys 14 | each key, i in user[config.userFields.otpRecoveryKeys] 15 | = key 16 | if i !== user[config.userFields.otpRecoveryKeys].length - 1 17 | if i % 2 18 | = '\n' 19 | else 20 | = ' ' 21 | form(action=l('/my-account/recovery-keys'), method='POST') 22 | input(type="hidden", name="_csrf", value=ctx.csrf) 23 | .d-flex.btn-group(role='group') 24 | button(type='submit').btn.btn-dark.rounded-top-0 25 | i.fa.fa-file-download 26 | = ' ' 27 | = t('Download') 28 | button(type='button', data-toggle="clipboard", data-clipboard-text=user[config.userFields.otpRecoveryKeys].join('\r\n')).btn.btn-dark.rounded-top-0 29 | i.fa.fa-clipboard 30 | = ' ' 31 | = t('Copy') 32 | form(action=ctx.path, method="POST", autocomplete="off", class=user[config.userFields.otpRecoveryKeys] ? '' : 'confirm-prompt') 33 | input(type="hidden", name="_csrf", value=ctx.csrf) 34 | if user[config.userFields.hasSetPassword] 35 | .form-group.floating-label.mt-4 36 | input#input-password.form-control.form-control-lg(type="password", autocomplete="off", name="password" required) 37 | label(for="input-password")= t('Confirm Password') 38 | button.btn.btn-primary.btn-lg.btn-block.mt-2(type="submit")= t('Continue') 39 | -------------------------------------------------------------------------------- /template/app/views/privacy.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | h1= t('Privacy Policy') 7 | p= t(`This privacy policy ("Policy") describes how ${config.appName} and its related companies ("Company") collect, use and share personal information of consumer users of this website, ${config.urls.web} (the "Site"). This Policy also applies to any of our other websites that post this Policy. This Policy does not apply to websites that post different statements.`) 8 | h2= t('What We Collect') 9 | p= t('We get information about you in a range of ways.') 10 | h3= t('Information You Give Us') 11 | p= t('We collect your email address, and other information you directly give us on our site.') 12 | h3= t('Information Automatically Collected') 13 | p= t('We automatically log information about you and your computer. For example, when visiting our Site, we log your computer operating system type, browser type, browser language, the website you visited before browsing to our Site, pages you viewed, how long you spent on a page, access times, Internet protocol (IP) address and information about your use of and actions on our Site.') 14 | h3= t('Cookies') 15 | p= t('We may log information using "cookies." Cookies are small data files stored on your hard drive by a website. Cookies help us make our Site and your visit better. We use cookies to see which parts of our Site people use and like and to count visits to our Site.') 16 | h2= t('Use of Personal Information') 17 | p= t('We use your personal information as follows:') 18 | ul 19 | li= t('We use your personal information to protect, investigate, and deter against fraudulent, unauthorized, or illegal activity.') 20 | h2= t('Sharing of Personal Information') 21 | p= t('We may share personal information as follows:') 22 | ul 23 | li 24 | = t('We may share personal information for legal, protection, and safety purposes.') 25 | ul 26 | li= t('We may share information to comply with laws.') 27 | li= t('We may share information to respond to lawful requests and legal process.') 28 | li= t(`We may share information to protect the rights and property of ${config.appName}, our agents, customers, and others. This includes enforcing our agreements, policies, and terms of use.`) 29 | li= t('We may share information in an emergency. This includes protecting the safety of our employees and agents, our customers, or any person.') 30 | h2= t('Information Choices and Changes') 31 | p= t('You can typically remove and reject cookies from our Site with your browser settings. Many browsers are set to accept cookies until you change your settings. If you remove or reject our cookies, it could affect how our Site works for you.') 32 | h2= t('Security of Your Personal Information') 33 | p= t('We take steps to help protect personal information. No company can fully prevent security risks, however.  Mistakes may happen. Bad actors may defeat even the best safeguards.') 34 | h2= t('Contact Information') 35 | p!= t('We welcome your comments or questions about this Privacy Policy.  You may also contact us by email.') 36 | h2= t('Changes to this Privacy Policy') 37 | p= t('We may change this Privacy Policy. If we make any changes, we will change the "last updated" date below.') 38 | p= t('This Privacy Policy was last updated on 12/18/2017 and was first published on 12/18/2017.') 39 | -------------------------------------------------------------------------------- /template/app/views/register-or-login.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | include _register-or-login 6 | +registerOrLogin(verb) 7 | -------------------------------------------------------------------------------- /template/app/views/reset-password.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | .text-center: h1.my-3.py-3= t('Reset Password') 7 | .row 8 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 9 | .alert.alert-primary.text-center.mb-4= t('Confirm your email address and set a new password.') 10 | form.ajax-form.confirm-prompt(action=ctx.path, method="POST") 11 | .form-group.floating-label 12 | input#input-email.form-control.form-control-lg(type="email", autofocus, autocomplete="email", name="email", placeholder='name@example.com') 13 | label(for="input-email")= t('Email address') 14 | .form-group.floating-label 15 | input#input-password.form-control.form-control-lg(type="password", autocomplete="new-password", name="password", placeholder=" ") 16 | label(for="input-password")= t('New password') 17 | button.btn.btn-success.btn-block.btn-lg(type="submit")= t('Continue') 18 | .alert.alert-warning.mt-3.text-center 19 | ul.list-inline.mb-0 20 | li.list-inline-item= t('Having trouble?') 21 | li.list-inline-item 22 | a.alert-link(href=l('/forgot-password'))= t('Start over') 23 | -------------------------------------------------------------------------------- /template/app/views/spinner/1.pug: -------------------------------------------------------------------------------- 1 | //- 1. rotating plane 2 | .sk-rotating-plane 3 | -------------------------------------------------------------------------------- /template/app/views/spinner/10.pug: -------------------------------------------------------------------------------- 1 | //- 10. fading circle 2 | .sk-fading-circle 3 | .sk-circle1.sk-circle 4 | .sk-circle2.sk-circle 5 | .sk-circle3.sk-circle 6 | .sk-circle4.sk-circle 7 | .sk-circle5.sk-circle 8 | .sk-circle6.sk-circle 9 | .sk-circle7.sk-circle 10 | .sk-circle8.sk-circle 11 | .sk-circle9.sk-circle 12 | .sk-circle10.sk-circle 13 | .sk-circle11.sk-circle 14 | .sk-circle12.sk-circle 15 | -------------------------------------------------------------------------------- /template/app/views/spinner/11.pug: -------------------------------------------------------------------------------- 1 | //- 11. folding cube 2 | .sk-folding-cube 3 | .sk-cube1.sk-cube 4 | .sk-cube2.sk-cube 5 | .sk-cube4.sk-cube 6 | .sk-cube3.sk-cube 7 | -------------------------------------------------------------------------------- /template/app/views/spinner/2.pug: -------------------------------------------------------------------------------- 1 | //- 2. double bounce 2 | .sk-double-bounce 3 | .sk-child.sk-double-bounce1 4 | .sk-child.sk-double-bounce2 5 | -------------------------------------------------------------------------------- /template/app/views/spinner/3.pug: -------------------------------------------------------------------------------- 1 | //- 3. wave 2 | .sk-wave 3 | .sk-rect.sk-rect1 4 | .sk-rect.sk-rect2 5 | .sk-rect.sk-rect3 6 | .sk-rect.sk-rect4 7 | .sk-rect.sk-rect5 8 | -------------------------------------------------------------------------------- /template/app/views/spinner/4.pug: -------------------------------------------------------------------------------- 1 | //- 4. wandering cubes 2 | .sk-wandering-cubes 3 | .sk-cube.sk-cube1 4 | .sk-cube.sk-cube2 5 | -------------------------------------------------------------------------------- /template/app/views/spinner/5.pug: -------------------------------------------------------------------------------- 1 | //- 5. pulse 2 | .sk-spinner.sk-spinner-pulse 3 | -------------------------------------------------------------------------------- /template/app/views/spinner/6.pug: -------------------------------------------------------------------------------- 1 | //- 6. chasing dots 2 | .sk-chasing-dots 3 | .sk-child.sk-dot1 4 | .sk-child.sk-dot2 5 | -------------------------------------------------------------------------------- /template/app/views/spinner/7.pug: -------------------------------------------------------------------------------- 1 | //- 7. three bounce 2 | .sk-three-bounce 3 | .sk-child.sk-bounce1 4 | .sk-child.sk-bounce2 5 | .sk-child.sk-bounce3 6 | -------------------------------------------------------------------------------- /template/app/views/spinner/8.pug: -------------------------------------------------------------------------------- 1 | //- 8. circle 2 | .sk-circle 3 | .sk-circle1.sk-child 4 | .sk-circle2.sk-child 5 | .sk-circle3.sk-child 6 | .sk-circle4.sk-child 7 | .sk-circle5.sk-child 8 | .sk-circle6.sk-child 9 | .sk-circle7.sk-child 10 | .sk-circle8.sk-child 11 | .sk-circle9.sk-child 12 | .sk-circle10.sk-child 13 | .sk-circle11.sk-child 14 | .sk-circle12.sk-child 15 | -------------------------------------------------------------------------------- /template/app/views/spinner/9.pug: -------------------------------------------------------------------------------- 1 | //- 9. cube grid 2 | .sk-cube-grid 3 | .sk-cube.sk-cube1 4 | .sk-cube.sk-cube2 5 | .sk-cube.sk-cube3 6 | .sk-cube.sk-cube4 7 | .sk-cube.sk-cube5 8 | .sk-cube.sk-cube6 9 | .sk-cube.sk-cube7 10 | .sk-cube.sk-cube8 11 | .sk-cube.sk-cube9 12 | -------------------------------------------------------------------------------- /template/app/views/spinner/spinner.pug: -------------------------------------------------------------------------------- 1 | 2 | //- for a list of all available spinners: 3 | //- 4 | 5 | #spinner.fixed-top.fade 6 | //-. 7 | for some reason #10 from SpinKit has issues with centering 8 | so we renamed #11 to #10 9 | also #4 has sizing issues so we excluded it too #} 10 | https://github.com/tobiasahlin/SpinKit/issues/122 #} 11 | 12 | //- change this to whatever spinner you'd like 13 | include 10 14 | -------------------------------------------------------------------------------- /template/app/views/support.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | .row 7 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 8 | form.ajax-form.confirm-prompt(action=ctx.path, method="POST") 9 | input(type="hidden", name="_csrf", value=ctx.csrf) 10 | .card.card-bg-light 11 | h4.card-header.text-center= t('Contact Support') 12 | .card-body 13 | .form-group.floating-label 14 | input#input-email.form-control(required, name="email", type="email", value=user ? user.email : '', placeholder='name@example.com') 15 | label(for="input-email")= t('Email address') 16 | .form-group.floating-label 17 | textarea#input-message.form-control(rows="3", maxlength=config.supportRequestMaxLength, name="message", placeholder=t('Write your message')) 18 | label(for="input-message")= t('Message') 19 | .card-footer 20 | button.btn.btn-block.btn-success.btn-lg(type="submit", data-toggle="tooltip", data-placement="bottom", title=t("We'll be in contact with you!"))= t('Send message') 21 | -------------------------------------------------------------------------------- /template/app/views/verify.pug: -------------------------------------------------------------------------------- 1 | 2 | extends layout 3 | 4 | block body 5 | .container.py-3 6 | .text-center: h1.my-3.py-3= t('Verify email') 7 | .row 8 | .col-sm-12.col-md-8.offset-md-2.col-lg-6.offset-lg-3 9 | .alert.alert-primary.text-center.mb-4!= t('Enter the verification code emailed to: %s', user.email) 10 | form.ajax-form(action=redirectTo ? `${ctx.path}?redirect_to=${redirectTo}` : ctx.path, method="POST") 11 | input(type="hidden", name="_csrf", value=ctx.csrf) 12 | .form-group.floating-label 13 | input#input-pin.form-control.form-control-lg.verification-form( 14 | type="text", 15 | autofocus, 16 | title=striptags(t('Please enter a %d digit verification code.', config.verificationPin.length)), 17 | inputmode='numeric', 18 | pattern=`[0-9]{${config.verificationPin.length}}`, 19 | minlength=config.verificationPin.length, 20 | maxlength=config.verificationPin.length, 21 | autocomplete="off", 22 | name="pin", 23 | placeholder=' ' 24 | ) 25 | label(for="input-pin")= t('Verification code') 26 | button.btn.btn-success.btn-block.btn-lg(type="submit")= t('Continue') 27 | .alert.alert-warning.mt-3.text-center 28 | = t("Didn't receive it?") 29 | = ' ' 30 | a.alert-link(href=redirectTo ? `${ctx.path}?resend=true&redirect_to=${redirectTo}` : `${ctx.path}?resend=true`)= t('Resend now') 31 | -------------------------------------------------------------------------------- /template/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffffff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /template/assets/css/_btn-auth.scss: -------------------------------------------------------------------------------- 1 | // 2 | // Special thanks to these two sources: 3 | // 4 | // 5 | // 6 | $roboto: 'Roboto-Medium', $font-family-sans-serif; 7 | 8 | @mixin button($height) { 9 | border-radius: #{$height * (1/40)}px; 10 | .btn-auth-icon { 11 | background-size: #{$height * (18/40)}px; 12 | background-repeat: no-repeat; 13 | background-position: center; 14 | display: inline-block; 15 | vertical-align: middle; 16 | width: #{$height}px; 17 | height: #{$height}px; 18 | border-radius: #{$height * (1/40)}px; 19 | margin-right: #{$height * (12/40)}px; 20 | } 21 | .btn-auth-text { 22 | font-size: #{$height * (14/40)}px; 23 | margin-left: #{$height * (6/40)}px; 24 | margin-right: #{$height * (6/40)}px; 25 | } 26 | } 27 | 28 | .btn-auth-google { 29 | &:hover { 30 | box-shadow: 0 0 3px 3px rgba(66, 133, 244, 0.3); 31 | } 32 | .btn-auth-icon { 33 | background-image: url('../img/google-logo.svg'); 34 | } 35 | } 36 | 37 | .btn-auth-github { 38 | &:hover { 39 | box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.3); 40 | } 41 | .btn-auth-icon { 42 | background-image: url('../img/github-logo.svg'); 43 | } 44 | } 45 | 46 | .btn-auth-google-dark { 47 | background-color: #4285f4; 48 | &:hover { 49 | background-color: #4285f4; 50 | } 51 | &:active { 52 | background-color: #3367d6; 53 | } 54 | .btn-auth-text { 55 | color: #fff; 56 | } 57 | .btn-auth-icon { 58 | background-color: #fff; 59 | } 60 | } 61 | 62 | .btn-auth-github-dark { 63 | background-color: #161514; 64 | &:hover { 65 | background-color: #161514; 66 | } 67 | &:active { 68 | background-color: #000; 69 | } 70 | .btn-auth-text { 71 | color: #fff; 72 | } 73 | .btn-auth-icon { 74 | background-color: #fff; 75 | } 76 | } 77 | 78 | .btn-auth-google-light, .btn-auth-github-light { 79 | $width: 100% !default; 80 | background-color: #fff; 81 | &:active { 82 | background-color: #eee; 83 | color: #6d6d6d; 84 | } 85 | .btn-auth-text { 86 | color: #757575; 87 | } 88 | } 89 | 90 | .btn-auth { 91 | transition: background-color .218s, border-color .218s, box-shadow .218s; 92 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.25); 93 | display: block; 94 | padding: 0; 95 | white-space: nowrap; 96 | overflow: hidden; 97 | outline: none; 98 | cursor: pointer; 99 | text-align: left; 100 | @include button(40); 101 | .btn-auth-wrapper { 102 | height: 100%; 103 | width: 100%; 104 | border: 1px solid transparent; 105 | } 106 | .btn-auth-text { 107 | font-family: $roboto; 108 | display: inline-block; 109 | letter-spacing: 0.21px; 110 | vertical-align: middle; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /template/assets/css/_custom.scss: -------------------------------------------------------------------------------- 1 | // https://github.com/twbs/bootstrap/issues/24374 2 | .min-vh-80 { 3 | min-height: 80vh !important; 4 | } 5 | .min-h-100 { 6 | min-height: 100%; 7 | } 8 | 9 | body { 10 | padding-top: 77px; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .text-decoration-underline { 17 | text-decoration: underline !important; 18 | } 19 | 20 | .dropdown-menu { 21 | max-height: 50vh; 22 | overflow-y: auto; 23 | } 24 | -------------------------------------------------------------------------------- /template/assets/css/_email.scss: -------------------------------------------------------------------------------- 1 | body.email { 2 | max-width: 600px !important; 3 | padding-top: 0; 4 | margin: 0 auto !important; 5 | background-color: #4C3142; 6 | img { 7 | max-width: 100%; 8 | object-fit: contain; 9 | } 10 | .markdown-body { 11 | margin-left: 0; 12 | margin-right: 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /template/assets/css/_markdown.scss: -------------------------------------------------------------------------------- 1 | @import 'node_modules/github-markdown-css/github-markdown'; 2 | @import 'node_modules/@primer/css/markdown/index.scss'; 3 | 4 | .markdown-body { 5 | margin-left: 20px; 6 | margin-right: 20px; 7 | font-family: $font-family-sans-serif; 8 | 9 | @for $index from 1 through 6 { 10 | h#{$index} { 11 | .anchor { 12 | float: left; 13 | padding-right: 5px; 14 | margin-left: -20px; 15 | } 16 | } 17 | } 18 | 19 | code, kbd, pre, .commit-tease-sha, .blob-num, .blob-code-inner, { 20 | font-family: $font-family-monospace; 21 | } 22 | 23 | code { 24 | color: #24292e; 25 | font-size: 100%; 26 | } 27 | 28 | pre { 29 | > code { 30 | display: block; 31 | } 32 | } 33 | 34 | pre, .highlight pre { 35 | font-size: 100%; 36 | padding: 1rem; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /template/assets/css/_responsive-backgrounds.scss: -------------------------------------------------------------------------------- 1 | @each $breakpoint in map-keys($grid-breakpoints) { 2 | @include media-breakpoint-up($breakpoint) { 3 | $infix: breakpoint-infix($breakpoint, $grid-breakpoints); 4 | 5 | @each $color, $value in $theme-colors { 6 | @include bg-variant(".bg#{$infix}-#{$color}", $value, true); 7 | } 8 | 9 | @if $enable-gradients { 10 | @each $color, $value in $theme-colors { 11 | @include bg-gradient-variant(".bg#{$infix}-gradient-#{$color}", $value); 12 | } 13 | } 14 | 15 | .bg#{$infix}-white { 16 | background-color: $white !important; 17 | } 18 | 19 | .bg#{$infix}-transparent { 20 | background-color: transparent !important; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /template/assets/css/_responsive-borders.scss: -------------------------------------------------------------------------------- 1 | // 2 | @each $breakpoint in map-keys($grid-breakpoints) { 3 | @include media-breakpoint-up($breakpoint) { 4 | $infix: breakpoint-infix($breakpoint, $grid-breakpoints); 5 | .border#{$infix} { border: $border-width solid $border-color; } 6 | 7 | .border#{$infix}-top { border-top: $border-width solid $border-color ; } 8 | .border#{$infix}-right { border-right: $border-width solid $border-color ; } 9 | .border#{$infix}-bottom { border-bottom: $border-width solid $border-color ; } 10 | .border#{$infix}-left { border-left: $border-width solid $border-color ; } 11 | 12 | .border#{$infix}-top-0 { border-top: 0 !important; } 13 | .border#{$infix}-right-0 { border-right: 0 !important; } 14 | .border#{$infix}-bottom-0 { border-bottom: 0 !important; } 15 | .border#{$infix}-left-0 { border-left: 0 !important; } 16 | 17 | .border#{$infix}-x { 18 | border-left: $border-width solid $border-color ; 19 | border-right: $border-width solid $border-color ; 20 | } 21 | 22 | .border#{$infix}-y { 23 | border-top: $border-width solid $border-color ; 24 | border-bottom: $border-width solid $border-color ; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /template/assets/css/_responsive-rounded.scss: -------------------------------------------------------------------------------- 1 | // 2 | .rounded-top-0 { 3 | border-top-left-radius: 0 !important; 4 | border-top-right-radius: 0 !important; 5 | } 6 | 7 | .rounded-left-0 { 8 | border-top-left-radius: 0 !important; 9 | border-bottom-left-radius: 0 !important; 10 | } 11 | 12 | .rounded-right-0 { 13 | border-top-right-radius: 0 !important; 14 | border-bottom-right-radius: 0 !important; 15 | } 16 | 17 | .rounded-bottom-0 { 18 | border-bottom-left-radius: 0 !important; 19 | border-bottom-right-radius: 0 !important; 20 | } 21 | 22 | // 23 | // TODO: refactor this so !important is preserved 24 | // NOTE: currently the below syntax is invalid SCSS as you cannot 25 | // append `!important` to a mixin invocation 26 | // 27 | // @each $breakpoint in map-keys($grid-breakpoints) { 28 | // @include media-breakpoint-up($breakpoint) { 29 | // $infix: breakpoint-infix($breakpoint, $grid-breakpoints) !important; 30 | // .rounded#{$infix}-top { @include border-top-radius($border-radius) !important; } 31 | // .rounded#{$infix}-right { @include border-right-radius($border-radius) !important } 32 | // .rounded#{$infix}-bottom { @include border-bottom-radius($border-radius) !important; } 33 | // .rounded#{$infix}-left { @include border-left-radius($border-radius) !important; } 34 | // .rounded#{$infix}-circle { border-radius: 50% !important; } 35 | // .rounded#{$infix}-0 { border-radius: 0 !important; } 36 | // 37 | // .rounded#{$infix}-top-0 { @include border-top-radius(0) !important; } 38 | // .rounded#{$infix}-right-0 { @include border-right-radius(0) !important; } 39 | // .rounded#{$infix}-left-0 { @include border-left-radius(0) !important; } 40 | // .rounded#{$infix}-bottom-0 { @include border-bottom-radius(0) !important; } 41 | // 42 | // .rounded#{$infix}-x { 43 | // @include border-left-radius($border-radius) !important; 44 | // @include border-right-radius($border-radius) !important; 45 | // } 46 | // 47 | // .rounded#{$infix}-y { 48 | // @include border-top-radius($border-radius) !important; 49 | // @include border-bottom-radius($border-radius) !important; 50 | // } 51 | // } 52 | // } 53 | -------------------------------------------------------------------------------- /template/assets/css/_swal2.scss: -------------------------------------------------------------------------------- 1 | // prevent resize bug 2 | .swal2-height-auto.h-100 { 3 | height: 100% !important; 4 | } 5 | 6 | // responsive toasts 7 | .swal2-popup.swal2-toast { 8 | flex-direction: column !important; 9 | .swal2-header { 10 | margin-bottom: .625em; 11 | } 12 | } 13 | 14 | @include media-breakpoint-up(lg) { 15 | .swal2-popup.swal2-toast { 16 | flex-direction: row !important; 17 | .swal2-header { 18 | margin-bottom: 0; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /template/assets/css/_variables.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: "../fonts" !default; 2 | $fa-font-display: "swap" !default; 3 | 4 | $font-family-sans-serif: '-apple-system', BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !default; 5 | 6 | $font-family-monospace: 'Inconsolata', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !default; 7 | 8 | $font-family-base: $font-family-sans-serif !default; 9 | 10 | $headings-font-family: 'Poppins', $font-family-sans-serif; 11 | 12 | $blue: #20C1ED !default; 13 | $cyan: #9DE5F9 !default; 14 | $green: #8CC63F !default; 15 | $yiq-contrasted-threshold: 200 !default; 16 | 17 | $enable-responsive-font-sizes: true !default; 18 | -------------------------------------------------------------------------------- /template/assets/css/app.scss: -------------------------------------------------------------------------------- 1 | // variables 2 | @import '_variables'; 3 | 4 | // bootstrap 5 | @import 'node_modules/bootstrap/scss/bootstrap'; 6 | 7 | // override sweetalert 2 styles with bootstraps 8 | $success: theme-color('primary'); 9 | $error: theme-color('danger'); 10 | $warning: theme-color('warning'); 11 | $info: theme-color('info'); 12 | $question: theme-color('light'); 13 | 14 | // sweetalert2 15 | @import 'node_modules/sweetalert2/src/sweetalert2'; 16 | 17 | // highlight.js 18 | @import 'node_modules/highlight.js/scss/default'; 19 | @import 'node_modules/highlight.js/scss/github'; 20 | 21 | // font awesome 22 | @import 'node_modules/@fortawesome/fontawesome-free/scss/fontawesome'; 23 | // @import 'node_modules/@fortawesome/fontawesome-free/scss/brands'; 24 | 25 | // 26 | $fontAwesomeBrands: 'Font Awesome 5 Brands'; 27 | $fontAwesomeFree: 'Font Awesome 5 Free'; 28 | .fab { 29 | font-family: $fontAwesomeBrands; 30 | font-weight: 400; 31 | } 32 | // @import 'node_modules/@fortawesome/fontawesome-free/scss/solid'; 33 | .fa, .fas { 34 | font-family: $fontAwesomeFree; 35 | font-weight: 900; 36 | } 37 | @import 'node_modules/@fortawesome/fontawesome-free/scss/v4-shims'; 38 | 39 | // spinkit loading spinner 40 | @import 'node_modules/@ladjs/assets/scss/spinner'; 41 | 42 | // support custom file inputs 43 | @import 'node_modules/@ladjs/assets/scss/custom-file-input'; 44 | 45 | // used on the register-or-login view to provide a horizontal rule 46 | // with the text "or" in between the horizontal rule line 47 | // (e.g. "------ or ------" that stretches full width) 48 | @import 'node_modules/@ladjs/assets/scss/hr-text'; 49 | 50 | // bootstrap fixes and add-ons 51 | @import 'node_modules/@ladjs/assets/scss/bootstrap'; 52 | 53 | // image helpers for emails, jquery-lazy, etc. 54 | @import 'node_modules/@ladjs/assets/scss/image-helpers'; 55 | 56 | // bootstrap floating labels 57 | @import 'node_modules/@tkrotoff/bootstrap-floating-label/src/bootstrap-floating-label'; 58 | 59 | // markdown 60 | @import '_markdown'; 61 | 62 | // email styling 63 | @import '_email'; 64 | 65 | // auth buttons 66 | @import '_btn-auth'; 67 | 68 | // sweetalert2 fix 69 | @import '_swal2'; 70 | 71 | // responsive borders 72 | @import '_responsive-borders'; 73 | 74 | // responsive backgrounds 75 | @import '_responsive-backgrounds'; 76 | 77 | // responsive rounded 78 | @import '_responsive-rounded'; 79 | 80 | // custom app styling 81 | @import '_custom'; 82 | -------------------------------------------------------------------------------- /template/assets/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /template/assets/img/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/android-chrome-384x384.png -------------------------------------------------------------------------------- /template/assets/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/apple-touch-icon.png -------------------------------------------------------------------------------- /template/assets/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/favicon-16x16.png -------------------------------------------------------------------------------- /template/assets/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/favicon-32x32.png -------------------------------------------------------------------------------- /template/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/favicon.ico -------------------------------------------------------------------------------- /template/assets/img/github-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template/assets/img/google-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /template/assets/img/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/mstile-150x150.png -------------------------------------------------------------------------------- /template/assets/img/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/social.png -------------------------------------------------------------------------------- /template/assets/img/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/assets/img/twitter.png -------------------------------------------------------------------------------- /template/assets/js/logger.js: -------------------------------------------------------------------------------- 1 | const Cabin = require('cabin'); 2 | const _ = require('lodash'); 3 | 4 | // setup our Cabin instance 5 | const cabin = new Cabin({ 6 | key: window.API_TOKEN || null, 7 | axe: { 8 | endpoint: `${window.API_URL}/v1/log`, 9 | showMeta: true, 10 | showStack: true, 11 | capture: true, 12 | silent: process.env.NODE_ENV === 'production' 13 | } 14 | }); 15 | 16 | // set the user if we're logged in 17 | if (_.isObject(window.USER)) cabin.setUser(window.USER); 18 | 19 | module.exports = cabin; 20 | -------------------------------------------------------------------------------- /template/assets/js/uncaught.js: -------------------------------------------------------------------------------- 1 | const StackTrace = require('stacktrace-js'); 2 | const prepareStackTrace = require('prepare-stack-trace'); 3 | const uncaught = require('uncaught'); 4 | 5 | const logger = require('./logger'); 6 | 7 | // 8 | // Sourced from the StackTrace example from CabinJS docs 9 | // 10 | // 11 | uncaught.start(); 12 | uncaught.addListener(async (err, event) => { 13 | if (!err) { 14 | if (typeof ErrorEvent === 'function' && event instanceof ErrorEvent) 15 | return logger.error(event.message, { event }); 16 | logger.error({ event }); 17 | return; 18 | } 19 | 20 | // this will transform the error's `stack` property 21 | // to be consistently similar to Gecko and V8 stackframes 22 | try { 23 | const stackframes = await StackTrace.fromError(err); 24 | err.stack = prepareStackTrace(err, stackframes); 25 | logger.error(err); 26 | } catch (err_) { 27 | logger.error(err); 28 | logger.error(err_); 29 | } 30 | }); 31 | 32 | module.exports = uncaught; 33 | -------------------------------------------------------------------------------- /template/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | 3 | # Allow crawling of all content 4 | User-agent: * 5 | Disallow: 6 | -------------------------------------------------------------------------------- /template/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lad.sh", 3 | "short_name": "Lad.sh", 4 | "icons": [ 5 | { 6 | "src": "/img/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#20C1ED", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /template/bree.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('./config/env'); 3 | 4 | const Bree = require('bree'); 5 | const Graceful = require('@ladjs/graceful'); 6 | 7 | const logger = require('./helpers/logger'); 8 | 9 | const bree = new Bree({ logger }); 10 | 11 | if (!module.parent) { 12 | const graceful = new Graceful({ 13 | brees: [bree], 14 | logger 15 | }); 16 | graceful.listen(); 17 | 18 | bree.start(); 19 | 20 | logger.info('Lad bree started'); 21 | } 22 | 23 | module.exports = bree; 24 | -------------------------------------------------------------------------------- /template/config/api.js: -------------------------------------------------------------------------------- 1 | const sharedConfig = require('@ladjs/shared-config'); 2 | 3 | const i18n = require('../helpers/i18n'); 4 | const logger = require('../helpers/logger'); 5 | const passport = require('../helpers/passport'); 6 | const routes = require('../routes'); 7 | 8 | module.exports = { 9 | ...sharedConfig('API'), 10 | routes: routes.api, 11 | logger, 12 | i18n, 13 | passport 14 | }; 15 | -------------------------------------------------------------------------------- /template/config/bree.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | root: path.join(__dirname, '..', 'jobs') 5 | }; 6 | -------------------------------------------------------------------------------- /template/config/cookies.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // 3 | // 4 | httpOnly: true, 5 | path: '/', 6 | overwrite: true, 7 | signed: true, 8 | maxAge: 24 * 60 * 60 * 1000, 9 | secure: process.env.WEB_PROTOCOL === 'https', 10 | // we use SameSite cookie support as an alternative to CSRF 11 | // 12 | // 'strict' is ideal, but would cause issues when redirecting out 13 | // for oauth flows to github, google, etc. 14 | sameSite: 'lax' 15 | }; 16 | -------------------------------------------------------------------------------- /template/config/env.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const test = process.env.NODE_ENV === 'test'; 4 | 5 | // note that we had to specify absolute paths here bc 6 | // otherwise tests run from the root folder wont work 7 | const env = require('@ladjs/env')({ 8 | path: path.join(__dirname, '..', test ? '.env.test' : '.env'), 9 | defaults: path.join(__dirname, '..', '.env.defaults'), 10 | schema: path.join(__dirname, '..', '.env.schema') 11 | }); 12 | 13 | module.exports = env; 14 | -------------------------------------------------------------------------------- /template/config/filters.js: -------------------------------------------------------------------------------- 1 | // const { parse } = require('node-html-parser'); 2 | const I18N = require('@ladjs/i18n'); 3 | const cheerio = require('cheerio'); 4 | const isSANB = require('is-string-and-not-blank'); 5 | 6 | const i18nConfig = require('../config/i18n'); 7 | const logger = require('../helpers/logger'); 8 | const markdown = require('../helpers/markdown'); 9 | 10 | function fixTableOfContents(content) { 11 | const $ = cheerio.load(content); 12 | const $h1 = $('h1').first(); 13 | if ($h1.length === 0) return content; 14 | const $h2 = $h1.next('h2'); 15 | if ($h2.length === 0) return content; 16 | const $a = $h1.find('a').first(); 17 | if ($a.length === 0) return content; 18 | $a.attr('id', 'top'); 19 | $a.attr('href', '#top'); 20 | const $a2 = $h2.find('a').first(); 21 | if ($a2.length === 0) return content; 22 | $a2.attr('id', 'table-of-contents'); 23 | $a2.attr('href', '#table-of-contents'); 24 | const $ul = $h2.next('ul'); 25 | if ($ul.length === 0) return content; 26 | const $links = $ul.find('a'); 27 | if ($links.length === 0) return content; 28 | const $h2s = $('h2'); 29 | $links.each(function () { 30 | const $link = $(this); 31 | const text = $link.text(); 32 | const href = $link.attr('href'); 33 | const id = href.slice(1); 34 | $h2s.each(function () { 35 | const $h = $(this); 36 | const $anchor = $h.find('a').first(); 37 | if ($anchor.length === 0) return; 38 | if ($h.text() === text) { 39 | $anchor.attr('href', href); 40 | // strip the # so id is accurate 41 | $anchor.attr('id', id); 42 | } 43 | }); 44 | }); 45 | 46 | return $.html(); 47 | } 48 | 49 | module.exports = { 50 | md: (string, options) => { 51 | if (!isSANB(options.locale)) 52 | return `
${fixTableOfContents( 53 | markdown.render(string) 54 | )}
`; 55 | // 56 | // NOTE: we want our own instance of i18n that does not auto reload files 57 | // 58 | const i18n = new I18N({ 59 | ...i18nConfig, 60 | autoReload: false, 61 | updateFiles: false, 62 | syncFiles: false, 63 | logger 64 | }); 65 | return `
${fixTableOfContents( 66 | i18n.api.t({ 67 | phrase: markdown.render(string), 68 | locale: options.locale 69 | }) 70 | )}
`; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /template/config/i18n.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const locales = require('./locales'); 4 | const phrases = require('./phrases'); 5 | 6 | module.exports = { 7 | // see @ladjs/i18n for a list of defaults 8 | // 9 | // but for complete configuration reference please see: 10 | // 11 | phrases, 12 | defaultLocale: 'en', 13 | directory: path.join(__dirname, '..', 'locales'), 14 | ignoredRedirectGlobs: ['/auth/*', '/auth/**/*'], 15 | lastLocaleField: 'last_locale', 16 | locales 17 | }; 18 | -------------------------------------------------------------------------------- /template/config/koa-cash.js: -------------------------------------------------------------------------------- 1 | const ms = require('ms'); 2 | const safeStringify = require('fast-safe-stringify'); 3 | const logger = require('../helpers/logger'); 4 | 5 | module.exports = (client) => ({ 6 | maxAge: ms('1y') / 1000, 7 | hash: (ctx) => `koa-cash:${ctx.request.url}`, 8 | setCachedHeader: true, 9 | async get(key) { 10 | let [buffer, data] = await Promise.all([ 11 | client.getBuffer(`buffer:${key}`), 12 | client.get(key) 13 | ]); 14 | if (!data) return; 15 | try { 16 | data = JSON.parse(data); 17 | if (buffer) data.body = buffer; 18 | return data; 19 | } catch (err) { 20 | logger.error(err); 21 | } 22 | }, 23 | async set(key, value, maxAge) { 24 | // 25 | // we must detect if the `value.body` is a buffer 26 | // and if so, we need to store it in redis as a buffer 27 | // and fetch it as a buffer using `getBuffer` as well 28 | // 29 | if (Buffer.isBuffer(value.body)) { 30 | const { body, ...data } = value; 31 | await client.mset( 32 | new Map([ 33 | [`buffer:${key}`, body, ...(maxAge > 0 ? ['EX', maxAge] : [])], 34 | [key, safeStringify(data), ...(maxAge > 0 ? ['EX', maxAge] : [])] 35 | ]) 36 | ); 37 | } else { 38 | if (maxAge <= 0) return client.set(key, safeStringify(value)); 39 | client.set(key, safeStringify(value), 'EX', maxAge); 40 | } 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /template/config/locales.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'ar', 3 | 'cs', 4 | 'da', 5 | 'de', 6 | 'en', 7 | 'es', 8 | 'fi', 9 | 'fr', 10 | 'he', 11 | 'hu', 12 | 'id', 13 | 'it', 14 | 'ja', 15 | 'ko', 16 | 'nl', 17 | 'no', 18 | 'pl', 19 | 'pt', 20 | 'ru', 21 | 'sv', 22 | 'th', 23 | 'tr', 24 | 'uk', 25 | 'vi', 26 | 'zh' 27 | ]; 28 | -------------------------------------------------------------------------------- /template/config/logger.js: -------------------------------------------------------------------------------- 1 | const Axe = require('axe'); 2 | const { WebClient } = require('@slack/web-api'); 3 | const signale = require('signale'); 4 | const pino = require('pino')({ 5 | customLevels: { 6 | log: 30 7 | }, 8 | hooks: { 9 | // 10 | logMethod(inputArgs, method) { 11 | return method.call(this, { 12 | // 13 | // message: inputArgs[0], 14 | msg: inputArgs[0], 15 | meta: inputArgs[1] 16 | }); 17 | } 18 | } 19 | }); 20 | 21 | const env = require('./env'); 22 | 23 | const isProduction = env.NODE_ENV === 'production'; 24 | 25 | const config = { 26 | logger: isProduction ? pino : signale, 27 | level: isProduction ? 'warn' : 'debug', 28 | showStack: env.SHOW_STACK, 29 | showMeta: env.SHOW_META, 30 | capture: false, 31 | name: env.APP_NAME 32 | }; 33 | 34 | // create our application logger that uses a custom callback function 35 | const axe = new Axe({ ...config }); 36 | 37 | if (env.SLACK_API_TOKEN) { 38 | // custom logger for Slack that inherits our Axe config 39 | // (with the exception of a `callback` function for logging to Slack) 40 | const slackLogger = new Axe(config); 41 | 42 | // create an instance of the Slack Web Client API for posting messages 43 | const web = new WebClient(env.SLACK_API_TOKEN, { 44 | // 45 | logger: slackLogger, 46 | logLevel: config.level 47 | }); 48 | 49 | axe.setCallback(async (level, message, meta) => { 50 | try { 51 | // if meta did not have `slack: true` 52 | // and it was not an error then return early 53 | if (!meta.slack && !['error', 'fatal'].includes(level)) return; 54 | 55 | // otherwise post a message to the slack channel 56 | const fields = [ 57 | { 58 | title: 'Level', 59 | value: meta.level, 60 | short: true 61 | }, 62 | { 63 | title: 'Environment', 64 | value: meta.app.environment, 65 | short: true 66 | }, 67 | { 68 | title: 'Hostname', 69 | value: meta.app.hostname, 70 | short: true 71 | }, 72 | { 73 | title: 'Hash', 74 | value: meta.app.hash, 75 | short: true 76 | } 77 | ]; 78 | 79 | if (meta.user && meta.user.email) 80 | fields.push({ 81 | title: 'Email', 82 | value: meta.user.email, 83 | short: true 84 | }); 85 | 86 | const result = await web.chat.postMessage({ 87 | channel: 'logs', 88 | username: 'Cabin', 89 | icon_emoji: ':evergreen_tree:', 90 | attachments: [ 91 | { 92 | title: meta.err && meta.err.message ? meta.err.message : message, 93 | color: 'danger', 94 | text: meta.err && meta.err.stack ? meta.err.stack : null, 95 | fields 96 | } 97 | ] 98 | }); 99 | 100 | // finally log the result from slack 101 | axe.info('web.chat.postMessage', { result, callback: false }); 102 | } catch (err) { 103 | axe.error(err, { callback: false }); 104 | } 105 | }); 106 | } 107 | 108 | module.exports = { 109 | logger: axe, 110 | capture: false 111 | }; 112 | -------------------------------------------------------------------------------- /template/config/meta.js: -------------------------------------------------------------------------------- 1 | // turn off max length eslint rule since this is a config file with long strs 2 | /* eslint max-len: 0 */ 3 | 4 | // meta tags is an object of paths 5 | // where each path is an array containing 6 | // 7 | // '/some/path': [ title, description ] 8 | // 9 | // note that you can include 10 | // if needed around certain text values in ordre to 11 | // prevent Google Translate from translating them 12 | // note that the helper named `meta` in `helpers/meta.js` 13 | // will automatically remove HTML tags from the strings 14 | // before returning them to be rendered in tags such as 15 | // `` and `<meta name="description">` 16 | // 17 | 18 | module.exports = function (config) { 19 | // currently we cannot use the `|` pipe character due to this issue 20 | // <https://github.com/mashpie/i18n-node/issues/274> 21 | // otherwise we'd have `| Lad` below, which is SEO standard 22 | // so instead we need to use `|` which is the html entity 23 | // which gets decoded to a `|` in the helper.meta function 24 | const lad = `| <span class="notranslate">${config.appName}</span>`; 25 | const meta = { 26 | // note that we don't do `Home ${lad}` because if we forget to define 27 | // meta for a specific route it'd be confusing to see Home 28 | // in the title bar in the user's browser 29 | '/': [config.appName, config.pkg.description], 30 | '/about': [`About ${lad}`, `Learn more about ${config.appName}`], 31 | '/terms': [`Terms ${lad}`, 'Read our terms and conditions of use'], 32 | '/privacy': [`Privacy Policy ${lad}`, 'Read our privacy policy'], 33 | '/support': [ 34 | `Support ${lad}`, 35 | `Ask ${config.appName} your questions or leave comments` 36 | ], 37 | '/login': [`Sign in ${lad}`, 'Sign in to your account'], 38 | '/logout': [`Sign out of ${lad}`, 'Sign out of your account'], 39 | '/register': [`Sign up ${lad}`, `Create a ${config.appName} account`], 40 | '/verify': [`Verify email ${lad}`, `Verify your ${config.appName} email`], 41 | '/my-account': [ 42 | `My Account ${lad}`, 43 | `Manage your ${config.appName} profile` 44 | ], 45 | '/my-account/api': [`API ${lad}`, 'Manage your API credentials'], 46 | '/dashboard': [ 47 | `Dashboard ${lad}`, 48 | `Access your ${config.appName} account dashboard` 49 | ], 50 | '/admin': [`Admin ${lad}`, `Access your ${config.appName} admin`], 51 | '/forgot-password': [ 52 | `Forgot password ${lad}`, 53 | 'Reset your account password' 54 | ], 55 | '/reset-password': [ 56 | `Reset password ${lad}`, 57 | 'Confirm your password reset token' 58 | ], 59 | '/auth': [`Auth ${lad}`, 'Authenticate yourself to log in'], 60 | '/otp': [ 61 | `Two Factor Auth ${lad}`, 62 | 'Authenticate yourself with optional OTP to log in' 63 | ], 64 | '/404': [ 65 | `Page not found ${lad}`, 66 | 'The page you requested could not be found' 67 | ], 68 | '/500': [`Server error ${lad}`, 'A server error has unfortunately occurred'] 69 | }; 70 | meta[config.loginRoute] = [`Sign in ${lad}`, 'Sign in to your account']; 71 | meta[config.verifyRoute] = [ 72 | `Verify email ${lad}`, 73 | `Verify your ${config.appName} email` 74 | ]; 75 | meta[config.otpRoutePrefix] = [ 76 | `Two Factor Auth ${lad}`, 77 | 'Authenticate yourself with optional OTP to log in' 78 | ]; 79 | return meta; 80 | }; 81 | -------------------------------------------------------------------------------- /template/config/utilities.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const ajc = require('array-join-conjunction'); 3 | const dashify = require('dashify'); 4 | const hljs = require('highlight.js'); 5 | const humanize = require('humanize-string'); 6 | const isBot = require('isbot'); 7 | const isSANB = require('is-string-and-not-blank'); 8 | const dayjs = require('dayjs'); 9 | const numeral = require('numeral'); 10 | const pluralize = require('pluralize'); 11 | const reservedEmailAddressesList = require('reserved-email-addresses-list'); 12 | const striptags = require('striptags'); 13 | const titleize = require('titleize'); 14 | const toEmoji = require('gemoji/name-to-emoji'); 15 | const validator = require('validator'); 16 | const { boolean } = require('boolean'); 17 | 18 | const json = (string, replacer = null, space = 2) => 19 | JSON.stringify(string, replacer, space); 20 | 21 | const emoji = (string) => (toEmoji[string] ? toEmoji[string] : ''); 22 | 23 | module.exports = { 24 | _, 25 | ajc, 26 | boolean, 27 | dashify, 28 | emoji, 29 | hljs, 30 | humanize, 31 | isBot, 32 | isSANB, 33 | json, 34 | dayjs, 35 | numeral, 36 | pluralize, 37 | reservedEmailAddressesList, 38 | striptags, 39 | titleize, 40 | validator 41 | }; 42 | -------------------------------------------------------------------------------- /template/config/web.js: -------------------------------------------------------------------------------- 1 | const i18n = require('../helpers/i18n'); 2 | const logger = require('../helpers/logger'); 3 | const passport = require('../helpers/passport'); 4 | const routes = require('../routes'); 5 | const env = require('./env'); 6 | const cookieOptions = require('./cookies'); 7 | const koaCashConfig = require('./koa-cash'); 8 | const config = require('.'); 9 | 10 | module.exports = (client) => ({ 11 | routes: routes.web, 12 | logger, 13 | i18n, 14 | cookies: cookieOptions, 15 | meta: config.meta, 16 | views: config.views, 17 | passport, 18 | koaCash: env.CACHE_RESPONSES ? koaCashConfig(client) : false, 19 | // temp disable until headers already sent error in koa-redirect-loop is fixed 20 | redirectLoop: false, 21 | cacheResponses: env.CACHE_RESPONSES 22 | ? { 23 | routes: [ 24 | '/css/(.*)', 25 | '/img/(.*)', 26 | '/js/(.*)', 27 | '/fonts/(.*)', 28 | '/browserconfig(.*)', 29 | '/robots(.*)', 30 | '/site(.*)', 31 | '/favicon(.*)' 32 | ] 33 | } 34 | : false 35 | }); 36 | -------------------------------------------------------------------------------- /template/emails/_content.pug: -------------------------------------------------------------------------------- 1 | .container 2 | .row 3 | .col-lg-6 4 | h4 Subheading 5 | p Donec id elit non mi porta gravida at eget metus. Maecenas faucibus mollis interdum. 6 | h4 Subheading 7 | p Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Cras mattis consectetur purus sit amet fermentum. 8 | h4 Subheading 9 | p Maecenas sed diam eget risus varius blandit sit amet non magna. 10 | .col-lg-6 11 | h4 Subheading 12 | p Donec id elit non mi porta gravida at eget metus. Maecenas faucibus mollis interdum. 13 | h4 Subheading 14 | p Morbi leo risus, porta ac consectetur ac, vestibulum at eros. Cras mattis consectetur purus sit amet fermentum. 15 | h4 Subheading 16 | p Maecenas sed diam eget risus varius blandit sit amet non magna. 17 | -------------------------------------------------------------------------------- /template/emails/_footer.pug: -------------------------------------------------------------------------------- 1 | footer.py-3.mt-3 2 | .container 3 | .row 4 | .col-12.text-center 5 | a(href=config.urls.web) 6 | img(src=manifest('img/apple-touch-icon.png'), width=90, height=90, alt='').d-inline-block.align-middle 7 | ul.m-0.p-0.text-white.py-3 8 | li.px-1.d-inline: small: a(href=`${config.urls.web}/support`).text-white= t('Support') 9 | li.px-1.d-inline: small: a(href=`${config.urls.web}/about`).text-white= t('About') 10 | li.px-1.d-inline: small: a(href=`${config.urls.web}/privacy`).text-white= t('Privacy') 11 | li.px-1.d-inline: small: a(href=`${config.urls.web}/terms`).text-white= t('Terms') 12 | p: small.text-white!= `© ${config.appName}` 13 | -------------------------------------------------------------------------------- /template/emails/_nav.pug: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/emails/_nav.pug -------------------------------------------------------------------------------- /template/emails/account-update/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block 9 | h5.card-header.text-center= t('Account update') 10 | .card-body 11 | .card-text 12 | p= t('You have successfully updated your account:') 13 | ul 14 | each field in accountUpdates 15 | if field.name === config.passport.fields.otpEnabled 16 | li= t(`Two-factor authentication has been ${field.current ? 'enabled' : 'disabled'}.`) 17 | else if field.name === config.userFields.apiToken 18 | li= t('API token has been reset.') 19 | else 20 | li= t(`${field.text} has changed from ${field.previous} to ${field.current}`) 21 | .card-footer.text-center: small.text-muted= t('If you did not make these changes, then please contact us immediately.') 22 | -------------------------------------------------------------------------------- /template/emails/account-update/subject.pug: -------------------------------------------------------------------------------- 1 | = t('Account update') 2 | -------------------------------------------------------------------------------- /template/emails/change-email/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Change your email') 10 | .card-body 11 | .card-text 12 | p!= t('If you wish to change your email from <strong>%s</strong> to <strong>%s</strong>, then click the link below within %s.', user[config.passportLocalMongoose.usernameField], user[config.userFields.changeEmailNewAddress], dayjs().from(dayjs(user[config.userFields.changeEmailTokenExpiresAt]), true)) 13 | a.btn.btn-lg.btn-block.btn-primary(href=link, role="button")= t('Confirm now') 14 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 15 | -------------------------------------------------------------------------------- /template/emails/change-email/subject.pug: -------------------------------------------------------------------------------- 1 | = `${emoji('email')} ${user[config.passport.fields.givenName] ? `${user[config.passport.fields.givenName]}, ${t('change your email')}` : t('Change your email')}` 2 | -------------------------------------------------------------------------------- /template/emails/inquiry/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Support inquiry') 10 | .card-body 11 | p.card-text 12 | if inquiry.is_email_only 13 | = t('Thank you for submitting a support request on our website. We will get back to you soon!') 14 | else 15 | = t('Below is a copy of your request submitted on ') 16 | = dayjs(inquiry.created_at).format('MM/DD/YY') 17 | = t(' at ') 18 | = dayjs(inquiry.created_at).format('h:mm A') 19 | = '.' 20 | hr 21 | p: pre: code= inquiry.message 22 | hr 23 | br 24 | = t('We will review your request and reply as soon as possible.') 25 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 26 | -------------------------------------------------------------------------------- /template/emails/inquiry/subject.pug: -------------------------------------------------------------------------------- 1 | = `${emoji('mega')} ${t('Your Support Request')} #${Date.now()}` 2 | -------------------------------------------------------------------------------- /template/emails/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang=locale) 3 | head 4 | block head 5 | meta(charset="utf-8") 6 | meta(name="viewport", content="width=device-width") 7 | meta(http-equiv="X-UA-Compatible", content="IE=edge") 8 | meta(name="x-apple-disable-message-reformatting") 9 | title= subject 10 | link(rel="stylesheet", href=manifest('css/app.css'), data-inline) 11 | body.email 12 | block body 13 | block nav 14 | include _nav 15 | block content 16 | include _content 17 | block footer 18 | include _footer 19 | -------------------------------------------------------------------------------- /template/emails/recovery/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Account recovery') 10 | .card-body 11 | p.card-text= t('You have successfully verified your account for recovery. An administrator will follow-up within 3-5 business days to unlock access to your account.') 12 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 13 | -------------------------------------------------------------------------------- /template/emails/recovery/subject.pug: -------------------------------------------------------------------------------- 1 | = t('Account recovery process started') 2 | -------------------------------------------------------------------------------- /template/emails/reset-password/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Password reset') 10 | .card-body 11 | .card-text 12 | p= `${user[config.passport.fields.givenName] ? user[config.passport.fields.givenName] : t('Hello')},` 13 | p= t(`Click the button below within ${dayjs(user[config.userFields.resetTokenExpiresAt]).fromNow(true)} to continue.`) 14 | a.btn.btn-lg.btn-block.btn-success(href=link, role="button")= t('Change your password') 15 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 16 | -------------------------------------------------------------------------------- /template/emails/reset-password/subject.pug: -------------------------------------------------------------------------------- 1 | = `${emoji('unlock')} ${user[config.passport.fields.givenName] ? `${user[config.passport.fields.givenName]}, ${t('reset your password')}` : t('Reset your password')}` 2 | -------------------------------------------------------------------------------- /template/emails/two-factor-reminder/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Two-factor authentication') 10 | .card-body 11 | p.card-text 12 | = t("Don't worry! Your account is completely secure. This is just a friendly reminder to enhance the security of your account.") 13 | = ' ' 14 | = t('We suggest that you enable two-factor authentication since your domains are using Enhanced Protection.') 15 | = ' ' 16 | = t('Doing so will help to keep your domains, aliases, and account even more secure.') 17 | a.btn.btn-lg.btn-block.btn-primary(href=`${config.urls.web}/${locale}/my-account/security`) 18 | .d-inline-block.align-middle 19 | = t('Manage security') 20 | .card-footer: small.text-muted= t('We send this reminder every three months to increase security.') 21 | -------------------------------------------------------------------------------- /template/emails/two-factor-reminder/subject.pug: -------------------------------------------------------------------------------- 1 | = t('Did you want to enable two-factor authentication?') 2 | -------------------------------------------------------------------------------- /template/emails/verify/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('Verify email') 10 | .card-body 11 | .card-text 12 | p= t('Use this verification code:') 13 | pre: code.display-3= pin 14 | p= t(`This pin expires within ${dayjs().from(dayjs(expiresAt), true)}.`) 15 | a.btn.btn-lg.btn-block.btn-success(href=link, role="button")= t('Verify now') 16 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 17 | -------------------------------------------------------------------------------- /template/emails/verify/subject.pug: -------------------------------------------------------------------------------- 1 | = `${t('Verification code:')} ${pin}` 2 | -------------------------------------------------------------------------------- /template/emails/welcome/html.pug: -------------------------------------------------------------------------------- 1 | 2 | extends ../layout 3 | 4 | block content 5 | .container.mt-3 6 | .row 7 | .col-12 8 | .card.d-block.text-center 9 | h5.card-header= t('The Best Node.js Framework') 10 | .card-body 11 | p.card-text= t('Thank you for signing up!') 12 | a.btn.btn-lg.btn-success(href=config.pkg.homepage, role="button")= t('Read the docs') 13 | .card-footer: small.text-muted= t('If you did not submit this request, then please reply to let us know.') 14 | -------------------------------------------------------------------------------- /template/emails/welcome/subject.pug: -------------------------------------------------------------------------------- 1 | = `${emoji('wave')} ${user[config.passport.fields.givenName] ? `${user[config.passport.fields.givenName]}, ${t('thanks for signing up!')}` : t('Thanks for signing up!')}` 2 | 3 | -------------------------------------------------------------------------------- /template/env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/env -------------------------------------------------------------------------------- /template/gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | *.awspublish* 8 | *.env 9 | *.pem 10 | build 11 | temp.md 12 | !test/.env 13 | !.gulpfile.env 14 | *.lcov 15 | .base64-cache 16 | .vscode 17 | package-lock.json 18 | *-ar.md 19 | *-AR.md 20 | *-cs.md 21 | *-CS.md 22 | *-da.md 23 | *-DA.md 24 | *-de.md 25 | *-DE.md 26 | *-en.md 27 | *-EN.md 28 | *-es.md 29 | *-ES.md 30 | *-fi.md 31 | *-FI.md 32 | *-fr.md 33 | *-FR.md 34 | *-he.md 35 | *-HE.md 36 | *-hu.md 37 | *-HU.md 38 | *-id.md 39 | *-ID.md 40 | *-it.md 41 | *-IT.md 42 | *-ja.md 43 | *-JA.md 44 | *-ko.md 45 | *-KO.md 46 | *-nl.md 47 | *-NL.md 48 | *-no.md 49 | *-NO.md 50 | *-pl.md 51 | *-PL.md 52 | *-pt.md 53 | *-PT.md 54 | *-ru.md 55 | *-RU.md 56 | *-sv.md 57 | *-SV.md 58 | *-th.md 59 | *-TH.md 60 | *-tr.md 61 | *-TR.md 62 | *-uk.md 63 | *-UK.md 64 | *-vi.md 65 | *-VI.md 66 | *-zh.md 67 | *-ZH.md 68 | -------------------------------------------------------------------------------- /template/helpers/email.js: -------------------------------------------------------------------------------- 1 | const Email = require('email-templates'); 2 | const _ = require('lodash'); 3 | 4 | const config = require('../config'); 5 | const getEmailLocals = require('./get-email-locals'); 6 | const logger = require('./logger'); 7 | 8 | const email = new Email(config.email); 9 | 10 | module.exports = async (data) => { 11 | try { 12 | logger.info('sending email', { data }); 13 | if (!_.isObject(data.locals)) data.locals = {}; 14 | const emailLocals = await getEmailLocals(); 15 | Object.assign(data.locals, emailLocals); 16 | const res = await email.send(data); 17 | logger.info('sent email', { res }); 18 | return res; 19 | } catch (err) { 20 | logger.error(err); 21 | throw err; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /template/helpers/get-email-locals.js: -------------------------------------------------------------------------------- 1 | async function getEmailLocals() { 2 | return {}; 3 | } 4 | 5 | module.exports = getEmailLocals; 6 | -------------------------------------------------------------------------------- /template/helpers/i18n.js: -------------------------------------------------------------------------------- 1 | const I18N = require('@ladjs/i18n'); 2 | 3 | const cookieOptions = require('../config/cookies'); 4 | const i18nConfig = require('../config/i18n'); 5 | const logger = require('./logger'); 6 | 7 | const i18n = new I18N({ 8 | ...i18nConfig, 9 | cookieOptions, 10 | logger 11 | }); 12 | 13 | module.exports = i18n; 14 | -------------------------------------------------------------------------------- /template/helpers/logger.js: -------------------------------------------------------------------------------- 1 | const Axe = require('axe'); 2 | 3 | const loggerConfig = require('../config/logger'); 4 | 5 | const logger = new Axe(loggerConfig); 6 | 7 | module.exports = logger; 8 | -------------------------------------------------------------------------------- /template/helpers/markdown.js: -------------------------------------------------------------------------------- 1 | const markdownIt = require('markdown-it'); 2 | const markdownItEmoji = require('markdown-it-emoji'); 3 | const markdownItGitHubHeadings = require('markdown-it-github-headings'); 4 | const markdownItHighlightJS = require('markdown-it-highlightjs'); 5 | const markdownItTaskCheckbox = require('markdown-it-task-checkbox'); 6 | 7 | // <https://github.com/markdown-it/markdown-it> 8 | // <https://github.com/valeriangalliat/markdown-it-highlightjs> 9 | // <https://github.com/jstransformers/jstransformer-markdown-it/issues/7#issuecomment-168945445> 10 | // <https://github.com/shime/livedown> 11 | const markdown = markdownIt({ 12 | html: true, 13 | linkify: true 14 | }); 15 | markdown.use(markdownItHighlightJS); 16 | markdown.use(markdownItTaskCheckbox); 17 | markdown.use(markdownItEmoji); 18 | markdown.use(markdownItGitHubHeadings, { 19 | prefix: '' 20 | }); 21 | 22 | module.exports = markdown; 23 | -------------------------------------------------------------------------------- /template/helpers/passport.js: -------------------------------------------------------------------------------- 1 | const Passport = require('@ladjs/passport'); 2 | 3 | const config = require('../config'); 4 | const { Users } = require('../app/models'); 5 | 6 | const passport = new Passport(Users, config.passport); 7 | 8 | module.exports = passport; 9 | -------------------------------------------------------------------------------- /template/helpers/policies.js: -------------------------------------------------------------------------------- 1 | const Policies = require('@ladjs/policies'); 2 | 3 | const { 4 | loginOtpRoute, 5 | verifyRoute, 6 | userFields, 7 | passport, 8 | appName, 9 | loginRoute 10 | } = require('../config'); 11 | const { Users } = require('../app/models'); 12 | 13 | const policies = new Policies( 14 | { 15 | schemeName: appName, 16 | hasVerifiedEmail: userFields.hasVerifiedEmail, 17 | verifyRoute, 18 | loginRoute, 19 | loginOtpRoute, 20 | passport 21 | }, 22 | (apiToken) => { 23 | const query = {}; 24 | query[userFields.apiToken] = apiToken; 25 | return Users.findOne(query); 26 | } 27 | ); 28 | 29 | module.exports = policies; 30 | -------------------------------------------------------------------------------- /template/helpers/send-verification-email.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | 3 | const config = require('../config'); 4 | const email = require('./email'); 5 | const logger = require('./logger'); 6 | 7 | async function sendVerificationEmail(ctx) { 8 | ctx.state.user = await ctx.state.user.sendVerificationEmail(ctx); 9 | 10 | // attempt to send them an email 11 | try { 12 | await email({ 13 | template: 'verify', 14 | message: { 15 | to: ctx.state.user[config.userFields.fullEmail] 16 | }, 17 | locals: { 18 | user: ctx.state.user.toObject(), 19 | expiresAt: ctx.state.user[config.userFields.verificationPinExpiresAt], 20 | pin: ctx.state.user[config.userFields.verificationPin], 21 | link: `${config.urls.web}${config.verifyRoute}?pin=${ 22 | ctx.state.user[config.userFields.verificationPin] 23 | }` 24 | } 25 | }); 26 | } catch (err) { 27 | logger.error(err); 28 | // reset if there was an error 29 | try { 30 | ctx.state.user = await ctx.state.user.sendVerificationEmail(ctx, true); 31 | } catch (err) { 32 | logger.error(err); 33 | } 34 | 35 | throw Boom.badRequest(ctx.translateError('EMAIL_FAILED_TO_SEND')); 36 | } 37 | 38 | return ctx.state.user; 39 | } 40 | 41 | module.exports = sendVerificationEmail; 42 | -------------------------------------------------------------------------------- /template/helpers/to-object.js: -------------------------------------------------------------------------------- 1 | const ObjectID = require('bson-objectid'); 2 | const _ = require('lodash'); 3 | 4 | function toObject(Model, doc) { 5 | if (_.isUndefined(Model) || _.isUndefined(doc)) 6 | throw new Error('Model and doc are required'); 7 | if (ObjectID.isValid(doc)) return doc; 8 | if (_.isFunction(doc.toObject)) return doc.toObject(); 9 | return new Model(doc).toObject(); 10 | } 11 | 12 | module.exports = toObject; 13 | -------------------------------------------------------------------------------- /template/index.js: -------------------------------------------------------------------------------- 1 | const bree = require('./bree'); 2 | const api = require('./api'); 3 | const web = require('./web'); 4 | const proxy = require('./proxy'); 5 | 6 | module.exports = { 7 | bree, 8 | api, 9 | web, 10 | proxy 11 | }; 12 | -------------------------------------------------------------------------------- /template/jobs/account-updates.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('../config/env'); 3 | 4 | const { parentPort } = require('worker_threads'); 5 | 6 | const Graceful = require('@ladjs/graceful'); 7 | const Mongoose = require('@ladjs/mongoose'); 8 | const sharedConfig = require('@ladjs/shared-config'); 9 | const humanize = require('humanize-string'); 10 | const titleize = require('titleize'); 11 | 12 | const config = require('../config'); 13 | const logger = require('../helpers/logger'); 14 | const email = require('../helpers/email'); 15 | 16 | const bree = require('../bree'); 17 | 18 | const Users = require('../app/models/user'); 19 | 20 | const breeSharedConfig = sharedConfig('BREE'); 21 | 22 | const mongoose = new Mongoose({ ...breeSharedConfig.mongoose, logger }); 23 | 24 | const graceful = new Graceful({ 25 | mongooses: [mongoose], 26 | brees: [bree], 27 | logger 28 | }); 29 | 30 | graceful.listen(); 31 | 32 | (async () => { 33 | await mongoose.connect(); 34 | 35 | const users = await Users.find({ 36 | account_updates: { 37 | $exists: true, 38 | $not: { $size: 0 } 39 | } 40 | }); 41 | 42 | // merge and map to actionable email 43 | await Promise.all( 44 | users.map(async (user) => { 45 | const accountUpdates = user[config.userFields.accountUpdates].map( 46 | (update) => { 47 | const { fieldName, current, previous } = update; 48 | return { 49 | name: fieldName, 50 | text: titleize(humanize(fieldName)), 51 | current, 52 | previous 53 | }; 54 | } 55 | ); 56 | 57 | // send account updates email 58 | try { 59 | await email({ 60 | template: 'account-update', 61 | message: { 62 | to: user[config.userFields.fullEmail] 63 | }, 64 | locals: { 65 | accountUpdates 66 | } 67 | }); 68 | // delete account updates 69 | user[config.userFields.accountUpdates] = []; 70 | await user.save(); 71 | } catch (err) { 72 | logger.error(err); 73 | } 74 | }) 75 | ); 76 | 77 | if (parentPort) parentPort.postMessage('done'); 78 | else process.exit(0); 79 | })(); 80 | -------------------------------------------------------------------------------- /template/jobs/index.js: -------------------------------------------------------------------------------- 1 | const { boolean } = require('boolean'); 2 | 3 | const jobs = [ 4 | { 5 | name: 'welcome-email', 6 | interval: '1m' 7 | }, 8 | { 9 | name: 'account-updates', 10 | interval: '1m' 11 | } 12 | // TODO: currently commented out until we have better translation solution 13 | // { 14 | // name: 'translate-phrases', 15 | // interval: '1h' 16 | // } 17 | // { 18 | // name: 'translate-markdown', 19 | // interval: '30m' 20 | // }, 21 | ]; 22 | 23 | if (boolean(process.env.AUTH_OTP_ENABLED)) 24 | jobs.push({ 25 | name: 'two-factor-reminder', 26 | interval: '3h' 27 | }); 28 | 29 | module.exports = jobs; 30 | -------------------------------------------------------------------------------- /template/jobs/translate-markdown.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('../config/env'); 3 | 4 | const { parentPort } = require('worker_threads'); 5 | 6 | const Mandarin = require('mandarin'); 7 | const I18N = require('@ladjs/i18n'); 8 | 9 | const i18nConfig = require('../config/i18n'); 10 | const logger = require('../helpers/logger'); 11 | 12 | // 13 | // NOTE: we want our own instance of i18n that does not auto reload files 14 | // 15 | const i18n = new I18N({ 16 | ...i18nConfig, 17 | autoReload: false, 18 | updateFiles: false, 19 | syncFiles: false, 20 | logger 21 | }); 22 | 23 | const mandarin = new Mandarin({ i18n, logger }); 24 | 25 | (async () => { 26 | await mandarin.markdown(); 27 | if (parentPort) parentPort.postMessage('done'); 28 | else process.exit(0); 29 | })(); 30 | -------------------------------------------------------------------------------- /template/jobs/translate-phrases.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('../config/env'); 3 | 4 | const { parentPort } = require('worker_threads'); 5 | 6 | const Mandarin = require('mandarin'); 7 | const I18N = require('@ladjs/i18n'); 8 | 9 | const i18nConfig = require('../config/i18n'); 10 | const logger = require('../helpers/logger'); 11 | 12 | // 13 | // NOTE: we want our own instance of i18n that does not auto reload files 14 | // 15 | const i18n = new I18N({ 16 | ...i18nConfig, 17 | autoReload: false, 18 | updateFiles: false, 19 | syncFiles: false, 20 | logger 21 | }); 22 | 23 | const mandarin = new Mandarin({ i18n, logger }); 24 | 25 | (async () => { 26 | await mandarin.translate(); 27 | if (parentPort) parentPort.postMessage('done'); 28 | else process.exit(0); 29 | })(); 30 | -------------------------------------------------------------------------------- /template/jobs/two-factor-reminder.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('../config/env'); 3 | 4 | const os = require('os'); 5 | const { parentPort } = require('worker_threads'); 6 | 7 | const Graceful = require('@ladjs/graceful'); 8 | const Mongoose = require('@ladjs/mongoose'); 9 | const dayjs = require('dayjs'); 10 | const pMap = require('p-map'); 11 | const sharedConfig = require('@ladjs/shared-config'); 12 | 13 | const config = require('../config'); 14 | const email = require('../helpers/email'); 15 | const logger = require('../helpers/logger'); 16 | const bree = require('../bree'); 17 | const Users = require('../app/models/user'); 18 | const Domains = require('../app/models/domain'); 19 | 20 | const breeSharedConfig = sharedConfig('BREE'); 21 | const concurrency = os.cpus().length; 22 | const mongoose = new Mongoose({ ...breeSharedConfig.mongoose, logger }); 23 | const graceful = new Graceful({ 24 | mongooses: [mongoose], 25 | brees: [bree], 26 | logger 27 | }); 28 | const threeMonthsAgo = dayjs().subtract(3, 'months').toDate(); 29 | 30 | // store boolean if the job is cancelled 31 | let isCancelled = false; 32 | 33 | // handle cancellation (this is a very simple example) 34 | if (parentPort) 35 | parentPort.once('message', (message) => { 36 | // 37 | // TODO: once we can manipulate concurrency option to p-map 38 | // we could make it `Number.MAX_VALUE` here to speed cancellation up 39 | // <https://github.com/sindresorhus/p-map/issues/28> 40 | // 41 | if (message === 'cancel') isCancelled = true; 42 | }); 43 | 44 | graceful.listen(); 45 | 46 | async function mapper(_id) { 47 | // return early if the job was already cancelled 48 | if (isCancelled) return; 49 | 50 | try { 51 | const user = await Users.findById(_id); 52 | 53 | // user could have been deleted in the interim 54 | if (!user) return; 55 | 56 | // check if they already enabled it 57 | // in the interim if so return early 58 | if (user[config.passport.fields.otpEnabled]) return; 59 | 60 | // in case email was sent for whatever reason 61 | if (user[config.userFields.twoFactorReminderSentAt]) return; 62 | 63 | // send email 64 | await email({ 65 | template: 'two-factor-reminder', 66 | message: { 67 | to: user[config.userFields.fullEmail] 68 | }, 69 | locals: { user: user.toObject() } 70 | }); 71 | 72 | // store that we sent this email 73 | await Users.findByIdAndUpdate(user._id, { 74 | $set: { 75 | [config.userFields.twoFactorReminderSentAt]: new Date() 76 | } 77 | }); 78 | } catch (err) { 79 | logger.error(err); 80 | } 81 | } 82 | 83 | (async () => { 84 | await mongoose.connect(); 85 | 86 | const _ids = await Domains.distinct('members.user', { 87 | plan: { 88 | $ne: 'free' 89 | } 90 | }); 91 | 92 | // filter for users that do not have two-factor auth set up yet 93 | const userIds = await Users.distinct('_id', { 94 | $and: [ 95 | { 96 | _id: { $in: _ids } 97 | }, 98 | { 99 | $or: [ 100 | { 101 | [config.userFields.twoFactorReminderSentAt]: { 102 | $exists: false 103 | } 104 | }, 105 | { 106 | [config.userFields.twoFactorReminderSentAt]: { 107 | $lte: threeMonthsAgo 108 | } 109 | } 110 | ] 111 | }, 112 | { 113 | [config.passport.fields.otpEnabled]: false 114 | } 115 | ] 116 | }); 117 | 118 | logger.info('sending reminders', { count: userIds.length, _ids }); 119 | 120 | // send emails and update `two_factor_reminder_sent_at` date 121 | await pMap(userIds, mapper, { concurrency }); 122 | 123 | if (parentPort) parentPort.postMessage('done'); 124 | else process.exit(0); 125 | })(); 126 | -------------------------------------------------------------------------------- /template/jobs/welcome-email.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('../config/env'); 3 | 4 | const { parentPort } = require('worker_threads'); 5 | 6 | const Graceful = require('@ladjs/graceful'); 7 | const Mongoose = require('@ladjs/mongoose'); 8 | const dayjs = require('dayjs'); 9 | const sharedConfig = require('@ladjs/shared-config'); 10 | 11 | const config = require('../config'); 12 | const email = require('../helpers/email'); 13 | const logger = require('../helpers/logger'); 14 | const bree = require('../bree'); 15 | const Users = require('../app/models/user'); 16 | 17 | const breeSharedConfig = sharedConfig('BREE'); 18 | 19 | const mongoose = new Mongoose({ ...breeSharedConfig.mongoose, logger }); 20 | 21 | const graceful = new Graceful({ 22 | mongooses: [mongoose], 23 | brees: [bree], 24 | logger 25 | }); 26 | 27 | graceful.listen(); 28 | 29 | (async () => { 30 | await mongoose.connect(); 31 | 32 | const object = { 33 | created_at: { 34 | $lte: dayjs().subtract(1, 'minute').toDate() 35 | } 36 | }; 37 | object[config.userFields.welcomeEmailSentAt] = { $exists: false }; 38 | object[config.userFields.hasVerifiedEmail] = true; 39 | 40 | const _ids = await Users.distinct('_id', object); 41 | 42 | // send welcome email 43 | await Promise.all( 44 | _ids.map(async (_id) => { 45 | try { 46 | const user = await Users.findById(_id); 47 | 48 | // in case user deleted their account 49 | if (!user) return; 50 | 51 | // in case email was sent for whatever reason 52 | if (user[config.userFields.welcomeEmailSentAt]) return; 53 | 54 | // send email 55 | await email({ 56 | template: 'welcome', 57 | message: { 58 | to: user[config.userFields.fullEmail] 59 | }, 60 | locals: { user: user.toObject() } 61 | }); 62 | 63 | // store that we sent this email 64 | await Users.findByIdAndUpdate(user._id, { 65 | $set: { 66 | [config.userFields.welcomeEmailSentAt]: new Date() 67 | } 68 | }); 69 | await user.save(); 70 | } catch (err) { 71 | logger.error(err); 72 | } 73 | }) 74 | ); 75 | 76 | if (parentPort) parentPort.postMessage('done'); 77 | else process.exit(0); 78 | })(); 79 | -------------------------------------------------------------------------------- /template/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["locales", "test", "build", "assets", "gulpfile.js"] 3 | } 4 | -------------------------------------------------------------------------------- /template/package-scripts.js: -------------------------------------------------------------------------------- 1 | const { series, concurrent } = require('nps-utils'); 2 | 3 | module.exports = { 4 | scripts: { 5 | all: series.nps('build', 'apps-and-watch'), 6 | appsAndWatch: concurrent.nps('apps', 'watch'), 7 | apps: concurrent.nps('bree', 'api', 'web'), 8 | 9 | bree: 'nodemon bree.js', 10 | api: 'nodemon api.js', 11 | web: 'nodemon web.js', 12 | 13 | watch: 'gulp watch', 14 | clean: 'gulp clean', 15 | build: 'gulp build', 16 | publishAssets: 'gulp publish', 17 | 18 | lintJs: 'gulp xo', 19 | lintMd: 'gulp remark', 20 | lintPug: 'gulp pug', 21 | lint: concurrent.nps('lint-js', 'lint-md', 'lint-pug'), 22 | 23 | // <https://github.com/kentcdodds/nps-utils/issues/24> 24 | pretest: concurrent.nps('lint', 'build', 'pretest-redis'), 25 | // <https://stackoverflow.com/a/16974060/3586413> 26 | pretestRedis: 27 | "redis-cli EVAL \"return redis.call('del', 'defaultKey', unpack(redis.call('keys', ARGV[1])))\" 0 *_limit_test:*", 28 | 29 | test: 'ava', 30 | testCoverage: series('nps pretest', 'nyc ava'), 31 | testUpdateSnapshots: series('nps pretest', 'ava --update-snapshots'), 32 | 33 | coverage: 'nyc report --reporter=text-lcov > coverage.lcov && codecov' 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /template/proxy.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('./config/env'); 3 | 4 | const Graceful = require('@ladjs/graceful'); 5 | const ProxyServer = require('@ladjs/proxy'); 6 | const ip = require('ip'); 7 | 8 | const logger = require('./helpers/logger'); 9 | 10 | const proxy = new ProxyServer({ 11 | logger 12 | }); 13 | 14 | if (!module.parent) { 15 | const graceful = new Graceful({ servers: [proxy], logger }); 16 | graceful.listen(); 17 | 18 | (async () => { 19 | try { 20 | await proxy.listen(proxy.config.port); 21 | if (process.send) process.send('ready'); 22 | const { port } = proxy.server.address(); 23 | logger.info( 24 | `Lad proxy server listening on ${port} (LAN: ${ip.address()}:${port})` 25 | ); 26 | } catch (err) { 27 | logger.error(err); 28 | // eslint-disable-next-line unicorn/no-process-exit 29 | process.exit(1); 30 | } 31 | })(); 32 | } 33 | -------------------------------------------------------------------------------- /template/routes/api/index.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | 3 | const v1 = require('./v1'); 4 | 5 | const router = new Router(); 6 | router.use(v1.routes()); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /template/routes/api/v1/index.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | 3 | const policies = require('../../../helpers/policies'); 4 | const api = require('../../../app/controllers/api'); 5 | 6 | const router = new Router({ 7 | prefix: '/v1' 8 | }); 9 | 10 | router.post('/log', api.v1.log.checkToken, api.v1.log.parseLog); 11 | router.post('/account', api.v1.users.create); 12 | router.get('/account', policies.ensureApiToken, api.v1.users.retrieve); 13 | router.put('/account', policies.ensureApiToken, api.v1.users.update); 14 | 15 | module.exports = router; 16 | -------------------------------------------------------------------------------- /template/routes/index.js: -------------------------------------------------------------------------------- 1 | const web = require('./web'); 2 | const api = require('./api'); 3 | 4 | module.exports = { 5 | web, 6 | api 7 | }; 8 | -------------------------------------------------------------------------------- /template/routes/web/admin.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | const render = require('koa-views-render'); 3 | const paginate = require('koa-ctx-paginate'); 4 | 5 | const policies = require('../../helpers/policies'); 6 | const web = require('../../app/controllers/web'); 7 | 8 | const router = new Router({ prefix: '/admin' }); 9 | 10 | router.use(policies.ensureAdmin); 11 | router.use(policies.ensureOtp); 12 | router.use(web.breadcrumbs); 13 | router.get('/', render('admin')); 14 | router.get('/users', paginate.middleware(10, 50), web.admin.users.list); 15 | router.get('/users/:id', web.admin.users.retrieve); 16 | router.put('/users/:id', web.admin.users.update); 17 | router.post('/users/:id/login', web.admin.users.login); 18 | router.delete('/users/:id', web.admin.users.remove); 19 | 20 | module.exports = router; 21 | -------------------------------------------------------------------------------- /template/routes/web/auth.js: -------------------------------------------------------------------------------- 1 | const Boom = require('@hapi/boom'); 2 | const Router = require('@koa/router'); 3 | const { boolean } = require('boolean'); 4 | 5 | const passport = require('../../helpers/passport'); 6 | const config = require('../../config'); 7 | const web = require('../../app/controllers/web'); 8 | 9 | const router = new Router({ prefix: '/auth' }); 10 | 11 | router 12 | .param('provider', (provider, ctx, next) => { 13 | if (!boolean(process.env[`AUTH_${provider.toUpperCase()}_ENABLED`])) { 14 | return ctx.throw(Boom.badRequest(ctx.translateError('INVALID_PROVIDER'))); 15 | } 16 | 17 | return next(); 18 | }) 19 | .get( 20 | '/:provider', 21 | web.auth.catchError, 22 | web.auth.parseReturnOrRedirectTo, 23 | (ctx, next) => { 24 | passport.authenticate( 25 | ctx.params.provider, 26 | config.passport[ctx.params.provider] 27 | )(ctx, next); 28 | } 29 | ) 30 | .get('/:provider/ok', web.auth.catchError, (ctx, next) => { 31 | const redirect = ctx.session.returnTo 32 | ? ctx.session.returnTo 33 | : ctx.state.l(config.passportCallbackOptions.successReturnToOrRedirect); 34 | return passport.authenticate(ctx.params.provider, { 35 | ...config.passportCallbackOptions, 36 | successReturnToOrRedirect: redirect 37 | })(ctx, next); 38 | }); 39 | 40 | if (boolean(process.env.AUTH_GOOGLE_ENABLED)) { 41 | router.get( 42 | '/google/consent', 43 | web.auth.catchError, 44 | passport.authenticate('google', { 45 | accessType: 'offline', 46 | prompt: 'consent', // See google strategy in passport helper 47 | scope: [ 48 | 'https://www.googleapis.com/auth/userinfo.email', 49 | 'https://www.googleapis.com/auth/userinfo.profile' 50 | ] 51 | }) 52 | ); 53 | } 54 | 55 | module.exports = router; 56 | -------------------------------------------------------------------------------- /template/routes/web/index.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | const render = require('koa-views-render'); 3 | const { boolean } = require('boolean'); 4 | 5 | const config = require('../../config'); 6 | const policies = require('../../helpers/policies'); 7 | const { web } = require('../../app/controllers'); 8 | 9 | const admin = require('./admin'); 10 | const auth = require('./auth'); 11 | const myAccount = require('./my-account'); 12 | const otp = require('./otp'); 13 | 14 | const router = new Router(); 15 | 16 | // status page crawlers often send `HEAD /` requests 17 | router.head('/', (ctx) => { 18 | ctx.body = 'OK'; 19 | }); 20 | // report URI support (not locale specific) 21 | router.post('/report', web.report); 22 | 23 | const localeRouter = new Router({ prefix: '/:locale' }); 24 | 25 | localeRouter 26 | .get('/', web.auth.homeOrDashboard) 27 | .get('/dashboard', (ctx) => { 28 | ctx.status = 301; 29 | ctx.redirect(ctx.state.l('/my-account')); 30 | }) 31 | .get('/about', render('about')) 32 | .get('/404', render('404')) 33 | .get('/500', render('500')) 34 | .get('/terms', render('terms')) 35 | .get('/privacy', render('privacy')) 36 | .get('/support', render('support')) 37 | .post('/support', web.support) 38 | .get('/forgot-password', render('forgot-password')) 39 | .post('/forgot-password', web.auth.forgotPassword) 40 | .get('/reset-password/:token', render('reset-password')) 41 | .post('/reset-password/:token', web.auth.resetPassword) 42 | .get( 43 | config.verifyRoute, 44 | policies.ensureLoggedIn, 45 | web.auth.parseReturnOrRedirectTo, 46 | web.auth.verify 47 | ) 48 | .post( 49 | config.verifyRoute, 50 | policies.ensureLoggedIn, 51 | web.auth.parseReturnOrRedirectTo, 52 | web.auth.verify 53 | ) 54 | .get('/logout', web.auth.logout) 55 | .get( 56 | config.loginRoute, 57 | policies.ensureLoggedOut, 58 | web.auth.parseReturnOrRedirectTo, 59 | web.auth.registerOrLogin 60 | ) 61 | .post(config.loginRoute, policies.ensureLoggedOut, web.auth.login) 62 | .get('/register', web.auth.parseReturnOrRedirectTo, web.auth.registerOrLogin) 63 | .post('/register', web.auth.register); 64 | 65 | localeRouter.use(myAccount.routes()); 66 | localeRouter.use(admin.routes()); 67 | 68 | if (boolean(process.env.AUTH_OTP_ENABLED)) localeRouter.use(otp.routes()); 69 | 70 | router.use(auth.routes()); 71 | router.use(localeRouter.routes()); 72 | 73 | module.exports = router; 74 | -------------------------------------------------------------------------------- /template/routes/web/my-account.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | const render = require('koa-views-render'); 3 | 4 | const policies = require('../../helpers/policies'); 5 | const web = require('../../app/controllers/web'); 6 | 7 | const router = new Router({ prefix: '/my-account' }); 8 | 9 | router.use(policies.ensureLoggedIn); 10 | router.use(policies.ensureOtp); 11 | router.use(web.breadcrumbs); 12 | router.get('/', (ctx) => { 13 | ctx.redirect(ctx.state.l('/my-account/profile')); 14 | }); 15 | router.put('/', web.myAccount.update); 16 | router.get('/change-email/:token', render('change-email')); 17 | router.post('/change-email/:token', web.auth.changeEmail); 18 | router.get('/profile', render('my-account/profile')); 19 | router.put('/profile', web.myAccount.update); 20 | router.delete('/security', web.myAccount.resetAPIToken); 21 | router.get('/security', render('my-account/security')); 22 | router.post('/recovery-keys', web.myAccount.recoveryKeys); 23 | 24 | module.exports = router; 25 | -------------------------------------------------------------------------------- /template/routes/web/otp.js: -------------------------------------------------------------------------------- 1 | const Router = require('@koa/router'); 2 | const render = require('koa-views-render'); 3 | 4 | const policies = require('../../helpers/policies'); 5 | const web = require('../../app/controllers/web'); 6 | const config = require('../../config'); 7 | 8 | const router = new Router({ prefix: config.otpRoutePrefix }); 9 | router.use(policies.ensureLoggedIn); 10 | 11 | router 12 | .get(config.otpRouteLoginPath, render('otp/login')) 13 | .post(config.otpRouteLoginPath, web.auth.loginOtp) 14 | .get('/setup', render('otp/setup')) 15 | .post('/setup', web.otp.setup) 16 | .post('/disable', web.otp.disable) 17 | .post('/recovery', web.otp.recovery) 18 | .get('/keys', render('otp/keys')) 19 | .post('/keys', web.auth.recoveryKey); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /template/test/_utils.js: -------------------------------------------------------------------------------- 1 | // Necessary utils for testing 2 | // Librarires required for testing 3 | const { MongoMemoryServer } = require('mongodb-memory-server'); 4 | const mongoose = require('mongoose'); 5 | const request = require('supertest'); 6 | const sinon = require('sinon'); 7 | const proxyquire = require('proxyquire'); 8 | const { factory, MongooseAdapter } = require('factory-girl'); 9 | 10 | // Models and server 11 | const config = require('../config'); 12 | const { Users } = require('../app/models'); 13 | 14 | let mongod; 15 | const adapter = new MongooseAdapter(); 16 | 17 | // create connection to mongoose before all tests 18 | exports.before = async () => { 19 | mongod = await MongoMemoryServer.create(); 20 | const uri = mongod.getUri(); 21 | await mongoose.connect(uri); 22 | 23 | factory.setAdapter(adapter); 24 | factory.define('user', Users, { 25 | email: factory.sequence('Users.email', (n) => `test${n}@example.com`), 26 | password: '!@K#NLK!#N' 27 | }); 28 | }; 29 | 30 | // create fixtures before each test 31 | exports.beforeEach = async (t) => { 32 | // setup stubs for serializeUser and deserializeUser 33 | t.context.serialize = sinon.stub().returns(() => {}); 34 | t.context.deserialize = sinon.stub().returns(() => {}); 35 | proxyquire('../helpers/passport', { 36 | '../config': { 37 | passport: { 38 | ...config.passport, 39 | serializeUser: t.context.serialize, 40 | deserializeUser: t.context.deserialize 41 | } 42 | } 43 | }); 44 | 45 | t.context.web = await request.agent(require('../web').server); 46 | t.context.api = await request.agent(require('../api').server); 47 | }; 48 | 49 | exports.afterEach = async () => { 50 | sinon.restore(); 51 | }; 52 | 53 | exports.after = async () => { 54 | mongoose.disconnect(); 55 | mongod.stop(); 56 | 57 | factory.cleanUp(); 58 | }; 59 | -------------------------------------------------------------------------------- /template/test/api/v1.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const config = require('../../config'); 4 | const phrases = require('../../config/phrases'); 5 | 6 | const utils = require('../utils'); 7 | 8 | test.before(utils.setupMongoose); 9 | test.after.always(utils.teardownMongoose); 10 | test.beforeEach(utils.setupApiServer); 11 | 12 | test('fails when no creds are presented', async (t) => { 13 | const { api } = t.context; 14 | const res = await api.get('/v1/account'); 15 | t.is(401, res.status); 16 | }); 17 | 18 | test("returns current user's account", async (t) => { 19 | const { api } = t.context; 20 | const body = { 21 | email: 'testglobal@api.example.com', 22 | password: 'FKOZa3kP0TxSCA' 23 | }; 24 | 25 | let res = await api.post('/v1/account').send(body); 26 | t.is(200, res.status); 27 | 28 | res = await api.get('/v1/account').set({ 29 | Authorization: `Basic ${Buffer.from( 30 | `${res.body[config.userFields.apiToken]}:` 31 | ).toString('base64')}` 32 | }); 33 | t.is(res.body.message, phrases.EMAIL_VERIFICATION_REQUIRED); 34 | t.is(401, res.status); 35 | }); 36 | -------------------------------------------------------------------------------- /template/test/config/snapshots/utilities.js.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/config/utilities.js` 2 | 3 | The actual snapshot is saved in `utilities.js.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## returns JSON with 2 spaces 8 | 9 | > Snapshot 1 10 | 11 | `{␊ 12 | "ok": "hey"␊ 13 | }` 14 | -------------------------------------------------------------------------------- /template/test/config/snapshots/utilities.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/test/config/snapshots/utilities.js.snap -------------------------------------------------------------------------------- /template/test/config/utilities.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const { json, emoji } = require('../../config/utilities'); 4 | 5 | test('returns JSON with 2 spaces', (t) => { 6 | t.snapshot(json({ ok: 'hey' })); 7 | }); 8 | 9 | test('returns valid emoji or empty string', (t) => { 10 | t.is(emoji('cat'), '🐱'); 11 | t.is(emoji('invalid_emoji'), ''); 12 | }); 13 | -------------------------------------------------------------------------------- /template/test/utils.js: -------------------------------------------------------------------------------- 1 | // Necessary utils for testing 2 | // Librarires required for testing 3 | const { MongoMemoryServer } = require('mongodb-memory-server'); 4 | const mongoose = require('mongoose'); 5 | const request = require('supertest'); 6 | const { factory, MongooseAdapter } = require('factory-girl'); 7 | const getPort = require('get-port'); 8 | 9 | factory.setAdapter(new MongooseAdapter()); 10 | 11 | // Models and server 12 | const config = require('../config'); 13 | const { Users } = require('../app/models'); 14 | 15 | let mongod; 16 | 17 | // 18 | // setup utilities 19 | // 20 | exports.setupMongoose = async () => { 21 | mongod = await MongoMemoryServer.create(); 22 | const uri = mongod.getUri(); 23 | await mongoose.connect(uri); 24 | }; 25 | 26 | exports.setupWebServer = async (t) => { 27 | // must require here in order to load changes made during setup 28 | const { app } = require('../web'); 29 | const port = await getPort(); 30 | t.context.web = request.agent(app.listen(port)); 31 | }; 32 | 33 | exports.setupApiServer = async (t) => { 34 | // must require here in order to load changes made during setup 35 | const { app } = require('../api'); 36 | const port = await getPort(); 37 | t.context.api = request.agent(app.listen(port)); 38 | }; 39 | 40 | // make sure to load the web server first using setupWebServer 41 | exports.loginUser = async (t) => { 42 | const { web, user, password } = t.context; 43 | 44 | await web.post('/en/login').send({ 45 | email: user.email, 46 | password 47 | }); 48 | }; 49 | 50 | // 51 | // teardown utilities 52 | // 53 | exports.teardownMongoose = async () => { 54 | await mongoose.disconnect(); 55 | mongod.stop(); 56 | }; 57 | 58 | // 59 | // factory definitions 60 | // <https://github.com/simonexmachina/factory-girl> 61 | // 62 | exports.defineUserFactory = async () => { 63 | factory.define('user', Users, (buildOptions) => { 64 | const user = { 65 | email: factory.sequence('Users.email', (n) => `test${n}@example.com`), 66 | password: buildOptions.password ? buildOptions.password : '!@K#NLK!#N' 67 | }; 68 | 69 | if (buildOptions.resetToken) { 70 | user[config.userFields.resetToken] = buildOptions.resetToken; 71 | user[config.userFields.resetTokenExpiresAt] = new Date( 72 | Date.now() + 10000 73 | ); 74 | } 75 | 76 | return user; 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /template/test/web/index.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const utils = require('../utils'); 4 | 5 | test.beforeEach(utils.setupWebServer); 6 | 7 | test('redirects to correct locale', async (t) => { 8 | const { web } = t.context; 9 | const res = await web.get('/'); 10 | t.is(res.status, 302); 11 | t.is(res.headers.location, '/en'); 12 | }); 13 | 14 | test('returns English homepage', async (t) => { 15 | const { web } = t.context; 16 | const res = await web.get('/en').set({ Accept: 'text/html' }); 17 | 18 | t.snapshot(res.text); 19 | }); 20 | 21 | test('returns Spanish homepage', async (t) => { 22 | const { web } = t.context; 23 | const res = await web.get('/es').set({ Accept: 'text/html' }); 24 | 25 | t.snapshot(res.text); 26 | }); 27 | 28 | test('returns English ToS', async (t) => { 29 | const { web } = t.context; 30 | const res = await web.get('/en/terms').set({ Accept: 'text/html' }); 31 | 32 | t.snapshot(res.text); 33 | }); 34 | 35 | test('returns Spanish ToS', async (t) => { 36 | const { web } = t.context; 37 | const res = await web.get('/es/terms').set({ Accept: 'text/html' }); 38 | 39 | t.snapshot(res.text); 40 | }); 41 | 42 | test('GET /:locale/about', async (t) => { 43 | const { web } = t.context; 44 | const res = await web.get('/en/about'); 45 | 46 | t.is(res.status, 200); 47 | t.assert(res.text.includes('About')); 48 | }); 49 | 50 | test('GET /:locale/404', async (t) => { 51 | const { web } = t.context; 52 | const res = await web.get('/en/404'); 53 | 54 | t.is(res.status, 200); 55 | t.assert(res.text.includes('Page not found')); 56 | }); 57 | 58 | test('GET /:locale/500', async (t) => { 59 | const { web } = t.context; 60 | const res = await web.get('/en/500'); 61 | 62 | t.is(res.status, 200); 63 | t.assert(res.text.includes('Server Error')); 64 | }); 65 | 66 | test('GET /:locale/privacy', async (t) => { 67 | const { web } = t.context; 68 | const res = await web.get('/en/privacy'); 69 | 70 | t.is(res.status, 200); 71 | t.assert(res.text.includes('Privacy Policy')); 72 | }); 73 | 74 | test('GET /:locale/support', async (t) => { 75 | const { web } = t.context; 76 | const res = await web.get('/en/support'); 77 | 78 | t.is(res.status, 200); 79 | t.assert(res.text.includes('Contact Support')); 80 | }); 81 | -------------------------------------------------------------------------------- /template/test/web/snapshots/index.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/test/web/snapshots/index.js.snap -------------------------------------------------------------------------------- /template/test/web/snapshots/otp.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/test/web/snapshots/otp.js.snap -------------------------------------------------------------------------------- /template/test/web/snapshots/support.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/template/test/web/snapshots/support.js.snap -------------------------------------------------------------------------------- /template/test/web/support.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const sinon = require('sinon'); 3 | 4 | const { Inquiries } = require('../../app/models'); 5 | 6 | const utils = require('../utils'); 7 | 8 | test.before(utils.setupMongoose); 9 | test.before((t) => { 10 | t.context.countDocuments = sinon 11 | .stub(Inquiries, 'countDocuments') 12 | .callThrough(); 13 | }); 14 | test.after.always((t) => { 15 | t.context.countDocuments.restore(); 16 | }); 17 | test.after.always(utils.teardownMongoose); 18 | test.beforeEach(utils.setupWebServer); 19 | 20 | test('creates inquiry', async (t) => { 21 | const { web } = t.context; 22 | const res = await web 23 | .post('/en/support') 24 | .send({ email: 'test@example.com', message: 'Test message!' }); 25 | 26 | t.is(res.status, 302); 27 | t.is(res.header.location, '/'); 28 | }); 29 | 30 | test('fails creating inquiry if last inquiry was within last 24 hours (HTML)', async (t) => { 31 | const { web, countDocuments } = t.context; 32 | const email = 'test2@example.com'; 33 | countDocuments 34 | .withArgs(sinon.match.hasNested('$or[1].email', email)) 35 | .resolves(1); 36 | 37 | const res = await web.post('/en/support').set({ Accept: 'text/html' }).send({ 38 | email, 39 | message: 'Test message!' 40 | }); 41 | 42 | t.is(res.status, 400); 43 | t.snapshot(res.text); 44 | }); 45 | 46 | test('fails creating inquiry if last inquiry was within last 24 hours (JSON)', async (t) => { 47 | const { web, countDocuments } = t.context; 48 | const email = 'test3@example.com'; 49 | countDocuments 50 | .withArgs(sinon.match.hasNested('$or[1].email', email)) 51 | .resolves(1); 52 | 53 | const res = await web.post('/en/support').send({ 54 | email, 55 | message: 'Test message!' 56 | }); 57 | 58 | t.is(res.status, 400); 59 | t.is( 60 | JSON.parse(res.text).message, 61 | 'You have reached the limit for sending support requests. Please try again.' 62 | ); 63 | 64 | t.context.countDocuments.restore(); 65 | }); 66 | -------------------------------------------------------------------------------- /template/web.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | require('./config/env'); 3 | 4 | const Graceful = require('@ladjs/graceful'); 5 | const Mongoose = require('@ladjs/mongoose'); 6 | const Redis = require('@ladjs/redis'); 7 | const Web = require('@ladjs/web'); 8 | const ip = require('ip'); 9 | const sharedConfig = require('@ladjs/shared-config'); 10 | 11 | const config = require('./config'); 12 | const logger = require('./helpers/logger'); 13 | const webConfig = require('./config/web'); 14 | 15 | const webSharedConfig = sharedConfig('WEB'); 16 | const client = new Redis(webSharedConfig.redis, logger); 17 | const web = new Web(webConfig(client)); 18 | 19 | if (!module.parent) { 20 | const mongoose = new Mongoose({ ...web.config.mongoose, logger }); 21 | 22 | const graceful = new Graceful({ 23 | mongooses: [mongoose], 24 | servers: [web], 25 | redisClients: [web.client, client], 26 | logger 27 | }); 28 | graceful.listen(); 29 | 30 | (async () => { 31 | try { 32 | await web.listen(web.config.port); 33 | if (process.send) process.send('ready'); 34 | const { port } = web.server.address(); 35 | logger.info( 36 | `Lad web server listening on ${port} (LAN: ${ip.address()}:${port})` 37 | ); 38 | if (config.env === 'development') 39 | logger.info( 40 | `Please visit ${config.urls.web} in your browser for testing` 41 | ); 42 | await mongoose.connect(); 43 | } catch (err) { 44 | logger.error(err); 45 | // eslint-disable-next-line unicorn/no-process-exit 46 | process.exit(1); 47 | } 48 | })(); 49 | } 50 | 51 | module.exports = web; 52 | -------------------------------------------------------------------------------- /test/snapshots/test.js.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladjs/lad/bc0f9722c3dd0af690be8a34e02751ce091a7891/test/snapshots/test.js.snap -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const sao = require('sao'); 4 | 5 | const template = path.join(__dirname, '..'); 6 | 7 | const defaults = { 8 | name: 'lad', 9 | description: 'my project description', 10 | author: 'Nick Baugh', 11 | email: 'niftylettuce@gmail.com', 12 | website: 'http://niftylettuce.com', 13 | username: 'niftylettuce' 14 | }; 15 | 16 | test('defaults', async (t) => { 17 | const stream = await sao.mockPrompt(template, { 18 | ...defaults, 19 | name: 'my-package-name' 20 | }); 21 | t.snapshot( 22 | stream.fileList 23 | .sort() 24 | .filter( 25 | (name) => 26 | !name.includes('/snapshots') && 27 | !name.startsWith('.base64-cache/') && 28 | !name.startsWith('locales/') 29 | ), 30 | 'generated files' 31 | ); 32 | const content = stream.fileContents('README.md'); 33 | t.snapshot(content, 'content of README.md'); 34 | }); 35 | 36 | test('invalid name', async (t) => { 37 | const error = await t.throwsAsync( 38 | sao.mockPrompt(template, { ...defaults, name: 'Foo Bar Baz Beep' }) 39 | ); 40 | t.regex(error.message, /package name cannot have uppercase letters/); 41 | }); 42 | 43 | test('invalid email', async (t) => { 44 | const error = await t.throwsAsync( 45 | sao.mockPrompt(template, { ...defaults, email: 'niftylettuce' }) 46 | ); 47 | t.regex(error.message, /Invalid email/); 48 | }); 49 | 50 | test('invalid website', async (t) => { 51 | const error = await t.throwsAsync( 52 | sao.mockPrompt(template, { ...defaults, website: 'niftylettuce' }) 53 | ); 54 | t.regex(error.message, /Invalid URL/); 55 | }); 56 | 57 | test('invalid username', async (t) => { 58 | const error = await t.throwsAsync( 59 | sao.mockPrompt(template, { ...defaults, username: '$$$' }) 60 | ); 61 | t.regex(error.message, /Invalid GitHub username/); 62 | }); 63 | 64 | test('invalid repo', async (t) => { 65 | const error = await t.throwsAsync( 66 | sao.mockPrompt(template, { 67 | ...defaults, 68 | username: 'lassjs', 69 | repo: 'https://bitbucket.org/foo/bar' 70 | }) 71 | ); 72 | t.regex( 73 | error.message, 74 | /Please include a valid GitHub.com URL without a trailing slash/ 75 | ); 76 | }); 77 | --------------------------------------------------------------------------------