├── .eslintignore ├── Procfile ├── .gitignore ├── test ├── unit │ ├── mock │ │ ├── model │ │ │ ├── site.mock.js │ │ │ ├── url.mock.js │ │ │ ├── setting.mock.js │ │ │ ├── key.mock.js │ │ │ └── user.mock.js │ │ ├── controller │ │ │ ├── api-v1.mock.js │ │ │ └── front-end.mock.js │ │ ├── nocache.mock.js │ │ ├── log.mock.js │ │ ├── get-app-url.mock.js │ │ ├── compression.mock.js │ │ ├── adaro.mock.js │ │ ├── knex.mock.js │ │ ├── bookshelf.mock.js │ │ ├── bind-logger.mock.js │ │ ├── morgan.mock.js │ │ ├── dashboard.mock.js │ │ └── express.mock.js │ ├── setup.test.js │ └── lib │ │ ├── middleware │ │ ├── not-found.test.js │ │ ├── require-auth.test.js │ │ ├── require-permission.test.js │ │ ├── flash.test.js │ │ ├── get-app-url.test.js │ │ └── auth-with-cookie.test.js │ │ └── util │ │ ├── bind-logger.test.js │ │ └── validation-error.test.js └── integration │ ├── .eslintrc.js │ ├── seed │ ├── basic │ │ ├── settings.js │ │ ├── sites.js │ │ └── auth.js │ └── no-auth │ │ ├── settings.js │ │ ├── auth.js │ │ └── sites.js │ ├── helpers │ ├── auth.js │ └── database.js │ ├── routes │ ├── api-v1 │ │ ├── index.test.js │ │ └── me-keys.test.js │ └── front-end │ │ ├── 404.test.js │ │ ├── docs-api-v1.test.js │ │ ├── index.test.js │ │ ├── settings-keys.test.js │ │ └── settings-profile.test.js │ └── setup.test.js ├── .eslintrc.js ├── view ├── template │ ├── home.dust │ ├── login.dust │ ├── sites │ │ ├── edit-url.dust │ │ ├── new-site.dust │ │ ├── new-url.dust │ │ ├── sites.dust │ │ ├── delete-url.dust │ │ └── site.dust │ ├── settings │ │ ├── edit-key.dust │ │ ├── new-key.dust │ │ ├── profile.dust │ │ ├── password.dust │ │ ├── delete-key.dust │ │ └── keys.dust │ ├── admin │ │ ├── edit-user.dust │ │ ├── new-user.dust │ │ ├── settings.dust │ │ ├── delete-user.dust │ │ └── users.dust │ ├── setup.dust │ ├── error.dust │ └── docs │ │ └── api-v1.dust ├── partial │ ├── form │ │ ├── logout.dust │ │ ├── key.dust │ │ ├── login.dust │ │ ├── app-settings.dust │ │ ├── password.dust │ │ ├── url.dust │ │ ├── setup.dust │ │ ├── site.dust │ │ └── user.dust │ ├── alert │ │ ├── error.dust │ │ └── success.dust │ └── nav │ │ ├── footer.dust │ │ ├── admin.dust │ │ ├── settings.dust │ │ ├── api-docs.dust │ │ └── main.dust ├── helper │ ├── index.js │ ├── current-year.js │ ├── current-url.js │ └── code-block.js └── layout │ ├── base.dust │ └── full.dust ├── data ├── seed │ └── demo │ │ ├── settings.js │ │ ├── site-pa11y-github.js │ │ ├── site-pa11y.js │ │ └── auth.js └── migration │ ├── 20180221234637_urls.js │ ├── 20180217203355_sites.js │ ├── 20191021121104_update_version_columns_jsonb.js │ └── 20171117142417_setup.js ├── lib ├── middleware │ ├── not-found.js │ ├── require-auth.js │ ├── require-permission.js │ ├── get-app-url.js │ ├── auth-with-cookie.js │ ├── flash.js │ └── auth-with-key.js ├── util │ ├── validation-error.js │ └── bind-logger.js └── dashboard.js ├── public ├── pa11y.svg └── main.css ├── .dustmiterc ├── controller ├── api-v1 │ ├── docs.js │ ├── index.js │ ├── urls.js │ ├── sites.js │ ├── users.js │ ├── keys.js │ └── me.js └── front-end │ ├── home.js │ ├── docs.js │ ├── auth.js │ ├── setup.js │ ├── index.js │ └── settings.js ├── app.json ├── env.sample ├── script ├── seed.js ├── migrate-up.js ├── migrate-down.js └── create-migration.js ├── CONTRIBUTING.md ├── index.js ├── .travis.yml ├── Makefile ├── package.json ├── model ├── setting.js ├── key.js ├── site.js ├── url.js └── user.js └── docs └── deploy └── heroku.md /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: make db-migrate-up 2 | web: node index.js 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .nyc_output 3 | coverage 4 | node_modules 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /test/unit/mock/model/site.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub(); 6 | -------------------------------------------------------------------------------- /test/unit/mock/model/url.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub(); 6 | -------------------------------------------------------------------------------- /test/unit/mock/controller/api-v1.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub(); 6 | -------------------------------------------------------------------------------- /test/unit/mock/controller/front-end.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | module.exports = sinon.stub(); 6 | -------------------------------------------------------------------------------- /test/integration/.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | globals: { 5 | dashboard: true, 6 | request: true 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: '@rowanmanning/eslint-config/es2017', 5 | rules: { 6 | camelcase: 'off' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /test/unit/mock/model/setting.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | const Setting = module.exports = sinon.stub(); 6 | Setting.get = sinon.stub(); 7 | -------------------------------------------------------------------------------- /view/template/home.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {
Hello World!
12 | 13 | {/content} 14 | -------------------------------------------------------------------------------- /view/template/login.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {The following errors occurred:
5 |Manage the way that Sidekick behaves.
16 | 17 | {>"partial/form/app-settings" action=requestPath cta="Save changes"/} 18 | 19 | {/content} 20 | -------------------------------------------------------------------------------- /view/template/settings/profile.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {Manage your profile.
16 | 17 | {>"partial/form/user" action="/settings/profile" cta="Save changes"/} 18 | 19 | {/content} 20 | -------------------------------------------------------------------------------- /view/template/setup.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {12 | You're nearly there! We need a few more things from you before Sidekick is ready to display 13 | your accessibility information. 14 |
15 | 16 | {>"partial/form/setup" action=requestPath cta="Set up Sidekick"/} 17 | 18 | {/content} 19 | -------------------------------------------------------------------------------- /view/helper/current-year.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Initialise the current year view helper. 5 | * @param {Object} dust - a Dust view engine. 6 | * @returns {undefined} Nothing. 7 | */ 8 | function initCurrentYearHelper(dust) { 9 | dust.helpers.currentYear = chunk => { 10 | const year = (new Date()).getFullYear(); 11 | return chunk.write(year); 12 | }; 13 | } 14 | 15 | module.exports = initCurrentYearHelper; 16 | -------------------------------------------------------------------------------- /view/partial/alert/success.dust: -------------------------------------------------------------------------------- 1 | 2 | {?errors} 3 |The following errors occurred:
5 |{success}
16 |Manage your password and access to Sidekick.
16 | 17 | {>"partial/form/password" action="/settings/password" cta="Change password"/} 18 | 19 | {/content} 20 | -------------------------------------------------------------------------------- /test/integration/helpers/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Log into the site and get a cookie jar 4 | async function getCookieJar(email, password) { 5 | const jar = request.jar(); 6 | await request.post('/login', { 7 | headers: { 8 | 'Content-Type': 'application/x-www-form-urlencoded' 9 | }, 10 | body: `email=${email}&password=${password}`, 11 | jar 12 | }); 13 | return jar; 14 | } 15 | 16 | module.exports = { 17 | getCookieJar 18 | }; 19 | -------------------------------------------------------------------------------- /data/seed/demo/site-pa11y-github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Pa11y website details 4 | exports.seed = async database => { 5 | 6 | // Insert demo site: github.com/pa11y/pa11y 7 | await database('sites').insert([ 8 | { 9 | id: 'r1BzElDDG', 10 | name: 'Pa11y GitHub Repo', 11 | base_url: 'https://github.com/pa11y/pa11y', 12 | is_runnable: true, 13 | is_scheduled: false, 14 | schedule: null, 15 | pa11y_config: JSON.stringify({}) 16 | } 17 | ]); 18 | 19 | }; 20 | -------------------------------------------------------------------------------- /view/partial/nav/footer.dust: -------------------------------------------------------------------------------- 1 | 2 | 18 | -------------------------------------------------------------------------------- /test/unit/mock/dashboard.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | const Dashboard = module.exports = sinon.stub(); 6 | 7 | const mockDashboard = module.exports.mockDashboard = { 8 | environment: 'production', 9 | log: require('./log.mock'), 10 | model: { 11 | Key: require('./model/key.mock'), 12 | Setting: require('./model/setting.mock'), 13 | User: require('./model/user.mock') 14 | }, 15 | options: {} 16 | }; 17 | 18 | Dashboard.resolves(mockDashboard); 19 | -------------------------------------------------------------------------------- /test/unit/setup.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('proclaim'); 4 | const mockery = require('mockery'); 5 | const sinon = require('sinon'); 6 | 7 | sinon.assert.expose(assert, { 8 | includeFail: false, 9 | prefix: '' 10 | }); 11 | 12 | beforeEach(() => { 13 | mockery.enable({ 14 | useCleanCache: true, 15 | warnOnUnregistered: false, 16 | warnOnReplace: false 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | mockery.deregisterAll(); 22 | mockery.disable(); 23 | }); 24 | -------------------------------------------------------------------------------- /.dustmiterc: -------------------------------------------------------------------------------- 1 | { 2 | "escapeCharactersMustBeValid": true, 3 | "helperMustBeInsideSection": [ 4 | "first", 5 | "last", 6 | "sep" 7 | ], 8 | "helperMustBeInsideSelect": [ 9 | "any", 10 | "none" 11 | ], 12 | "helperMustHaveBody": [ 13 | "first", 14 | "last", 15 | "sep" 16 | ], 17 | "helperMustNotBeUsed": [ 18 | "default", 19 | "idx", 20 | "if" 21 | ], 22 | "helpersMustBeCamelCase": true, 23 | "logicHelpersMustHaveKeyAndValue": true, 24 | "referencesMustBeCamelCase": true 25 | } 26 | -------------------------------------------------------------------------------- /lib/middleware/require-auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const httpError = require('http-errors'); 4 | 5 | /** 6 | * Create a middleware function which requires an authenticated user. 7 | * @returns {Function} A middleware function. 8 | */ 9 | function requireAuth() { 10 | return (request, response, next) => { 11 | if (!request.authUser) { 12 | return next(httpError(401, 'You must authenticate to perform this action')); 13 | } 14 | next(); 15 | }; 16 | } 17 | 18 | module.exports = requireAuth; 19 | -------------------------------------------------------------------------------- /controller/api-v1/docs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Initialise the documentation controller. 5 | * @param {Dashboard} dashboard - A dashboard instance. 6 | * @param {Object} router - The API Express router. 7 | * @returns {undefined} Nothing 8 | */ 9 | function initDocsController(dashboard, router) { 10 | 11 | // The base path of the API redirects to the documentation 12 | router.get('/', (request, response) => { 13 | response.redirect('/docs/api/v1'); 14 | }); 15 | 16 | } 17 | 18 | module.exports = initDocsController; 19 | -------------------------------------------------------------------------------- /test/unit/mock/model/key.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | const Key = module.exports = sinon.stub(); 6 | Key.fetchOneById = sinon.stub(); 7 | Key.checkSecret = sinon.stub().resolves(true); 8 | 9 | const mockKey = module.exports.mockKey = { 10 | get: sinon.stub(), 11 | serialize: sinon.stub() 12 | }; 13 | 14 | const mockSerializedKey = module.exports.mockSerializedKey = { 15 | id: 'mock-key-id' 16 | }; 17 | 18 | Key.returns(mockKey); 19 | mockKey.serialize.returns(mockSerializedKey); 20 | Key.fetchOneById.resolves(mockKey); 21 | -------------------------------------------------------------------------------- /controller/front-end/home.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Initialise the home controller. 5 | * @param {Dashboard} dashboard - A dashboard instance. 6 | * @param {Object} router - The front end Express router. 7 | * @returns {undefined} Nothing 8 | */ 9 | function initHomeController(dashboard, router) { 10 | 11 | // Home page 12 | router.get('/', (request, response) => { 13 | if (request.permissions.read) { 14 | response.render('template/home'); 15 | } else { 16 | response.redirect('/login'); 17 | } 18 | }); 19 | 20 | } 21 | 22 | module.exports = initHomeController; 23 | -------------------------------------------------------------------------------- /view/partial/nav/admin.dust: -------------------------------------------------------------------------------- 1 | 2 | 21 | -------------------------------------------------------------------------------- /test/unit/mock/model/user.mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | 5 | const User = module.exports = sinon.stub(); 6 | User.fetchOneById = sinon.stub(); 7 | 8 | const mockUser = module.exports.mockUser = { 9 | get: sinon.stub(), 10 | serialize: sinon.stub() 11 | }; 12 | 13 | const mockSerializedUser = module.exports.mockSerializedUser = { 14 | id: 'mock-user-id', 15 | permissions: { 16 | read: true, 17 | write: true, 18 | delete: false, 19 | admin: false 20 | } 21 | }; 22 | 23 | User.returns(mockUser); 24 | mockUser.serialize.returns(mockSerializedUser); 25 | User.fetchOneById.resolves(mockUser); 26 | -------------------------------------------------------------------------------- /lib/util/validation-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Create a validation error which matches Joi's. 5 | * @param {(Array26 | You don't have any sites yet. 27 | {?permissions.write} 28 | You can create one here.
29 | {/permissions.write} 30 | {/sites} 31 | 32 | {/content} 33 | -------------------------------------------------------------------------------- /view/helper/current-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Initialise the current URL view helper. 5 | * @param {Object} dust - a Dust view engine. 6 | * @returns {undefined} Nothing. 7 | */ 8 | function currentUrl(dust) { 9 | dust.helpers.currentUrl = (chunk, context, bodies, params) => { 10 | if (!params.test || !bodies.block) { 11 | return chunk; 12 | } 13 | const pattern = params.test 14 | .replace('*', '.*') 15 | .replace('/', '\\/') 16 | .replace(/\/$/, '/?'); 17 | const regExp = new RegExp(`^${pattern}$`); 18 | if (regExp.test(context.get('requestPath') || '')) { 19 | return chunk.render(bodies.block, context); 20 | } 21 | return chunk; 22 | }; 23 | } 24 | 25 | module.exports = currentUrl; 26 | -------------------------------------------------------------------------------- /view/template/sites/delete-url.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {12 | This action is permanent and unrecoverable. 13 | Any data associated with this URL 14 | will no longer be accessible. 15 |
16 | 17 |18 | Are you sure you wish to delete the URL 19 | {form.url.name} ({form.url.address})? 20 |
21 | 22 | 29 | 30 | {/content} 31 | -------------------------------------------------------------------------------- /view/partial/form/key.dust: -------------------------------------------------------------------------------- 1 | 2 | 22 | -------------------------------------------------------------------------------- /view/layout/full.dust: -------------------------------------------------------------------------------- 1 | {>"layout/base"/} 2 | 3 | {! A layout with all of the trimmings !} 4 | 5 | {{siteWideAlert}
10 | 11 | {/siteWideAlert} 12 | 13 |16 | This action is permanent and unrecoverable. 17 | Any applications or scripts using this API key 18 | will cease to work. 19 |
20 | 21 |22 | Are you sure you wish to delete the key 23 | {form.key.description}? 24 |
25 | 26 | 33 | 34 | {/content} 35 | -------------------------------------------------------------------------------- /view/template/admin/delete-user.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {16 | This action is permanent and unrecoverable, 17 | This user will no longer be able to log in 18 | and all of their API keys will be deleted. 19 |
20 | 21 |22 | Are you sure you wish to delete the user 23 | {form.user.email}? 24 |
25 | 26 | 33 | 34 | {/content} 35 | -------------------------------------------------------------------------------- /view/partial/form/login.dust: -------------------------------------------------------------------------------- 1 | 2 | 25 | -------------------------------------------------------------------------------- /data/migration/20180221234637_urls.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = async database => { 4 | 5 | // Create the URLs table 6 | await database.schema.createTable('urls', table => { 7 | 8 | // Meta information 9 | table.string('id').unique().primary(); 10 | table.timestamp('created_at').defaultTo(database.fn.now()); 11 | table.timestamp('updated_at').defaultTo(database.fn.now()); 12 | 13 | // URL information 14 | table.string('site_id').notNullable(); 15 | table.string('name').notNullable(); 16 | table.string('address').notNullable(); 17 | table.json('pa11y_config').notNullable().defaultTo('{}'); 18 | 19 | // Foreign key contraints 20 | table.foreign('site_id').references('sites.id').onDelete('CASCADE'); 21 | 22 | }); 23 | 24 | }; 25 | 26 | exports.down = async database => { 27 | await database.schema.dropTable('urls'); 28 | }; 29 | -------------------------------------------------------------------------------- /test/integration/routes/api-v1/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('proclaim'); 4 | const database = require('../../helpers/database'); 5 | let response; 6 | 7 | describe('GET /api/v1', () => { 8 | 9 | describe('when everything is valid', () => { 10 | 11 | before(async () => { 12 | await database.seed(dashboard, 'basic'); 13 | response = await request.get('/api/v1'); 14 | }); 15 | 16 | it('responds with a 302 status', () => { 17 | assert.strictEqual(response.statusCode, 302); 18 | }); 19 | 20 | it('responds with a Location header pointing to the API documentation page', () => { 21 | assert.strictEqual(response.headers.location, '/docs/api/v1'); 22 | }); 23 | 24 | it('responds with plain text', () => { 25 | assert.include(response.headers['content-type'], 'text/plain'); 26 | }); 27 | 28 | }); 29 | 30 | }); 31 | -------------------------------------------------------------------------------- /view/partial/form/app-settings.dust: -------------------------------------------------------------------------------- 1 | 2 | 23 | -------------------------------------------------------------------------------- /data/migration/20180217203355_sites.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = async database => { 4 | 5 | // Create the sites table 6 | await database.schema.createTable('sites', table => { 7 | 8 | // Meta information 9 | table.string('id').unique().primary(); 10 | table.timestamp('created_at').defaultTo(database.fn.now()); 11 | table.timestamp('updated_at').defaultTo(database.fn.now()); 12 | 13 | // Site information 14 | table.string('name').notNullable(); 15 | table.string('base_url').notNullable(); 16 | table.boolean('is_runnable').notNullable().defaultTo(true); 17 | table.boolean('is_scheduled').notNullable().defaultTo(false); 18 | table.string('schedule'); 19 | table.json('pa11y_config').notNullable().defaultTo('{}'); 20 | 21 | }); 22 | 23 | }; 24 | 25 | exports.down = async database => { 26 | await database.schema.dropTable('sites'); 27 | }; 28 | -------------------------------------------------------------------------------- /view/template/error.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {You must log in before you'll be able to access this page
15 | {/eq} 16 | 17 | {@eq value=404} 18 |The page you're looking for can't be found.
19 | {/eq} 20 | 21 | {@eq value=500} 22 |Something went wrong internally.
23 | {/eq} 24 | 25 | {/select} 26 | 27 | {! We only send the stack if the app is running in development mode !} 28 | {?error.stack} 29 |Note: you can see the full error because Sidekick is running in development mode:
31 |{error.stack}
32 | {/error.stack}
33 |
34 | {/content}
35 |
--------------------------------------------------------------------------------
/test/integration/seed/no-auth/auth.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const bcrypt = require('bcrypt');
4 |
5 | // Test authentication details
6 | exports.seed = async database => {
7 |
8 | // Insert test users
9 | await database('users').insert([
10 | {
11 | id: 'mock-owner-id',
12 | email: 'owner@example.com',
13 | password: await bcrypt.hash('password', 10),
14 | is_owner: true,
15 | allow_read: true,
16 | allow_write: true,
17 | allow_delete: true,
18 | allow_admin: true
19 | },
20 | {
21 | id: 'mock-user-id',
22 | email: 'user@example.com',
23 | password: await bcrypt.hash('password', 10)
24 | }
25 | ]);
26 |
27 | // Insert test API keys
28 | await database('keys').insert([
29 | {
30 | id: 'mock-key',
31 | secret: await bcrypt.hash('mock-secret', 5),
32 | user_id: 'mock-user-id',
33 | description: 'Example key'
34 | }
35 | ]);
36 |
37 | };
38 |
--------------------------------------------------------------------------------
/test/integration/routes/front-end/404.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const database = require('../../helpers/database');
5 | let response;
6 |
7 | describe('GET /404', () => {
8 |
9 | before(async () => {
10 | await database.seed(dashboard, 'basic');
11 | });
12 |
13 | describe('when everything is valid', () => {
14 |
15 | before(async () => {
16 | response = await request.get('/404');
17 | });
18 |
19 | it('responds with a 404 status', () => {
20 | assert.strictEqual(response.statusCode, 404);
21 | });
22 |
23 | it('responds with HTML', () => {
24 | assert.include(response.headers['content-type'], 'text/html');
25 | });
26 |
27 | it('it responds with an error page', () => {
28 | const body = response.body.document.querySelector('body');
29 | assert.match(body.innerHTML, /not found/i);
30 | });
31 |
32 | });
33 |
34 | });
35 |
--------------------------------------------------------------------------------
/controller/front-end/docs.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const requirePermission = require('../../lib/middleware/require-permission');
4 |
5 | /**
6 | * Initialise the documentation controller.
7 | * @param {Dashboard} dashboard - A dashboard instance.
8 | * @param {Object} router - The API Express router.
9 | * @returns {undefined} Nothing
10 | */
11 | function initDocsController(dashboard, router) {
12 |
13 | // API documentation routes
14 | router.get('/docs/api/v1', requirePermission('read'), (request, response) => {
15 | response.render('template/docs/api-v1');
16 | });
17 | router.get('/docs/api/v1/sites', requirePermission('read'), (request, response) => {
18 | response.render('template/docs/api-v1-sites');
19 | });
20 | router.get('/docs/api/v1/users', requirePermission('read'), (request, response) => {
21 | response.render('template/docs/api-v1-users');
22 | });
23 |
24 | }
25 |
26 | module.exports = initDocsController;
27 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributing Guide
3 |
4 | Thanks for getting involved :tada:
5 |
6 | The Pa11y team loves to see new contributors, and we strive to provide a welcoming and inclusive environment. We ask that all contributors read and follow [our code of conduct][code-of-conduct] before joining. If you represent an organisation, then you might find our [guide for companies][companies] helpful.
7 |
8 | Our website outlines the many ways that you can contribute to Pa11y:
9 |
10 | - [Help us to talk to our users][communications]
11 | - [Help us out with design][designers]
12 | - [Help us with our code][developers]
13 |
14 |
15 |
16 | [code-of-conduct]: http://pa11y.org/contributing/code-of-conduct/
17 | [communications]: http://pa11y.org/contributing/communications/
18 | [companies]: http://pa11y.org/contributing/companies/
19 | [designers]: http://pa11y.org/contributing/designers/
20 | [developers]: http://pa11y.org/contributing/developers/
21 |
--------------------------------------------------------------------------------
/script/create-migration.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | 'use strict';
3 |
4 | const dotenv = require('dotenv');
5 | const Dashboard = require('../lib/dashboard');
6 |
7 | // Load configurations from an .env file if present
8 | dotenv.config();
9 |
10 | // Check for a migration name
11 | const migrationName = process.argv[2];
12 | if (!migrationName) {
13 | console.log('Usage: ./script/create-migration.js ${code}`);
30 | };
31 | }
32 |
33 | /**
34 | * Capture the text output by a chunk render.
35 | * @param {Object} block - a Dust block.
36 | * @param {Object} context - a Dust context.
37 | * @returns {undefined} Nothing.
38 | */
39 | function captureBody(block, context) {
40 | let output = '';
41 | const chunk = {
42 | w: string => { // eslint-disable-line id-length
43 | output += string;
44 | return chunk;
45 | },
46 | f: string => { // eslint-disable-line id-length
47 | return chunk.w(string);
48 | }
49 | };
50 | block(chunk, context);
51 | return output;
52 | }
53 |
54 | module.exports = codeBlock;
55 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 |
2 | # Reusable Makefile
3 | # ------------------------------------------------------------------------
4 | # This section of the Makefile should not be modified, it includes
5 | # commands from my reusable Makefile: https://github.com/rowanmanning/make
6 | -include node_modules/@rowanmanning/make/javascript/index.mk
7 | # [edit below this line]
8 | # ------------------------------------------------------------------------
9 |
10 |
11 | # Running tasks
12 | # -------------
13 |
14 | # Start the application in production mode
15 | start:
16 | @NODE_ENV=production node index.js
17 |
18 | # Start the application in development mode and auto-restart
19 | # whenever code changes
20 | start-dev:
21 | @NODE_ENV=development nodemon -e dust,js,json index.js
22 |
23 |
24 | # Configuration tasks
25 | # -------------------
26 |
27 | # Configure the application for local development
28 | config: .env
29 |
30 | # Duplicate the sample environment variable file
31 | .env:
32 | @echo "Creating .env file from env.sample"
33 | @cp ./env.sample ./.env;
34 | @$(TASK_DONE)
35 |
36 |
37 | # Database tasks
38 | # --------------
39 |
40 | # Create the local development database
41 | db-create:
42 | @psql -c "CREATE DATABASE pa11y_sidekick"
43 | @$(TASK_DONE)
44 |
45 | # Create the local automated testing database
46 | db-create-test:
47 | @psql -c "CREATE DATABASE pa11y_sidekick_test"
48 | @$(TASK_DONE)
49 |
50 | # Migrate to the latest version of the database schema
51 | db-migrate-up:
52 | @./script/migrate-up.js
53 | @$(TASK_DONE)
54 |
55 | # Roll back the most recent migration
56 | db-migrate-down:
57 | @./script/migrate-down.js
58 | @$(TASK_DONE)
59 |
60 | # Seed the database with some demo data
61 | db-seed:
62 | @./script/seed.js
63 | @$(TASK_DONE)
64 |
--------------------------------------------------------------------------------
/controller/front-end/auth.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const express = require('express');
4 |
5 | /**
6 | * Initialise the authentication controller.
7 | * @param {Dashboard} dashboard - A dashboard instance.
8 | * @param {Object} router - The front end Express router.
9 | * @returns {undefined} Nothing
10 | */
11 | function initAuthController(dashboard, router) {
12 | const User = dashboard.model.User;
13 |
14 | // Display the login page
15 | router.get('/login', (request, response) => {
16 | response.render('template/login', {
17 | form: {
18 | login: {
19 | referer: request.query.referer || null
20 | }
21 | }
22 | });
23 | });
24 |
25 | // Perform the login action
26 | router.post('/login', express.urlencoded({extended: false}), async (request, response, next) => {
27 | try {
28 |
29 | // Attempt to log in
30 | const user = await User.fetchOneByEmail(request.body.email);
31 | if (user && await User.checkPassword(request.body.password, user.get('password'))) {
32 | request.session.userId = user.get('id');
33 | return response.redirect(request.body.referer || '/');
34 | }
35 |
36 | // Logging in failed, re-render the login page
37 | response.status(401).render('template/login', {
38 | form: {
39 | login: {
40 | email: request.body.email,
41 | errors: [
42 | 'Email address is not registered, or password is incorrect'
43 | ],
44 | referer: request.body.referer || null
45 | }
46 | }
47 | });
48 |
49 | } catch (error) {
50 | return next(error);
51 | }
52 | });
53 |
54 | // Perform the logout action
55 | router.post('/logout', (request, response) => {
56 | if (request.session) {
57 | delete request.session;
58 | }
59 | return response.redirect('/');
60 | });
61 |
62 | }
63 |
64 | module.exports = initAuthController;
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pa11y-sidekick",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "An accessibility dashboard",
6 | "keywords": [],
7 | "author": "Team Pa11y",
8 | "contributors": [
9 | "Rowan Manning (http://rowanmanning.com/)"
10 | ],
11 | "repository": {
12 | "type": "git",
13 | "url": "https://github.com/pa11y/sidekick.git"
14 | },
15 | "homepage": "https://github.com/pa11y/sidekick",
16 | "bugs": "https://github.com/pa11y/sidekick/issues",
17 | "license": "LGPL-3.0",
18 | "engines": {
19 | "node": "^8",
20 | "npm": "^5"
21 | },
22 | "dependencies": {
23 | "adaro": "^1.0.4",
24 | "bcrypt": "^3.0.6",
25 | "bookshelf": "^0.15.1",
26 | "canvas": "^2.6.0",
27 | "compression": "^1.7.4",
28 | "connect-session-knex": "^1.4.0",
29 | "dotenv": "^8.2.0",
30 | "express": "^4.17.1",
31 | "express-session": "^1.17.0",
32 | "http-errors": "^1.7.3",
33 | "@hapi/joi": "^15.1.1",
34 | "knex": "^0.17.6",
35 | "lodash": "^4.17.15",
36 | "morgan": "^1.9.1",
37 | "nocache": "^2.1.0",
38 | "pg": "^7.12.1",
39 | "require-header": "^1.0.4",
40 | "shortid": "^2.2.15",
41 | "winston": "^2.4.0"
42 | },
43 | "devDependencies": {
44 | "@rowanmanning/eslint-config": "^2.1.0",
45 | "@rowanmanning/make": "^1.2.0",
46 | "dustmite": "^2.0.4",
47 | "eslint": "^5.2.0",
48 | "jsdom": "^15.2.0",
49 | "mocha": "^6.2.2",
50 | "mockery": "^2.1.0",
51 | "nodemon": "^1.19.4",
52 | "nyc": "^14.1.1",
53 | "proclaim": "^3.6.0",
54 | "request": "^2.88.0",
55 | "request-promise-native": "^1.0.7",
56 | "sinon": "^7.5.0"
57 | },
58 | "optionalDependencies": {
59 | "fsevents": "^2.1.1"
60 | },
61 | "main": "./lib/dashboard.js",
62 | "scripts": {
63 | "test": "make ci",
64 | "start": "node index.js"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/view/partial/form/setup.dust:
--------------------------------------------------------------------------------
1 |
2 |
50 |
--------------------------------------------------------------------------------
/test/unit/lib/middleware/require-auth.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const mockery = require('mockery');
5 | const sinon = require('sinon');
6 |
7 | describe('lib/middleware/require-auth', () => {
8 | let httpError;
9 | let requireAuth;
10 |
11 | beforeEach(() => {
12 |
13 | httpError = sinon.spy(require('http-errors'));
14 | mockery.registerMock('http-errors', httpError);
15 |
16 | requireAuth = require('../../../../lib/middleware/require-auth');
17 | });
18 |
19 | it('exports a function', () => {
20 | assert.isFunction(requireAuth);
21 | });
22 |
23 | describe('requireAuth()', () => {
24 | let middleware;
25 |
26 | beforeEach(() => {
27 | middleware = requireAuth();
28 | });
29 |
30 | it('returns a middleware function', () => {
31 | assert.isFunction(middleware);
32 | });
33 |
34 | describe('middleware(request, response, next)', () => {
35 | let next;
36 | let request;
37 |
38 | beforeEach(() => {
39 | request = {
40 | authUser: {}
41 | };
42 | next = sinon.spy();
43 | middleware(request, {}, next);
44 | });
45 |
46 | it('calls `next` with nothing', () => {
47 | assert.calledOnce(next);
48 | assert.calledWithExactly(next);
49 | });
50 |
51 | describe('when no user is authenticated', () => {
52 |
53 | beforeEach(() => {
54 | delete request.authUser;
55 | next.resetHistory();
56 | middleware(request, {}, next);
57 | });
58 |
59 | it('creates a 401 HTTP error', () => {
60 | assert.calledOnce(httpError);
61 | assert.calledWithExactly(httpError, 401, 'You must authenticate to perform this action');
62 | });
63 |
64 | it('calls `next` with the created error', () => {
65 | assert.calledOnce(next);
66 | assert.calledWithExactly(next, httpError.firstCall.returnValue);
67 | });
68 |
69 | });
70 |
71 | });
72 |
73 | });
74 |
75 | });
76 |
--------------------------------------------------------------------------------
/test/unit/lib/util/validation-error.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 |
5 | describe('lib/util/validation-error', () => {
6 | let validationError;
7 |
8 | beforeEach(() => {
9 | validationError = require('../../../../lib/util/validation-error');
10 | });
11 |
12 | it('exports a function', () => {
13 | assert.isFunction(validationError);
14 | });
15 |
16 | describe('validationError(messages)', () => {
17 | let error;
18 |
19 | beforeEach(() => {
20 | error = validationError([
21 | 'mock message 1',
22 | 'mock message 2'
23 | ]);
24 | });
25 |
26 | it('returns an error object', () => {
27 | assert.isObject(error);
28 | assert.instanceOf(error, Error);
29 | });
30 |
31 | describe('.name', () => {
32 |
33 | it('is "ValidationError"', () => {
34 | assert.strictEqual(error.name, 'ValidationError');
35 | });
36 |
37 | });
38 |
39 | describe('.message', () => {
40 |
41 | it('is a generic validation message', () => {
42 | assert.strictEqual(error.message, 'Validation failed');
43 | });
44 |
45 | });
46 |
47 | describe('.details', () => {
48 |
49 | it('is an array of validation details', () => {
50 | assert.isArray(error.details);
51 | assert.deepEqual(error.details, [
52 | {
53 | message: 'mock message 1'
54 | },
55 | {
56 | message: 'mock message 2'
57 | }
58 | ]);
59 | });
60 |
61 | });
62 |
63 | describe('when `messages` is a string', () => {
64 | let error;
65 |
66 | beforeEach(() => {
67 | error = validationError('mock message');
68 | });
69 |
70 | describe('.details', () => {
71 |
72 | it('is an array of validation details', () => {
73 | assert.isArray(error.details);
74 | assert.deepEqual(error.details, [
75 | {
76 | message: 'mock message'
77 | }
78 | ]);
79 | });
80 |
81 | });
82 |
83 | });
84 |
85 | });
86 |
87 | });
88 |
--------------------------------------------------------------------------------
/test/integration/routes/front-end/docs-api-v1.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const auth = require('../../helpers/auth');
5 | const database = require('../../helpers/database');
6 | let response;
7 |
8 | describe('GET /docs/api/v1', () => {
9 |
10 | before(async () => {
11 | await database.seed(dashboard, 'basic');
12 | });
13 |
14 | describe('when a user is logged in', () => {
15 |
16 | before(async () => {
17 | response = await request.get('/docs/api/v1', {
18 | jar: await auth.getCookieJar('read@example.com', 'password')
19 | });
20 | });
21 |
22 | it('responds with a 200 status', () => {
23 | assert.strictEqual(response.statusCode, 200);
24 | });
25 |
26 | it('responds with HTML', () => {
27 | assert.include(response.headers['content-type'], 'text/html');
28 | });
29 |
30 | });
31 |
32 | describe('when no user is logged in', () => {
33 |
34 | before(async () => {
35 | response = await request.get('/docs/api/v1');
36 | });
37 |
38 | it('responds with a 403 status', () => {
39 | assert.strictEqual(response.statusCode, 403);
40 | });
41 |
42 | it('responds with HTML', () => {
43 | assert.include(response.headers['content-type'], 'text/html');
44 | });
45 |
46 | it('it responds with an error page', () => {
47 | const body = response.body.document.querySelector('body');
48 | assert.match(body.innerHTML, /not authorised/i);
49 | });
50 |
51 | });
52 |
53 | describe('when no user is logged in and the public read access setting is enabled', () => {
54 |
55 | before(async () => {
56 | await database.seed(dashboard, 'no-auth');
57 | response = await request.get('/');
58 | });
59 |
60 | it('responds with a 200 status', () => {
61 | assert.strictEqual(response.statusCode, 200);
62 | });
63 |
64 | it('responds with HTML', () => {
65 | assert.include(response.headers['content-type'], 'text/html');
66 | });
67 |
68 | });
69 |
70 | });
71 |
--------------------------------------------------------------------------------
/test/integration/routes/front-end/index.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const auth = require('../../helpers/auth');
5 | const database = require('../../helpers/database');
6 | let response;
7 |
8 | describe('GET /', () => {
9 |
10 | before(async () => {
11 | await database.seed(dashboard, 'basic');
12 | });
13 |
14 | describe('when a user is logged in', () => {
15 |
16 | before(async () => {
17 | response = await request.get('/', {
18 | jar: await auth.getCookieJar('read@example.com', 'password')
19 | });
20 | });
21 |
22 | it('responds with a 200 status', () => {
23 | assert.strictEqual(response.statusCode, 200);
24 | });
25 |
26 | it('responds with HTML', () => {
27 | assert.include(response.headers['content-type'], 'text/html');
28 | });
29 |
30 | // Temporary until home page has more content
31 | it('it responds with the home page', () => {
32 | const body = response.body.document.querySelector('body');
33 | assert.match(body.innerHTML, /hello world/i);
34 | });
35 |
36 | });
37 |
38 | describe('when no user is logged in', () => {
39 |
40 | before(async () => {
41 | response = await request.get('/');
42 | });
43 |
44 | it('responds with a 302 status', () => {
45 | assert.strictEqual(response.statusCode, 302);
46 | });
47 |
48 | it('responds with a Location header pointing to the login page', () => {
49 | assert.strictEqual(response.headers.location, '/login');
50 | });
51 |
52 | it('responds with plain text', () => {
53 | assert.include(response.headers['content-type'], 'text/plain');
54 | });
55 |
56 | });
57 |
58 | describe('when no user is logged in and the public read access setting is enabled', () => {
59 |
60 | before(async () => {
61 | await database.seed(dashboard, 'no-auth');
62 | response = await request.get('/');
63 | });
64 |
65 | it('responds with a 200 status', () => {
66 | assert.strictEqual(response.statusCode, 200);
67 | });
68 |
69 | it('responds with HTML', () => {
70 | assert.include(response.headers['content-type'], 'text/html');
71 | });
72 |
73 | });
74 |
75 | });
76 |
--------------------------------------------------------------------------------
/test/integration/routes/front-end/settings-keys.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const auth = require('../../helpers/auth');
5 | const database = require('../../helpers/database');
6 | let response;
7 |
8 | describe('GET /settings/keys', () => {
9 |
10 | before(async () => {
11 | await database.seed(dashboard, 'basic');
12 | });
13 |
14 | describe('when a user is logged in', () => {
15 |
16 | describe('when everything is valid', () => {
17 |
18 | before(async () => {
19 | response = await request.get('/settings/keys', {
20 | jar: await auth.getCookieJar('read@example.com', 'password')
21 | });
22 | });
23 |
24 | it('responds with a 200 status', () => {
25 | assert.strictEqual(response.statusCode, 200);
26 | });
27 |
28 | it('responds with HTML', () => {
29 | assert.include(response.headers['content-type'], 'text/html');
30 | });
31 |
32 | it('responds with a link to generate a new key', () => {
33 | const link = response.body.document.querySelector('a[href="/settings/keys/new"]');
34 | assert.isNotNull(link);
35 | });
36 |
37 | it('responds with a table containing all the user\'s API keys', () => {
38 | const table = response.body.document.querySelector('[data-test=keys-table]');
39 | assert.isNotNull(table);
40 | assert.match(table.textContent, /key with read permissions/i);
41 | assert.match(table.textContent, /mock-read-key/i);
42 | assert.isNotNull(table.querySelector('a[href="/settings/keys/mock-read-key/edit"]'), 'Has an edit link');
43 | assert.isNotNull(table.querySelector('a[href="/settings/keys/mock-read-key/delete"]'), 'Has a delete link');
44 | });
45 |
46 | });
47 |
48 | });
49 |
50 | describe('when no user is logged in', () => {
51 |
52 | before(async () => {
53 | response = await request.get('/settings/keys');
54 | });
55 |
56 | it('responds with a 401 status', () => {
57 | assert.strictEqual(response.statusCode, 401);
58 | });
59 |
60 | it('it responds with an error page', () => {
61 | const body = response.body.document.querySelector('body');
62 | assert.match(body.innerHTML, /must authenticate/i);
63 | });
64 |
65 | });
66 |
67 | });
68 |
--------------------------------------------------------------------------------
/view/partial/form/site.dust:
--------------------------------------------------------------------------------
1 |
2 |
63 |
--------------------------------------------------------------------------------
/test/unit/lib/middleware/require-permission.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const assert = require('proclaim');
4 | const mockery = require('mockery');
5 | const sinon = require('sinon');
6 |
7 | describe('lib/middleware/require-permission', () => {
8 | let Dashboard;
9 | let httpError;
10 | let requirePermission;
11 |
12 | beforeEach(() => {
13 |
14 | Dashboard = require('../../mock/dashboard.mock');
15 |
16 | httpError = sinon.spy(require('http-errors'));
17 | mockery.registerMock('http-errors', httpError);
18 |
19 | requirePermission = require('../../../../lib/middleware/require-permission');
20 | });
21 |
22 | it('exports a function', () => {
23 | assert.isFunction(requirePermission);
24 | });
25 |
26 | describe('requirePermission(level)', () => {
27 | let middleware;
28 |
29 | beforeEach(() => {
30 | middleware = requirePermission('mock-level');
31 | });
32 |
33 | it('returns a middleware function', () => {
34 | assert.isFunction(middleware);
35 | });
36 |
37 | describe('middleware(request, response, next)', () => {
38 | let dashboard;
39 | let next;
40 | let request;
41 |
42 | beforeEach(() => {
43 | dashboard = Dashboard.mockDashboard;
44 | request = {
45 | app: {
46 | dashboard
47 | },
48 | permissions: {
49 | 'mock-level': true
50 | }
51 | };
52 | next = sinon.spy();
53 | return middleware(request, {}, next);
54 | });
55 |
56 | it('calls `next` with nothing', () => {
57 | assert.calledOnce(next);
58 | assert.calledWithExactly(next);
59 | });
60 |
61 | describe('when the authenticated user does not have the required permission', () => {
62 |
63 | beforeEach(() => {
64 | request.permissions['mock-level'] = false;
65 | next.resetHistory();
66 | return middleware(request, {}, next);
67 | });
68 |
69 | it('creates a 403 HTTP error', () => {
70 | assert.calledOnce(httpError);
71 | assert.calledWithExactly(httpError, 403, 'You are not authorised to perform this action');
72 | });
73 |
74 | it('calls `next` with the created error', () => {
75 | assert.calledOnce(next);
76 | assert.calledWithExactly(next, httpError.firstCall.returnValue);
77 | });
78 |
79 | });
80 |
81 | });
82 |
83 | });
84 |
85 | });
86 |
--------------------------------------------------------------------------------
/view/template/settings/keys.dust:
--------------------------------------------------------------------------------
1 | {>"layout/full"/}
2 |
3 | {19 | Manage your Sidekick API keys. For information on how to use these keys, 20 | See the API documentation. 21 |
22 | 23 | {?form.key.created} 24 |
26 | Your new API key has been generated
27 | It's important to store the details below somewhere secure, as the
28 | API secret will never be displayed again.
29 |
| API Key | 34 |{form.key.created.id} |
35 |
|---|---|
| API Secret | 38 |{form.key.created.secret} |
39 |
48 | Your API key "{form.key.deleted.description}" has been deleted 49 |
50 || Description | 59 |Generated | 60 |API Key | 61 |Controls | 62 |
|---|---|---|---|
| {description} | 68 |{meta.dateCreated} | 69 |{id} |
70 | 71 | Edit 72 | Delete 73 | | 74 |
You don't have any API keys yet; you can generate one here.
81 | {/keys} 82 | 83 | {/content} 84 | -------------------------------------------------------------------------------- /data/migration/20171117142417_setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.up = async database => { 4 | 5 | // Create the settings table 6 | await database.schema.createTable('settings', table => { 7 | 8 | // Meta information 9 | table.string('id').unique().primary(); 10 | table.timestamp('created_at').defaultTo(database.fn.now()); 11 | table.timestamp('updated_at').defaultTo(database.fn.now()); 12 | 13 | // Setting information 14 | table.json('value').defaultTo('null'); 15 | 16 | }); 17 | 18 | // Create the users table 19 | await database.schema.createTable('users', table => { 20 | 21 | // Meta information 22 | table.string('id').unique().primary(); 23 | table.timestamp('created_at').defaultTo(database.fn.now()); 24 | table.timestamp('updated_at').defaultTo(database.fn.now()); 25 | 26 | // User information 27 | table.string('email').notNullable().unique(); 28 | table.string('password').notNullable(); 29 | table.boolean('is_owner').notNullable().defaultTo(false); 30 | table.boolean('allow_read').notNullable().defaultTo(true); 31 | table.boolean('allow_write').notNullable().defaultTo(true); 32 | table.boolean('allow_delete').notNullable().defaultTo(false); 33 | table.boolean('allow_admin').notNullable().defaultTo(false); 34 | 35 | }); 36 | 37 | // Create the keys table 38 | await database.schema.createTable('keys', table => { 39 | 40 | // Meta information 41 | table.string('id').unique().primary(); 42 | table.string('user_id').notNullable(); 43 | table.timestamp('created_at').defaultTo(database.fn.now()); 44 | table.timestamp('updated_at').defaultTo(database.fn.now()); 45 | 46 | // Key information 47 | table.string('secret').notNullable(); 48 | table.string('description').notNullable(); 49 | 50 | // Indexes and foreign key contraints 51 | table.foreign('user_id').references('users.id').onDelete('CASCADE'); 52 | 53 | }); 54 | 55 | // Create the sessions table 56 | await database.schema.createTable('sessions', table => { 57 | 58 | // Session table columns 59 | table.string('sid').unique().primary(); 60 | table.json('sess').notNullable(); 61 | table.timestamp('expired').notNullable().index(); 62 | 63 | }); 64 | 65 | }; 66 | 67 | exports.down = async database => { 68 | await database.schema.dropTable('sessions'); 69 | await database.schema.dropTable('keys'); 70 | await database.schema.dropTable('users'); 71 | await database.schema.dropTable('settings'); 72 | }; 73 | -------------------------------------------------------------------------------- /controller/front-end/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const validationError = require('../../lib/util/validation-error'); 5 | 6 | /** 7 | * Initialise the setup controller. 8 | * @param {Dashboard} dashboard - A dashboard instance. 9 | * @param {Object} router - The front end Express router. 10 | * @returns {undefined} Nothing 11 | */ 12 | function initSetupController(dashboard, router) { 13 | const Setting = dashboard.model.Setting; 14 | const User = dashboard.model.User; 15 | 16 | // Setup page 17 | router.get('/', async (request, response, next) => { 18 | if (await Setting.get('setupComplete')) { 19 | return next(); 20 | } 21 | response.render('template/setup'); 22 | }); 23 | 24 | // Setup action 25 | router.post('/', express.urlencoded({extended: false}), async (request, response, next) => { 26 | if (await Setting.get('setupComplete')) { 27 | return next(); 28 | } 29 | try { 30 | 31 | // Check that the password and confirmation match 32 | if (request.body.adminPassword !== request.body.adminPasswordConfirm) { 33 | throw validationError('password and confirmed password do not match'); 34 | } 35 | 36 | // Set up the admin user 37 | await User.create({ 38 | email: request.body.adminEmail, 39 | password: request.body.adminPassword, 40 | is_owner: true, 41 | allow_read: true, 42 | allow_write: true, 43 | allow_delete: true, 44 | allow_admin: true 45 | }); 46 | 47 | // Set all the settings 48 | await Setting.set('publicReadAccess', Boolean(request.body.publicReadAccess)); 49 | await Setting.set('setupComplete', true); 50 | 51 | // All done 52 | request.flash.set('site-wide-alert', 'Sidekick has been successfully set up! You can now log in with the super admin details you entered'); 53 | if (request.body.publicReadAccess) { 54 | return response.redirect('/'); 55 | } 56 | return response.redirect('/login'); 57 | 58 | } catch (error) { 59 | if (error.name === 'ValidationError') { 60 | return response.status(400).render('template/setup', { 61 | form: { 62 | setup: { 63 | adminEmail: request.body.adminEmail, 64 | publicReadAccess: request.body.publicReadAccess, 65 | errors: error.details.map(detail => detail.message) 66 | } 67 | } 68 | }); 69 | } 70 | return next(error); 71 | } 72 | }); 73 | 74 | } 75 | 76 | module.exports = initSetupController; 77 | -------------------------------------------------------------------------------- /view/template/admin/users.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {Manage the users who have access to Sidekick.
19 | 20 | {?form.user.created} 21 |23 | The user "{form.user.created.email}" has been created. 24 |
25 |31 | The user with email "{form.user.deleted.email}" has been deleted 32 |
33 || User | 41 |Created | 42 |Permissions | 43 |Controls | 44 ||||
|---|---|---|---|---|---|---|
| Read | 47 |Write | 48 |Delete | 49 |Admin | 50 ||||
| 56 | {?isOwner} 57 | {email} (owner) 58 | {:else} 59 | {@eq key=id value=authUser.id} 60 | {email} (you) 61 | {:else} 62 | {email} 63 | {/eq} 64 | {/isOwner} 65 | | 66 |{meta.dateCreated} | 67 |{?permissions.read}Yes{:else}No{/permissions.read} | 68 |{?permissions.write}Yes{:else}No{/permissions.write} | 69 |{?permissions.delete}Yes{:else}No{/permissions.delete} | 70 |{?permissions.admin}Yes{:else}No{/permissions.admin} | 71 |72 | {?isOwner} 73 | Owners cannot be modified here 74 | {:else} 75 | {@eq key=id value=authUser.id} 76 | You cannot modify yourself here 77 | {:else} 78 | Edit 79 | Delete 80 | {/eq} 81 | {/isOwner} 82 | | 83 |
12 | The site "{form.site.created.name}" has been created. 13 |
14 | 15 | {/form.site.created} 16 | 17 || Name | 45 |Path | 46 |Pa11y Config | 47 |Options | 48 |
|---|---|---|---|
| {name} | 54 |{address} | 55 |
56 | {#pa11yConfig}
57 |
|
70 | {?permissions.write}
71 | 72 | Edit 73 | Delete 74 | | 75 | {/permissions.write} 76 |
You don't have any URLs to test for this site yet{?permissions.write}; you can add one here{/permissions.write}.
83 | {/urls} 84 | 85 | {/content} 86 | -------------------------------------------------------------------------------- /model/setting.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Initialise the Setting model. 5 | * @param {Dashboard} dashboard - A dashboard instance. 6 | * @returns {Model} A Bookshelf model. 7 | */ 8 | function initSettingModel(dashboard) { 9 | 10 | // Storage point for in-memory settings 11 | let inMemorySettings; 12 | 13 | // Model prototypal methods 14 | const Setting = dashboard.database.Model.extend({ 15 | tableName: 'settings', 16 | 17 | // Model initialization 18 | initialize() { 19 | 20 | // When a model is created... 21 | this.on('creating', () => { 22 | // Fill out automatic fields 23 | this.attributes.created_at = new Date(); 24 | }); 25 | 26 | // When a model is saved... 27 | this.on('saving', () => { 28 | // Fill out automatic fields 29 | this.attributes.updated_at = new Date(); 30 | }); 31 | 32 | }, 33 | 34 | // Override default serialization so we can control output 35 | serialize() { 36 | return { 37 | id: this.get('id'), 38 | value: this.get('value'), 39 | meta: { 40 | dateCreated: this.get('created_at'), 41 | dateUpdated: this.get('updated_at') 42 | } 43 | }; 44 | } 45 | 46 | // Model static methods 47 | }, { 48 | 49 | // Fetch all settings as an object with key/value pairs 50 | async fetchAsObject(forceRefresh = false) { 51 | if (!inMemorySettings || forceRefresh) { 52 | const settings = {}; 53 | (await Setting.fetchAll()).toArray().forEach(setting => { 54 | settings[setting.get('id')] = setting; 55 | }); 56 | inMemorySettings = settings; 57 | } 58 | return inMemorySettings; 59 | }, 60 | 61 | // Clear the in-memory settings cache 62 | clearInMemoryCache() { 63 | inMemorySettings = undefined; 64 | }, 65 | 66 | // Get the value of a single setting 67 | async get(settingId) { 68 | const setting = (await Setting.fetchAsObject())[settingId]; 69 | return (setting ? setting.get('value') : undefined); 70 | }, 71 | 72 | // Set a setting 73 | async set(settingId, value) { 74 | let setting = (await Setting.fetchAsObject())[settingId]; 75 | value = JSON.stringify(value); 76 | 77 | // The setting already exists, update it 78 | if (setting) { 79 | setting.set('value', value); 80 | await setting.save(); 81 | 82 | // The setting doesn't exist, create it 83 | } else { 84 | setting = new Setting({ 85 | id: settingId, 86 | value 87 | }); 88 | await setting.save(null, { 89 | method: 'insert' 90 | }); 91 | } 92 | 93 | // Clear the in-memory cache 94 | this.clearInMemoryCache(); 95 | } 96 | 97 | }); 98 | 99 | return Setting; 100 | } 101 | 102 | module.exports = initSettingModel; 103 | -------------------------------------------------------------------------------- /controller/api-v1/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const authWithKey = require('../../lib/middleware/auth-with-key'); 4 | const express = require('express'); 5 | const initDocsController = require('./docs'); 6 | const initKeysController = require('./keys'); 7 | const initMeController = require('./me'); 8 | const initSitesController = require('./sites'); 9 | const initUrlsController = require('./urls'); 10 | const initUsersController = require('./users'); 11 | const notFound = require('../../lib/middleware/not-found'); 12 | 13 | /** 14 | * Initialise the V1 API controller. 15 | * @param {Dashboard} dashboard - A dashboard instance. 16 | * @returns {Object} An Express router. 17 | */ 18 | function initApiV1Contoller(dashboard) { 19 | 20 | // Create an Express router for the V1 API 21 | const router = new express.Router({ 22 | caseSensitive: true, 23 | strict: true 24 | }); 25 | 26 | // Set permissive CORS headers for the API 27 | router.use((request, response, next) => { 28 | response.set({ 29 | 'Access-Control-Allow-Headers': 'X-Api-Key, X-Api-Secret', 30 | 'Access-Control-Allow-Methods': 'DELETE, GET, HEAD, POST, PATCH', 31 | 'Access-Control-Allow-Origin': '*' 32 | }); 33 | next(); 34 | }); 35 | 36 | // Allow authentication through an API key/secret pair 37 | router.use(authWithKey()); 38 | 39 | // Mount routes. Order here is important 40 | initDocsController(dashboard, router); 41 | initSitesController(dashboard, router); 42 | initUrlsController(dashboard, router); 43 | initUsersController(dashboard, router); 44 | initKeysController(dashboard, router); 45 | initMeController(dashboard, router); 46 | 47 | // Middleware to handle errors 48 | router.use(notFound()); 49 | router.use((error, request, response, next) => { // eslint-disable-line no-unused-vars 50 | 51 | // The status code should be a client or server error 52 | let statusCode = (error.status && error.status >= 400 ? error.status : 500); 53 | 54 | // Handle Joi validation errors differently 55 | let validationDetails; 56 | if (error.name === 'ValidationError') { 57 | statusCode = (statusCode < 500 ? statusCode : 400); 58 | error.message = 'Validation failed'; 59 | validationDetails = error.details.map(detail => detail.message); 60 | } 61 | 62 | // Output helpful error information as JSON 63 | response.status(statusCode).send({ 64 | status: statusCode, 65 | message: error.message, 66 | validation: validationDetails, 67 | stack: (dashboard.environment === 'development' ? error.stack : undefined) 68 | }); 69 | 70 | // Output server errors in the logs – we need 71 | // to know about these 72 | if (error.status >= 500) { 73 | dashboard.log.error(error.stack); 74 | } 75 | 76 | }); 77 | 78 | return router; 79 | } 80 | 81 | module.exports = initApiV1Contoller; 82 | -------------------------------------------------------------------------------- /view/partial/form/user.dust: -------------------------------------------------------------------------------- 1 | 2 | 81 | -------------------------------------------------------------------------------- /controller/api-v1/urls.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const httpError = require('http-errors'); 5 | const requirePermission = require('../../lib/middleware/require-permission'); 6 | 7 | /** 8 | * Initialise the URLs controller. 9 | * @param {Dashboard} dashboard - A dashboard instance. 10 | * @param {Object} router - The API Express router. 11 | * @returns {undefined} Nothing 12 | */ 13 | function initUrlsController(dashboard, router) { 14 | const Url = dashboard.model.Url; 15 | 16 | // Add param callback for URL IDs 17 | router.param('urlId', async (request, response, next, urlId) => { 18 | try { 19 | request.urlFromParam = await Url.fetchOneByIdAndSiteId(urlId, request.params.siteId); 20 | return next(request.urlFromParam ? undefined : httpError(404)); 21 | } catch (error) { 22 | return next(error); 23 | } 24 | }); 25 | 26 | // List a single site's URLs by site ID 27 | router.get('/sites/:siteId/urls', requirePermission('read'), async (request, response, next) => { 28 | try { 29 | const site = request.siteFromParam; 30 | response.send(await Url.fetchBySiteId(site.get('id'))); 31 | } catch (error) { 32 | return next(error); 33 | } 34 | }); 35 | 36 | // Create a new URL for a site 37 | router.post('/sites/:siteId/urls', requirePermission('write'), express.json(), async (request, response, next) => { 38 | try { 39 | const site = request.siteFromParam; 40 | const url = await Url.create({ 41 | site_id: site.get('id'), 42 | name: request.body.name, 43 | address: request.body.address, 44 | pa11y_config: request.body.pa11yConfig 45 | }); 46 | response.status(201).send(url); 47 | } catch (error) { 48 | return next(error); 49 | } 50 | }); 51 | 52 | // Get a single site URL by ID 53 | router.get('/sites/:siteId/urls/:urlId', requirePermission('read'), (request, response, next) => { 54 | try { 55 | const url = request.urlFromParam; 56 | response.send(url); 57 | } catch (error) { 58 | return next(error); 59 | } 60 | }); 61 | 62 | // Update a URL 63 | router.patch('/sites/:siteId/urls/:urlId', requirePermission('write'), express.json(), async (request, response, next) => { 64 | try { 65 | const url = request.urlFromParam; 66 | await url.update({ 67 | name: request.body.name, 68 | address: request.body.address, 69 | pa11y_config: request.body.pa11yConfig 70 | }); 71 | response.status(200).send(url); 72 | } catch (error) { 73 | return next(error); 74 | } 75 | }); 76 | 77 | // Delete a url 78 | router.delete('/sites/:siteId/urls/:urlId', requirePermission('delete'), async (request, response, next) => { 79 | try { 80 | const url = request.urlFromParam; 81 | await url.destroy(); 82 | response.status(204).send({}); 83 | } catch (error) { 84 | return next(error); 85 | } 86 | }); 87 | 88 | } 89 | 90 | module.exports = initUrlsController; 91 | -------------------------------------------------------------------------------- /controller/api-v1/sites.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const httpError = require('http-errors'); 5 | const requirePermission = require('../../lib/middleware/require-permission'); 6 | 7 | /** 8 | * Initialise the sites controller. 9 | * @param {Dashboard} dashboard - A dashboard instance. 10 | * @param {Object} router - The API Express router. 11 | * @returns {undefined} Nothing 12 | */ 13 | function initSitesController(dashboard, router) { 14 | const Site = dashboard.model.Site; 15 | 16 | // Add a param callback for site IDs 17 | router.param('siteId', async (request, response, next, siteId) => { 18 | try { 19 | request.siteFromParam = await Site.fetchOneById(siteId); 20 | return next(request.siteFromParam ? undefined : httpError(404)); 21 | } catch (error) { 22 | return next(error); 23 | } 24 | }); 25 | 26 | // List all sites 27 | router.get('/sites', requirePermission('read'), async (request, response, next) => { 28 | try { 29 | response.send(await Site.fetchAll()); 30 | } catch (error) { 31 | return next(error); 32 | } 33 | }); 34 | 35 | // Create a new site 36 | router.post('/sites', requirePermission('write'), express.json(), async (request, response, next) => { 37 | try { 38 | const site = await Site.create({ 39 | name: request.body.name, 40 | base_url: request.body.baseUrl, 41 | is_runnable: request.body.isRunnable, 42 | is_scheduled: request.body.isScheduled, 43 | schedule: request.body.schedule, 44 | pa11y_config: request.body.pa11yConfig 45 | }); 46 | response.status(201).send(site); 47 | } catch (error) { 48 | return next(error); 49 | } 50 | }); 51 | 52 | // Get a single site by ID 53 | router.get('/sites/:siteId', requirePermission('read'), (request, response, next) => { 54 | try { 55 | const site = request.siteFromParam; 56 | response.send(site); 57 | } catch (error) { 58 | return next(error); 59 | } 60 | }); 61 | 62 | // Update a site by ID 63 | router.patch('/sites/:siteId', requirePermission('write'), express.json(), async (request, response, next) => { 64 | try { 65 | const site = request.siteFromParam; 66 | await site.update({ 67 | name: request.body.name, 68 | base_url: request.body.baseUrl, 69 | is_runnable: request.body.isRunnable, 70 | is_scheduled: request.body.isScheduled, 71 | schedule: request.body.schedule, 72 | pa11y_config: request.body.pa11yConfig 73 | }); 74 | response.status(200).send(site); 75 | } catch (error) { 76 | return next(error); 77 | } 78 | }); 79 | 80 | // Delete a site by ID 81 | router.delete('/sites/:siteId', requirePermission('delete'), async (request, response, next) => { 82 | try { 83 | const site = request.siteFromParam; 84 | await site.destroy(); 85 | response.status(204).send({}); 86 | } catch (error) { 87 | return next(error); 88 | } 89 | }); 90 | 91 | } 92 | 93 | module.exports = initSitesController; 94 | -------------------------------------------------------------------------------- /controller/api-v1/users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const httpError = require('http-errors'); 5 | const requirePermission = require('../../lib/middleware/require-permission'); 6 | 7 | /** 8 | * Initialise the users controller. 9 | * @param {Dashboard} dashboard - A dashboard instance. 10 | * @param {Object} router - The API Express router. 11 | * @returns {undefined} Nothing 12 | */ 13 | function initUsersController(dashboard, router) { 14 | const User = dashboard.model.User; 15 | 16 | // Add a param callback for user IDs 17 | router.param('userId', async (request, response, next, userId) => { 18 | try { 19 | request.userFromParam = await User.fetchOneById(userId); 20 | return next(request.userFromParam ? undefined : httpError(404)); 21 | } catch (error) { 22 | return next(error); 23 | } 24 | }); 25 | 26 | // List all users 27 | router.get('/users', requirePermission('admin'), async (request, response, next) => { 28 | try { 29 | response.send(await User.fetchAll()); 30 | } catch (error) { 31 | return next(error); 32 | } 33 | }); 34 | 35 | // Create a new user 36 | router.post('/users', requirePermission('admin'), express.json(), async (request, response, next) => { 37 | try { 38 | const user = await User.create({ 39 | email: request.body.email, 40 | password: request.body.password, 41 | allow_read: request.body.read, 42 | allow_write: request.body.write, 43 | allow_delete: request.body.delete, 44 | allow_admin: request.body.admin 45 | }); 46 | response.status(201).send(user); 47 | } catch (error) { 48 | return next(error); 49 | } 50 | }); 51 | 52 | // Get a single user by ID 53 | router.get('/users/:userId', requirePermission('admin'), (request, response, next) => { 54 | try { 55 | const user = request.userFromParam; 56 | response.send(user); 57 | } catch (error) { 58 | return next(error); 59 | } 60 | }); 61 | 62 | // Update a user 63 | router.patch('/users/:userId', requirePermission('admin'), express.json(), async (request, response, next) => { 64 | try { 65 | const user = request.userFromParam; 66 | if (user.get('is_owner')) { 67 | throw httpError(403, 'You are not authorized to modify an owner'); 68 | } 69 | await user.update({ 70 | email: request.body.email, 71 | password: request.body.password, 72 | allow_read: request.body.read, 73 | allow_write: request.body.write, 74 | allow_delete: request.body.delete, 75 | allow_admin: request.body.admin 76 | }); 77 | response.status(200).send(user); 78 | } catch (error) { 79 | return next(error); 80 | } 81 | }); 82 | 83 | // Delete a user 84 | router.delete('/users/:userId', requirePermission('admin'), async (request, response, next) => { 85 | try { 86 | const user = request.userFromParam; 87 | if (request.authUser && user.get('id') === request.authUser.id) { 88 | throw httpError(403, 'You are not authorized to delete yourself'); 89 | } 90 | if (user.get('is_owner')) { 91 | throw httpError(403, 'You are not authorized to modify an owner'); 92 | } 93 | await user.destroy(); 94 | response.status(204).send({}); 95 | } catch (error) { 96 | return next(error); 97 | } 98 | }); 99 | 100 | } 101 | 102 | module.exports = initUsersController; 103 | -------------------------------------------------------------------------------- /controller/api-v1/keys.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const httpError = require('http-errors'); 5 | const requirePermission = require('../../lib/middleware/require-permission'); 6 | 7 | /** 8 | * Initialise the keys controller. 9 | * @param {Dashboard} dashboard - A dashboard instance. 10 | * @param {Object} router - The API Express router. 11 | * @returns {undefined} Nothing 12 | */ 13 | function initKeysController(dashboard, router) { 14 | const Key = dashboard.model.Key; 15 | 16 | // Add param callback for API key IDs 17 | router.param('keyId', async (request, response, next, keyId) => { 18 | try { 19 | request.keyFromParam = await Key.fetchOneByIdAndUserId(keyId, request.params.userId); 20 | return next(request.keyFromParam ? undefined : httpError(404)); 21 | } catch (error) { 22 | return next(error); 23 | } 24 | }); 25 | 26 | // List a single user's keys by user ID 27 | router.get('/users/:userId/keys', requirePermission('admin'), async (request, response, next) => { 28 | try { 29 | const user = request.userFromParam; 30 | response.send(await Key.fetchByUserId(user.get('id'))); 31 | } catch (error) { 32 | return next(error); 33 | } 34 | }); 35 | 36 | // Create a new key for a user 37 | router.post('/users/:userId/keys', requirePermission('admin'), express.json(), async (request, response, next) => { 38 | try { 39 | const user = request.userFromParam; 40 | const secret = Key.generateSecret(); 41 | const key = await Key.create({ 42 | user_id: user.get('id'), 43 | description: request.body.description, 44 | secret 45 | }); 46 | response.status(201).send({ 47 | key: key.get('id'), 48 | secret 49 | }); 50 | } catch (error) { 51 | return next(error); 52 | } 53 | }); 54 | 55 | // Get a single user key by ID 56 | router.get('/users/:userId/keys/:keyId', requirePermission('admin'), (request, response, next) => { 57 | try { 58 | const key = request.keyFromParam; 59 | response.send(key); 60 | } catch (error) { 61 | return next(error); 62 | } 63 | }); 64 | 65 | // Update a key 66 | router.patch('/users/:userId/keys/:keyId', requirePermission('admin'), express.json(), async (request, response, next) => { 67 | try { 68 | const user = request.userFromParam; 69 | const key = request.keyFromParam; 70 | if (user.get('is_owner')) { 71 | throw httpError(403, 'You are not authorized to modify an owner\'s keys'); 72 | } 73 | await key.update({ 74 | description: request.body.description 75 | }); 76 | response.status(200).send(key); 77 | } catch (error) { 78 | return next(error); 79 | } 80 | }); 81 | 82 | // Delete a key 83 | router.delete('/users/:userId/keys/:keyId', requirePermission('admin'), async (request, response, next) => { 84 | try { 85 | const user = request.userFromParam; 86 | const key = request.keyFromParam; 87 | if (request.authKey && key.get('id') === request.authKey.id) { 88 | throw httpError(403, 'You are not authorized to delete the key currently being used to authenticate'); 89 | } 90 | if (user.get('is_owner')) { 91 | throw httpError(403, 'You are not authorized to modify an owner\'s keys'); 92 | } 93 | await key.destroy(); 94 | response.status(204).send({}); 95 | } catch (error) { 96 | return next(error); 97 | } 98 | }); 99 | 100 | } 101 | 102 | module.exports = initKeysController; 103 | -------------------------------------------------------------------------------- /test/integration/seed/basic/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bcrypt = require('bcrypt'); 4 | 5 | // Test authentication details 6 | exports.seed = async database => { 7 | 8 | // Insert test users 9 | await database('users').insert([ 10 | { 11 | id: 'mock-owner-id', 12 | email: 'owner@example.com', 13 | password: await bcrypt.hash('password', 10), 14 | is_owner: true, 15 | allow_read: true, 16 | allow_write: true, 17 | allow_delete: true, 18 | allow_admin: true 19 | }, 20 | { 21 | id: 'mock-admin-id', 22 | email: 'admin@example.com', 23 | password: await bcrypt.hash('password', 10), 24 | allow_read: true, 25 | allow_write: true, 26 | allow_delete: true, 27 | allow_admin: true 28 | }, 29 | { 30 | id: 'mock-delete-id', 31 | email: 'delete@example.com', 32 | password: await bcrypt.hash('password', 10), 33 | allow_read: true, 34 | allow_write: true, 35 | allow_delete: true, 36 | allow_admin: false 37 | }, 38 | { 39 | id: 'mock-write-id', 40 | email: 'write@example.com', 41 | password: await bcrypt.hash('password', 10), 42 | allow_read: true, 43 | allow_write: true, 44 | allow_delete: false, 45 | allow_admin: false 46 | }, 47 | { 48 | id: 'mock-read-id', 49 | email: 'read@example.com', 50 | password: await bcrypt.hash('password', 10), 51 | allow_read: true, 52 | allow_write: false, 53 | allow_delete: false, 54 | allow_admin: false 55 | }, 56 | { 57 | id: 'mock-noaccess-id', 58 | email: 'noaccess@example.com', 59 | password: await bcrypt.hash('password', 10), 60 | allow_read: false, 61 | allow_write: false, 62 | allow_delete: false, 63 | allow_admin: false 64 | }, 65 | { 66 | id: 'mock-nokeys-id', 67 | email: 'nokeys@example.com', 68 | password: await bcrypt.hash('password', 10), 69 | allow_read: false, 70 | allow_write: false, 71 | allow_delete: false, 72 | allow_admin: false 73 | } 74 | ]); 75 | 76 | // Insert test API keys 77 | await database('keys').insert([ 78 | { 79 | id: 'mock-owner-key', 80 | secret: await bcrypt.hash('mock-owner-secret', 5), 81 | user_id: 'mock-owner-id', 82 | description: 'Key with admin permissions belonging to an owner' 83 | }, 84 | { 85 | id: 'mock-admin-key', 86 | secret: await bcrypt.hash('mock-admin-secret', 5), 87 | user_id: 'mock-admin-id', 88 | description: 'Key with admin permissions' 89 | }, 90 | { 91 | id: 'mock-delete-key', 92 | secret: await bcrypt.hash('mock-delete-secret', 5), 93 | user_id: 'mock-delete-id', 94 | description: 'Key with delete permissions' 95 | }, 96 | { 97 | id: 'mock-write-key', 98 | secret: await bcrypt.hash('mock-write-secret', 5), 99 | user_id: 'mock-write-id', 100 | description: 'Key with write permissions' 101 | }, 102 | { 103 | id: 'mock-read-key', 104 | secret: await bcrypt.hash('mock-read-secret', 5), 105 | user_id: 'mock-read-id', 106 | description: 'Key with read permissions' 107 | }, 108 | { 109 | id: 'mock-read-key-2', 110 | secret: await bcrypt.hash('mock-read-secret', 5), 111 | user_id: 'mock-read-id', 112 | description: 'Key with read permissions again' 113 | }, 114 | { 115 | id: 'mock-noaccess-key', 116 | secret: await bcrypt.hash('mock-noaccess-secret', 5), 117 | user_id: 'mock-noaccess-id', 118 | description: 'Key with no permissions' 119 | } 120 | ]); 121 | 122 | }; 123 | -------------------------------------------------------------------------------- /controller/api-v1/me.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const httpError = require('http-errors'); 5 | const requireAuth = require('../../lib/middleware/require-auth'); 6 | 7 | /** 8 | * Initialise the "me" controller. 9 | * @param {Dashboard} dashboard - A dashboard instance. 10 | * @param {Object} router - The API Express router. 11 | * @returns {undefined} Nothing 12 | */ 13 | function initMeController(dashboard, router) { 14 | const Key = dashboard.model.Key; 15 | const User = dashboard.model.User; 16 | 17 | // Require authentication for all of these routes 18 | router.use('/me', requireAuth()); 19 | 20 | // Add param callback for current user API key IDs 21 | router.param('myKeyId', async (request, response, next, myKeyId) => { 22 | try { 23 | request.keyFromParam = await Key.fetchOneByIdAndUserId(myKeyId, request.authUser.id); 24 | return next(request.keyFromParam ? undefined : httpError(404)); 25 | } catch (error) { 26 | return next(error); 27 | } 28 | }); 29 | 30 | // Get the currently authenticated user 31 | router.get('/me', (request, response) => { 32 | response.send(request.authUser); 33 | }); 34 | 35 | // Update the currently authenticated user 36 | router.patch('/me', express.json(), async (request, response, next) => { 37 | try { 38 | const user = await User.fetchOneById(request.authUser.id); 39 | await user.update({ 40 | email: request.body.email, 41 | password: request.body.password 42 | }); 43 | response.status(200).send(user); 44 | } catch (error) { 45 | return next(error); 46 | } 47 | }); 48 | 49 | // List the currently authenticated user's keys 50 | router.get('/me/keys', async (request, response, next) => { 51 | try { 52 | response.send(await Key.fetchByUserId(request.authUser.id)); 53 | } catch (error) { 54 | return next(error); 55 | } 56 | }); 57 | 58 | // Create a new key for the currently authenticated user 59 | router.post('/me/keys', express.json(), async (request, response, next) => { 60 | try { 61 | const secret = Key.generateSecret(); 62 | const key = await Key.create({ 63 | user_id: request.authUser.id, 64 | description: request.body.description, 65 | secret 66 | }); 67 | response.status(201).send({ 68 | key: key.get('id'), 69 | secret 70 | }); 71 | } catch (error) { 72 | return next(error); 73 | } 74 | }); 75 | 76 | // Get a single currently authenticated user key by ID 77 | router.get('/me/keys/:myKeyId', (request, response, next) => { 78 | try { 79 | const key = request.keyFromParam; 80 | response.send(key); 81 | } catch (error) { 82 | return next(error); 83 | } 84 | }); 85 | 86 | // Update a single currently authenticated user key 87 | router.patch('/me/keys/:myKeyId', express.json(), async (request, response, next) => { 88 | try { 89 | const key = request.keyFromParam; 90 | await key.update({ 91 | description: request.body.description 92 | }); 93 | response.status(200).send(key); 94 | } catch (error) { 95 | return next(error); 96 | } 97 | }); 98 | 99 | // Delete a single currently authenticated user key 100 | router.delete('/me/keys/:myKeyId', async (request, response, next) => { 101 | try { 102 | const key = request.keyFromParam; 103 | if (request.authKey && key.get('id') === request.authKey.id) { 104 | return next(httpError(403, 'You are not authorized to delete the key currently being used to authenticate')); 105 | } 106 | await key.destroy(); 107 | response.status(204).send({}); 108 | } catch (error) { 109 | return next(error); 110 | } 111 | }); 112 | 113 | } 114 | 115 | module.exports = initMeController; 116 | -------------------------------------------------------------------------------- /controller/front-end/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const authWithCookie = require('../../lib/middleware/auth-with-cookie'); 4 | const express = require('express'); 5 | const flash = require('../../lib/middleware/flash'); 6 | const initAdminController = require('./admin'); 7 | const initAuthController = require('./auth'); 8 | const initDocsController = require('./docs'); 9 | const initHomeController = require('./home'); 10 | const initSettingsController = require('./settings'); 11 | const initSetupController = require('./setup'); 12 | const initSitesController = require('./sites'); 13 | const notFound = require('../../lib/middleware/not-found'); 14 | const session = require('express-session'); 15 | const SessionStore = require('connect-session-knex')(session); 16 | const uuid = require('uuid/v4'); 17 | 18 | /** 19 | * Initialise the Front End controller. 20 | * @param {Dashboard} dashboard - A dashboard instance. 21 | * @returns {Object} An Express router. 22 | */ 23 | function initFrontEndController(dashboard) { 24 | const Setting = dashboard.model.Setting; 25 | 26 | // Create an Express router for the front end 27 | const router = new express.Router({ 28 | caseSensitive: true, 29 | strict: true 30 | }); 31 | 32 | // Serve static files 33 | router.use(express.static(`${__dirname}/../../public`)); 34 | 35 | // Set up session middleware, backed by the database 36 | router.use(session({ 37 | cookie: { 38 | maxAge: 1000 * 60 * 60 * 24 * 7 // 1 week 39 | }, 40 | name: 'sidekick.sid', 41 | resave: false, 42 | saveUninitialized: false, 43 | secret: dashboard.options.sessionSecret || uuid(), 44 | store: new SessionStore({ 45 | knex: dashboard.database.knex, 46 | createtable: false 47 | }), 48 | unset: 'destroy' 49 | })); 50 | 51 | // Set up flash messaging 52 | router.use(flash()); 53 | 54 | // Allow authentication through a session cookie 55 | router.use(authWithCookie()); 56 | 57 | // Add default view data 58 | router.use((request, response, next) => { 59 | response.locals.siteWideAlert = request.flash.get('site-wide-alert'); 60 | response.locals.appUrl = request.appUrl; 61 | response.locals.authUser = request.authUser; 62 | response.locals.permissions = request.permissions; 63 | response.locals.requestPath = request.path; 64 | response.locals.requestUrl = request.url; 65 | response.locals.publicReadAccess = Setting.get('publicReadAccess'); 66 | next(); 67 | }); 68 | 69 | // Mount routes 70 | // IMPORTANT: the setup controller MUST be mounted before the home controller 71 | initSetupController(dashboard, router); 72 | initHomeController(dashboard, router); 73 | initAuthController(dashboard, router); 74 | initDocsController(dashboard, router); 75 | initSitesController(dashboard, router); 76 | initSettingsController(dashboard, router); 77 | initAdminController(dashboard, router); 78 | 79 | // Middleware to handle errors 80 | router.use(notFound()); 81 | router.use((error, request, response, next) => { // eslint-disable-line no-unused-vars 82 | 83 | // The status code should be a client or server error 84 | const statusCode = (error.status && error.status >= 400 ? error.status : 500); 85 | 86 | // Render a helpful error page 87 | response.status(statusCode).render('template/error', { 88 | error: { 89 | status: statusCode, 90 | message: error.message, 91 | stack: (dashboard.environment === 'development' ? error.stack : undefined) 92 | } 93 | }); 94 | 95 | // Output server errors in the logs – we need 96 | // to know about these 97 | if (error.status >= 500) { 98 | dashboard.log.error(error.stack); 99 | } 100 | 101 | }); 102 | 103 | return router; 104 | } 105 | 106 | module.exports = initFrontEndController; 107 | -------------------------------------------------------------------------------- /view/template/docs/api-v1.dust: -------------------------------------------------------------------------------- 1 | {>"layout/full"/} 2 | 3 | {16 | Sidekick comes with a REST API which can be used to automate certain tasks or push data 17 | into the dashboard from external sources. The documentation here outlines which endpoints 18 | are available. 19 |
20 | 21 |
34 | The current version of the API is v1. All endpoints are prefixed with this
35 | version identifier.
36 |
42 | Many endpoints in the API require authentication. Before you can authenticate, you'll need to 43 | generate an API key and secret. You can do that here. 44 |
45 | 46 |47 | When you have an API key and secret you must set them as headers whenever you make a request 48 | to the API: 49 |
50 | 51 | {@codeBlock language="http"} 52 | X-Api-Key: xxx-xxxx-xxx 53 | X-Api-Secret: xxx-xxxx-xxx 54 | {/codeBlock} 55 | 56 | 57 |60 | Different endpoints require different user permissions to access. The documentation for each 61 | endpoint will outline which is required. 62 |
63 | 64 |
65 | The permission levels are as follows. Note that these don't stack: a user with WRITE
66 | access does not automatically have READ access.
67 |
| Permission | 74 |Description | 75 |
|---|---|
READ
80 | | Allowed to list and get most resources | 81 |
WRITE
84 | | Allowed to create new resources and update existing resources | 85 |
DELETE
88 | | Allowed to delete resources | 89 |
ADMIN
92 | | Allowed to administer users and access | 93 |
99 | If you don't know what permission level an API key and secret grant you, you can check by 100 | authenticating and 101 | requesting the authenticated user details. 102 |
103 | 104 | 105 |
108 | All endpoints respond with a Content-Type header of application/json
109 | unless explicitly stated otherwise.
110 |
116 | All endpoints respond with appropriate and permissive 117 | CORS headers. 118 |
119 | 120 | 121 |124 | The API returns error messages as JSON following the schema below: 125 |
126 | 127 | {@codeBlock language="json"} 128 | { 129 | "status": 404, // The HTTP status code of the response 130 | "message": "Not Found", // A short description of the error that occurred 131 | "validation": [ 132 | "example" // Additional validation messages (optional) 133 | ] 134 | } 135 | {/codeBlock} 136 | 137 | {/content} 138 | -------------------------------------------------------------------------------- /docs/deploy/heroku.md: -------------------------------------------------------------------------------- 1 | 2 | # Heroku Deployment Guide 3 | 4 | Running Sidekick on [Heroku] is nice and easy compared to hosting on a server of your own. We provide manual instructions as well as a one-click button to get you set up. 5 | 6 | 7 | ## Table of Contents 8 | 9 | - [One-Click Setup](#one-click-setup) 10 | - [Manual Setup](#manual-setup) 11 | - [Questions and Troubleshooting](#questions-and-troubleshooting) 12 | 13 | 14 | ## One-Click Setup 15 | 16 | The button below sets up a full Heroku application, including the required add-ons and configuration. You'll need a Heroku account to get started, and Sidekick will run fine on the free tier which has a few limitations. 17 | 18 | For most Heroku users, all you need to do is click the button and fill out a few fields. Then complete the setup step in the new Sidekick application. 19 | 20 | [![Deploy to Heroku][heroku-button-image]][heroku-button-url] 21 | 22 | 23 | ## Manual Setup 24 | 25 | To deploy Sidekick to Heroku manually you'll need a Heroku account. Then follow the instructions below: 26 | 27 | 1. Visit