diff --git a/.eslintrc-node.js b/.eslintrc.js similarity index 80% rename from .eslintrc-node.js rename to .eslintrc.js index f6c64e35..3dcf104e 100644 --- a/.eslintrc-node.js +++ b/.eslintrc.js @@ -19,6 +19,8 @@ module.exports = { "camelcase": "off", "no-var": "error", "prefer-const": "error", + "array-bracket-spacing": ["error", "always", {"objectsInArrays": false}], + "object-curly-spacing": ["error", "always"], // Override some eslint base rules because we're using node. "no-console": "off", diff --git a/app.js b/app.js index 1f243074..9fd1fea3 100644 --- a/app.js +++ b/app.js @@ -19,517 +19,519 @@ Promise.all([ boot.forwardHttp(), boot.ensureHttpsCertificates(), boot.ensureDockerTlsCertificates() -]).then(() => { - // You can customize these values in './db.json'. - const hostname = db.get('hostname', 'localhost'); - const https = db.get('https'); - const ports = db.get('ports'); - const security = db.get('security'); - - // The main Janitor server. - const app = camp.start({ - documentRoot: process.cwd() + '/static', - saveRequestChunks: true, - port: ports.https, - secure: !security.forceHttp, - key: https.key, - cert: https.crt, - ca: https.ca - }); +]) + .catch(error => { log('[fail] could not start app', error); }) + .then(() => { + // You can customize these values in './db.json'. + const hostname = db.get('hostname', 'localhost'); + const https = db.get('https'); + const ports = db.get('ports'); + const security = db.get('security'); + + // The main Janitor server. + const app = camp.start({ + documentRoot: process.cwd() + '/static', + saveRequestChunks: true, + port: ports.https, + secure: !security.forceHttp, + key: https.key, + cert: https.crt, + ca: https.ca + }); - log('[ok] Janitor → http' + (security.forceHttp ? '' : 's') + '://' + - hostname + ':' + ports.https); + log('[ok] Janitor → http' + (security.forceHttp ? '' : 's') + '://' + + hostname + ':' + ports.https); - // Protect the server and its users with a security policies middleware. - const enforceSecurityPolicies = (request, response, next) => { - // Only accept requests addressed to our actual hostname. - const requestedHostname = request.headers.host; - if (requestedHostname !== hostname) { - routes.drop(response, 'invalid hostname: ' + requestedHostname); - return; - } + // Protect the server and its users with a security policies middleware. + const enforceSecurityPolicies = (request, response, next) => { + // Only accept requests addressed to our actual hostname. + const requestedHostname = request.headers.host; + if (requestedHostname !== hostname) { + routes.drop(response, 'invalid hostname: ' + requestedHostname); + return; + } - // Tell browsers to only use secure HTTPS connections for this web app. - response.setHeader('Strict-Transport-Security', 'max-age=31536000'); + // Tell browsers to only use secure HTTPS connections for this web app. + response.setHeader('Strict-Transport-Security', 'max-age=31536000'); - // Prevent browsers from accidentally seeing scripts where they shouldn't. - response.setHeader('X-Content-Type-Options', 'nosniff'); + // Prevent browsers from accidentally seeing scripts where they shouldn't. + response.setHeader('X-Content-Type-Options', 'nosniff'); - // Tell browsers this web app should never be embedded into an iframe. - response.setHeader('X-Frame-Options', 'DENY'); + // Tell browsers this web app should never be embedded into an iframe. + response.setHeader('X-Frame-Options', 'DENY'); - next(); - }; + next(); + }; - if (!security.forceInsecure) { - app.handle(enforceSecurityPolicies); - } else { - log('[warning] disabled all https security policies'); - } + if (!security.forceInsecure) { + app.handle(enforceSecurityPolicies); + } else { + log('[warning] disabled all https security policies'); + } + + // Authenticate signed-in user requests and sessions with a server middleware. + app.handle((request, response, next) => { + users.get(request, (user, session) => { + request.session = session; + request.user = user; + next(); + }); + }); - // Authenticate signed-in user requests and sessions with a server middleware. - app.handle((request, response, next) => { - users.get(request, (user, session) => { - request.session = session; - request.user = user; + // Authenticate OAuth2 requests with a server middleware. + app.handle((request, response, next) => { + request.oauth2scope = users.getOAuth2ScopeWithUser(request); next(); }); - }); - // Authenticate OAuth2 requests with a server middleware. - app.handle((request, response, next) => { - request.oauth2scope = users.getOAuth2ScopeWithUser(request); - next(); - }); + // Mount the Janitor API. + selfapi(app, '/api', api); - // Mount the Janitor API. - selfapi(app, '/api', api); + // Public landing page. + app.route(/^\/$/, (data, match, end, query) => { + const { user } = query.req; + routes.landingPage(query.res, user); + }); - // Public landing page. - app.route(/^\/$/, (data, match, end, query) => { - const { user } = query.req; - routes.landingPage(query.res, user); - }); + // Public API (when wrongly used with a trailing '/'). + app.route(/^\/api\/(.+)\/$/, (data, match, end, query) => { + routes.redirect(query.res, '/api/' + match[1]); + }); - // Public API (when wrongly used with a trailing '/'). - app.route(/^\/api\/(.+)\/$/, (data, match, end, query) => { - routes.redirect(query.res, '/api/' + match[1]); - }); + // Public API reference. + app.route(/^\/reference\/api\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('api reference'); + routes.apiPage(query.res, api, user); + }); - // Public API reference. - app.route(/^\/reference\/api\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('api reference'); - routes.apiPage(query.res, api, user); - }); + // New Public API reference. + app.route(/^\/reference\/api-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('api reference'); + routes.apiPageNew(query.res, api, user); + }); - // New Public API reference. - app.route(/^\/reference\/api-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('api reference'); - routes.apiPageNew(query.res, api, user); - }); + // Public blog page. + app.route(/^\/blog\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('blog'); + routes.blogPage(query.res, user); + }); - // Public blog page. - app.route(/^\/blog\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('blog'); - routes.blogPage(query.res, user); - }); + // New public blog page. + app.route(/^\/blog-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + log('blog-new'); + routes.blogPageNew(response, user, blog); + }); - // New public blog page. - app.route(/^\/blog-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - log('blog-new'); - routes.blogPageNew(response, user, blog); - }); + // Public live data page. + app.route(/^\/data\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.dataPage(query.res, user); + }); - // Public live data page. - app.route(/^\/data\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.dataPage(query.res, user); - }); + // Public live data page. + app.route(/^\/data-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.dataPageNew(query.res, user); + }); - // Public live data page. - app.route(/^\/data-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.dataPageNew(query.res, user); - }); + // Public design page + app.route(/^\/design\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.designPage(query.res, user); + }); - // Public design page - app.route(/^\/design\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.designPage(query.res, user); - }); + // new login page + app.route(/^\/login-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + routes.newLoginPage(query.res, user); + }); - // new login page - app.route(/^\/login-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - routes.newLoginPage(query.res, user); - }); + // Public project pages. + app.route(/^\/projects(\/[\w-]+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + const projectUri = match[1]; + if (!projectUri) { + // No particular project was requested, show them all. + routes.projectsPage(query.res, user); + return; + } - // Public project pages. - app.route(/^\/projects(\/[\w-]+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - const projectUri = match[1]; - if (!projectUri) { - // No particular project was requested, show them all. - routes.projectsPage(query.res, user); - return; - } + const projectId = projectUri.slice(1); + const project = db.get('projects')[projectId]; + if (!project) { + // The requested project doesn't exist. + routes.notFoundPage(query.res, user); + return; + } - const projectId = projectUri.slice(1); - const project = db.get('projects')[projectId]; - if (!project) { - // The requested project doesn't exist. - routes.notFoundPage(query.res, user); - return; - } + routes.projectPage(query.res, project, user); + }); - routes.projectPage(query.res, project, user); - }); + // New public project pages. + app.route(/^\/projects-new(\/[\w-]+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + const projectUri = match[1]; + if (!projectUri) { + // No particular project was requested, show them all. + routes.projectsPageNew(query.res, user); + return; + } - // New public project pages. - app.route(/^\/projects-new(\/[\w-]+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - const projectUri = match[1]; - if (!projectUri) { - // No particular project was requested, show them all. - routes.projectsPageNew(query.res, user); - return; - } + const projectId = projectUri.slice(1); + const project = db.get('projects')[projectId]; + if (!project) { + // The requested project doesn't exist. + routes.notFoundPageNew(query.res, user); + return; + } - const projectId = projectUri.slice(1); - const project = db.get('projects')[projectId]; - if (!project) { - // The requested project doesn't exist. - routes.notFoundPageNew(query.res, user); - return; - } + routes.projectPageNew(query.res, project, user); + }); - routes.projectPageNew(query.res, project, user); - }); + // User logout. + app.route(/^\/logout\/?$/, (data, match, end, query) => { + users.logout(query.req, error => { + if (error) { + log('[fail] logout', error); + } + + routes.redirect(query.res, '/'); + }); + }); - // User logout. - app.route(/^\/logout\/?$/, (data, match, end, query) => { - users.logout(query.req, error => { - if (error) { - log('[fail] logout', error); + // User login page. + app.route(/^\/login\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!user) { + routes.loginPage(query.res); + return; } routes.redirect(query.res, '/'); }); - }); - // User login page. - app.route(/^\/login\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!user) { - routes.loginPage(query.res); - return; - } - - routes.redirect(query.res, '/'); - }); - - // User login via GitHub. - app.route(/^\/login\/github\/?$/, async (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - // Don't allow signing in only with GitHub just yet. - routes.notFoundPage(response, user); - return; - } + // User login via GitHub. + app.route(/^\/login\/github\/?$/, async (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + // Don't allow signing in only with GitHub just yet. + routes.notFoundPage(response, user); + return; + } - let accessToken = null; - let refreshToken = null; - try { - ({ accessToken, refreshToken } = await github.authenticate(request)); - } catch (error) { - log('[fail] github authentication', error); - routes.notFoundPage(response, user); - return; - } + let accessToken = null; + let refreshToken = null; + try { + ({ accessToken, refreshToken } = await github.authenticate(request)); + } catch (error) { + log('[fail] github authentication', error); + routes.notFoundPage(response, user); + return; + } - try { - await users.refreshGitHubAccount(user, accessToken, refreshToken); - } catch (error) { - log('[fail] could not refresh github account', error); - } + try { + await users.refreshGitHubAccount(user, accessToken, refreshToken); + } catch (error) { + log('[fail] could not refresh github account', error); + } - routes.redirect(response, '/settings/integrations/'); - }); + routes.redirect(response, '/settings/integrations/'); + }); - // User OAuth2 authorization. - app.route(/^\/login\/oauth\/authorize\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.notFoundPage(response, user); - return; - } + // User OAuth2 authorization. + app.route(/^\/login\/oauth\/authorize\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.notFoundPage(response, user); + return; + } - hosts.issueOAuth2AuthorizationCode(request).then(data => { - routes.redirect(response, data.redirect_url); - }).catch(error => { - log('[fail] oauth2 authorize', error); - // Note: Such OAuth2 sanity problems should rarely happen, but if they - // do become more frequent, we should inform the user about what's - // happening here instead of showing a generic 404 page. - routes.notFoundPage(response, user); + hosts.issueOAuth2AuthorizationCode(request).then(data => { + routes.redirect(response, data.redirect_url); + }).catch(error => { + log('[fail] oauth2 authorize', error); + // Note: Such OAuth2 sanity problems should rarely happen, but if they + // do become more frequent, we should inform the user about what's + // happening here instead of showing a generic 404 page. + routes.notFoundPage(response, user); + }); }); - }); - // OAuth2 access token request. - app.route(/^\/login\/oauth\/access_token\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - if (request.method !== 'POST') { - routes.notFoundPage(response, request.user); - return; - } + // OAuth2 access token request. + app.route(/^\/login\/oauth\/access_token\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + if (request.method !== 'POST') { + routes.notFoundPage(response, request.user); + return; + } - const authenticatedHostname = hosts.authenticate(request); - if (!authenticatedHostname) { - response.statusCode = 403; // Forbidden - response.json({ error: 'Unauthorized' }); - return; - } + const authenticatedHostname = hosts.authenticate(request); + if (!authenticatedHostname) { + response.statusCode = 403; // Forbidden + response.json({ error: 'Unauthorized' }); + return; + } - hosts.issueOAuth2AccessToken(request).then(data => { - response.json(data, null, 2); - }).catch(error => { - log('[fail] oauth2 token', error); - response.statusCode = 400; // Bad Request - response.json({ error: 'Could not issue OAuth2 access token' }, null, 2); + hosts.issueOAuth2AccessToken(request).then(data => { + response.json(data, null, 2); + }).catch(error => { + log('[fail] oauth2 token', error); + response.statusCode = 400; // Bad Request + response.json({ error: 'Could not issue OAuth2 access token' }, null, 2); + }); }); - }); - // User contributions list. (legacy - redirect to containers page) - app.route(/^\/contributions\/?$/, (data, match, end, query) => { - routes.redirect(query.res, '/containers/', true); - }); - - // User containers list. - app.route(/^\/containers\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } - - routes.containersPage(response, user); - }); + // User contributions list. (legacy - redirect to containers page) + app.route(/^\/contributions\/?$/, (data, match, end, query) => { + routes.redirect(query.res, '/containers/', true); + }); - // User new containers list. - app.route(/^\/containers-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + // User containers list. + app.route(/^\/containers\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - routes.containersPageNew(response, user); - }); + routes.containersPage(response, user); + }); - // User notifications. - app.route(/^\/notifications\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!user) { - routes.loginPage(query.res); - return; - } + // User new containers list. + app.route(/^\/containers-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - routes.notificationsPage(query.res, user); - }); + routes.containersPageNew(response, user); + }); - // User settings. - app.route(/^\/settings(\/\w+)?\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + // User notifications. + app.route(/^\/notifications\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!user) { + routes.loginPage(query.res); + return; + } - // Select the requested section, or serve the default one. - const sectionUri = match[1]; - const section = sectionUri ? sectionUri.slice(1) : 'account'; + routes.notificationsPage(query.res, user); + }); - routes.settingsPage(request, response, section, user); - }); + // User settings. + app.route(/^\/settings(\/\w+)?\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - // New settings page. - app.route(/^\/settings-new\/?$/, (data, match, end, query) => { - const { req: request, res: response } = query; - const { user } = request; - if (!user) { - routes.loginPage(response); - return; - } + // Select the requested section, or serve the default one. + const sectionUri = match[1]; + const section = sectionUri ? sectionUri.slice(1) : 'account'; - routes.settingsPageNew(request, response, user); - }); + routes.settingsPage(request, response, section, user); + }); - // User account (now part of settings). - app.route(/^\/account\/?$/, (data, match, end, query) => { - routes.redirect(query.res, '/settings/account/', true); - }); + // New settings page. + app.route(/^\/settings-new\/?$/, (data, match, end, query) => { + const { req: request, res: response } = query; + const { user } = request; + if (!user) { + routes.loginPage(response); + return; + } - app.route(/^\/[.,;)]$/, (data, match, end, query) => { - routes.redirect(query.res, '/', true); - }); + routes.settingsPageNew(request, response, user); + }); - // Admin sections. - app.route(/^\/admin(\/\w+)?\/?$/, (data, match, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - routes.notFoundPage(query.res, user); - return; - } + // User account (now part of settings). + app.route(/^\/account\/?$/, (data, match, end, query) => { + routes.redirect(query.res, '/settings/account/', true); + }); - // Select the requested section, or serve the default one. - const sectionUri = match[1]; - const section = sectionUri ? sectionUri.slice(1) : 'docker'; + app.route(/^\/[.,;)]$/, (data, match, end, query) => { + routes.redirect(query.res, '/', true); + }); - log('admin', section, '(' + user._primaryEmail + ')'); + // Admin sections. + app.route(/^\/admin(\/\w+)?\/?$/, (data, match, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + routes.notFoundPage(query.res, user); + return; + } - routes.adminPage(query.res, section, user); - }); + // Select the requested section, or serve the default one. + const sectionUri = match[1]; + const section = sectionUri ? sectionUri.slice(1) : 'docker'; - // New 404 Not Found page - app.route(/^\/404-new\/?$/, (data, match, end, query) => { - const { user } = query.req; - log('404-new', match[0]); - routes.notFoundPageNew(query.res, user); - }); + log('admin', section, '(' + user._primaryEmail + ')'); - // 404 Not Found. - app.notfound(/.*/, (data, match, end, query) => { - const { user } = query.req; - log('404', match[0]); - routes.notFoundPage(query.res, user); - }); + routes.adminPage(query.res, section, user); + }); - // Alpha version sign-up. - app.ajax.on('signup', (data, end) => { - const email = data.email; - const users = db.get('users'); - const waitlist = db.get('waitlist'); + // New 404 Not Found page + app.route(/^\/404-new\/?$/, (data, match, end, query) => { + const { user } = query.req; + log('404-new', match[0]); + routes.notFoundPageNew(query.res, user); + }); - log('signup', email); + // 404 Not Found. + app.notfound(/.*/, (data, match, end, query) => { + const { user } = query.req; + log('404', match[0]); + routes.notFoundPage(query.res, user); + }); - if (waitlist[email]) { - end({ status: 'already-added' }); - return; - } + // Alpha version sign-up. + app.ajax.on('signup', (data, end) => { + const email = data.email; + const users = db.get('users'); + const waitlist = db.get('waitlist'); - if (users[email]) { - end({ status: 'already-invited' }); - return; - } + log('signup', email); - waitlist[email] = Date.now(); - db.save(); + if (waitlist[email]) { + end({ status: 'already-added' }); + return; + } - end({ status: 'added' }); - }); + if (users[email]) { + end({ status: 'already-invited' }); + return; + } - // Alpha version invite. - app.ajax.on('invite', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + waitlist[email] = Date.now(); + db.save(); - const email = data.email; - if (email in db.get('users')) { - end({ status: 'already-invited' }); - return; - } + end({ status: 'added' }); + }); - users.sendInviteEmail(email, error => { - if (error) { - const message = String(error); - log(message, '(while inviting ' + email + ')'); - end({ status: 'error', message: message }); + // Alpha version invite. + app.ajax.on('invite', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); return; } - end({ status: 'invited' }); - }); - }); - // Request a log-in key via email. - app.ajax.on('login', (data, end, query) => { - const { user } = query.req; - if (user) { - end({ status: 'logged-in' }); - return; - } - - const email = data.email; - users.sendLoginEmail(email, query.req, error => { - if (error) { - const message = String(error); - log(message, '(while emailing ' + email + ')'); - end({ status: 'error', message: message }); + const email = data.email; + if (email in db.get('users')) { + end({ status: 'already-invited' }); return; } - end({ status: 'email-sent' }); - }); - }); - // Change the parameters of a project. - app.ajax.on('projectdb', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + users.sendInviteEmail(email, error => { + if (error) { + const message = String(error); + log(message, '(while inviting ' + email + ')'); + end({ status: 'error', message: message }); + return; + } + end({ status: 'invited' }); + }); + }); - if (!data.id) { - end({ status: 'error', message: 'Invalid project ID' }); - return; - } + // Request a log-in key via email. + app.ajax.on('login', (data, end, query) => { + const { user } = query.req; + if (user) { + end({ status: 'logged-in' }); + return; + } - machines.setProject(data); - end({ status: 'success' }); - }); + const email = data.email; + users.sendLoginEmail(email, query.req, error => { + if (error) { + const message = String(error); + log(message, '(while emailing ' + email + ')'); + end({ status: 'error', message: message }); + return; + } + end({ status: 'email-sent' }); + }); + }); - // Update the base image of a project. - app.ajax.on('update', (data, end, query) => { - const { user } = query.req; - if (!users.isAdmin(user)) { - end(); - return; - } + // Change the parameters of a project. + app.ajax.on('projectdb', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); + return; + } - machines.update(data.project, error => { - if (error) { - end({ status: 'error', message: String(error) }); + if (!data.id) { + end({ status: 'error', message: 'Invalid project ID' }); return; } + + machines.setProject(data); end({ status: 'success' }); }); - // For longer requests, make sure we reply before the browser retries. - setTimeout(() => { - end({ status: 'started' }); - }, 42000); - }); + // Update the base image of a project. + app.ajax.on('update', (data, end, query) => { + const { user } = query.req; + if (!users.isAdmin(user)) { + end(); + return; + } - // Save a new user key, or update an existing one. - app.ajax.on('key', (data, end, query) => { - const { user } = query.req; - if (!user || !data.name || !data.key) { - end(); - return; - } + machines.update(data.project, error => { + if (error) { + end({ status: 'error', message: String(error) }); + return; + } + end({ status: 'success' }); + }); + + // For longer requests, make sure we reply before the browser retries. + setTimeout(() => { + end({ status: 'started' }); + }, 42000); + }); - let key = ''; - if (data.name !== 'cloud9') { - end({ status: 'error', message: 'Unknown key name' }); - return; - } + // Save a new user key, or update an existing one. + app.ajax.on('key', (data, end, query) => { + const { user } = query.req; + if (!user || !data.name || !data.key) { + end(); + return; + } - // Extract a valid SSH public key from the user's input. - // Regex adapted from https://gist.github.com/paranoiq/1932126. - const match = data.key.match(/ssh-rsa [\w+/]+[=]{0,3}/); - if (!match) { - end({ status: 'error', message: 'Invalid SSH key' }); - return; - } + let key = ''; + if (data.name !== 'cloud9') { + end({ status: 'error', message: 'Unknown key name' }); + return; + } - key = match[0]; - log('key', data.name, user._primaryEmail); + // Extract a valid SSH public key from the user's input. + // Regex adapted from https://gist.github.com/paranoiq/1932126. + const match = data.key.match(/ssh-rsa [\w+/]+[=]{0,3}/); + if (!match) { + end({ status: 'error', message: 'Invalid SSH key' }); + return; + } - user.keys[data.name] = key; - db.save(); + key = match[0]; + log('key', data.name, user._primaryEmail); - end({ status: 'key-saved' }); + user.keys[data.name] = key; + db.save(); + + end({ status: 'key-saved' }); + }); }); -}); diff --git a/join.js b/join.js index 6bdf4b6f..a29b4f8f 100644 --- a/join.js +++ b/join.js @@ -31,6 +31,7 @@ Promise.all([ boot.verifyJanitorOAuth2Access() ]) .then(() => boot.registerDockerClient()) + .catch((err) => log('[fail] could not join cluster', err)) .then(() => { log('[ok] joined cluster as [hostname = ' + hostname + ']'); diff --git a/lib/__mocks__/db.js b/lib/__mocks__/db.js new file mode 100644 index 00000000..3074643b --- /dev/null +++ b/lib/__mocks__/db.js @@ -0,0 +1,15 @@ +let store = {}; + +exports.get = function (key, defaultValue) { + if (!store[key]) { + store[key] = defaultValue || {}; + } + + return store[key]; +}; + +exports.save = () => {}; + +exports.__setData = (newStore) => { + store = Object.assign({}, newStore); +}; diff --git a/lib/blog.js b/lib/blog.js index 0a440c7c..e5e0202f 100644 --- a/lib/blog.js +++ b/lib/blog.js @@ -94,7 +94,7 @@ exports.synchronize = async function () { blog.topics = await Promise.all(topicsPromises); const now = Date.now(); metrics.set(blog, 'updated', now); - metrics.push(blog, 'pull-time', [now, now - time]); + metrics.push(blog, 'pull-time', [ now, now - time ]); db.save(); return { count: topics.length }; }; diff --git a/lib/boot.js b/lib/boot.js index 173b313f..6ef93287 100644 --- a/lib/boot.js +++ b/lib/boot.js @@ -42,7 +42,9 @@ exports.forwardHttp = function () { }); const listen = promisify(forwarder.listen); - return listen.call(forwarder, ports.http); + return listen.call(forwarder, ports.http).then(() => { + log('[ok] forwarding http:// → https://'); + }); }; // Verify HTTPS certificates and generate new ones if necessary. @@ -108,8 +110,8 @@ exports.ensureDockerTlsCertificates = async function () { } catch (error) { if (error.code !== 'ENOENT') { log('[fail] could not read docker-tls certificates', error); + throw error; } - throw error; } let caValid = certificates.isValid({ diff --git a/lib/proxy-heuristics.js b/lib/proxy-heuristics.js index 51aa8101..0851f16c 100644 --- a/lib/proxy-heuristics.js +++ b/lib/proxy-heuristics.js @@ -26,7 +26,7 @@ exports.handleProxyUrls = function (request, response, next) { // Look for a container ID and port in `request.url`. let match = exports.proxyUrlPrefix.exec(request.url); if (match) { - [url, containerId, port, path] = match; + [ url, containerId, port, path ] = match; // We want the proxied `path` to always begin with a '/'. // However `path` is empty in URLs like '/abc123/8080?p=1', so we redirect @@ -52,7 +52,7 @@ exports.handleProxyUrls = function (request, response, next) { const referer = nodeurl.parse(request.headers.referer); match = exports.proxyUrlPrefix.exec(referer.pathname); if (match) { - [url, containerId, port, path] = match; + [ url, containerId, port, path ] = match; } } diff --git a/lib/sessions.js b/lib/sessions.js index 1b5f2b69..3ad712f1 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -7,7 +7,7 @@ const db = require('./db'); const log = require('./log'); const login = new EmailLogin({ - db: './tokens/', + db: db.get('tokens', './tokens/'), mailer: db.get('mailer') }); const useSecureCookies = !db.get('security').forceInsecure; diff --git a/package.json b/package.json index a889c102..d5390184 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "scripts": { "app": "SCRIPT=app npm start", "join": "SCRIPT=join npm start", - "lint": "eslint -c .eslintrc-node.js *.js api/ lib/ && eslint -c .eslintrc-browser.js static/", - "lint-fix": "eslint -c .eslintrc-node.js *.js api/ lib/ --fix && eslint -c .eslintrc-browser.js static/ --fix", + "lint": "eslint *.js api/ lib/ test/ static/", + "lint-fix": "eslint *.js api/ lib/ test/ static/ --fix", "rebase": "git pull -q --rebase origin master && git submodule -q update --rebase && npm update", "prestart": "npm stop && touch janitor.log janitor.pid && chmod 600 janitor.log janitor.pid", "start": "if [ -z \"$SCRIPT\" ] ; then printf \"Run which Janitor script? [join/app]:\" && read SCRIPT ; fi ; node \"$SCRIPT\" >> janitor.log 2>&1 & printf \"$!\\n\" > janitor.pid", "poststart": "printf \"[$(date -uIs)] Background process started (PID $(cat janitor.pid), LOGS $(pwd)/janitor.log).\\n\"", "stop": "if [ -e janitor.pid -a -n \"$(ps h $(cat janitor.pid))\" ] ; then kill $(cat janitor.pid) && printf \"[$(date -uIs)] Background process stopped (PID $(cat janitor.pid)).\\n\" ; fi ; rm -f janitor.pid", - "test": "cd tests && node tests.js", + "test": "jest", "prewatch": "touch janitor.log && chmod 600 janitor.log", "watch": "watch-run --initial --pattern 'app.js,package.json,api/**,lib/**,templates/**' --stop-on-error npm run app & tail -f janitor.log -n 0" }, @@ -52,6 +52,7 @@ "eslint-plugin-node": "^6.0.0", "eslint-plugin-promise": "^3.5.0", "eslint-plugin-standard": "^3.0.1", + "jest": "^22.4.3", "watch-run": "^1.2.5" }, "engines": { diff --git a/.eslintrc-browser.js b/static/.eslintrc.js similarity index 100% rename from .eslintrc-browser.js rename to static/.eslintrc.js diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 00000000..7a2d6a8f --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + "env": { + "jest": true + }, + "extends": [ + "../.eslintrc.js" + ] +}; \ No newline at end of file diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 00000000..d78e1886 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,134 @@ +// Copyright © 2017 Jan Keromnes. All rights reserved. +// The following code is covered by the AGPL-3.0 license. + +'use strict'; + +const { Camp } = require('camp'); +const selfapi = require('selfapi'); +const stream = require('stream'); +const { promisify } = require('util'); + +describe('Janitor API self-tests', () => { + jest.mock('../lib/boot'); + jest.mock('../lib/db'); + jest.mock('../lib/docker'); + + const db = require('../lib/db'); + db.__setData({ + // Tell our fake Janitor app that it runs on the fake host "example.com" + hostname: 'example.com', + // Disable sending any emails (for invites or signing in) + mailer: { + block: true + }, + security: { + // Disable Let's Encrypt HTTPS certificate generation and verification + forceHttp: true, + // Disable any security policies that could get in the way of testing + forceInsecure: true + }, + tokens: 'test/tokens' + }); + + const boot = require('../lib/boot'); + boot.ensureDockerTlsCertificates.mockResolvedValue(); + + const docker = require('../lib/docker'); + const hosts = require('../lib/hosts'); + const machines = require('../lib/machines'); + const users = require('../lib/users'); + + // Fake several Docker methods for testing. + // TODO Maybe use a real Docker Remote API server (or a full mock) for more + // realistic tests? + docker.pullImage.mockImplementation((parameters) => { + const readable = new stream.Readable(); + readable.push('ok'); + readable.push(null); // End the stream. + return Promise.resolve(readable); + }); + docker.inspectImage.mockResolvedValue({ Created: 1500000000000 }); + docker.tagImage.mockResolvedValue(); + docker.runContainer.mockResolvedValue({ container: { id: 'abcdef0123456789' }, logs: '' }); + docker.copyIntoContainer.mockResolvedValue(); + docker.execInContainer.mockResolvedValue(); + docker.listChangedFilesInContainer.mockResolvedValue([ + { Path: '/tmp', Kind: 0 }, + { Path: '/tmp/test', Kind: 1 } + ]); + docker.version.mockResolvedValue({ Version: '17.06.0-ce' }); + + const api = require('../api/'); + + const app = new Camp({ + documentRoot: 'static' + }); + + beforeAll(async () => { + function registerTestUser () { + // Grant administrative privileges to the fake email "admin@example.com". + db.get('admins')['admin@example.com'] = true; + // Create the user "admin@example.com" by "sending" them an invite email. + return promisify(users.sendInviteEmail)('admin@example.com'); + } + + function createTestHost () { + return promisify(hosts.create)('example.com', {}); + } + + function createTestProject () { + machines.setProject({ + 'id': 'test-project', + '/name': 'Test Project', + '/docker/host': 'example.com', + '/docker/image': 'image:latest', + }); + } + + function createTestContainer () { + const user = db.get('users')['admin@example.com']; + // Create a new user machine for the project "test-project". + return machines.spawn(user, 'test-project'); + } + + await Promise.all([ + registerTestUser(), + createTestHost(), + createTestProject(), + ]); + await createTestContainer(); + + // Authenticate test requests with a server middleware. + const sessions = require('../lib/sessions'); + app.handle((request, response, next) => { + sessions.get(request, (error, session, token) => { + if (error || !session || !session.id) { + console.error('[fail] session:', session, error); + response.statusCode = 500; // Internal Server Error + response.end(); + return; + } + request.session = session; + if (!('client_secret' in request.query)) { + request.user = db.get('users')['admin@example.com']; + } + next(); + }); + }); + + // Mount the Janitor API. + selfapi(app, '/api', api); + + await promisify(app.listen).call(app, 0, '127.0.0.1'); + }); + + afterAll(() => { + return promisify(app.close).call(app); + }); + + it('follows API examples', async () => { + const { passed, failed } = await promisify(api.test).call(api, `http://127.0.0.1:${app.address().port}`); + console.info(`${passed.length} passed, ${failed.length} failed`); + expect(failed).toHaveLength(0); + }); +}); diff --git a/tests/tests.js b/tests/tests.js deleted file mode 100644 index 085b000f..00000000 --- a/tests/tests.js +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright © 2017 Jan Keromnes. All rights reserved. -// The following code is covered by the AGPL-3.0 license. - -'use strict'; - -const fs = require('fs'); -const net = require('net'); -const path = require('path'); -const selfapi = require('selfapi'); -const stream = require('stream'); - -if (path.basename(process.cwd()) !== 'tests') { - console.error('Warning: Tests need to run inside the tests/ folder.\n' + - ' This is to prevent interference between tests and production.\n' + - ' Teleporting to tests/ now.'); - process.chdir('tests'); -} - -try { - fs.unlinkSync('./db.json'); -} catch (error) { - if (error.code !== 'ENOENT') { - console.error(error.stack); - process.exit(1); - } -} - -try { - fs.symlinkSync('../templates', './templates', 'dir'); -} catch (error) { - if (error.code !== 'EEXIST') { - console.error(error.stack); - process.exit(1); - } -} - -let tests = []; - -tests.push({ - title: 'Janitor API self-tests', - - test: (port, callback) => { - const db = require('../lib/db'); - // Tell our fake Janitor app that it runs on the fake host "example.com": - db.get('hostname', 'example.com'); - // Disable sending any emails (for invites or signing in): - db.get('mailer').block = true; - // Disable Let's Encrypt HTTPS certificate generation and verification: - db.get('security').forceHttp = true; - // Disable any security policies that could get in the way of testing: - db.get('security').forceInsecure = true; - - const boot = require('../lib/boot'); - const docker = require('../lib/docker'); - const hosts = require('../lib/hosts'); - const machines = require('../lib/machines'); - const users = require('../lib/users'); - - // Fake several Docker methods for testing. - // TODO Maybe use a real Docker Remote API server (or a full mock) for more - // realistic tests? - docker.pullImage = function (parameters, callback) { - const readable = new stream.Readable(); - readable.push('ok'); - readable.push(null); // End the stream. - callback(null, readable); - }; - docker.inspectImage = function (parameters, callback) { - callback(null, { Created: 1500000000000 }); - }; - docker.tagImage = function (parameters, callback) { callback(); }; - docker.runContainer = function (parameters, callback) { - callback(null, { id: 'abcdef0123456789' }, ''); - }; - docker.copyIntoContainer = docker.execInContainer = - function (parameters, callback) { callback(); }; - docker.listChangedFilesInContainer = function (parameters, callback) { - callback(null, [ - { Path: '/tmp', Kind: 0 }, - { Path: '/tmp/test', Kind: 1 } - ]); - }; - docker.version = function (parameters, callback) { - callback(null, { Version: '17.06.0-ce' }); - }; - - function registerTestUser (next) { - // Grant administrative privileges to the fake email "admin@example.com". - db.get('admins')['admin@example.com'] = true; - // Create the user "admin@example.com" by "sending" them an invite email. - users.sendInviteEmail('admin@example.com', error => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - function createTestHost (next) { - hosts.create('example.com', {}, (error, host) => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - function createTestProject (next) { - machines.setProject({ - 'id': 'test-project', - '/name': 'Test Project', - '/docker/host': 'example.com', - '/docker/image': 'image:latest', - }); - next(); - } - - function createTestContainer (next) { - const user = db.get('users')['admin@example.com']; - // Create a new user machine for the project "test-project". - machines.spawn(user, 'test-project', error => { - if (error) { - callback(error); - return; - } - next(); - }); - } - - boot.executeInParallel([ - boot.forwardHttp, - boot.ensureDockerTlsCertificates, - registerTestUser, - createTestHost, - createTestProject, - ], () => { - createTestContainer(() => { - const camp = require('camp'); - const app = camp.start({ - documentRoot: process.cwd() + '/../static', - port: port - }); - - // Authenticate test requests with a server middleware. - const sessions = require('../lib/sessions'); - app.handle((request, response, next) => { - sessions.get(request, (error, session, token) => { - if (error || !session || !session.id) { - console.error('[fail] session:', session, error); - response.statusCode = 500; // Internal Server Error - response.end(); - return; - } - request.session = session; - if (!('client_secret' in request.query)) { - request.user = db.get('users')['admin@example.com']; - } - next(); - }); - }); - - // Mount the Janitor API. - const api = require('../api/'); - selfapi(app, '/api', api); - - // Test the API against its own examples. - api.test('http://localhost:' + port, (error, results) => { - if (error) { - callback(error); - return; - } - - if (results.failed.length > 0) { - var total = results.passed.length + results.failed.length; - callback(results.passed.length + '/' + total + ' API test' + - (total === 1 ? '' : 's') + ' passed. Failed tests: ' + - JSON.stringify(results.failed, null, 2)); - return; - } - - callback(); - }); - }); - }); - } -}); - -/* -tests.push({ - title: 'Docker host joining the cluster', - - test: (port, callback) => { - // TODO Start app (`node app` or similar) - // TODO Start cluster host (`node join` or similar, on different ports) - // TODO Verify that cluster registration works - // TODO verify that - callback(new Error('Not implemented yet')); - } -}); -*/ - -/** - * To add a new test, simply copy-paste and fill in the following code block: - -tests.push({ - title: '', - test: (port, callback) => { - // test some things - // callback(error); - } -}); - -*/ - -let nextPort = 9000; -function getPort (callback) { - const port = nextPort++; - const server = net.createServer(); - server.listen(port, (error) => { - server.once('close', () => callback(port)); - server.close(); - }); - server.on('error', error => getPort(callback)); -} - -let unfinishedTests = tests.length; -function reportTest (test, error) { - if (error) { - process.exitCode = 1; - console.error('[fail]', test.title); - console.error(...(error.stack ? [ error.stack ] : [ 'Error:', error ])); - } else { - console.log('[ok]', test.title); - } - unfinishedTests--; - if (unfinishedTests === 0) { - process.exit(); - } -} - -function runTest (test) { - getPort(port => { - try { - test.test(port, error => reportTest(test, error)); - } catch (error) { - reportTest(test, error); - } - }); -} - -while (tests.length > 0) { - // Randomly take a test out of the tests array, and run it. - const test = tests.splice(Math.floor(Math.random() * tests.length), 1)[0]; - runTest(test); -}