├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature-improvement-request.md │ ├── feature_request.md │ └── support-request.md ├── SECURITY.md ├── codeql │ └── codeql-config.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── codeql.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.js ├── config.json ├── core ├── initRoute.js ├── loadConfig.js ├── loadController.js ├── loadCustomTags.js ├── loadHooks.js ├── loadModel.js ├── loadModules.js ├── loadPermissions.js ├── loadRest.js ├── loadServer.js ├── parseMethodRouteKey.js └── runHook.js ├── drivers ├── db │ ├── Interface.js │ └── Memory.js └── render │ ├── Html.js │ └── Interface.js ├── hooks.json ├── hooks └── sapling │ ├── model │ └── retrieve.js │ └── user │ ├── forgot.js │ ├── logged.js │ ├── login.js │ ├── logout.js │ ├── recover.js │ ├── register.js │ └── update.js ├── index.js ├── lib ├── Cluster.js ├── Hash.js ├── Notifications.js ├── Redirect.js ├── Request.js ├── Response.js ├── SaplingError.js ├── Storage.js ├── Templating.js ├── UnauthorizedError.js ├── Uploads.js ├── User.js ├── Utils.js ├── Validation.js └── ValidationError.js ├── package-lock.json ├── package.json ├── permissions.json ├── public └── app.css ├── static ├── mail │ └── lostpass.html └── response │ ├── 404.html │ ├── 500.html │ ├── data.html │ └── error.html ├── test ├── _data │ ├── accessible │ │ ├── bar │ │ │ └── foo.txt │ │ └── file.txt │ ├── config │ │ ├── compression.json │ │ ├── config.production.json │ │ ├── cors.json │ │ ├── corsProduction.json │ │ ├── custom.production.json │ │ ├── mangled.json │ │ ├── mangledProd.production.json │ │ ├── production.json │ │ └── strict.json │ ├── controller │ │ ├── controller.json │ │ ├── mangled.json │ │ ├── plain │ │ │ ├── bar.html │ │ │ ├── index.html │ │ │ ├── sub │ │ │ │ ├── foo.html │ │ │ │ └── index.html │ │ │ └── unrelated.txt │ │ ├── protectedFiles │ │ │ ├── _protected.html │ │ │ └── sub │ │ │ │ ├── _protected.html │ │ │ │ └── foo.html │ │ └── protectedFolders │ │ │ ├── _protected │ │ │ ├── _protected.html │ │ │ └── foo.html │ │ │ └── bar.html │ ├── files │ │ ├── archive.zip │ │ ├── audio.wav │ │ ├── document.docx │ │ ├── font.otf │ │ ├── image.png │ │ ├── other.txt │ │ ├── photo.jpg │ │ └── video.mp4 │ ├── hooks │ │ ├── get.json │ │ ├── inaccessible.json │ │ └── mangled.json │ ├── inaccessible │ │ ├── bar │ │ │ └── foo.txt │ │ └── file.txt │ ├── models │ │ ├── access │ │ │ └── posts.json │ │ ├── mangled │ │ │ └── posts.json │ │ ├── object │ │ │ └── posts.json │ │ ├── string │ │ │ └── posts.json │ │ └── unrelated │ │ │ └── .dotfile │ ├── permissions │ │ ├── array.json │ │ ├── incompleteObject.json │ │ ├── invalid.json │ │ ├── invalidObject.json │ │ ├── methods.json │ │ ├── object.json │ │ ├── string.json │ │ └── undefinedMethod.json │ └── views │ │ ├── csrf.html │ │ ├── loose.html │ │ ├── plain.html │ │ ├── safe.html │ │ └── tight.html ├── _utils │ ├── app.js │ ├── getFileObject.js │ ├── request.js │ └── response.js ├── core │ ├── initRoute.test.js │ ├── loadConfig.test.js │ ├── loadController.test.js │ ├── loadCustomTags.test.js │ ├── loadHooks.test.js │ ├── loadModel.test.js │ ├── loadModules.test.js │ ├── loadPermissions.test.js │ ├── loadRest.test.js │ ├── loadServer.test.js │ ├── parseMethodRouteKey.test.js │ └── runHook.test.js ├── drivers │ ├── db │ │ ├── Interface.test.js │ │ └── Memory.test.js │ └── render │ │ ├── Html.test.js │ │ └── Interface.test.js ├── hooks │ ├── model │ │ └── retrieve.test.js │ └── user │ │ ├── forgot.test.js │ │ ├── logged.test.js │ │ ├── login.test.js │ │ ├── logout.test.js │ │ ├── recover.test.js │ │ ├── register.test.js │ │ └── update.test.js └── lib │ ├── Cluster.test.js │ ├── Hash.test.js │ ├── Notifications.test.js │ ├── Redirect.test.js │ ├── Request.test.js │ ├── Response.test.js │ ├── SaplingError.test.js │ ├── Storage.test.js │ ├── Templating.test.js │ ├── Uploads.test.js │ ├── User.test.js │ ├── Utils.test.js │ └── Validation.test.js └── views ├── index.html └── my-account.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: saplingjs 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report any unexpected behaviour or bug in the Sapling framework itself 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Short description** 13 | 14 | 15 | **Step to reproduce** 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 20 | **Expected behavior** 21 | 22 | 23 | **Actual behavior** 24 | 25 | 26 | **Screenshots** 27 | 28 | 29 | **Environment** 30 | - Sapling version: 31 | - Node.js version: 32 | - OS and version: 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-improvement-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature improvement request 3 | about: Suggest an improvement for an existing feature 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What feature is this concerning?** 11 | 12 | 13 | **What are you trying to achieve?** 14 | 15 | 16 | **What would fix it?** 17 | 18 | 19 | **Additional context** 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a brand-new feature 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What are you trying to achieve?** 11 | 12 | 13 | **What would fix it?** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: Any problems or questions you might have related to Sapling 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What feature is this concerning?** 11 | 12 | 13 | **What's the problem?** 14 | 15 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Sapling is still in active early development. No releases should be considered production-ready, but security issues are fixed with new releases as soon as possible. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please report any security vulnerabilities to **security@saplingjs.com**. A maintainer will respond within 48 hours. 10 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | paths-ignore: 2 | - node_modules 3 | - '**/*.test.js' 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | reviewers: 13 | - "groenroos" 14 | labels: 15 | - "maintenance" 16 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "Code quality" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '43 23 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: JavaScript 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v3 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v2 30 | with: 31 | config-file: ./.github/codeql/codeql-config.yml 32 | languages: javascript 33 | 34 | - name: Perform CodeQL analysis 35 | uses: github/codeql-action/analyze@v2 36 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | lint: 11 | name: JavaScript 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: '16' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run linter 24 | run: npm run lint 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test-node-18: 11 | name: Node.js v18 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: '18' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run test 24 | run: npm run test:report 25 | 26 | - name: Report coverage 27 | run: npm run test:send 28 | 29 | test-node-16: 30 | name: Node.js v16 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-node@v2 36 | with: 37 | node-version: '16' 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - name: Run test 43 | run: npm run test 44 | 45 | test-node-14: 46 | name: Node.js v14 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - uses: actions/checkout@v2 51 | - uses: actions/setup-node@v2 52 | with: 53 | node-version: '14' 54 | 55 | - name: Install dependencies 56 | run: npm ci 57 | 58 | - name: Run test 59 | run: npm run test 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | env 4 | tmp 5 | coverage 6 | coverage.lcov 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-present Oskari Groenroos 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | 10 | **Sapling** is a Node.js framework for building websites, web apps and APIs as fast as you can imagine them. With unrivalled speed of development, you can prototype quickly, iterate with ease, and deploy the same day. Zero code, zero config – unless you want to. And what's more, it comes with all the SaaS features you never want to write, all built-in. 11 | 12 | ## Documentation 13 | 14 | For the full documentation, go to [saplingjs.com/docs](https://saplingjs.com/docs/). 15 | 16 | ## Quick installation 17 | 18 | 1. Make sure you have Node.js 14 or later. 19 | 2. Run `npm i -g @sapling/cli && sapling create` 20 | 21 | ## Ecosystem 22 | 23 | Project | Description 24 | --------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------- 25 | [@sapling/cli](https://github.com/saplingjs/cli) | Command Line Interface for creating and managing Sapling projects. 26 | [@sapling/gui](https://github.com/saplingjs/gui) | UI for editing and managing a Sapling project. 27 | [@sapling/vue-components](https://github.com/saplingjs/vue-components) | Assortment of optional unopinionated semi-automatic frontend Vue components for common UI tasks. 28 | [@sapling/db-driver-mongodb](https://github.com/saplingjs/db-driver-mongodb) | Support for MongoDB databases. 29 | [@sapling/render-driver-handlebars](https://github.com/saplingjs/render-driver-handlebars) | Support for the Handlebars templating engine. 30 | [@sapling/render-driver-nunjucks](https://github.com/saplingjs/render-driver-nunjucks) | Support for the Nunjucks templating engine. 31 | [@sapling/render-driver-pug](https://github.com/saplingjs/render-driver-pug) | Support for the Pug templating engine. 32 | 33 | ## Questions & Issues 34 | 35 | Bug reports, feature requests and support queries can be filed as [issues on GitHub](https://github.com/saplingjs/sapling/issues). Please use the templates provided and fill in all the requested details. 36 | 37 | ## Changelog 38 | 39 | Detailed changes for each release are documented in the [release notes](https://github.com/saplingjs/sapling/releases). 40 | 41 | ## License 42 | 43 | [MIT](https://opensource.org/licenses/MIT) 44 | 45 | Originally adapted from "Sproute" by Louis Stowasser. 46 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App 3 | * 4 | * Initialises a Sapling instance and handles incoming requests 5 | */ 6 | 7 | /* System dependencies */ 8 | import async from 'async'; 9 | 10 | /* Internal dependencies */ 11 | import { console } from './lib/Cluster.js'; 12 | import Response from './lib/Response.js'; 13 | import Utils from './lib/Utils.js'; 14 | 15 | import parseMethodRouteKey from './core/parseMethodRouteKey.js'; 16 | import runHook from './core/runHook.js'; 17 | 18 | 19 | /** 20 | * The App class 21 | */ 22 | class App { 23 | /** 24 | * Load and construct all aspects of the app 25 | * 26 | * @param {string} dir Directory for the site files 27 | * @param {object} opts Optional options to override the defaults and filesystem ones 28 | * @param {function} next Callback after initialisation 29 | */ 30 | constructor(dir, options, next) { 31 | /* Global vars */ 32 | this.dir = dir; 33 | options = options || {}; 34 | this.opts = options; 35 | 36 | /* Define an admin session for big ops */ 37 | this.adminSession = { 38 | user: { role: 'admin' }, 39 | }; 40 | 41 | /* Load utility functions */ 42 | this.utils = new Utils(this); 43 | 44 | /* Load everything */ 45 | async.series([ 46 | async callback => { 47 | const { default: loadConfig } = await import('./core/loadConfig.js'); 48 | await loadConfig.call(this, callback); 49 | }, 50 | async callback => { 51 | if (options.loadServer !== false) { 52 | const { default: loadServer } = await import('./core/loadServer.js'); 53 | await loadServer.call(this, options, callback); 54 | } 55 | }, 56 | async callback => { 57 | if (options.loadModel !== false) { 58 | const { default: loadModel } = await import('./core/loadModel.js'); 59 | await loadModel.call(this, callback); 60 | } 61 | }, 62 | async callback => { 63 | if (options.loadPermissions !== false) { 64 | const { default: loadPermissions } = await import('./core/loadPermissions.js'); 65 | await loadPermissions.call(this, callback); 66 | } 67 | }, 68 | async callback => { 69 | if (options.loadController !== false) { 70 | const { default: loadController } = await import('./core/loadController.js'); 71 | await loadController.call(this, callback); 72 | } 73 | }, 74 | async callback => { 75 | if (options.loadHooks !== false) { 76 | const { default: loadHooks } = await import('./core/loadHooks.js'); 77 | await loadHooks.call(this, callback); 78 | } 79 | }, 80 | async callback => { 81 | if (options.loadViews !== false) { 82 | const { default: loadCustomTags } = await import('./core/loadCustomTags.js'); 83 | await loadCustomTags.call(this, callback); 84 | } 85 | }, 86 | async callback => { 87 | const { default: loadModules } = await import('./core/loadModules.js'); 88 | await loadModules.call(this, callback); 89 | }, 90 | async callback => { 91 | if (options.loadViews !== false) { 92 | for (const route in this.controller) { 93 | if (Object.prototype.hasOwnProperty.call(this.controller, route)) { 94 | const { default: initRoute } = await import('./core/initRoute.js'); 95 | await initRoute.call(this, route, this.controller[route]); 96 | } 97 | } 98 | } 99 | 100 | if (options.loadREST !== false) { 101 | const { default: loadRest } = await import('./core/loadRest.js'); 102 | await loadRest.call(this, callback); 103 | } 104 | }, 105 | callback => { 106 | this.server.use((request, response) => { 107 | new Response(this, request, response, null, false); 108 | }); 109 | callback(); 110 | }, 111 | ], error => { 112 | if (error) { 113 | console.error('Error starting Sapling'); 114 | console.error(error); 115 | console.error(error.stack); 116 | return false; 117 | } 118 | 119 | if (next) { 120 | next(); 121 | } 122 | }); 123 | } 124 | 125 | /* Load remaining methods */ 126 | parseMethodRouteKey = parseMethodRouteKey; 127 | runHook = runHook; 128 | } 129 | 130 | export default App; 131 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | {"name":"untitled"} -------------------------------------------------------------------------------- /core/initRoute.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Initialise route 3 | */ 4 | 5 | /* Dependencies */ 6 | import { console } from '../lib/Cluster.js'; 7 | import Response from '../lib/Response.js'; 8 | import SaplingError from '../lib/SaplingError.js'; 9 | 10 | 11 | /** 12 | * Initialise the given route; load and render the view, 13 | * create the appropriate listeners. 14 | * 15 | * @param {string} route Name of the route to be loaded 16 | * @param {function} view Chain callback 17 | */ 18 | export default async function initRoute(route, view) { 19 | console.log('Loaded route', `${route}`); 20 | 21 | /* Create a handler for incoming requests */ 22 | const handler = async (request, response) => 23 | /* Run a hook, if it exists */ 24 | await this.runHook('get', route, request, response, null, async () => { 25 | const html = await this.templating.renderView(view, {}, request); 26 | 27 | return html instanceof SaplingError ? new Response(this, request, response, html) : new Response(this, request, response, null, html); 28 | }); 29 | 30 | /* Listen on both GET and POST with the same handler */ 31 | this.server.get(route, handler); 32 | this.server.post(route, handler); 33 | 34 | /* Save the routes for later */ 35 | this.routeStack.get.push(route); 36 | this.routeStack.post.push(route); 37 | } 38 | -------------------------------------------------------------------------------- /core/loadConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load configuration 3 | */ 4 | 5 | /* Dependencies */ 6 | import { promises as fs } from 'node:fs'; 7 | import path from 'node:path'; 8 | import process from 'node:process'; 9 | import yargs from 'yargs'; 10 | /* eslint-disable-next-line n/file-extension-in-import */ 11 | import { hideBin } from 'yargs/helpers'; 12 | import _ from 'underscore'; 13 | import chalk from 'chalk'; 14 | 15 | import { console } from '../lib/Cluster.js'; 16 | import SaplingError from '../lib/SaplingError.js'; 17 | 18 | 19 | /** 20 | * Digest config files and apply default config 21 | * 22 | * @returns {object} Config 23 | */ 24 | export async function digest() { 25 | let config = {}; 26 | 27 | const argv = yargs(hideBin(process.argv)).argv; 28 | 29 | /* Default configuration values */ 30 | const defaultConfig = { 31 | publicDir: 'public', 32 | modelsDir: 'models', 33 | viewsDir: 'views', 34 | hooksDir: 'hooks', 35 | autoRouting: true, 36 | routes: 'routes.json', 37 | hooks: 'hooks.json', 38 | permissions: 'permissions.json', 39 | extension: 'html', 40 | secret: this.utils.randString(), 41 | showError: true, 42 | strict: false, 43 | limit: 100, 44 | production: 'auto', 45 | db: { 46 | driver: 'Memory', 47 | }, 48 | render: { 49 | driver: 'HTML', 50 | }, 51 | sessionStore: { 52 | type: null, 53 | options: {}, 54 | }, 55 | mail: { 56 | host: process.env.MAIL_HOST || '', 57 | port: process.env.MAIL_PORT || 465, 58 | secure: this.utils.trueBoolean(process.env.MAIL_TLS) || true, 59 | auth: { 60 | user: process.env.MAIL_USER, 61 | pass: process.env.MAIL_PASS, 62 | }, 63 | }, 64 | upload: { 65 | type: 'local', 66 | destination: 'public/uploads', 67 | thumbnails: [ 68 | { 69 | name: 'web', 70 | width: 1280, 71 | }, 72 | { 73 | name: 'thumb', 74 | width: 128, 75 | height: 128, 76 | fit: 'cover', 77 | }, 78 | ], 79 | }, 80 | port: argv.port || this.opts.port || 3000, 81 | url: '', 82 | }; 83 | 84 | Object.assign(config, defaultConfig); 85 | 86 | /* Location of the configuration */ 87 | const configPath = path.join(this.dir, this.configFile || 'config.json'); 88 | 89 | /* Load the configuration */ 90 | if (await this.utils.exists(configPath)) { 91 | /* If we have a config file, let's load it */ 92 | const file = await fs.readFile(configPath); 93 | 94 | /* Parse and merge the config, or throw an error if it's malformed */ 95 | try { 96 | const c = JSON.parse(file.toString()); 97 | _.extend(config, c); 98 | } catch (error) { 99 | throw new SaplingError('Error loading config', error); 100 | } 101 | } else { 102 | /* If not, let's add a fallback */ 103 | _.extend(config, { name: 'untitled' }); 104 | } 105 | 106 | /* Detect production environment */ 107 | if (config.production === 'auto') { 108 | config.production = process.env.NODE_ENV === 'production'; 109 | } 110 | 111 | /* Figure out automatic CORS */ 112 | if (!('cors' in config)) { 113 | config.cors = !config.production; 114 | } 115 | 116 | /* Figure out automatic compression */ 117 | if (!('compression' in config)) { 118 | config.compression = config.production; 119 | } 120 | 121 | /* Set other config based on production */ 122 | if (config.production === true || config.production === 'on') { 123 | /* Check if there's a separate production config */ 124 | const prodConfigPath = path.join(this.dir, (this.configFile && this.configFile.replace('.json', `.${process.env.NODE_ENV}.json`)) || `config.${process.env.NODE_ENV}.json`); 125 | 126 | if (await this.utils.exists(prodConfigPath)) { 127 | /* If we have a config file, let's load it */ 128 | const file = await fs.readFile(prodConfigPath); 129 | 130 | config = {}; 131 | Object.assign(config, defaultConfig); 132 | 133 | /* Parse and merge the config, or throw an error if it's malformed */ 134 | try { 135 | const pc = JSON.parse(file.toString()); 136 | _.extend(config, pc); 137 | } catch (error) { 138 | throw new SaplingError('Error loading production config', error); 139 | } 140 | } 141 | 142 | /* Set immutable production vars */ 143 | config.strict = true; 144 | config.showError = false; 145 | 146 | /* Set verbose to false unless otherwise set */ 147 | if (!('verbose' in config)) { 148 | config.verbose = false; 149 | } 150 | } 151 | 152 | return config; 153 | } 154 | 155 | 156 | /** 157 | * Load the configuration data. Should exist in a file 158 | * called "config.json" and must be valid JSON. 159 | * 160 | * @param {function} next Chain callback 161 | */ 162 | export default async function loadConfig(next) { 163 | /* Digest config */ 164 | this.config = await digest.call(this); 165 | 166 | /* Set logging verbosity */ 167 | process.env.VERBOSE_LOGGING = ('verbose' in this.config) ? this.config.verbose : true; 168 | 169 | /* Log config */ 170 | console.log('CONFIG', this.config); 171 | console.logAlways(this.config.production ? chalk.green('Production mode is ON') : chalk.yellow('Production mode is OFF')); 172 | console.logAlways(this.config.strict ? chalk.green('Strict mode is ON') : chalk.yellow('Strict mode is OFF')); 173 | 174 | /* Set the app name */ 175 | this.name = this.config.name; 176 | 177 | if (next) { 178 | next(); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /core/loadController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load controller 3 | */ 4 | 5 | /* Dependencies */ 6 | import { promises as fs } from 'node:fs'; 7 | import path from 'node:path'; 8 | 9 | import { console } from '../lib/Cluster.js'; 10 | import Templating from '../lib/Templating.js'; 11 | 12 | 13 | /** 14 | * Digest controller.json and/or views directory for the controller 15 | * 16 | * @returns {object} Controller 17 | */ 18 | export async function digest() { 19 | let controller = {}; 20 | 21 | /* Generate a controller from the available views */ 22 | if ((this.config.autoRouting === 'on' || this.config.autoRouting === true) && this.config.viewsDir !== null) { 23 | const viewsPath = path.join(this.dir, this.config.viewsDir); 24 | 25 | if (await this.utils.exists(viewsPath)) { 26 | const viewsLstat = await fs.lstat(viewsPath); 27 | 28 | if (viewsLstat.isDirectory()) { 29 | /* Load all views in the views directory */ 30 | const views = await this.utils.getFiles(viewsPath); 31 | 32 | /* Go through each view */ 33 | for (const view_ of views) { 34 | const segments = path.relative(this.dir, view_).split('/'); 35 | 36 | /* Filter out the views where any segment begins with _ */ 37 | const protectedSegments = segments.filter(item => { 38 | const re = /^_/; 39 | return re.test(item); 40 | }); 41 | 42 | if (protectedSegments.length > 0) { 43 | continue; 44 | } 45 | 46 | /* Filter out any files that do not use the correct file extension */ 47 | if (this.config.extension !== null && view_.split('.').slice(-1)[0] !== this.config.extension) { 48 | continue; 49 | } 50 | 51 | /* Filter out filesystem bits */ 52 | const view = view_.replace(path.resolve(this.dir, this.config.viewsDir), '').replace(`.${this.config.extension}`, ''); 53 | let route = view.replace('/index', ''); 54 | 55 | /* Make sure root index is a slash and not an empty key */ 56 | if (route === '') { 57 | route = '/'; 58 | } 59 | 60 | /* Create an automatic GET route for a given view */ 61 | controller[route] = view.replace(/^\/+/g, ''); 62 | } 63 | } 64 | } 65 | } 66 | 67 | /* Location of the controller file */ 68 | const controllerPath = path.join(this.dir, this.config.routes || ''); 69 | 70 | /* Load the controller file */ 71 | if (await this.utils.exists(controllerPath)) { 72 | const controllerLstat = await fs.lstat(controllerPath); 73 | 74 | if (controllerLstat.isFile()) { 75 | /* Parse and merge the controller, or throw an error if it's malformed */ 76 | try { 77 | /* Load the controller file */ 78 | const file = await fs.readFile(controllerPath); 79 | const routes = JSON.parse(file.toString()); 80 | 81 | /* Remove file extension */ 82 | for (const route of Object.keys(routes)) { 83 | routes[route] = routes[route].split('.').slice(0, -1).join('.'); 84 | } 85 | 86 | /* Merge routes if autorouting, replace routes if not */ 87 | if (this.config.autoRouting === 'on' || this.config.autoRouting === true) { 88 | Object.assign(controller, routes); 89 | } else { 90 | controller = routes; 91 | } 92 | } catch (error) { 93 | console.error(`Controller at path: \`${controllerPath}\` could not be loaded.`, error); 94 | } 95 | } 96 | } 97 | 98 | return controller; 99 | } 100 | 101 | 102 | /** 103 | * Load the controller JSON file into routes. 104 | * 105 | * @param {function} next Chain callback 106 | */ 107 | export default async function loadController(next) { 108 | /* Load templating engine */ 109 | this.templating = new Templating(this); 110 | await this.templating.importDriver(); 111 | 112 | /* Digest controller */ 113 | this.controller = await digest.call(this, this.config); 114 | console.log('CONTROLLER', this.controller); 115 | 116 | if (next) { 117 | next(); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /core/loadCustomTags.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load custom tags 3 | */ 4 | 5 | /** 6 | * Setup custom tags into the template parser to 7 | * return data from the storage engine. 8 | * 9 | * @param {function} next Chain callback 10 | */ 11 | export default async function loadCustomTags(next) { 12 | await this.templating.renderer.registerTags({ 13 | 14 | /** 15 | * Set a template variable with data from a given 16 | * data API URL. The driver implementation must 17 | * handle assigning the return value to a template 18 | * variable. 19 | * 20 | * @param {string} url Data API URL 21 | * @param {string} role Optional user role, defaults to current user role 22 | */ 23 | async get(url, role) { 24 | /* See if this url has a permission associated */ 25 | const baseurl = url.split('?')[0]; 26 | const permission = this.user.getRolesForRoute('get', baseurl); 27 | 28 | /* If no role is provided, use current */ 29 | const session = role ? { user: { role } } : ((this.data && this.data.session) || {}); 30 | 31 | /* Check permission */ 32 | const allowed = this.user.isUserAllowed(permission, session.user || {}); 33 | 34 | /* Not allowed so give an empty array */ 35 | if (!allowed) { 36 | return this.storage.formatResponse([]); 37 | } 38 | 39 | /* Request the data */ 40 | return await this.storage.get({ 41 | url, 42 | permission: { role: permission }, 43 | session, 44 | }); 45 | }, 46 | }); 47 | 48 | if (next) { 49 | next(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /core/loadHooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load hooks 3 | */ 4 | 5 | /* Dependencies */ 6 | import { promises as fs } from 'node:fs'; 7 | import path from 'node:path'; 8 | 9 | import { console } from '../lib/Cluster.js'; 10 | import Response from '../lib/Response.js'; 11 | import SaplingError from '../lib/SaplingError.js'; 12 | 13 | 14 | /** 15 | * Digest hooks files 16 | * 17 | * @returns {object} Hooks 18 | */ 19 | export async function digest() { 20 | /* Location of the hooks file */ 21 | const hooksPath = path.join(this.dir, this.config.hooks); 22 | 23 | const formattedHooks = {}; 24 | 25 | /* Load the hooks file */ 26 | if (await this.utils.exists(hooksPath)) { 27 | /* If we have a hooks file, let's load it */ 28 | let file = null; 29 | let hooks = {}; 30 | 31 | /* Read the hooks file, or throw an error if it can't be done */ 32 | try { 33 | file = await fs.readFile(hooksPath); 34 | } catch { 35 | throw new SaplingError(`Hooks at ${hooksPath} could not be read.`); 36 | } 37 | 38 | /* Parse the hooks, or throw an error if it's malformed */ 39 | try { 40 | hooks = JSON.parse(file.toString()); 41 | } catch { 42 | throw new SaplingError(`Hooks at ${hooksPath} could not be parsed.`); 43 | } 44 | 45 | /* Set exported functions as object values */ 46 | for (const hook of Object.keys(hooks)) { 47 | const { method, route } = this.parseMethodRouteKey(hook); 48 | const { default: hookMethod } = await import(path.join(this.dir, this.config.hooksDir, hooks[hook])); 49 | formattedHooks[`${method} ${route}`] = hookMethod; 50 | } 51 | } 52 | 53 | return formattedHooks; 54 | } 55 | 56 | 57 | /** 58 | * Load the hooks JSON file, and listen to non-data API hooks. 59 | * 60 | * @param {function} next Chain callback 61 | */ 62 | export default async function loadHooks(next) { 63 | /* Digest hooks */ 64 | this.hooks = await digest.call(this); 65 | 66 | for (const hook of Object.keys(this.hooks)) { 67 | const { method, route } = this.parseMethodRouteKey(hook); 68 | 69 | /* Initialise hook if it doesn't exist in the controller */ 70 | if (!(route in this.controller) && !route.startsWith('/data') && !route.startsWith('data')) { 71 | /* Listen on */ 72 | this.server[method](route, async (request, response) => 73 | /* Run a hook, if it exists */ 74 | await this.runHook(method, route, request, response, null, () => new Response(this, request, response, null)), 75 | ); 76 | 77 | /* Save the route for later */ 78 | this.routeStack[method].push(route); 79 | } 80 | } 81 | 82 | console.log('HOOKS', Object.keys(this.hooks)); 83 | 84 | if (next) { 85 | next(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /core/loadModel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load model 3 | */ 4 | 5 | /* Dependencies */ 6 | import { promises as fs } from 'node:fs'; 7 | import path from 'node:path'; 8 | 9 | import { console } from '../lib/Cluster.js'; 10 | import SaplingError from '../lib/SaplingError.js'; 11 | import Storage from '../lib/Storage.js'; 12 | 13 | 14 | /** 15 | * Digest model files and apply some formatting 16 | * 17 | * @returns {object} Schema 18 | */ 19 | export async function digest() { 20 | const modelPath = path.join(this.dir, this.config.modelsDir); 21 | const schema = {}; 22 | let files = {}; 23 | 24 | /* Load all models in the model directory */ 25 | if (await this.utils.exists(modelPath)) { 26 | files = await fs.readdir(modelPath); 27 | } else { 28 | console.warn(`Models directory \`${modelPath}\` does not exist`); 29 | } 30 | 31 | /* Go through each model */ 32 | for (let i = 0; i < files.length; ++i) { 33 | const file = files[i].toString(); 34 | const table = file.split('.')[0]; 35 | 36 | if (table === '') { 37 | files.splice(i--, 1); 38 | continue; 39 | } 40 | 41 | const model = await fs.readFile(path.join(modelPath, file)); 42 | 43 | /* Read the model JSON into the schema */ 44 | try { 45 | /* Attempt to parse the JSON */ 46 | const parsedModel = JSON.parse(model.toString()); 47 | 48 | for (const rule of Object.keys(parsedModel)) { 49 | /* Convert string-based definitions into their object-based normals */ 50 | if (typeof parsedModel[rule] === 'string') { 51 | parsedModel[rule] = { type: parsedModel[rule] }; 52 | } 53 | 54 | /* Normalise access definition */ 55 | if ('access' in parsedModel[rule]) { 56 | if (typeof parsedModel[rule].access === 'string') { 57 | parsedModel[rule].access = { r: parsedModel[rule].access, w: parsedModel[rule].access }; 58 | } 59 | } else { 60 | parsedModel[rule].access = { r: 'anyone', w: 'anyone' }; 61 | } 62 | } 63 | 64 | /* Save */ 65 | schema[table] = parsedModel; 66 | } catch { 67 | throw new SaplingError(`Error parsing model \`${table}\``); 68 | } 69 | } 70 | 71 | return schema; 72 | } 73 | 74 | 75 | /** 76 | * Load the model structures and initialise 77 | * the storage instance for this app. 78 | * 79 | * @param {function} next Chain callback 80 | */ 81 | export default async function loadModel(next) { 82 | /* Create a storage instance based on the models */ 83 | this.storage = new Storage(this, await digest.call(this)); 84 | 85 | if (next) { 86 | next(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /core/loadModules.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load modules 3 | */ 4 | 5 | /* Dependencies */ 6 | import Notifications from '../lib/Notifications.js'; 7 | import Request from '../lib/Request.js'; 8 | import Uploads from '../lib/Uploads.js'; 9 | import User from '../lib/User.js'; 10 | 11 | 12 | /** 13 | * Load all separate modules as needed 14 | * 15 | * @param {function} next Chain callback 16 | */ 17 | export default async function loadModules(next) { 18 | this.user = new User(this); 19 | this.request = new Request(this); 20 | 21 | if (this.config.mail) { 22 | this.notifications = new Notifications(this); 23 | } 24 | 25 | if (this.config.upload) { 26 | this.uploads = new Uploads(this); 27 | } 28 | 29 | if (next) { 30 | next(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /core/loadPermissions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load permissions 3 | */ 4 | 5 | /* Dependencies */ 6 | import { promises as fs } from 'node:fs'; 7 | import path from 'node:path'; 8 | 9 | import { console } from '../lib/Cluster.js'; 10 | import Response from '../lib/Response.js'; 11 | import SaplingError from '../lib/SaplingError.js'; 12 | import UnauthorizedError from '../lib/UnauthorizedError.js'; 13 | 14 | 15 | /** 16 | * Digest and validate permission files and apply some formatting 17 | * 18 | * @returns {object} Permissions 19 | */ 20 | export async function digest() { 21 | /* Load the permissions file */ 22 | const permissionsPath = path.join(this.dir, this.config.permissions); 23 | const formattedPerms = {}; 24 | let loadedPerms = {}; 25 | 26 | try { 27 | loadedPerms = JSON.parse(await fs.readFile(permissionsPath)); 28 | } catch { 29 | console.warn(`Permissions at path: ${permissionsPath} not found.`); 30 | } 31 | 32 | /* Loop over the urls in permissions */ 33 | for (const url of Object.keys(loadedPerms)) { 34 | /* Format expected: "GET /url/here" */ 35 | const { method, route } = this.parseMethodRouteKey(url); 36 | 37 | /* The minimum role level required for this method+route combination */ 38 | let perm = loadedPerms[url]; 39 | 40 | if (typeof loadedPerms[url] === 'string') { 41 | /* If it's a string, convert it to object */ 42 | perm = { role: [loadedPerms[url]], redirect: false }; 43 | } else if (Array.isArray(loadedPerms[url])) { 44 | /* If it's an array, convert it to object */ 45 | perm = { role: loadedPerms[url], redirect: false }; 46 | } else if (typeof loadedPerms[url] === 'object' && loadedPerms[url] !== null) { 47 | /* If it's an object, ensure it's proper */ 48 | if (!('role' in loadedPerms[url])) { 49 | throw new SaplingError(`Permission setting for ${url} is missing a role`); 50 | } 51 | 52 | if (!(typeof loadedPerms[url].role === 'string' || Array.isArray(loadedPerms[url].role))) { 53 | throw new SaplingError(`Permission setting for ${url} is malformed`); 54 | } 55 | 56 | if (typeof loadedPerms[url].role === 'string') { 57 | perm = { role: [loadedPerms[url].role], redirect: loadedPerms[url].redirect }; 58 | } 59 | } else { 60 | /* If it's something else, we don't want it */ 61 | throw new SaplingError(`Permission setting for ${url} is malformed`); 62 | } 63 | 64 | /* Save to object */ 65 | formattedPerms[`${method} ${route}`] = perm; 66 | } 67 | 68 | return formattedPerms; 69 | } 70 | 71 | 72 | /** 73 | * Load the permissions file, and implement the middleware 74 | * to validate the permission before continuing to the 75 | * route handler. 76 | * 77 | * @param {function} next Chain callback 78 | */ 79 | export default async function loadPermissions(next) { 80 | /* Digest permissions */ 81 | this.permissions = await digest.call(this); 82 | 83 | /* Loop over the urls in permissions */ 84 | for (const url of Object.keys(this.permissions)) { 85 | /* Format expected: "GET /url/here" */ 86 | const { method, route } = this.parseMethodRouteKey(url); 87 | 88 | /* Create middleware for each particular method+route combination */ 89 | this.server[method](route, (request, response, next) => { 90 | console.log('PERMISSION', method, route, this.permissions[url]); 91 | 92 | /* Save for later */ 93 | request.permission = this.permissions[url]; 94 | 95 | /* If the current route is not allowed for the current user, display an error */ 96 | if (this.user.isUserAllowed(request.permission.role, request.session.user) === false) { 97 | if (request.permission.redirect) { 98 | response.redirect(request.permission.redirect); 99 | } else { 100 | return new Response(this, request, response, new UnauthorizedError()); 101 | } 102 | } else { 103 | next(); 104 | } 105 | }); 106 | } 107 | 108 | if (next) { 109 | next(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /core/loadRest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load REST 3 | */ 4 | 5 | /* Dependencies */ 6 | import Response from '../lib/Response.js'; 7 | import SaplingError from '../lib/SaplingError.js'; 8 | 9 | 10 | /** 11 | * Setup the endpoints for the /data interface 12 | * 13 | * @param {function} next Chain callback 14 | */ 15 | export default async function loadRest(next) { 16 | /* Direct user creation to a special case endpoint */ 17 | this.server.post(/\/data\/users\/?$/, async (request, response) => { 18 | this.runHook('post', '/api/user/register', request, response); 19 | }); 20 | 21 | /* Otherwise, send each type of query to be handled by Storage */ 22 | this.server.get('/data/*', async (request, response) => { 23 | /* Get data */ 24 | const data = await this.storage.get(request, response); 25 | 26 | /* Run hooks, then send data */ 27 | this.runHook('get', request.originalUrl, request, response, data, (app, request, response, data) => { 28 | if (data) { 29 | new Response(this, request, response, null, data || []); 30 | } else { 31 | new Response(this, request, response, new SaplingError('Something went wrong')); 32 | } 33 | }); 34 | }); 35 | this.server.post('/data/*', async (request, response) => { 36 | /* Send data */ 37 | const data = await this.storage.post(request, response); 38 | 39 | /* Run hooks, then send data */ 40 | this.runHook('post', request.originalUrl, request, response, data, (app, request, response, data) => { 41 | if (data) { 42 | new Response(this, request, response, null, data || []); 43 | } else { 44 | new Response(this, request, response, new SaplingError('Something went wrong')); 45 | } 46 | }); 47 | }); 48 | this.server.delete('/data/*', async (request, response) => { 49 | /* Delete data */ 50 | await this.storage.delete(request, response); 51 | 52 | /* Run hooks, then send data */ 53 | this.runHook('delete', request.originalUrl, request, response, [], (app, request, response, data) => { 54 | if (data) { 55 | new Response(this, request, response, null, data || []); 56 | } else { 57 | new Response(this, request, response, new SaplingError('Something went wrong')); 58 | } 59 | }); 60 | }); 61 | 62 | if (next) { 63 | next(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /core/loadServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Load server 3 | */ 4 | 5 | /* Dependencies */ 6 | import path from 'node:path'; 7 | 8 | import { App as TinyHTTP } from '@tinyhttp/app'; 9 | import sirv from 'sirv'; 10 | import session from 'express-session'; 11 | import cookieParser from 'cookie-parser'; 12 | import bodyParser from 'body-parser'; 13 | import logger from 'morgan'; 14 | import compression from 'compression'; 15 | import csrf from 'csurf'; 16 | import SaplingError from '../lib/SaplingError.js'; 17 | import Response from '../lib/Response.js'; 18 | import Cluster from '../lib/Cluster.js'; 19 | import Utils from '../lib/Utils.js'; 20 | 21 | 22 | /** 23 | * Configure the Express server from the config data. 24 | * 25 | * @param {object} opts Options for reload and listen 26 | * @param {function} next Chain callback 27 | */ 28 | export default function loadServer({ reload, listen }, next) { 29 | let server; 30 | 31 | if (reload && this.server) { 32 | this.routeStack = { get: [], post: [], delete: [] }; 33 | // This.server.routes = server._router.map; 34 | // this.server.stack.length = 2; 35 | } else { 36 | server = new TinyHTTP(); 37 | this.routeStack = { get: [], post: [], delete: [] }; 38 | } 39 | 40 | 41 | /* Compress if requested */ 42 | if (this.config.compression) { 43 | server.use(compression()); 44 | } 45 | 46 | /* Use the app secret from config, or generate one if needed */ 47 | const secret = this.config.secret || (this.config.secret = this.utils.randString()); 48 | server.use(cookieParser(secret)); 49 | 50 | 51 | /* Persist sessions through reload */ 52 | if (!server.sessionHandler) { 53 | /* Set session options */ 54 | const sessionConfig = { 55 | secret, 56 | resave: false, 57 | saveUninitialized: true, 58 | cookie: { maxAge: null }, 59 | }; 60 | 61 | /* If we've defined a type, load it */ 62 | if ('type' in this.config.sessionStore && this.config.sessionStore.type !== null) { 63 | const Store = import(this.config.sessionStore.type); 64 | sessionConfig.store = new Store(this.config.sessionStore.options); 65 | } 66 | 67 | /* Create session handler */ 68 | server.sessionHandler = session(sessionConfig); 69 | } 70 | 71 | server.use(server.sessionHandler); 72 | 73 | 74 | /* Handle the directory for our static resources */ 75 | if ('publicDir' in this.config) { 76 | /* If it's a string, coerce into an array */ 77 | this.config.publicDir = new Utils().coerceArray(this.config.publicDir); 78 | 79 | /* Loop through it */ 80 | for (const publicDir of this.config.publicDir) { 81 | const publicDirPath = path.join(this.dir, publicDir); 82 | server.use(`/${publicDir}`, sirv(publicDirPath, { maxAge: 1 })); 83 | } 84 | } 85 | 86 | 87 | server.use(bodyParser.urlencoded({ extended: true })); 88 | server.use(bodyParser.json()); 89 | server.use(logger(Cluster.logger)); 90 | 91 | 92 | /* Use CSRF protection if enabled or in strict mode */ 93 | if (this.config.csrf || this.config.strict) { 94 | server.use(csrf({ cookie: false })); 95 | 96 | server.onError = (error, request, response, next) => { 97 | if (error.code !== 'EBADCSRFTOKEN') { 98 | return next(error); 99 | } 100 | 101 | new Response(this, request, response, new SaplingError('Invalid CSRF token')); 102 | }; 103 | } 104 | 105 | /* Enable the /data data interface */ 106 | server.use('/data/', ({ method }, response, n) => { 107 | /* Send CORS headers if explicitly enabled in config */ 108 | if (this.config.cors === true) { 109 | response.header('Access-Control-Allow-Origin', '*'); 110 | response.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); 111 | response.header('Access-Control-Allow-Headers', 'Content-Type'); 112 | } 113 | 114 | /* Handle preflight requests */ 115 | if (method === 'OPTIONS') { 116 | return response.sendStatus(200); 117 | } 118 | 119 | n(); 120 | }); 121 | 122 | /* Define the /api interface */ 123 | server.use('/api/', (request, response, n) => { 124 | /* Send CORS headers if explicitly enabled in config */ 125 | if (this.config.cors) { 126 | response.header('Access-Control-Allow-Origin', '*'); 127 | response.header('Access-Control-Allow-Methods', 'GET,POST'); 128 | response.header('Access-Control-Allow-Headers', 'Content-Type'); 129 | } 130 | 131 | n(); 132 | }); 133 | 134 | /* Start listening on the given port */ 135 | if (listen !== false) { 136 | Cluster.listening(this.config.port); 137 | server.http = server.listen(this.config.port); 138 | } 139 | 140 | this.server = server; 141 | 142 | if (next) { 143 | next(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /core/parseMethodRouteKey.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse method-route key 3 | */ 4 | 5 | /* Dependencies */ 6 | import SaplingError from '../lib/SaplingError.js'; 7 | 8 | 9 | /** 10 | * Parse a string with a method and route into their 11 | * constituent parts. 12 | * 13 | * @param {string} key 14 | */ 15 | export default function parseMethodRouteKey(key) { 16 | const object = { 17 | method: false, 18 | route: false, 19 | }; 20 | 21 | /* Format expected: "GET /url/here" */ 22 | const parts = key.split(' '); 23 | 24 | /* Behave differently based on the number of segments */ 25 | switch (parts.length) { 26 | case 1: 27 | /* Default to get */ 28 | object.method = 'get'; 29 | /* Assume the only part is the URL */ 30 | object.route = parts[0]; 31 | break; 32 | 33 | case 2: 34 | /* First part is the method: get, post, delete */ 35 | object.method = parts[0].toLowerCase(); 36 | /* Second part is the URL */ 37 | object.route = parts[1]; 38 | break; 39 | 40 | default: 41 | throw new SaplingError(`Problem parsing '${key}': too many segments`); 42 | } 43 | 44 | /* Remove any trailing slashes */ 45 | object.route = object.route.replace(/\/+$/, ''); 46 | 47 | /* Send an error if the method isn't an acceptable method */ 48 | if (!['get', 'post', 'delete'].includes(object.method)) { 49 | throw new SaplingError(`Problem parsing '${key}': ${object.method} is not a valid method`); 50 | } 51 | 52 | return object; 53 | } 54 | -------------------------------------------------------------------------------- /core/runHook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run hook 3 | */ 4 | 5 | /* Dependencies */ 6 | import routeMatcher from 'path-match'; 7 | 8 | import { console } from '../lib/Cluster.js'; 9 | 10 | 11 | /** 12 | * Run any registered hook that matches the given method 13 | * and route. Returns null if no hook found, otherwise 14 | * returns what the hook returns. 15 | * 16 | * @param {string} method Method of the route being tested 17 | * @param {string} route Route being tested 18 | * @param {string} request Request object 19 | * @param {string} response Response object 20 | * @param {string} data Data, if any 21 | * @param {function} next Callback for after the hook 22 | */ 23 | export default async function runHook(method, route, request, response, data, next) { 24 | console.log('Finding hooks for', method, route); 25 | 26 | let found = false; 27 | 28 | /* Go through all hook definitions */ 29 | for (const hook of Object.keys(this.hooks)) { 30 | /* Get hook definition route */ 31 | const { method: hookMethod, route: hookRoute } = this.parseMethodRouteKey(hook); 32 | 33 | /* If the route and method match, run the hook */ 34 | if (routeMatcher()(hookRoute)(route) !== false && hookMethod.toLowerCase() === method.toLowerCase()) { 35 | await this.hooks[hook](this, request, response, data, next); 36 | found = true; 37 | break; 38 | } 39 | } 40 | 41 | /* Return whatever was found */ 42 | if (!found) { 43 | return next(this, request, response, data); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /drivers/db/Interface.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Database Interface 3 | * 4 | * This is the blank slate for abstracting any database system for use 5 | * in Sapling. A new database driver should implement the below methods 6 | * in whatever way makes sense for the particular database technology. 7 | */ 8 | 9 | 10 | /* Dependencies */ 11 | import SaplingError from '../../lib/SaplingError.js'; 12 | 13 | 14 | /** 15 | * The Interface class 16 | */ 17 | export default class Interface { 18 | /** 19 | * The connection object that should be populated by the connect() method 20 | */ 21 | connection = null 22 | 23 | 24 | /** 25 | * Establish a connection to the database server 26 | * 27 | * @param {object} config {name: Name of the database, host: Host IP, port: Port number} 28 | */ 29 | async connect(config) { 30 | throw new SaplingError('Method not implemented: connect'); 31 | } 32 | 33 | 34 | /** 35 | * Create a collection in the database where one doesn't yet exist 36 | * 37 | * @param {string} collection Name for the collection being created 38 | * @param {array} fields Model object 39 | */ 40 | async createCollection(collection, fields) { 41 | throw new SaplingError('Method not implemented: createCollection'); 42 | } 43 | 44 | 45 | /** 46 | * Create an index for the specified fields 47 | * 48 | * @param {string} collection Name of the target collection 49 | * @param {object} fields Object of indices to create. Key is field name, value is index type, e.g. 'unique' 50 | */ 51 | async createIndex(collection, fields) { 52 | throw new SaplingError('Method not implemented: createIndex'); 53 | } 54 | 55 | 56 | /** 57 | * Find one or more records for the given conditions in the given collection 58 | * 59 | * @param {string} collection Name of the target collection 60 | * @param {object} conditions The search query 61 | * @param {object} options Driver specific options for the operation 62 | */ 63 | async read(collection, conditions, options) { 64 | throw new SaplingError('Method not implemented: read'); 65 | } 66 | 67 | 68 | /** 69 | * Create one new records in the given collection 70 | * 71 | * @param {string} collection Name of the target collection 72 | * @param {object} data Data for the collection 73 | */ 74 | async write(collection, data) { 75 | throw new SaplingError('Method not implemented: write'); 76 | } 77 | 78 | 79 | /** 80 | * Modify the given values in data in any and all records matching the given conditions 81 | * 82 | * @param {string} collection Name of the target collection 83 | * @param {object} conditions The search query 84 | * @param {object} data New data for the matching record(s). Omitted values does not imply deletion. 85 | */ 86 | async modify(collection, conditions, data) { 87 | throw new SaplingError('Method not implemented: modify'); 88 | } 89 | 90 | 91 | /** 92 | * Delete any and all matching records for the given conditions 93 | * 94 | * @param {string} collection Name of the target collection 95 | * @param {object} conditions The search query 96 | */ 97 | async remove(collection, conditions) { 98 | throw new SaplingError('Method not implemented: remove'); 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /drivers/render/Html.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HTML driver for Sapling 3 | * 4 | * A simple fallback render driver that just loads the HTML files 5 | * its given. 6 | */ 7 | 8 | /* Dependencies */ 9 | import { promises as fs } from 'node:fs'; 10 | import path from 'node:path'; 11 | import _ from 'underscore'; 12 | import SaplingError from '../../lib/SaplingError.js'; 13 | import Interface from './Interface.js'; 14 | 15 | 16 | /** 17 | * The HTML class 18 | */ 19 | export default class HTML extends Interface { 20 | /** 21 | * Initialise HTML 22 | */ 23 | constructor(App, viewsPath) { 24 | super(); 25 | this.app = App; 26 | this.viewsPath = viewsPath; 27 | } 28 | 29 | 30 | /** 31 | * Render a template file 32 | * 33 | * @param {string} template Path of the template file being rendered, relative to root 34 | * @param {object} data Object of data to pass to the template 35 | */ 36 | async render(template, data) { 37 | /* Read the template file */ 38 | let html = ''; 39 | try { 40 | html = await fs.readFile(path.resolve(this.viewsPath, template), 'utf8'); 41 | } catch (error) { 42 | return new SaplingError(error); 43 | } 44 | 45 | /* Do some rudimentary var replacement */ 46 | html = html.replace(/{{ ?([\w.]+) ?(?:\| ?safe ?)?}}/gi, (tag, identifier) => 47 | /* Return either matching data, or the tag literal */ 48 | _.get(data, identifier, tag), 49 | ); 50 | 51 | return html; 52 | } 53 | 54 | 55 | /** 56 | * Register custom tags with the template engine 57 | */ 58 | registerTags() { 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /drivers/render/Interface.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Render Interface 3 | * 4 | * This is the blank slate for abstracting any template rendering 5 | * system for use in Sapling. A new render driver should implement 6 | * the below methods in whatever way makes sense for the particular 7 | * database technology. 8 | */ 9 | 10 | 11 | /* Dependencies */ 12 | import SaplingError from '../../lib/SaplingError.js'; 13 | 14 | 15 | /** 16 | * The Interface class 17 | */ 18 | export default class Interface { 19 | /** 20 | * Load parent app 21 | */ 22 | constructor(App, viewsPath) { 23 | this.app = App; 24 | this.viewsPath = viewsPath; 25 | } 26 | 27 | 28 | /** 29 | * Render a template file 30 | * 31 | * @param {string} template Path of the template file being rendered, relative to root 32 | * @param {object} data Object of data to pass to the template 33 | */ 34 | async render(template, data) { 35 | throw new SaplingError('Method not implemented: render'); 36 | } 37 | 38 | 39 | /** 40 | * Register custom tags with the template engine 41 | * 42 | * @param {object} tags Object of functions 43 | */ 44 | async registerTags(tags) { 45 | /** 46 | * Tags.get 47 | * 48 | * Set a template variable with data from a given 49 | * data API URL. The driver implementation must 50 | * handle assigning the return value to a template 51 | * variable. 52 | * 53 | * @param {string} url Data API URL 54 | * @param {string} role Optional user role, defaults to current user role 55 | */ 56 | 57 | throw new SaplingError('Method not implemented: registerTags'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /hooks.json: -------------------------------------------------------------------------------- 1 | { 2 | "POST /api/user/login": "sapling/user/login.js", 3 | "GET /api/user/logged": "sapling/user/logged.js", 4 | "GET /api/user/logout": "sapling/user/logout.js", 5 | "POST /api/user/register": "sapling/user/register.js", 6 | "POST /api/user/update": "sapling/user/update.js", 7 | "POST /api/user/forgot": "sapling/user/forgot.js", 8 | "POST /api/user/recover": "sapling/user/recover.js", 9 | "GET /api/model/:model": "sapling/model/retrieve.js" 10 | } -------------------------------------------------------------------------------- /hooks/sapling/model/retrieve.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Retrieve Model 3 | * 4 | * Fetch the details of the given model 5 | */ 6 | 7 | /* Dependencies */ 8 | import Response from '@sapling/sapling/lib/Response.js'; 9 | import SaplingError from '@sapling/sapling/lib/SaplingError.js'; 10 | 11 | 12 | /* Hook /api/model/:model */ 13 | export default async function retrieve(app, request, response) { 14 | if (request.params.model) { 15 | /* Fetch the given model */ 16 | const rules = app.storage.getRules(request.params.model); 17 | 18 | /* If no model, respond with an error */ 19 | if (Object.keys(rules).length === 0) { 20 | return new Response(app, request, response, new SaplingError('No such model')); 21 | } 22 | 23 | /* Remove any internal/private model values (begin with _) */ 24 | for (const k in rules) { 25 | if (k.startsWith('_')) { 26 | delete rules[k]; 27 | } 28 | } 29 | 30 | /* Send it out */ 31 | return new Response(app, request, response, null, rules); 32 | } 33 | 34 | return new Response(app, request, response, new SaplingError('No model specified')); 35 | } 36 | -------------------------------------------------------------------------------- /hooks/sapling/user/forgot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Forgot 3 | * 4 | * Handle creating a reset token for accounts where the 5 | * user has forgotten the password. 6 | */ 7 | 8 | /* Dependencies */ 9 | import { console } from '@sapling/sapling/lib/Cluster.js'; 10 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 11 | import Response from '@sapling/sapling/lib/Response.js'; 12 | import SaplingError from '@sapling/sapling/lib/SaplingError.js'; 13 | import Validation from '@sapling/sapling/lib/Validation.js'; 14 | import ValidationError from '@sapling/sapling/lib/ValidationError.js'; 15 | 16 | 17 | /* Hook /api/user/forgot */ 18 | export default async function forgot(app, request, response) { 19 | /* Check email for format */ 20 | const errors = new Validation().validate(request.body.email, 'email', { email: true, required: true }); 21 | if (errors.length > 0) { 22 | return new Response(app, request, response, new ValidationError(errors)); 23 | } 24 | 25 | /* Get authkey and identifiable from database */ 26 | const { email } = await app.storage.get({ 27 | url: `/data/users/email/${request.body.email}/?single=true`, 28 | session: app.adminSession, 29 | }); 30 | 31 | /* Only do stuff if we found a user */ 32 | if (email) { 33 | /* Make sure key is > Date.now() */ 34 | let key = (Date.now() + (2 * 60 * 60 * 1000)).toString(16); 35 | key += app.utils.randString(); 36 | 37 | /* Save key for later */ 38 | await app.storage.post({ 39 | url: `/data/users/email/${request.body.email}`, 40 | body: { _authkey: key }, 41 | session: app.adminSession, 42 | }); 43 | 44 | /* Data for recovery email */ 45 | const templateData = { 46 | name: app.name, 47 | key, 48 | url: app.config.url, 49 | }; 50 | 51 | /* Send authkey via email */ 52 | try { 53 | await app.notifications.sendNotification('lostpass', templateData, email); 54 | } catch (error) { 55 | console.error(new SaplingError(error)); 56 | } 57 | } 58 | 59 | /* Respond the same way whether or not we did anything */ 60 | /* If we need to redirect, let's redirect */ 61 | if (!(new Redirect(app, request, response)).do()) { 62 | /* Respond positively */ 63 | return new Response(app, request, response); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /hooks/sapling/user/logged.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Logged In Status 3 | * 4 | * Fetch whether the user is currently logged in or not. Returns false 5 | * if the user isn't logged in, or the user object if they are. 6 | */ 7 | 8 | /* Dependencies */ 9 | import _ from 'underscore'; 10 | 11 | import Response from '@sapling/sapling/lib/Response.js'; 12 | 13 | 14 | /* Hook /api/user/logged */ 15 | export default async function logged(app, request, response) { 16 | /* If session exists */ 17 | if (request.session && request.session.user) { 18 | /* Get the user from storage */ 19 | const user = await app.storage.get({ 20 | url: `/data/users/_id/${request.session.user._id}/?single=true`, 21 | session: request.session, 22 | }); 23 | 24 | /* Set the user session */ 25 | request.session.user = _.extend({}, user); 26 | 27 | /* Remove sensitive fields */ 28 | delete request.session.user.password; 29 | delete request.session.user._salt; 30 | 31 | /* Respond with the user object */ 32 | return new Response(app, request, response, null, request.session.user); 33 | } 34 | 35 | /* If no session, return empty object */ 36 | return new Response(app, request, response, null, {}); 37 | } 38 | -------------------------------------------------------------------------------- /hooks/sapling/user/login.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Login 3 | * 4 | * Attempt to log the user in, and return an error on invalid input, 5 | * or a success message or redirection on success. 6 | */ 7 | 8 | /* Dependencies */ 9 | import _ from 'underscore'; 10 | 11 | import Hash from '@sapling/sapling/lib/Hash.js'; 12 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 13 | import Response from '@sapling/sapling/lib/Response.js'; 14 | import ValidationError from '../../../lib/ValidationError.js'; 15 | 16 | 17 | /* Hook /api/user/login */ 18 | export default async function login(app, request, response) { 19 | /* Fetch the user model */ 20 | const rules = app.storage.getRules('users'); 21 | 22 | /* Find all identifiable fields */ 23 | const identifiables = Object.keys(rules).filter(field => rules[field].identifiable); 24 | 25 | /* Figure out which request value is used */ 26 | let identValue = false; 27 | let identConditions = []; 28 | 29 | if ('_identifiable' in request.body) { 30 | /* If present, use the general _identifiable post value */ 31 | identValue = request.body._identifiable; 32 | 33 | /* Construct conditional selector, where every field marked as identifiable will be checked for the value */ 34 | for (const ident of identifiables) { 35 | identConditions.push({ [ident]: identValue }); 36 | } 37 | } else { 38 | /* Otherwise just check for any other identifiables in the request */ 39 | for (const ident of identifiables) { 40 | if (ident in request.body) { 41 | /* Once found, set as the value and storage search condition */ 42 | identValue = request.body[ident]; 43 | identConditions = [{ [ident]: identValue }]; 44 | } 45 | } 46 | } 47 | 48 | /* If identValue wasn't assigned, reject request */ 49 | if (identValue === false) { 50 | return new Response(app, request, response, new ValidationError({ 51 | status: '401', 52 | code: '1001', 53 | title: 'Invalid Input', 54 | detail: 'No email address or identifiable provided.', 55 | meta: { 56 | key: 'identifiable', 57 | rule: 'required', 58 | }, 59 | })); 60 | } 61 | 62 | /* Get the user from storage for each identifiable */ 63 | let data = []; 64 | for (const condition of identConditions) { 65 | data = data.concat(await app.storage.db.read('users', condition, { limit: 1 }, [])); 66 | } 67 | 68 | /* If no user is found, throw error */ 69 | if (data.length === 0) { 70 | return new Response(app, request, response, new ValidationError({ 71 | status: '401', 72 | code: '4001', 73 | title: 'Invalid User or Password', 74 | detail: 'Either the user does not exist or the password is incorrect.', 75 | meta: { 76 | type: 'login', 77 | error: 'invalid', 78 | }, 79 | })); 80 | } 81 | 82 | /* If no password was provided, throw error */ 83 | if (!request.body.password) { 84 | return new Response(app, request, response, new ValidationError({ 85 | status: '422', 86 | code: '1001', 87 | title: 'Invalid Input', 88 | detail: 'You must provide a value for key `password`', 89 | meta: { 90 | key: 'password', 91 | rule: 'required', 92 | }, 93 | })); 94 | } 95 | 96 | /* Select first result */ 97 | const user = data[0]; 98 | 99 | /* Hash the incoming password */ 100 | const password = await new Hash().hash(request.body.password, user._salt); 101 | 102 | /* If the password matches */ 103 | if (user.password === password.toString('base64')) { 104 | /* Create a user session */ 105 | request.session.user = _.extend({}, user); 106 | 107 | /* Remove the sensitive stuff */ 108 | delete request.session.user.password; 109 | delete request.session.user._salt; 110 | } else { 111 | /* Return an error if the password didn't match */ 112 | return new Response(app, request, response, new ValidationError({ 113 | status: '401', 114 | code: '4001', 115 | title: 'Invalid User or Password', 116 | detail: 'Either the user does not exist or the password is incorrect.', 117 | meta: { 118 | type: 'login', 119 | error: 'invalid', 120 | }, 121 | })); 122 | } 123 | 124 | /* If we need to redirect, let's redirect */ 125 | if (!(new Redirect(app, request, response, request.session.user)).do()) { 126 | /* Otherwise, reply with the user object */ 127 | return new Response(app, request, response, null, request.session.user); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /hooks/sapling/user/logout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Logout 3 | * 4 | * Log out the current user. 5 | */ 6 | 7 | /* Dependencies */ 8 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 9 | import Response from '@sapling/sapling/lib/Response.js'; 10 | 11 | 12 | /* Hook /api/user/logout */ 13 | export default async function logout(app, request, response) { 14 | /* Destroy the session */ 15 | request.session.destroy(); 16 | request.session = null; 17 | 18 | /* Redirect if needed, respond otherwise */ 19 | if (!(new Redirect(app, request, response)).do()) { 20 | return new Response(app, request, response); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /hooks/sapling/user/recover.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Recover 3 | * 4 | * Handle recovering a user account. 5 | */ 6 | 7 | /* Dependencies */ 8 | import Hash from '@sapling/sapling/lib/Hash.js'; 9 | 10 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 11 | import Response from '@sapling/sapling/lib/Response.js'; 12 | import ValidationError from '@sapling/sapling/lib/ValidationError.js'; 13 | 14 | 15 | /* Hook /api/user/recover */ 16 | export default async function recover(app, request, response) { 17 | /* If the new password has not been provided, throw error */ 18 | if (!request.body.new_password) { 19 | return new Response(app, request, response, new ValidationError({ 20 | status: '422', 21 | code: '1001', 22 | title: 'Invalid Input', 23 | detail: 'You must provide a value for key `new_password`', 24 | meta: { 25 | key: 'password', 26 | rule: 'required', 27 | }, 28 | })); 29 | } 30 | 31 | /* If the new password does not match rules, throw error */ 32 | const validation = app.request.validateData({ 33 | body: { password: request.body.new_password }, 34 | collection: 'users', 35 | type: 'filter', 36 | }, response); 37 | 38 | if (validation.length > 0) { 39 | return new Response(app, request, response, new ValidationError(validation)); 40 | } 41 | 42 | /* If the auth key has not been provided, throw error */ 43 | if (!request.body.auth) { 44 | return new Response(app, request, response, new ValidationError({ 45 | status: '422', 46 | code: '1001', 47 | title: 'Invalid Input', 48 | detail: 'You must provide a value for key `auth`', 49 | meta: { 50 | key: 'auth', 51 | rule: 'required', 52 | }, 53 | })); 54 | } 55 | 56 | /* Check key time */ 57 | let key = request.body.auth; 58 | key = Number.parseInt(key.slice(0, Math.max(0, key.length - 11)), 16); 59 | 60 | const diff = key - Date.now(); 61 | 62 | /* If the key has expired, show error */ 63 | if (Number.isNaN(diff) || diff <= 0) { 64 | return new Response(app, request, response, new ValidationError({ 65 | status: '401', 66 | code: '4003', 67 | title: 'Authkey Expired', 68 | detail: 'The authkey has expired and can no longer be used.', 69 | meta: { 70 | type: 'recover', 71 | error: 'expired', 72 | }, 73 | })); 74 | } 75 | 76 | /* Get users matching the key with admin privs */ 77 | const user = await app.storage.get({ 78 | url: `/data/users/_authkey/${request.body.auth}/?single=true`, 79 | session: app.adminSession, 80 | }); 81 | 82 | /* If there is no such user */ 83 | if (!user) { 84 | return new Response(app, request, response, new ValidationError({ 85 | status: '401', 86 | code: '4004', 87 | title: 'Authkey Invalid', 88 | detail: 'The authkey could not be located in the database.', 89 | meta: { 90 | type: 'recover', 91 | error: 'invalid', 92 | }, 93 | })); 94 | } 95 | 96 | /* Hash and delete the new password */ 97 | const hash = await new Hash().hash(request.body.new_password); 98 | delete request.body.new_password; 99 | 100 | /* Update the new password and clear the key */ 101 | const { data: userData } = await app.storage.post({ 102 | url: `/data/users/_id/${user._id}`, 103 | body: { password: hash[1], _salt: hash[0], _authkey: '' }, 104 | session: app.adminSession, 105 | }); 106 | 107 | /* Clean the output */ 108 | if (userData.length > 0) { 109 | if ('password' in userData[0]) { 110 | delete userData[0].password; 111 | } 112 | 113 | if ('_salt' in userData[0]) { 114 | delete userData[0]._salt; 115 | } 116 | } 117 | 118 | /* If we need to redirect, let's redirect */ 119 | if (!(new Redirect(app, request, response, userData)).do()) { 120 | /* Respond with the user object */ 121 | return new Response(app, request, response, null, userData); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /hooks/sapling/user/register.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Register 3 | * 4 | * Create a new user. 5 | */ 6 | 7 | /* Dependencies */ 8 | import _ from 'underscore'; 9 | 10 | import { console } from '@sapling/sapling/lib/Cluster.js'; 11 | import Hash from '@sapling/sapling/lib/Hash.js'; 12 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 13 | import Response from '@sapling/sapling/lib/Response.js'; 14 | import ValidationError from '../../../lib/ValidationError.js'; 15 | 16 | 17 | /* Hook /api/user/register */ 18 | export default async function register(app, request, response) { 19 | /* Error collection */ 20 | const errors = []; 21 | 22 | /* If a role is specified, check the current user is allowed to create it */ 23 | if (request.session.user) { 24 | if (request.body.role && !app.user.isRoleAllowed(request.session.user.role, request.body.role)) { 25 | errors.push({ message: `Do not have permission to create the role \`${request.body.role}\`.` }); 26 | } 27 | } else if (request.body.role) { 28 | errors.push({ message: `Do not have permission to create the role \`${request.body.role}\`.` }); 29 | } 30 | 31 | /* If no email is given */ 32 | if (!request.body.email) { 33 | errors.push({ 34 | status: '422', 35 | code: '1001', 36 | title: 'Invalid Input', 37 | detail: 'You must provide a value for key `email`', 38 | meta: { 39 | key: 'email', 40 | rule: 'required', 41 | }, 42 | }); 43 | } 44 | 45 | /* If no password is given */ 46 | if (!request.body.password) { 47 | errors.push({ 48 | status: '422', 49 | code: '1001', 50 | title: 'Invalid Input', 51 | detail: 'You must provide a value for key `password`', 52 | meta: { 53 | key: 'password', 54 | rule: 'required', 55 | }, 56 | }); 57 | } 58 | 59 | /* Validate for format */ 60 | /* Doing this here because by the time we do it Storage, the password's been hashed */ 61 | const validation = app.request.validateData(_.extend(request, { collection: 'users' }), response); 62 | 63 | /* Show the above errors, if any */ 64 | const combinedErrors = [...errors, ...validation]; 65 | if (combinedErrors.length > 0) { 66 | return new Response(app, request, response, new ValidationError(combinedErrors)); 67 | } 68 | 69 | /* Hash the password, and add it to the request */ 70 | const hash = await new Hash().hash(request.body.password); 71 | request.body._salt = hash[0]; 72 | request.body.password = hash[1]; 73 | 74 | /* Remove all possible confirmation fields */ 75 | delete request.body.password2; 76 | delete request.body.confirm_password; 77 | delete request.body.password_confirm; 78 | 79 | /* Save to the database */ 80 | const { data: userData } = await app.storage.post({ 81 | url: '/data/users', 82 | session: request.session, 83 | permission: request.permission, 84 | body: request.body, 85 | }, response); 86 | 87 | /* If post() already gave a response */ 88 | if (userData instanceof Response) { 89 | return userData; 90 | } 91 | 92 | /* Clean the output */ 93 | for (const record of userData) { 94 | delete record.password; 95 | delete record._salt; 96 | } 97 | 98 | console.log('REGISTER', errors, userData); 99 | 100 | /* If we need to redirect, let's redirect */ 101 | if (!(new Redirect(app, request, response, userData)).do()) { 102 | /* Respond with the user object */ 103 | return new Response(app, request, response, null, userData); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /hooks/sapling/user/update.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User Update 3 | * 4 | * Attempt to edit the details of the currently logged-in 5 | * user. 6 | */ 7 | 8 | /* Dependencies */ 9 | import Hash from '@sapling/sapling/lib/Hash.js'; 10 | 11 | import Redirect from '@sapling/sapling/lib/Redirect.js'; 12 | import Response from '@sapling/sapling/lib/Response.js'; 13 | import ValidationError from '@sapling/sapling/lib/ValidationError.js'; 14 | 15 | 16 | /* Hook /api/user/update */ 17 | export default async function update(app, request, response) { 18 | /* If the user isn't logged in */ 19 | if (!request.session || !request.session.user) { 20 | return new Response(app, request, response, new ValidationError({ 21 | status: '401', 22 | code: '4002', 23 | title: 'Unauthorized', 24 | detail: 'You must log in before completing this action.', 25 | meta: { 26 | type: 'login', 27 | error: 'unauthorized', 28 | }, 29 | })); 30 | } 31 | 32 | /* If password isn't provided */ 33 | if (!request.body.password) { 34 | return new Response(app, request, response, new ValidationError({ 35 | status: '422', 36 | code: '1001', 37 | title: 'Invalid Input', 38 | detail: 'You must provide a value for key `password`', 39 | meta: { 40 | key: 'password', 41 | rule: 'required', 42 | }, 43 | })); 44 | } 45 | 46 | /* If the new password has been provided, validate it */ 47 | if (request.body.new_password) { 48 | const validation = app.request.validateData({ 49 | body: { password: request.body.new_password }, 50 | collection: 'users', 51 | type: 'filter', 52 | }, response); 53 | 54 | if (validation.length > 0) { 55 | return new Response(app, request, response, new ValidationError(validation)); 56 | } 57 | } 58 | 59 | /* Get the current user */ 60 | const user = await app.storage.get({ 61 | url: `/data/users/_id/${request.session.user._id}/?single=true`, 62 | session: request.session, 63 | }); 64 | 65 | /* Hash the incoming password */ 66 | const password = await new Hash().hash(request.body.password, user._salt); 67 | 68 | /* If password is valid, update details */ 69 | if (user.password === password.toString('base64')) { 70 | /* Delete password field */ 71 | delete request.body.password; 72 | 73 | /* Handle password change */ 74 | if (request.body.new_password) { 75 | /* Hash and delete the new password */ 76 | const hash = await new Hash().hash(request.body.new_password); 77 | 78 | /* Add fields to request body */ 79 | request.body._salt = hash[0]; 80 | request.body.password = hash[1]; 81 | } 82 | 83 | /* Delete new password field */ 84 | delete request.body.new_password; 85 | } else { 86 | /* Throw error if password didn't match */ 87 | return new Response(app, request, response, new ValidationError({ 88 | status: '422', 89 | code: '1009', 90 | title: 'Incorrect Password', 91 | detail: 'Value for key `password` did not match the password in the database.', 92 | meta: { 93 | key: 'password', 94 | rule: 'match', 95 | }, 96 | })); 97 | } 98 | 99 | /* Send to the database */ 100 | const { data: userData } = await app.storage.post({ 101 | url: `/data/users/_id/${user._id}`, 102 | body: request.body, 103 | session: request.session, 104 | }); 105 | 106 | /* Clean the output */ 107 | for (const record of userData) { 108 | delete record.password; 109 | delete record._salt; 110 | } 111 | 112 | /* If we need to redirect, let's redirect */ 113 | if (!(new Redirect(app, request, response, userData)).do()) { 114 | /* Respond with the user object */ 115 | return new Response(app, request, response, null, userData); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /****************************************** 4 | * * 5 | * S A P L I N G * 6 | * -------------------------------------- * 7 | * A minimalist Node.js framework for * 8 | * faster-than-light web development. * 9 | * * 10 | *****************************************/ 11 | 12 | /* Require native clustering bits */ 13 | import cluster from 'node:cluster'; 14 | import { promises as fs } from 'node:fs'; 15 | import os from 'node:os'; 16 | import path from 'node:path'; 17 | import process from 'node:process'; 18 | import chalk from 'chalk'; 19 | import yargs from 'yargs'; 20 | /* eslint-disable-next-line n/file-extension-in-import */ 21 | import { hideBin } from 'yargs/helpers'; 22 | 23 | import Utils from './lib/Utils.js'; 24 | 25 | import App from './app.js'; 26 | 27 | 28 | const argv = yargs(hideBin(process.argv)).argv; 29 | 30 | 31 | /* Determine if session store is configured */ 32 | const configPath = path.join(process.cwd(), 'config.json'); 33 | let sessionAvailable = false; 34 | 35 | /* If we have a config file, let's load it */ 36 | if (await new Utils().exists(configPath)) { 37 | /* Parse config, or throw an error if it's malformed */ 38 | try { 39 | const file = await fs.readFile(configPath); 40 | 41 | const c = JSON.parse(file.toString()); 42 | if ('session' in c && 'driver' in c.session) { 43 | sessionAvailable = true; 44 | } 45 | } catch (error) { 46 | console.error('Error loading config'); 47 | console.error(error, error.stack); 48 | } 49 | } 50 | 51 | if (cluster.isMaster && !argv.single && sessionAvailable) { 52 | console.log(chalk.green.bold('Starting Sapling!')); 53 | 54 | /* Create a new instance for each CPU available */ 55 | const cpus = os.cpus().length; 56 | 57 | console.log(`Utilising ${cpus} CPUs`); 58 | for (let i = 0; i < cpus; i++) { 59 | cluster.fork(); 60 | } 61 | } else { 62 | if (argv.single || !sessionAvailable) { 63 | console.log(chalk.green.bold('Starting a single instance of Sapling!')); 64 | } 65 | 66 | /* Load a single instance */ 67 | new App(process.cwd()); 68 | } 69 | -------------------------------------------------------------------------------- /lib/Cluster.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cluster 3 | * 4 | * Handles console logging in a way that the responding core is 5 | * identified in each log message. 6 | */ 7 | 8 | /* Dependencies */ 9 | import cluster from 'node:cluster'; 10 | import process from 'node:process'; 11 | import chalk from 'chalk'; 12 | 13 | const pid = process.pid; 14 | 15 | 16 | let currentIndent = 0; 17 | const indentAmount = 4; 18 | const originalConsole = console; 19 | 20 | 21 | /* Prefixing native console methods with worker ID */ 22 | const prefixedConsole = { 23 | log(...args) { 24 | if (process.env.NODE_ENV !== 'test' && Cluster.isVerbose()) { 25 | originalConsole.log(Cluster.workerID() + ' '.repeat(currentIndent), ...args); 26 | } 27 | }, 28 | logAlways(...args) { 29 | if (process.env.NODE_ENV !== 'test') { 30 | originalConsole.log(Cluster.workerID() + ' '.repeat(currentIndent), ...args); 31 | } 32 | }, 33 | warn(...args) { 34 | if (process.env.NODE_ENV !== 'test' && Cluster.isVerbose()) { 35 | originalConsole.warn(Cluster.workerID() + ' '.repeat(currentIndent), ...args); 36 | } 37 | }, 38 | error(...args) { 39 | if (process.env.NODE_ENV !== 'test') { 40 | originalConsole.error(Cluster.workerID() + ' '.repeat(currentIndent), ...args); 41 | } 42 | }, 43 | group(...args) { 44 | if (process.env.NODE_ENV !== 'test' && Cluster.isVerbose()) { 45 | originalConsole.log(Cluster.workerID(), chalk.blue.bold(...args)); 46 | currentIndent += indentAmount; 47 | } 48 | }, 49 | groupEnd() { 50 | if (process.env.NODE_ENV !== 'test' && Cluster.isVerbose()) { 51 | currentIndent -= indentAmount; 52 | } 53 | }, 54 | }; 55 | 56 | 57 | const Cluster = { 58 | console: prefixedConsole, 59 | 60 | /* Create an access log line */ 61 | logger(tokens, request, response) { 62 | if (process.env.NODE_ENV !== 'test') { 63 | return `${Cluster.workerID()} ${[ 64 | chalk.cyan(`[${tokens.date(request, response, 'iso')}]`), 65 | tokens.method(request, response), 66 | tokens.url(request, response), 67 | tokens.status(request, response), 68 | tokens['response-time'](request, response), 69 | 'ms', 70 | ].join(' ')}`; 71 | } 72 | }, 73 | 74 | /* Format the worker ID + process ID as a tag to prefix to other messages */ 75 | workerID() { 76 | return chalk.magenta(`[W${cluster.worker ? cluster.worker.id : 0}/${pid}]`); 77 | }, 78 | 79 | /* Log a wakeup message when a new worker is created */ 80 | listening(port) { 81 | if (process.env.NODE_ENV !== 'test') { 82 | console.log(`${chalk.magenta(`Worker ${cluster.worker ? cluster.worker.id : 0} (${pid})`)} now listening on port ${port}`); 83 | } 84 | }, 85 | 86 | /* Return whether verbose logging is on or off */ 87 | isVerbose() { 88 | return process.env.VERBOSE_LOGGING !== 'false'; 89 | }, 90 | }; 91 | 92 | export { prefixedConsole as console }; 93 | export default Cluster; 94 | -------------------------------------------------------------------------------- /lib/Hash.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Hash 3 | * 4 | * Hash a given password 5 | */ 6 | 7 | /* Dependencies */ 8 | import crypto from 'node:crypto'; 9 | 10 | import SaplingError from './SaplingError.js'; 11 | 12 | 13 | /** 14 | * The Hash class 15 | */ 16 | export default class Hash { 17 | /* Bytesize */ 18 | length = 128; 19 | 20 | 21 | /* Iterations (~300ms) */ 22 | iterations = 12000; 23 | 24 | 25 | /** 26 | * Initialise the Hash class 27 | * 28 | * @param {int} length Bytesize 29 | * @param {int} iterations Iterations 30 | */ 31 | constructor(length, iterations) { 32 | if (length) { 33 | this.length = length; 34 | } 35 | 36 | if (iterations) { 37 | this.iterations = iterations; 38 | } 39 | } 40 | 41 | 42 | /** 43 | * Hashes a password with optional `salt`, otherwise 44 | * generate a salt for `pass` and return an array with 45 | * salt and hash. 46 | * 47 | * @param {string} password The password to hash 48 | * @param {string} salt Optional pre-existing salt 49 | */ 50 | async hash(password, salt) { 51 | /* If we're using an existing salt */ 52 | if (arguments.length === 2) { 53 | /* Throw errors if arguments are missing */ 54 | if (!password) { 55 | throw new SaplingError('Password missing'); 56 | } 57 | 58 | if (!salt) { 59 | throw new SaplingError('Salt missing'); 60 | } 61 | 62 | /* Hash the password, return error or the hash */ 63 | return new Promise((resolve, reject) => { 64 | crypto.pbkdf2(password, salt, this.iterations, this.length, 'sha256', (error, key) => { 65 | if (error) { 66 | reject(new SaplingError(error)); 67 | } else { 68 | resolve(key.toString('base64')); 69 | } 70 | }); 71 | }); 72 | } 73 | 74 | /* Throw errors if argument is missing */ 75 | if (!password) { 76 | throw new SaplingError('Password missing'); 77 | } 78 | 79 | return new Promise((resolve, reject) => { 80 | crypto.randomBytes(this.length, (error, salt) => { 81 | /* Return any error */ 82 | if (error) { 83 | return reject(new SaplingError(error)); 84 | } 85 | 86 | /* Hash password */ 87 | salt = salt.toString('base64'); 88 | crypto.pbkdf2(password, salt, this.iterations, this.length, 'sha256', (error, hash) => { 89 | /* Return any error */ 90 | if (error) { 91 | return reject(new SaplingError(error)); 92 | } 93 | 94 | /* Send an array with salt and hashed password */ 95 | resolve([salt, hash.toString('base64')]); 96 | }); 97 | }); 98 | }); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/Notifications.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Notifications 3 | * 4 | * Sending email notifications to the end user 5 | */ 6 | 7 | /* Dependencies */ 8 | import path from 'node:path'; 9 | import _ from 'underscore'; 10 | 11 | import frontMatter from 'front-matter'; 12 | import nodemailer from 'nodemailer'; 13 | import SaplingError from './SaplingError.js'; 14 | import Templating from './Templating.js'; 15 | import Validation from './Validation.js'; 16 | 17 | 18 | /** 19 | * The Notifications class 20 | */ 21 | export default class Notifications { 22 | /** 23 | * Initialise the Notifications class 24 | * 25 | * @param {object} App The App instance 26 | */ 27 | constructor(App) { 28 | this.app = App; 29 | 30 | /* Create a Templating instance */ 31 | this.templating = new Templating(this.app, path.join(this.app.dir, 'static/mail')); 32 | 33 | /* Load the config */ 34 | this.config = _.extend({}, this.app.config.mail); 35 | 36 | /* Create mailer if we have the necessary config */ 37 | if (this.config.host && this.config.auth.user && this.config.auth.pass) { 38 | this.mailer = nodemailer.createTransport(this.config); 39 | } 40 | } 41 | 42 | 43 | /** 44 | * Send a notification in the available method(s) 45 | * 46 | * @param {string} template Name of the notification template 47 | * @param {object} data Data that will be injected into the template 48 | * @param {string} recipient Email address of the recipient 49 | */ 50 | async sendNotification(template, data, recipient) { 51 | let html = ''; 52 | let meta = {}; 53 | 54 | await this.templating.importDriver(); 55 | 56 | try { 57 | /* Read template */ 58 | html = await this.templating.renderView(template, data); 59 | 60 | /* Parse front matter */ 61 | meta = frontMatter(html); 62 | } catch (error) { 63 | throw new SaplingError(`Could not load notification template \`${template}\`.`, error); 64 | } 65 | 66 | /* Check the email address is proper */ 67 | const errors = new Validation().validateEmail(recipient, 'recipient', { email: true }); 68 | if (errors.length > 0) { 69 | throw new SaplingError(`Cannot send notification: ${recipient} is not a valid email address`); 70 | } 71 | 72 | /* Send notification */ 73 | if (this.mailer) { 74 | return this.mailer.sendMail({ 75 | to: recipient, 76 | subject: meta.attributes.subject || 'Message', 77 | html: meta.body, 78 | }); 79 | } 80 | 81 | throw new SaplingError('Cannot send notification: mail host or authentication not set'); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/Redirect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Redirect 3 | * 4 | * Figures out if the current request needs to be redirected, 5 | * and does so if needed. Otherwise returns false. 6 | */ 7 | 8 | /* Dependencies */ 9 | import { inject } from 'regexparam'; 10 | 11 | 12 | /** 13 | * The Redirect class 14 | */ 15 | export default class Redirect { 16 | /** 17 | * Initialise the Redirect class 18 | * 19 | * @param {object} App The App instance 20 | * @param {object} request Request object from Express 21 | * @param {object} response Response object from Express 22 | * @param {object} data Data to apply to redirect destination 23 | */ 24 | constructor(App, request, response, data) { 25 | this.app = App; 26 | this.request = request; 27 | this.response = response; 28 | 29 | this.data = Array.isArray(data) ? data[0] : data; 30 | } 31 | 32 | 33 | /** 34 | * Execute the redirection 35 | * 36 | * @returns {boolean} Whether redirection happened or not 37 | */ 38 | do() { 39 | if ('redirect' in this.request.query || 'goto' in this.request.query) { 40 | const destination = String(this.request.query.redirect || this.request.query.goto); 41 | this.response.redirect(inject(destination, this.data)); 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/SaplingError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SaplingError 3 | * 4 | * Uniform error handling 5 | */ 6 | 7 | /** 8 | * The SaplingError class 9 | */ 10 | export default class SaplingError extends Error { 11 | /** 12 | * Initialise the SaplingError class 13 | * 14 | * @param {any} error The error(s) to be displayed 15 | */ 16 | constructor(error, ...parameters) { 17 | super(error, ...parameters); 18 | 19 | /* Maintains proper stack trace for where our error was thrown */ 20 | if (Error.captureStackTrace) { 21 | Error.captureStackTrace(this, SaplingError); 22 | } 23 | 24 | this.name = 'SaplingError'; 25 | 26 | /* Create empty error structure */ 27 | this.json = { errors: [] }; 28 | 29 | /* Parse the incoming error information */ 30 | this.parse(error); 31 | } 32 | 33 | 34 | /** 35 | * Parse the error into a uniform format 36 | * 37 | * @param {any} error Error message, either a string or an object, or an array of either 38 | */ 39 | parse(error) { 40 | if (typeof error === 'string') { 41 | /* If the error is a string, simply use it as a title for an otherwise empty error */ 42 | this.json.errors.push({ 43 | title: error, 44 | }); 45 | } else if (Array.isArray(error)) { 46 | /* If it's an array, assume multiple errors and parse each separately */ 47 | for (const element of error) { 48 | this.parse(element); 49 | } 50 | } else { 51 | /* If it's anything else, assume it's a fully qualified error already */ 52 | this.json.errors.push(error); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/Templating.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Templating 3 | * 4 | * Middleware between the app and the database driver to do all 5 | * the higher level heavy lifting with data. 6 | */ 7 | 8 | /* Dependencies */ 9 | import path from 'node:path'; 10 | import _ from 'underscore'; 11 | 12 | import SaplingError from './SaplingError.js'; 13 | 14 | 15 | /** 16 | * The Templating class 17 | */ 18 | export default class Templating { 19 | /** 20 | * Render class 21 | */ 22 | renderer = null; 23 | 24 | 25 | /** 26 | * Initialise the Templating class 27 | * 28 | * @param {object} App The App instance 29 | * @param {any} viewsPath Path to the directory for views 30 | */ 31 | constructor(App, viewsPath) { 32 | this.app = App; 33 | 34 | /* Infer base path if not supplied */ 35 | this.viewsPath = viewsPath ? viewsPath : path.join(this.app.dir, this.app.config.viewsDir); 36 | } 37 | 38 | 39 | /** 40 | * Import render driver if needed; no-op if already loaded. 41 | */ 42 | async importDriver() { 43 | if (this.renderer === null) { 44 | /* Set up the the desired driver */ 45 | const driver = String(this.app.config.render.driver).toLowerCase(); 46 | 47 | if (driver === 'html') { 48 | const { default: Html } = await import('../drivers/render/Html.js'); 49 | this.renderer = new Html(this.app, this.viewsPath); 50 | } else { 51 | try { 52 | const { default: Driver } = await import(`@sapling/render-driver-${driver}`); 53 | this.renderer = new Driver(this.app, this.viewsPath); 54 | } catch { 55 | try { 56 | const { default: Custom } = await import(driver); 57 | this.renderer = new Custom(this.app, this.viewsPath); 58 | } catch { 59 | throw new SaplingError(`Cannot find any render driver for '${driver}'`); 60 | } 61 | } 62 | } 63 | } 64 | 65 | return this.renderer; 66 | } 67 | 68 | 69 | /** 70 | * Render a given view and send it to the browser. 71 | * 72 | * @param {string} view The name of the view being rendered 73 | * @param {object} data Query data 74 | * @param {object} request Express request object 75 | */ 76 | async renderView(view, data, request) { 77 | await this.importDriver(); 78 | 79 | /* Build the data to pass into template */ 80 | if (request) { 81 | _.extend(data, { 82 | params: _.extend({}, request.params), 83 | query: request.query, 84 | headers: request.headers, 85 | session: request.session, 86 | form: request.body, 87 | $_POST: request.body, // Php-like alias 88 | $_GET: request.query, 89 | self: { 90 | dir: this.viewsPath, 91 | url: request.url, 92 | method: request.method, 93 | name: this.app.name, 94 | }, 95 | }); 96 | } 97 | 98 | /* Add CSRF token if needed */ 99 | if (this.app.config.csrf || this.app.config.strict) { 100 | _.extend(data, { 101 | csrfToken: request.csrfToken(), 102 | }); 103 | } 104 | 105 | /* Create new template engine instance */ 106 | return await this.renderer.render(`${view}.${this.app.config.extension}`, data); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /lib/UnauthorizedError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UnauthorizedError 3 | * 4 | * Handle errors related to insufficient permissions 5 | */ 6 | 7 | import SaplingError from './SaplingError.js'; 8 | 9 | 10 | /** 11 | * The UnauthorizedError class 12 | */ 13 | export default class UnauthorizedError extends SaplingError { 14 | /** 15 | * Initialise the UnauthorizedError class 16 | */ 17 | constructor(...parameters) { 18 | super(...parameters); 19 | 20 | this.name = 'UnauthorizedError'; 21 | 22 | /* Create empty error structure */ 23 | this.json = { 24 | errors: [ 25 | { 26 | title: 'Unauthorized', 27 | }, 28 | ], 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/Uploads.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Uploads 3 | * 4 | * Handle file uploads 5 | */ 6 | 7 | /* Dependencies */ 8 | import fs from 'node:fs'; 9 | import path from 'node:path'; 10 | import fileUpload from 'express-fileupload'; 11 | 12 | import filenamify from 'filenamify'; 13 | import { unusedFilename } from 'unused-filename'; 14 | import imageSize from 'image-size'; 15 | import sharp from 'sharp'; 16 | 17 | import Response from './Response.js'; 18 | import Utils from './Utils.js'; 19 | import Validation from './Validation.js'; 20 | import ValidationError from './ValidationError.js'; 21 | 22 | 23 | /** 24 | * The Uploads class 25 | */ 26 | export default class Uploads { 27 | /* File categories */ 28 | uploadTypes = { 29 | archive: ['application/zip', 'application/gzip', 'application/x-7z-compressed', 'application/x-bzip', 'application/x-bzip2', 'application/vnd.rar', 'application/x-rar-compressed', 'application/x-zip-compressed', 'application/x-tar'], 30 | image: ['image/png', 'image/jpeg', 'image/webp'], 31 | video: ['video/ogg', 'video/mp4', 'video/H264', 'video/mpeg', 'video/webm'], 32 | audio: ['audio/wav', 'audio/vnd.wave', 'audio/wave', 'audio/x-wav', 'audio/webm', 'audio/ogg', 'audio/mpeg', 'audio/aac'], 33 | document: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], 34 | font: ['font/ttf', 'font/otf', 'font/woff', 'font/woff2'], 35 | }; 36 | 37 | 38 | /** 39 | * Initialise the Uploads class 40 | * 41 | * @param {object} App The App instance 42 | */ 43 | constructor(App) { 44 | this.app = App; 45 | 46 | /* Allow file uploads */ 47 | this.app.server.use(fileUpload({ 48 | useTempFiles: true, 49 | })); 50 | 51 | /* Ensure the upload directory exists */ 52 | this.uploadDir = path.join(this.app.dir, this.app.config.upload.destination); 53 | 54 | if (!fs.existsSync(this.uploadDir)) { 55 | fs.mkdirSync(this.uploadDir); 56 | } 57 | } 58 | 59 | 60 | /** 61 | * Handle any and all file uploads in a given request 62 | * 63 | * @param {object} request Request object 64 | * @param {object} response Response object 65 | * @param {object} rules Current collection model, if any 66 | */ 67 | async handleUpload(request, response, rules) { 68 | const data = {}; 69 | 70 | for (const fileField of Object.keys(request.files)) { 71 | /* Either it's defined in a model or we don't care */ 72 | if ((fileField in Object.keys(rules) && rules[fileField].type === 'file') || !this.app.config.strict) { 73 | const file = request.files[fileField]; 74 | const rule = rules[fileField]; 75 | const validator = new Validation(); 76 | 77 | /* Make sure the filename is valid and available */ 78 | const filePath = await unusedFilename(path.join(this.app.uploads.uploadDir, filenamify(file.name))); 79 | file.extension = file.name.split('.').slice(-1)[0]; 80 | 81 | /* Figure out file type */ 82 | file.group = 'other'; 83 | for (const type of Object.keys(this.uploadTypes)) { 84 | if (this.uploadTypes[type].includes(file.mimetype)) { 85 | file.group = type; 86 | } 87 | } 88 | 89 | /* Special case for some archives */ 90 | if ((file.extension === 'zip' || file.extension === 'rar') && file.mimetype === 'application/octet-stream') { 91 | file.group = 'archive'; 92 | } 93 | 94 | /* If we have a model */ 95 | if (rule) { 96 | /* Ensure the file matches the given filetype (mime, group or ext) */ 97 | validator.validateFileType(file, fileField, rule); 98 | 99 | /* Ensure it's not too large */ 100 | validator.validateFileMaxsize(file, fileField, rule); 101 | } 102 | 103 | /* Create file meta */ 104 | const fileObject = { 105 | url: path.join('/', path.relative(this.app.dir, filePath)), 106 | filesize: file.size, 107 | type: file.group, 108 | extension: file.extension, 109 | mimetype: file.mimetype, 110 | }; 111 | 112 | /* If it's an image */ 113 | if (file.group === 'image') { 114 | /* Get the width and height */ 115 | const dimensions = await imageSize(file.tempFilePath); 116 | fileObject.width = dimensions.width; 117 | fileObject.height = dimensions.height; 118 | 119 | /* Validate dimensions */ 120 | if (rule) { 121 | validator.validateFileMinwidth(dimensions, fileField, rule); 122 | validator.validateFileMaxwidth(dimensions, fileField, rule); 123 | validator.validateFileMinheight(dimensions, fileField, rule); 124 | validator.validateFileMaxheight(dimensions, fileField, rule); 125 | } 126 | 127 | /* Generate thumbnails, if any */ 128 | const thumbDefinition = rule && rule.thumbnails ? rule.thumbnails : this.app.config.upload.thumbnails; 129 | const thumbs = thumbDefinition ? new Utils().coerceArray(thumbDefinition) : []; 130 | 131 | for (const [i, thumb] of thumbs.entries()) { 132 | /* Construct path for thumbnail */ 133 | const thumbPath = path.join(this.app.uploads.uploadDir, '/thumbs/', thumb.name || i); 134 | if (!fs.existsSync(thumbPath)) { 135 | fs.mkdirSync(thumbPath, { recursive: true }); 136 | } 137 | 138 | /* Resize according to options, and save */ 139 | await sharp(file.tempFilePath) 140 | .rotate() /* Rotate for EXIF orientation */ 141 | .resize({ 142 | width: thumb.width, 143 | height: thumb.height, 144 | fit: thumb.fit || 'cover', 145 | }) 146 | .toFile(await unusedFilename(path.join(thumbPath, filenamify(file.name)))); 147 | } 148 | } 149 | 150 | /* If there are any errors, give up */ 151 | if (validator.errors.length > 0) { 152 | return new Response(this.app, request, response, new ValidationError(validator.errors)); 153 | } 154 | 155 | /* Move to storage */ 156 | await file.mv(filePath); 157 | 158 | data[fileField] = fileObject; 159 | } 160 | } 161 | 162 | return data; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /lib/User.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User 3 | * 4 | * Built-in user account and permissions functionality 5 | */ 6 | 7 | /* Dependencies */ 8 | import routeMatcher from 'path-match'; 9 | 10 | import Response from './Response.js'; 11 | import UnauthorizedError from './UnauthorizedError.js'; 12 | import Utils from './Utils.js'; 13 | 14 | 15 | /** 16 | * The User class 17 | */ 18 | export default class User { 19 | /** 20 | * Initialise the User class 21 | * 22 | * @param {object} App The App instance 23 | */ 24 | constructor(App) { 25 | this.app = App; 26 | } 27 | 28 | 29 | /** 30 | * Check if the given user is permitted carry out a specific action 31 | * for the given permission level 32 | * 33 | * @param {string} permission The role level required for a given action 34 | * @param {object} user The user object 35 | */ 36 | isUserAllowed(permission, user) { 37 | /* Ensure array */ 38 | if (typeof permission === 'string') { 39 | permission = [permission]; 40 | } 41 | 42 | /* Stranger must NOT be logged in */ 43 | if (permission.includes('stranger')) { 44 | if (user) { 45 | return false; 46 | } 47 | } else if (permission.includes('member') || permission.includes('owner')) { 48 | /* "member" or "owner" must be logged in */ 49 | /* "owner" is handled further in the process */ 50 | if (!user) { 51 | return false; 52 | } 53 | } else if (permission.includes('anyone')) { 54 | /* Remove any restriction from "anyone" routes */ 55 | return true; 56 | } else { 57 | /* Handle custom roles */ 58 | const role = (user && user.role) ? user.role : 'stranger'; 59 | return this.isRoleAllowed(role, permission); 60 | } 61 | 62 | /* Default to allowed */ 63 | return true; 64 | } 65 | 66 | 67 | /** 68 | * Check if a given role equals or supersedes the required role. 69 | * 70 | * @param {string} test The role being tested 71 | * @param {any} role String or array of strings of access level being tested against 72 | * @returns {boolean} true if "test" is a higher or equal level role as "role"; false if it is lesser 73 | */ 74 | isRoleAllowed(test, roles) { 75 | /* Ensure array */ 76 | if (typeof roles === 'string') { 77 | roles = [roles]; 78 | } 79 | 80 | const rules = this.app.storage.getRules('users'); 81 | 82 | return roles.some(role => { 83 | /* Get the indices of both comparison targets */ 84 | const roleIndex = rules.role.values.indexOf(role); 85 | const testIndex = rules.role.values.indexOf(test); 86 | 87 | /* "admin" or "anyone" must always return true */ 88 | if (test === 'admin' || role === 'anyone') { 89 | return true; 90 | } 91 | 92 | /* If we cannot find the role, assume no */ 93 | if (roleIndex === -1 || testIndex === -1) { 94 | return false; 95 | } 96 | 97 | /* Otherwise do a straight comparison of indices */ 98 | return (testIndex <= roleIndex); 99 | }); 100 | } 101 | 102 | 103 | /** 104 | * Check if the given user is logged in for routes 105 | * that require logging in. 106 | * 107 | * @param {object} request The request object from Express 108 | * @param {object} response The response object from Express 109 | */ 110 | isUserAuthenticatedForRoute(request, response) { 111 | if ( 112 | request.permission && !request.permission.role.includes('anyone') 113 | && ((request.permission.role.includes('stranger') && 'user' in request.session) 114 | || (!request.permission.role.includes('stranger') && !('user' in request.session))) 115 | ) { 116 | new Response(this.app, request, response, new UnauthorizedError({ 117 | status: '401', 118 | code: '4002', 119 | title: 'Unauthorized', 120 | detail: 'You are not authorized to access this resource.', 121 | meta: { 122 | type: 'login', 123 | error: 'unauthorized', 124 | }, 125 | })); 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | 132 | 133 | /** 134 | * Get the defined permission role for a given method and route 135 | * 136 | * @param {string} method One of "get", "post" or "delete" 137 | * @param {string} url The route being tested 138 | */ 139 | getRolesForRoute(method, url) { 140 | const lowerMethod = String(method).toLowerCase(); 141 | const routes = this.app.routeStack[lowerMethod] || []; 142 | 143 | /* Go through all the routes */ 144 | for (const route of routes) { 145 | /* If the given route matches the url */ 146 | if (routeMatcher()(route)(url) !== false) { 147 | /* Find the given permission rule in the loaded permissionset */ 148 | const permissionKey = `${lowerMethod} ${route}`.toLowerCase(); 149 | const userType = permissionKey in this.app.permissions ? this.app.permissions[permissionKey].role : 'anyone'; 150 | 151 | /* Return the first match */ 152 | return new Utils().coerceArray(userType); 153 | } 154 | } 155 | 156 | /* Default to anyone */ 157 | return ['anyone']; 158 | } 159 | 160 | 161 | /** 162 | * Get the user role from the session 163 | * 164 | * @param {object} request Request object from Express 165 | * @returns {string/null} Role as string, or null if not logged in 166 | */ 167 | getRole(request) { 168 | if (request.session && request.session.user) { 169 | return request.session.user.role; 170 | } 171 | 172 | return null; 173 | } 174 | 175 | 176 | /** 177 | * Returns an array of fields that should 178 | * be omitted from the response due to permissions. 179 | * 180 | * @param {string} role The role being checked 181 | * @param {string} rules The rules of the collection being checked against 182 | */ 183 | disallowedFields(role, rules) { 184 | const omit = []; 185 | 186 | /* Loop every field in the collection */ 187 | for (const key in rules) { 188 | if (Object.prototype.hasOwnProperty.call(rules, key)) { 189 | const rule = rules[key]; 190 | 191 | /* Normalise the access rule to be an object with r,w */ 192 | const access = typeof rule.access === 'string' ? { 193 | r: rule.access, 194 | w: rule.access, 195 | } : rule.access; 196 | 197 | /* Skip if not defined or anyone can view */ 198 | if (!access || access.r === 'anyone' || access.r === 'owner') { 199 | continue; 200 | } 201 | 202 | /* Leave out the fields that the viewer can't access */ 203 | if (this.isRoleAllowed(role, access.r) === false) { 204 | omit.push(key); 205 | } 206 | } 207 | } 208 | 209 | return omit; 210 | } 211 | 212 | 213 | /** 214 | * Get a list of fields in a given model that only "owner" 215 | * level users are allowed to see. 216 | * 217 | * @param {string} rules The model being checked 218 | */ 219 | ownerFields(rules) { 220 | const fields = []; 221 | 222 | /* Loop every field in the collection */ 223 | for (const key in rules) { 224 | if (Object.prototype.hasOwnProperty.call(rules, key)) { 225 | const rule = rules[key]; 226 | 227 | /* Normalise the access rule to be an object with r */ 228 | const access = typeof rule.access === 'string' ? { 229 | r: rule.access, 230 | } : rule.access; 231 | 232 | /* Get the fields that are owner-only */ 233 | if (access && access.r === 'owner') { 234 | fields.push(key); 235 | } 236 | } 237 | } 238 | 239 | return fields; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /lib/Utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utils 3 | * 4 | * General purpose utility functions 5 | */ 6 | 7 | /* Dependencies */ 8 | import fs from 'node:fs'; 9 | 10 | import SaplingError from './SaplingError.js'; 11 | 12 | 13 | /** 14 | * The Utils class 15 | */ 16 | export default class Utils { 17 | /** 18 | * Initialise the Utils class 19 | * 20 | * @param {object} App The App instance 21 | */ 22 | constructor(App) { 23 | this.app = App; 24 | } 25 | 26 | 27 | /** 28 | * Generate a random string 29 | */ 30 | randString() { 31 | return (`00000000${Math.random().toString(36).slice(2)}`).slice(-11); 32 | } 33 | 34 | 35 | /** 36 | * Get all files recursively from a given directory 37 | * 38 | * @param {string} dir Directory path 39 | */ 40 | async getFiles(dir) { 41 | let results = []; 42 | let list; 43 | 44 | try { 45 | list = await fs.promises.readdir(dir); 46 | } catch { 47 | throw new SaplingError(`Cannot read directory: ${dir}`); 48 | } 49 | 50 | for (const file of list) { 51 | const dirfile = dir + '/' + file; 52 | const stat = await fs.promises.stat(dirfile); 53 | if (stat && stat.isDirectory()) { 54 | /* Recurse into a subdirectory */ 55 | results = results.concat(await this.getFiles(dirfile)); 56 | } else { 57 | /* Is a file */ 58 | results.push(dirfile); 59 | } 60 | } 61 | 62 | return results; 63 | } 64 | 65 | 66 | /** 67 | * Check if a file or directory exists 68 | * 69 | * @param {string} file Path to file or directory 70 | * @returns Boolean 71 | */ 72 | async exists(file) { 73 | try { 74 | await fs.promises.access(file, fs.constants.F_OK); 75 | return true; 76 | } catch { 77 | return false; 78 | } 79 | } 80 | 81 | 82 | /** 83 | * Deep clone an object 84 | * 85 | * @param {object} obj Object to be cloned 86 | */ 87 | deepClone(object) { 88 | return JSON.parse(JSON.stringify(object)); 89 | } 90 | 91 | 92 | /** 93 | * Convert any input to a logical boolean value 94 | * 95 | * @param {any} value Value to be converted 96 | * @returns Boolean 97 | */ 98 | trueBoolean(value) { 99 | switch (typeof value) { 100 | case 'boolean': 101 | return value; 102 | 103 | case 'string': 104 | if (value.toLowerCase() === 'true' || value.toLowerCase() === 'yes' || value.toLowerCase() === 'on') { 105 | return true; 106 | } 107 | 108 | return false; 109 | 110 | case 'number': 111 | case 'bigint': 112 | return Boolean(value); 113 | 114 | case 'undefined': 115 | return undefined; 116 | 117 | case 'object': 118 | if (value === null) { 119 | return null; 120 | } 121 | 122 | if (Array.isArray(value) && value.length === 0) { 123 | return false; 124 | } 125 | 126 | return true; 127 | 128 | case 'function': 129 | return this.trueBoolean(value()); 130 | 131 | default: 132 | return false; 133 | } 134 | } 135 | 136 | 137 | /** 138 | * Test a string against a rule that has asterisk wildcards 139 | * 140 | * @param {string} string String to be tested 141 | * @param {string} rule Pattern to be tested against 142 | * @returns {boolean} Whether or not the string matches the pattern 143 | */ 144 | matchWildcard(string, rule) { 145 | const escapeRegex = string => string.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'); 146 | return new RegExp(`^${String(rule).split('*').map(element => escapeRegex(element)).join('.*')}$`, 'i').test(String(string)); 147 | } 148 | 149 | 150 | /** 151 | * Make sure the value passed is an array 152 | * 153 | * @param {any} array Value to be coerced 154 | * @returns {array} Array 155 | */ 156 | coerceArray(array) { 157 | return Array.isArray(array) ? array : [array]; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /lib/ValidationError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ValidationError 3 | * 4 | * Handle data validation errors 5 | */ 6 | 7 | import SaplingError from './SaplingError.js'; 8 | 9 | 10 | /** 11 | * The ValidationError class 12 | */ 13 | export default class ValidationError extends SaplingError { 14 | /** 15 | * Initialise the ValidationError class 16 | * 17 | * @param {any} error The error(s) to be displayed 18 | */ 19 | constructor(error, ...parameters) { 20 | super(error, ...parameters); 21 | 22 | this.name = 'ValidationError'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sapling/sapling", 3 | "version": "0.2.1", 4 | "license": "MIT", 5 | "description": "Node.js framework for faster-than-light web development.", 6 | "type": "module", 7 | "keywords": [ 8 | "framework", 9 | "server", 10 | "express", 11 | "fast", 12 | "prototyping", 13 | "development" 14 | ], 15 | "homepage": "https://www.saplingjs.com", 16 | "bugs": "https://github.com/saplingjs/sapling/issues", 17 | "author": { 18 | "name": "Oskari Groenroos", 19 | "email": "oskari@groenroos.fi", 20 | "url": "https://www.groenroos.fi" 21 | }, 22 | "contributors": [ 23 | { 24 | "name": "Louis Stowasser" 25 | } 26 | ], 27 | "bin": "./index.js", 28 | "dependencies": { 29 | "@tinyhttp/app": "^2.0.11", 30 | "@tinyhttp/url": "^2.0.3", 31 | "async": "^3.2.0", 32 | "body-parser": "1.20.1", 33 | "chalk": "^5.0.0", 34 | "compression": "^1.7.4", 35 | "cookie-parser": "1.4.6", 36 | "cron": "^2.0.0", 37 | "csurf": "^1.11.0", 38 | "express-fileupload": "^1.2.1", 39 | "express-session": "1.17.3", 40 | "filenamify": "^5.0.0", 41 | "front-matter": "^4.0.2", 42 | "image-size": "^1.0.0", 43 | "isobject": "^4.0.0", 44 | "moment": "2.29.4", 45 | "morgan": "^1.9.1", 46 | "nodemailer": "6.9.1", 47 | "path-match": "^1.2.4", 48 | "regexparam": "^2.0.0", 49 | "sharp": "^0.31.3", 50 | "sirv": "^2.0.0", 51 | "underscore": "1.13.6", 52 | "unused-filename": "^4.0.0", 53 | "yargs": "^17.0.1" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.13.10", 57 | "@babel/eslint-parser": "^7.13.4", 58 | "@sapling/sapling": "file:.", 59 | "ava": "^5.2.0", 60 | "c8": "^7.8.0", 61 | "eslint": "^8.1.0", 62 | "husky": "4.3.8", 63 | "lint-staged": ">=10", 64 | "maildev": "^2.0.2", 65 | "mime-types": "^2.1.29", 66 | "strip-ansi": "^7.0.1", 67 | "supertest": "^6.1.3", 68 | "webpack": "^5.28.0", 69 | "xo": "^0.52.2" 70 | }, 71 | "scripts": { 72 | "precommit": "lint-staged", 73 | "lint-fix": "xo --fix", 74 | "lint": "xo", 75 | "test": "npx ava", 76 | "test:coverage": "npx c8 ava", 77 | "test:report": "npx c8 --reporter=lcov npm test", 78 | "test:send": "npx codecov" 79 | }, 80 | "files": [ 81 | "core", 82 | "drivers", 83 | "hooks", 84 | "lib", 85 | "public", 86 | "static", 87 | "views", 88 | "app.js", 89 | "index.js", 90 | "config.json", 91 | "hooks.json", 92 | "permissions.json" 93 | ], 94 | "xo": { 95 | "ignores": [ 96 | "drivers/db/Interface.js", 97 | "drivers/render/Interface.js", 98 | "node_modules/**/*.*", 99 | "test/**/*.*" 100 | ], 101 | "parser": "@babel/eslint-parser", 102 | "parserOptions": { 103 | "requireConfigFile": false 104 | }, 105 | "rules": { 106 | "max-params": [ 107 | "warn", 108 | { 109 | "max": 6 110 | } 111 | ], 112 | "no-multiple-empty-lines": [ 113 | "error", 114 | { 115 | "max": 2 116 | } 117 | ], 118 | "no-await-in-loop": "off", 119 | "no-new": "off", 120 | "no-return-await": "off", 121 | "object-curly-spacing": [ 122 | "error", 123 | "always" 124 | ], 125 | "unicorn/filename-case": [ 126 | "error", 127 | { 128 | "cases": { 129 | "camelCase": true, 130 | "pascalCase": true 131 | } 132 | } 133 | ], 134 | "unicorn/numeric-separators-style": "off" 135 | } 136 | }, 137 | "ava": { 138 | "workerThreads": false, 139 | "files": [ 140 | "test/**/*", 141 | "!test/_utils", 142 | "!test/_data" 143 | ] 144 | }, 145 | "husky": { 146 | "hooks": { 147 | "pre-commit": "lint-staged" 148 | } 149 | }, 150 | "lint-staged": { 151 | "!(*test).js": [ 152 | "npm run lint" 153 | ] 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /permissions.json: -------------------------------------------------------------------------------- 1 | { 2 | "GET /my-account": "member" 3 | } -------------------------------------------------------------------------------- /public/app.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, *:before, *:after { 6 | box-sizing: inherit; 7 | } 8 | 9 | body { 10 | font-family: Inter,BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif; 11 | text-align: center; 12 | color: #4a4a4a; 13 | } 14 | 15 | a { 16 | color: #4dcf89; 17 | text-decoration: none; 18 | } 19 | 20 | h1, h2, h3 { 21 | font-weight: normal; 22 | } 23 | 24 | code { 25 | background-color: #f5f5f5; 26 | color: #da1c42; 27 | font-size: .875em; 28 | font-weight: 400; 29 | border-radius: 3px; 30 | padding: .25em .5em .25em; 31 | } 32 | 33 | .container { 34 | max-width: 960px; 35 | margin: 128px auto; 36 | padding: 0 16px; 37 | } 38 | 39 | .container svg { 40 | width: 100%; 41 | height: auto; 42 | max-width: 250px; 43 | margin: 0 auto 48px; 44 | } 45 | 46 | .columns { 47 | display: flex; 48 | align-items: flex-start; 49 | justify-content: center; 50 | margin: 32px -32px; 51 | } 52 | 53 | .column { 54 | width: 50%; 55 | padding: 0 32px; 56 | text-align: left; 57 | } 58 | 59 | hr { 60 | border: 0; 61 | height: 3px; 62 | background: rgba(0,0,0,.1); 63 | margin: 32px 0; 64 | } 65 | 66 | .separator { 67 | display: flex; 68 | align-items: center; 69 | margin: 64px 0 32px; 70 | } 71 | 72 | .separator hr { 73 | margin: 0; 74 | flex: 1; 75 | } 76 | 77 | .separator h2 { 78 | padding: 0 16px; 79 | font-size: 1em; 80 | text-transform: uppercase; 81 | color: rgba(0,0,0,.6); 82 | letter-spacing: 0.03em; 83 | } 84 | 85 | label { 86 | display: block; 87 | margin-top: 8px; 88 | padding: 8px 0; 89 | } 90 | 91 | input, textarea { 92 | display: block; 93 | padding: 8px; 94 | width: 100%; 95 | border: 1px solid rgba(0,0,0,.6); 96 | border-radius: 4px; 97 | font-family: Inter,BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif; 98 | font-size: 1em; 99 | } 100 | 101 | textarea { 102 | max-width: 100%; 103 | min-height: 192px; 104 | } 105 | 106 | button { 107 | margin: 16px 0; 108 | border: 0; 109 | border-radius: 4px; 110 | background: #4dcf89; 111 | color: white; 112 | padding: 8px 24px; 113 | font-family: Inter,BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif; 114 | font-size: 1em; 115 | } -------------------------------------------------------------------------------- /static/mail/lostpass.html: -------------------------------------------------------------------------------- 1 | --- 2 | subject: Forgotten Password 3 | --- 4 | 5 |You have received this email because you forgot your password. If it was not you, simply ignore this email.
8 | 9 |Click the following link to reset the password. The link will expire within 2 hours.
10 | 11 |{{ url }}/api/user/recover?auth={{ key }}
-------------------------------------------------------------------------------- /static/response/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |This page either does not exist, or you do not have permission to see it.
39 |A critical error has occurred with this website. Please try again later.
39 |