commit df9d321ac3d11e8e9d0c19cb3f9d227205e5a57e Author: Girish Ramakrishnan Date: Mon Jul 20 00:09:47 2015 -0700 app.portBindings and newManifest.tcpPorts may be null diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..d79789d5e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Skip files when using git archive +.gitattributes export-ignore +.gitignore export-ignore +/scripts export-ignore +test export-ignore + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..12c6e8a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +coverage/ +docs/ +webadmin/dist/ +setup/splash/website/ + +# vim swam files +*.swp + +# supervisor +supervisord.pid +supervisord.log + diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 000000000..ad6d169fb --- /dev/null +++ b/.jshintrc @@ -0,0 +1,7 @@ +{ + "node": true, + "browser": true, + "unused": true, + "globalstrict": true, + "predef": [ "angular", "$" ] +} diff --git a/README.md b/README.md new file mode 100644 index 000000000..941e7236c --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +The Box +======= + +Development setup +----------------- +* sudo useradd -m yellowtent +** This dummy user is required for supervisor 'box' configs +** Add admin-localhost as 127.0.0.1 in /etc/hosts +** All apps will be installed as hypened-subdomains of localhost. You should add + hyphened-subdomains of your apps into /etc/hosts + diff --git a/app.js b/app.js new file mode 100755 index 000000000..b677d3f29 --- /dev/null +++ b/app.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +'use strict'; + +require('supererror')({ splatchError: true }); + +var server = require('./src/server.js'), + ldap = require('./src/ldap.js'), + config = require('./src/config.js'); + +console.log(); +console.log('=========================================='); +console.log(' Cloudron will use the following settings '); +console.log('=========================================='); +console.log(); +console.log(' Environment: ', config.CLOUDRON ? 'CLOUDRON' : 'TEST'); +console.log(' Version: ', config.version()); +console.log(' Admin Origin: ', config.adminOrigin()); +console.log(' Appstore token: ', config.token()); +console.log(' Appstore API server origin: ', config.apiServerOrigin()); +console.log(' Appstore Web server origin: ', config.webServerOrigin()); +console.log(); +console.log('=========================================='); +console.log(); + +server.start(function (err) { + if (err) { + console.error('Error starting server', err); + process.exit(1); + } + + console.log('Server listening on port ' + config.get('port')); + + ldap.start(function (error) { + if (error) { + console.error('Error LDAP starting server', err); + process.exit(1); + } + + console.log('LDAP server listen on port ' + config.get('ldapPort')); + }); +}); + +var NOOP_CALLBACK = function () { }; + +process.on('SIGINT', function () { server.stop(NOOP_CALLBACK); }); +process.on('SIGTERM', function () { server.stop(NOOP_CALLBACK); }); diff --git a/apphealthtask.js b/apphealthtask.js new file mode 100755 index 000000000..53463e842 --- /dev/null +++ b/apphealthtask.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +'use strict'; + +require('supererror')({ splatchError: true }); + +var appdb = require('./src/appdb.js'), + assert = require('assert'), + async = require('async'), + database = require('./src/database.js'), + DatabaseError = require('./src/databaseerror.js'), + debug = require('debug')('box:apphealthtask'), + docker = require('./src/docker.js'), + mailer = require('./src/mailer.js'), + superagent = require('superagent'), + util = require('util'); + +exports = module.exports = { + run: run +}; + +var HEALTHCHECK_INTERVAL = 10 * 1000; // every 10 seconds. this needs to be small since the UI makes only healthy apps clickable +var UNHEALTHY_THRESHOLD = 3 * 60 * 1000; // 3 minutes +var gHealthInfo = { }; // { time, emailSent } + +function debugApp(app, args) { + assert(!app || typeof app === 'object'); + + var prefix = app ? app.location : '(no app)'; + debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); +} + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + async.series([ + database.initialize, + mailer.initialize + ], callback); +} + +function setHealth(app, health, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof health, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var now = new Date(); + + if (!(app.id in gHealthInfo)) { // add new apps to list + gHealthInfo[app.id] = { time: now, emailSent: false }; + } + + if (health === appdb.HEALTH_HEALTHY) { + gHealthInfo[app.id].time = now; + } else if (Math.abs(now - gHealthInfo[app.id].time) > UNHEALTHY_THRESHOLD) { + if (gHealthInfo[app.id].emailSent) return callback(null); + + debugApp(app, 'marking as unhealthy since not seen for more than %s minutes', UNHEALTHY_THRESHOLD/(60 * 1000)); + + mailer.appDied(app); + gHealthInfo[app.id].emailSent = true; + } else { + debugApp(app, 'waiting for sometime to update the app health'); + return callback(null); + } + + appdb.setHealth(app.id, health, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null); // app uninstalled? + if (error) return callback(error); + + app.health = health; + + callback(null); + }); +} + + +// callback is called with error for fatal errors and not if health check failed +function checkAppHealth(app, callback) { + if (app.installationState !== appdb.ISTATE_INSTALLED || app.runState !== appdb.RSTATE_RUNNING) { + debugApp(app, 'skipped. istate:%s rstate:%s', app.installationState, app.runState); + return callback(null); + } + + var container = docker.getContainer(app.containerId), + manifest = app.manifest; + + container.inspect(function (err, data) { + if (err || !data || !data.State) { + debugApp(app, 'Error inspecting container'); + return setHealth(app, appdb.HEALTH_ERROR, callback); + } + + if (data.State.Running !== true) { + debugApp(app, 'exited'); + return setHealth(app, appdb.HEALTH_DEAD, callback); + } + + // poll through docker network instead of nginx to bypass any potential oauth proxy + var healthCheckUrl = 'http://127.0.0.1:' + app.httpPort + manifest.healthCheckPath; + superagent + .get(healthCheckUrl) + .redirects(0) + .timeout(HEALTHCHECK_INTERVAL) + .end(function (error, res) { + + if (error || res.status >= 400) { // 2xx and 3xx are ok + debugApp(app, 'not alive : %s', error || res.status); + setHealth(app, appdb.HEALTH_UNHEALTHY, callback); + } else { + debugApp(app, 'alive'); + setHealth(app, appdb.HEALTH_HEALTHY, callback); + } + }); + }); +} + +function processApps(callback) { + appdb.getAll(function (error, apps) { + if (error) return callback(error); + + async.each(apps, checkAppHealth, function (error) { + if (error) console.error(error); + callback(null); + }); + }); +} + +function run() { + processApps(function (error) { + if (error) console.error(error); + + setTimeout(run, HEALTHCHECK_INTERVAL); + }); +} + +if (require.main === module) { + initialize(function (error) { + if (error) { + console.error('apphealth task exiting with error', error); + process.exit(1); + } + + run(); + }); +} + diff --git a/assets/avatar.png b/assets/avatar.png new file mode 100644 index 000000000..7be2943b9 Binary files /dev/null and b/assets/avatar.png differ diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 000000000..f9f38493b Binary files /dev/null and b/assets/favicon.ico differ diff --git a/crashnotifier.js b/crashnotifier.js new file mode 100644 index 000000000..a96cacff6 --- /dev/null +++ b/crashnotifier.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +'use strict'; + +// WARNING This is a supervisor eventlistener! +// The communication happens via stdin/stdout +// !! No console.log() allowed +// !! Do not set DEBUG + +var supervisor = require('supervisord-eventlistener'), + assert = require('assert'), + exec = require('child_process').exec, + util = require('util'), + fs = require('fs'), + mailer = require('./src/mailer.js'); + +var gLastNotifyTime = {}; +var gCooldownTime = 1000 * 60 * 5; // 5 min + +function collectLogs(program, callback) { + assert.strictEqual(typeof program, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var logFilePath = util.format('/var/log/supervisor/%s.log', program); + + if (!fs.existsSync(logFilePath)) return callback(new Error(util.format('Log file %s does not exist.', logFilePath))); + + fs.readFile(logFilePath, 'utf-8', function (error, data) { + if (error) return callback(error); + + var lines = data.split('\n'); + var boxLogLines = lines.slice(-100); + + exec('dmesg', function (error, stdout /*, stderr */) { + if (error) console.error(error); + + var lines = stdout.split('\n'); + var dmesgLogLines = lines.slice(-100); + + var result = ''; + result += program + '.log\n'; + result += '-------------------------------------\n'; + result += boxLogLines.join('\n'); + result += '\n\n'; + result += 'dmesg\n'; + result += '-------------------------------------\n'; + result += dmesgLogLines.join('\n'); + + callback(null, result); + }); + }); +} + +supervisor.on('PROCESS_STATE_EXITED', function (headers, data) { + if (data.expected === '1') return console.error('Normal app %s exit', data.processname); + + console.error('%s exited unexpectedly', data.processname); + + collectLogs(data.processname, function (error, result) { + if (error) { + console.error('Failed to collect logs.', error); + result = util.format('Failed to collect logs.', error); + } + + if (!gLastNotifyTime[data.processname] || gLastNotifyTime[data.processname] < Date.now() - gCooldownTime) { + console.error('Send mail.'); + mailer.sendCrashNotification(data.processname, result); + gLastNotifyTime[data.processname] = Date.now(); + } else { + console.error('Do not send mail, already sent one recently.'); + } + }); +}); + +mailer.initialize(function () { + supervisor.listen(process.stdin, process.stdout); + console.error('Crashnotifier listening...'); +}); diff --git a/docs/makeDocs b/docs/makeDocs new file mode 100755 index 000000000..3326ee29a --- /dev/null +++ b/docs/makeDocs @@ -0,0 +1,5 @@ +#!/bin/sh + +set -eu + +./node_modules/.bin/apidoc -i src/routes -o docs diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 000000000..e1a52ae80 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,159 @@ +/* jslint node:true */ + +'use strict'; + +var ejs = require('gulp-ejs'), + gulp = require('gulp'), + del = require('del'), + concat = require('gulp-concat'), + uglify = require('gulp-uglify'), + serve = require('gulp-serve'), + sass = require('gulp-sass'), + sourcemaps = require('gulp-sourcemaps'), + minifyCSS = require('gulp-minify-css'), + autoprefixer = require('gulp-autoprefixer'), + argv = require('yargs').argv; + +gulp.task('3rdparty', function () { + gulp.src([ + 'webadmin/src/3rdparty/**/*.js', + 'webadmin/src/3rdparty/**/*.map', + 'webadmin/src/3rdparty/**/*.css', + 'webadmin/src/3rdparty/**/*.otf', + 'webadmin/src/3rdparty/**/*.eot', + 'webadmin/src/3rdparty/**/*.svg', + 'webadmin/src/3rdparty/**/*.ttf', + 'webadmin/src/3rdparty/**/*.woff', + 'webadmin/src/3rdparty/**/*.woff2' + ]) + .pipe(gulp.dest('webadmin/dist/3rdparty/')) + .pipe(gulp.dest('setup/splash/website/3rdparty')); + + gulp.src('node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js') + .pipe(gulp.dest('webadmin/dist/3rdparty/js')) + .pipe(gulp.dest('setup/splash/website/3rdparty/js')); +}); + + +// -------------- +// JavaScript +// -------------- + +gulp.task('js', ['js-index', 'js-setup', 'js-update', 'js-error'], function () {}); + +var oauth = { + clientId: argv.clientId || 'cid-webadmin', + clientSecret: argv.clientSecret || 'unused', + apiOrigin: argv.apiOrigin || '' +}; + +console.log(); +console.log('Using OAuth credentials:'); +console.log(' ClientId: %s', oauth.clientId); +console.log(' ClientSecret: %s', oauth.clientSecret); +console.log(' Cloudron API: %s', oauth.apiOrigin || 'default'); +console.log(); + +gulp.task('js-index', function () { + gulp.src([ + 'webadmin/src/js/index.js', + 'webadmin/src/js/client.js', + 'webadmin/src/js/appstore.js', + 'webadmin/src/js/main.js', + 'webadmin/src/views/*.js' + ]) + .pipe(ejs({ oauth: oauth }, { ext: '.js' })) + .pipe(sourcemaps.init()) + .pipe(concat('index.js', { newLine: ';' })) + .pipe(uglify()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('webadmin/dist/js')); +}); + +gulp.task('js-setup', function () { + gulp.src(['webadmin/src/js/setup.js', 'webadmin/src/js/client.js']) + .pipe(ejs({ oauth: oauth }, { ext: '.js' })) + .pipe(sourcemaps.init()) + .pipe(concat('setup.js', { newLine: ';' })) + .pipe(uglify()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('webadmin/dist/js')); +}); + +gulp.task('js-error', function () { + gulp.src(['webadmin/src/js/error.js']) + .pipe(sourcemaps.init()) + .pipe(uglify()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('webadmin/dist/js')); +}); + +gulp.task('js-update', function () { + gulp.src(['webadmin/src/js/update.js']) + .pipe(sourcemaps.init()) + .pipe(uglify()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('webadmin/dist/js')) + .pipe(gulp.dest('setup/splash/website/js')); +}); + + +// -------------- +// HTML +// -------------- + +gulp.task('html', ['html-views', 'html-update'], function () { + return gulp.src('webadmin/src/*.html').pipe(gulp.dest('webadmin/dist')); +}); + +gulp.task('html-update', function () { + return gulp.src(['webadmin/src/update.html']).pipe(gulp.dest('setup/splash/website')); +}); + +gulp.task('html-views', function () { + return gulp.src('webadmin/src/views/**/*.html').pipe(gulp.dest('webadmin/dist/views')); +}); + +// -------------- +// CSS +// -------------- + +gulp.task('css', function () { + return gulp.src('webadmin/src/*.scss') + .pipe(sourcemaps.init()) + .pipe(sass({ includePaths: ['node_modules/bootstrap-sass/assets/stylesheets/'] }).on('error', sass.logError)) + .pipe(autoprefixer()) + .pipe(minifyCSS()) + .pipe(sourcemaps.write()) + .pipe(gulp.dest('webadmin/dist')) + .pipe(gulp.dest('setup/splash/website')); +}); + +gulp.task('images', function () { + return gulp.src('webadmin/src/img/**') + .pipe(gulp.dest('webadmin/dist/img')); +}); + +// -------------- +// Utilities +// -------------- + +gulp.task('watch', ['default'], function () { + gulp.watch(['webadmin/src/*.scss'], ['css']); + gulp.watch(['webadmin/src/img/*'], ['images']); + gulp.watch(['webadmin/src/**/*.html'], ['html']); + gulp.watch(['webadmin/src/views/*.html'], ['html-views']); + gulp.watch(['webadmin/src/js/update.js'], ['js-update']); + gulp.watch(['webadmin/src/js/error.js'], ['js-error']); + gulp.watch(['webadmin/src/js/setup.js', 'webadmin/src/js/client.js'], ['js-setup']); + gulp.watch(['webadmin/src/js/index.js', 'webadmin/src/js/client.js', 'webadmin/src/js/appstore.js', 'webadmin/src/js/main.js', 'webadmin/src/views/*.js'], ['js-index']); + gulp.watch(['webadmin/src/3rdparty/**/*'], ['3rdparty']); +}); + +gulp.task('clean', function () { + del.sync(['webadmin/dist', 'setup/splash/website']); +}); + +gulp.task('default', ['clean', 'html', 'js', '3rdparty', 'images', 'css'], function () {}); + +gulp.task('develop', ['watch'], serve({ root: 'webadmin/dist', port: 4000 })); diff --git a/janitor.js b/janitor.js new file mode 100755 index 000000000..f82a1f83a --- /dev/null +++ b/janitor.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +'use strict'; + +require('supererror')({ splatchError: true }); + +var assert = require('assert'), + debug = require('debug')('box:janitor'), + async = require('async'), + tokendb = require('./src/tokendb.js'), + authcodedb = require('./src/authcodedb.js'), + database = require('./src/database.js'); + +var TOKEN_CLEANUP_INTERVAL = 30000; + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + async.series([ + database.initialize + ], callback); +} + +function cleanupExpiredTokens(callback) { + assert.strictEqual(typeof callback, 'function'); + + tokendb.delExpired(function (error, result) { + if (error) return callback(error); + + debug('Cleaned up %s expired tokens.', result); + + callback(null); + }); +} + +function cleanupExpiredAuthCodes(callback) { + assert.strictEqual(typeof callback, 'function'); + + authcodedb.delExpired(function (error, result) { + if (error) return callback(error); + + debug('Cleaned up %s expired authcodes.', result); + + callback(null); + }); +} + +function run() { + cleanupExpiredTokens(function (error) { + if (error) console.error(error); + + cleanupExpiredAuthCodes(function (error) { + if (error) console.error(error); + + setTimeout(run, TOKEN_CLEANUP_INTERVAL); + }); + }); +} + +if (require.main === module) { + initialize(function (error) { + if (error) { + console.error('janitor task exiting with error', error); + process.exit(1); + } + + run(); + }); +} + diff --git a/migrations/20141021192552-db-create.js b/migrations/20141021192552-db-create.js new file mode 100644 index 000000000..d03658861 --- /dev/null +++ b/migrations/20141021192552-db-create.js @@ -0,0 +1,14 @@ +var dbm = require('db-migrate'); +var type = dbm.dataType; +var url = require('url'); + +exports.up = function(db, callback) { + var dbName = url.parse(process.env.DATABASE_URL).path.substr(1); // remove slash + + // by default, mysql collates case insensitively. 'utf8_general_cs' is not available + db.runSql('ALTER DATABASE ' + dbName + ' DEFAULT CHARACTER SET=utf8 DEFAULT COLLATE utf8_bin', callback); +}; + +exports.down = function(db, callback) { + callback(); +}; diff --git a/migrations/20141021192554-db-init.js b/migrations/20141021192554-db-init.js new file mode 100644 index 000000000..1226d9a1b --- /dev/null +++ b/migrations/20141021192554-db-init.js @@ -0,0 +1,19 @@ +var dbm = require('db-migrate'); +var type = dbm.dataType; + +var fs = require('fs'), + async = require('async'), + path = require('path'); + +exports.up = function(db, callback) { + var schema = fs.readFileSync(path.join(__dirname, 'initial-schema.sql')).toString('utf8'); + var statements = schema.split(';'); + async.eachSeries(statements, function (statement, callback) { + if (statement.trim().length === 0) return callback(null); + db.runSql(statement, callback); + }, callback); +}; + +exports.down = function(db, callback) { + db.runSql('DROP TABLE users, tokens, clients, apps, appPortBindings, authcodes, settings', callback); +}; diff --git a/migrations/20150303114527-users-add-resetToken.js b/migrations/20150303114527-users-add-resetToken.js new file mode 100644 index 000000000..49b5e7c3a --- /dev/null +++ b/migrations/20150303114527-users-add-resetToken.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE users ADD COLUMN resetToken VARCHAR(128) DEFAULT ""', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE users DROP COLUMN resetToken', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150303160528-tokens-expires-bigint.js b/migrations/20150303160528-tokens-expires-bigint.js new file mode 100644 index 000000000..d6a40f30d --- /dev/null +++ b/migrations/20150303160528-tokens-expires-bigint.js @@ -0,0 +1,20 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('DELETE FROM tokens', [], function (error) { + if (error) console.error(error); + + db.runSql('ALTER TABLE tokens MODIFY expires BIGINT', [], function (error) { + if (error) console.error(error); + callback(error); + }); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE tokens MODIFY expires VARCHAR(512)', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/20150303171203-authcodes-add-expiresAt.js b/migrations/20150303171203-authcodes-add-expiresAt.js new file mode 100644 index 000000000..acbedb5c0 --- /dev/null +++ b/migrations/20150303171203-authcodes-add-expiresAt.js @@ -0,0 +1,16 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE authcodes ADD COLUMN expiresAt BIGINT NOT NULL', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE authcodes DROP COLUMN expiresAt', function (error) { + if (error) console.error(error); + callback(error); + }); +}; \ No newline at end of file diff --git a/migrations/20150308054950-appportbindings-add-environmentVariable.js b/migrations/20150308054950-appportbindings-add-environmentVariable.js new file mode 100644 index 000000000..d5b1a0b50 --- /dev/null +++ b/migrations/20150308054950-appportbindings-add-environmentVariable.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE appPortBindings ADD COLUMN environmentVariable VARCHAR(128) NOT NULL', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE appPortBindings DROP COLUMN environmentVariable', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150309023251-appportbindings-drop-containerport.js b/migrations/20150309023251-appportbindings-drop-containerport.js new file mode 100644 index 000000000..2d7337fdb --- /dev/null +++ b/migrations/20150309023251-appportbindings-drop-containerport.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE appPortBindings DROP COLUMN containerPort', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE appPortBindings ADD COLUMN containerPort VARCHAR(5) NOT NULL', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150311120713-tokens-rename-userid-to-identifier.js b/migrations/20150311120713-tokens-rename-userid-to-identifier.js new file mode 100644 index 000000000..516859017 --- /dev/null +++ b/migrations/20150311120713-tokens-rename-userid-to-identifier.js @@ -0,0 +1,20 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('DELETE FROM tokens', [], function (error) { + if (error) console.error(error); + + db.runSql('ALTER TABLE tokens CHANGE userId identifier VARCHAR(128) NOT NULL', [], function (error) { + if (error) console.error(error); + callback(error); + }); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE tokens CHANGE identifier userId VARCHAR(128) NOT NULL', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/20150323044254-apps-drop-version.js b/migrations/20150323044254-apps-drop-version.js new file mode 100644 index 000000000..183f68267 --- /dev/null +++ b/migrations/20150323044254-apps-drop-version.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN version', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN version VARCHAR(32)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150323174906-apps-alter-health.js b/migrations/20150323174906-apps-alter-health.js new file mode 100644 index 000000000..4f1cd0473 --- /dev/null +++ b/migrations/20150323174906-apps-alter-health.js @@ -0,0 +1,16 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN healthy, ADD COLUMN health VARCHAR(128)', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN health, ADD COLUMN healthy INTEGER', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/20150326072904-apps-add-lastBackupId.js b/migrations/20150326072904-apps-add-lastBackupId.js new file mode 100644 index 000000000..365741f8b --- /dev/null +++ b/migrations/20150326072904-apps-add-lastBackupId.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN lastBackupId VARCHAR(128)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN lastBackupId', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150404093025-apps-add-createdAt.js b/migrations/20150404093025-apps-add-createdAt.js new file mode 100644 index 000000000..819031545 --- /dev/null +++ b/migrations/20150404093025-apps-add-createdAt.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN createdAt', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150430165335-settings-default-autoupdatepattern.js b/migrations/20150430165335-settings-default-autoupdatepattern.js new file mode 100644 index 000000000..1a6b19e3f --- /dev/null +++ b/migrations/20150430165335-settings-default-autoupdatepattern.js @@ -0,0 +1,12 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + // everyday at 1am + db.runSql('INSERT settings (name, value) VALUES("autoupdate_pattern", ?)', [ '00 00 1 * * *' ], callback); +}; + +exports.down = function(db, callback) { + db.runSql('DELETE * FROM settings WHERE name="autoupdate_pattern"', [ ], callback); +} + diff --git a/migrations/20150430210225-settings-default-timezone.js b/migrations/20150430210225-settings-default-timezone.js new file mode 100644 index 000000000..ec0d5ced8 --- /dev/null +++ b/migrations/20150430210225-settings-default-timezone.js @@ -0,0 +1,15 @@ +dbm = dbm || require('db-migrate'); +var safe = require('safetydance'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + var tz = safe.fs.readFileSync('/etc/timezone', 'utf8'); + tz = tz ? tz.trim() : 'America/Los_Angeles'; + + db.runSql('INSERT settings (name, value) VALUES("time_zone", ?)', [ tz ], callback); +}; + +exports.down = function(db, callback) { + db.runSql('DELETE * FROM settings WHERE name="time_zone"', [ ], callback); +}; + diff --git a/migrations/20150615075901-users-add-unique-constraints.js b/migrations/20150615075901-users-add-unique-constraints.js new file mode 100644 index 000000000..86eb99a67 --- /dev/null +++ b/migrations/20150615075901-users-add-unique-constraints.js @@ -0,0 +1,24 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; +var async = require('async'); + +exports.up = function(db, callback) { + + // http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address + + async.series([ + db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254)'), + db.runSql.bind(db, 'ALTER TABLE users ADD CONSTRAINT users_username UNIQUE (username)'), + db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254)'), + db.runSql.bind(db, 'ALTER TABLE users ADD CONSTRAINT users_email UNIQUE (email)'), + ], callback); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE users DROP INDEX users_username'), + db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(512)'), + db.runSql.bind(db, 'ALTER TABLE users DROP INDEX users_email'), + db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(512)'), + ], callback); +}; diff --git a/migrations/20150615134751-users-adjust-username-and-email-constraints.js b/migrations/20150615134751-users-adjust-username-and-email-constraints.js new file mode 100644 index 000000000..983854efc --- /dev/null +++ b/migrations/20150615134751-users-adjust-username-and-email-constraints.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; +var async = require('async'); + +exports.up = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254) NOT NULL'), + db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254) NOT NULL'), + ], callback); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE users MODIFY username VARCHAR(254)'), + db.runSql.bind(db, 'ALTER TABLE users MODIFY email VARCHAR(254)'), + ], callback); +}; diff --git a/migrations/20150618192028-apps-add-lastManifestJson.js b/migrations/20150618192028-apps-add-lastManifestJson.js new file mode 100644 index 000000000..7d4256228 --- /dev/null +++ b/migrations/20150618192028-apps-add-lastManifestJson.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN lastManifestJson VARCHAR(2048)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN lastManifestJson', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150710044740-apps-rename-lastBackupConfigJson.js b/migrations/20150710044740-apps-rename-lastBackupConfigJson.js new file mode 100644 index 000000000..c286595cf --- /dev/null +++ b/migrations/20150710044740-apps-rename-lastBackupConfigJson.js @@ -0,0 +1,17 @@ +var dbm = global.dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps CHANGE lastManifestJson lastBackupConfigJson VARCHAR(2048)', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps CHANGE lastBackupConfigJson lastManifestJson VARCHAR(2048)', [], function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150710170847-apps-add-oldConfigJson.js b/migrations/20150710170847-apps-add-oldConfigJson.js new file mode 100644 index 000000000..48e308650 --- /dev/null +++ b/migrations/20150710170847-apps-add-oldConfigJson.js @@ -0,0 +1,17 @@ +dbm = dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN oldConfigJson VARCHAR(2048)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN oldConfigJson', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/20150719014338-settings-remove-all.js b/migrations/20150719014338-settings-remove-all.js new file mode 100644 index 000000000..08a2cb25c --- /dev/null +++ b/migrations/20150719014338-settings-remove-all.js @@ -0,0 +1,10 @@ +var dbm = global.dbm || require('db-migrate'); +var type = dbm.dataType; + +exports.up = function(db, callback) { + db.runSql('DELETE FROM settings', [ ], callback); +}; + +exports.down = function(db, callback) { + callback(); +}; diff --git a/migrations/initial-schema.sql b/migrations/initial-schema.sql new file mode 100644 index 000000000..0166db093 --- /dev/null +++ b/migrations/initial-schema.sql @@ -0,0 +1,67 @@ +CREATE TABLE IF NOT EXISTS users( + id VARCHAR(128) NOT NULL UNIQUE, + username VARCHAR(512) NOT NULL, + email VARCHAR(512) NOT NULL, + password VARCHAR(1024) NOT NULL, + salt VARCHAR(512) NOT NULL, + createdAt VARCHAR(512) NOT NULL, + modifiedAt VARCHAR(512) NOT NULL, + admin INTEGER NOT NULL, + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS tokens( + accessToken VARCHAR(128) NOT NULL UNIQUE, + userId VARCHAR(128) NOT NULL, + clientId VARCHAR(128), + scope VARCHAR(512) NOT NULL, + expires VARCHAR(512) NOT NULL, + PRIMARY KEY(accessToken)); + +CREATE TABLE IF NOT EXISTS clients( + id VARCHAR(128) NOT NULL UNIQUE, + appId VARCHAR(128) NOT NULL, + clientSecret VARCHAR(512) NOT NULL, + redirectURI VARCHAR(512) NOT NULL, + scope VARCHAR(512) NOT NULL, + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS apps( + id VARCHAR(128) NOT NULL UNIQUE, + appStoreId VARCHAR(128) NOT NULL, + version VARCHAR(32), + installationState VARCHAR(512) NOT NULL, + installationProgress VARCHAR(512), + runState VARCHAR(512), + healthy INTEGER, + containerId VARCHAR(128), + manifestJson VARCHAR(2048), + httpPort INTEGER, + location VARCHAR(128) NOT NULL UNIQUE, + dnsRecordId VARCHAR(512), + accessRestriction VARCHAR(512), + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS appPortBindings( + hostPort INTEGER NOT NULL UNIQUE, + containerPort VARCHAR(5) NOT NULL, + appId VARCHAR(128) NOT NULL, + FOREIGN KEY(appId) REFERENCES apps(id), + PRIMARY KEY(hostPort)); + +CREATE TABLE IF NOT EXISTS authcodes( + authCode VARCHAR(128) NOT NULL UNIQUE, + userId VARCHAR(128) NOT NULL, + clientId VARCHAR(128) NOT NULL, + PRIMARY KEY(authCode)); + +CREATE TABLE IF NOT EXISTS settings( + name VARCHAR(128) NOT NULL UNIQUE, + value VARCHAR(512), + PRIMARY KEY(name)); + +CREATE TABLE IF NOT EXISTS appAddonConfigs( + appId VARCHAR(128) NOT NULL, + addonId VARCHAR(32) NOT NULL, + value VARCHAR(512) NOT NULL, + FOREIGN KEY(appId) REFERENCES apps(id)); + diff --git a/migrations/schema.sql b/migrations/schema.sql new file mode 100644 index 000000000..72aff0d13 --- /dev/null +++ b/migrations/schema.sql @@ -0,0 +1,82 @@ +#### WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING +#### This file is not used by any code and is here to document the latest schema + +#### General ideas +#### Default char set is utf8 and DEFAULT COLLATE is utf8_bin. Collate affects comparisons in WHERE and ORDER +#### Strict mode is enabled +#### VARCHAR - stored as part of table row (use for strings) +#### TEXT - stored offline from table row (use for strings) +#### BLOB - stored offline from table row (use for binary data) +#### https://dev.mysql.com/doc/refman/5.0/en/storage-requirements.html + +CREATE TABLE IF NOT EXISTS users( + id VARCHAR(128) NOT NULL UNIQUE, + username VARCHAR(254) NOT NULL UNIQUE, + email VARCHAR(254) NOT NULL UNIQUE, + password VARCHAR(1024) NOT NULL, + salt VARCHAR(512) NOT NULL, + createdAt VARCHAR(512) NOT NULL, + modifiedAt VARCHAR(512) NOT NULL, + admin INTEGER NOT NULL, + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS tokens( + accessToken VARCHAR(128) NOT NULL UNIQUE, + identifier VARCHAR(128) NOT NULL, + clientId VARCHAR(128), + scope VARCHAR(512) NOT NULL, + expires BIGINT NOT NULL, + PRIMARY KEY(accessToken)); + +CREATE TABLE IF NOT EXISTS clients( + id VARCHAR(128) NOT NULL UNIQUE, + appId VARCHAR(128) NOT NULL, + clientSecret VARCHAR(512) NOT NULL, + redirectURI VARCHAR(512) NOT NULL, + scope VARCHAR(512) NOT NULL, + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS apps( + id VARCHAR(128) NOT NULL UNIQUE, + appStoreId VARCHAR(128) NOT NULL, + installationState VARCHAR(512) NOT NULL, + installationProgress VARCHAR(512), + runState VARCHAR(512), + health VARCHAR(128), + containerId VARCHAR(128), + manifestJson VARCHAR(2048), + httpPort INTEGER, // this is the nginx proxy port and not manifest.httpPort + location VARCHAR(128) NOT NULL UNIQUE, + dnsRecordId VARCHAR(512), + accessRestriction VARCHAR(512), + createdAt TIMESTAMP(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + lastBackupId VARCHAR(128), + lastBackupConfigJson VARCHAR(2048), // used for appstore and non-appstore installs. it's here so it's easy to do REST validation + PRIMARY KEY(id)); + +CREATE TABLE IF NOT EXISTS appPortBindings( + hostPort INTEGER NOT NULL UNIQUE, + environmentVariable VARCHAR(128) NOT NULL, + appId VARCHAR(128) NOT NULL, + FOREIGN KEY(appId) REFERENCES apps(id), + PRIMARY KEY(hostPort)); + +CREATE TABLE IF NOT EXISTS authcodes( + authCode VARCHAR(128) NOT NULL UNIQUE, + userId VARCHAR(128) NOT NULL, + clientId VARCHAR(128) NOT NULL, + expiresAt BIGINT NOT NULL, + PRIMARY KEY(authCode)); + +CREATE TABLE IF NOT EXISTS settings( + name VARCHAR(128) NOT NULL UNIQUE, + value VARCHAR(512), + PRIMARY KEY(name)); + +CREATE TABLE IF NOT EXISTS appAddonConfigs( + appId VARCHAR(128) NOT NULL, + addonId VARCHAR(32) NOT NULL, + value VARCHAR(512) NOT NULL, + FOREIGN KEY(appId) REFERENCES apps(id)); + diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json new file mode 100644 index 000000000..4b9961c01 --- /dev/null +++ b/npm-shrinkwrap.json @@ -0,0 +1,2477 @@ +{ + "name": "Cloudron", + "version": "0.0.1", + "dependencies": { + "async": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/async/-/async-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.2.1.tgz" + }, + "body-parser": { + "version": "1.13.1", + "from": "https://registry.npmjs.org/body-parser/-/body-parser-1.13.1.tgz", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.13.1.tgz", + "dependencies": { + "bytes": { + "version": "2.1.0", + "from": "https://registry.npmjs.org/bytes/-/bytes-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.1.0.tgz" + }, + "content-type": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/content-type/-/content-type-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.1.tgz" + }, + "depd": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz" + }, + "http-errors": { + "version": "1.3.1", + "from": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "dependencies": { + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "statuses": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz" + } + } + }, + "iconv-lite": { + "version": "0.4.10", + "from": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.10.tgz", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.10.tgz" + }, + "on-finished": { + "version": "2.3.0", + "from": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "dependencies": { + "ee-first": { + "version": "1.1.1", + "from": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + } + } + }, + "qs": { + "version": "2.4.2", + "from": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz" + }, + "raw-body": { + "version": "2.1.1", + "from": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.1.tgz", + "dependencies": { + "unpipe": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" + } + } + }, + "type-is": { + "version": "1.6.3", + "from": "https://registry.npmjs.org/type-is/-/type-is-1.6.3.tgz", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.3.tgz", + "dependencies": { + "media-typer": { + "version": "0.3.0", + "from": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + }, + "mime-types": { + "version": "2.1.1", + "from": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "dependencies": { + "mime-db": { + "version": "1.13.0", + "from": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz" + } + } + } + } + } + } + }, + "cloudron-manifestformat": { + "version": "1.4.0", + "from": "cloudron-manifestformat@1.4.0", + "dependencies": { + "java-packagename-regex": { + "version": "1.0.0", + "from": "java-packagename-regex@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/java-packagename-regex/-/java-packagename-regex-1.0.0.tgz" + }, + "safetydance": { + "version": "0.0.15", + "from": "safetydance@0.0.15", + "resolved": "http://registry.npmjs.org/safetydance/-/safetydance-0.0.15.tgz" + }, + "tv4": { + "version": "1.1.12", + "from": "tv4@>=1.1.9 <2.0.0", + "resolved": "http://registry.npmjs.org/tv4/-/tv4-1.1.12.tgz" + } + } + }, + "connect-ensure-login": { + "version": "0.1.1", + "from": "https://registry.npmjs.org/connect-ensure-login/-/connect-ensure-login-0.1.1.tgz", + "resolved": "https://registry.npmjs.org/connect-ensure-login/-/connect-ensure-login-0.1.1.tgz" + }, + "connect-lastmile": { + "version": "0.0.12", + "from": "https://registry.npmjs.org/connect-lastmile/-/connect-lastmile-0.0.12.tgz", + "resolved": "https://registry.npmjs.org/connect-lastmile/-/connect-lastmile-0.0.12.tgz", + "dependencies": { + "debug": { + "version": "2.1.3", + "from": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz", + "dependencies": { + "ms": { + "version": "0.7.0", + "from": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz", + "resolved": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz" + } + } + } + } + }, + "connect-timeout": { + "version": "1.6.2", + "from": "https://registry.npmjs.org/connect-timeout/-/connect-timeout-1.6.2.tgz", + "resolved": "https://registry.npmjs.org/connect-timeout/-/connect-timeout-1.6.2.tgz", + "dependencies": { + "http-errors": { + "version": "1.3.1", + "from": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "dependencies": { + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "statuses": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz" + } + } + }, + "ms": { + "version": "0.7.1", + "from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + }, + "on-headers": { + "version": "1.0.0", + "from": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz" + } + } + }, + "cookie-parser": { + "version": "1.3.5", + "from": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.3.5.tgz", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.3.5.tgz", + "dependencies": { + "cookie": { + "version": "0.1.3", + "from": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz" + }, + "cookie-signature": { + "version": "1.0.6", + "from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + } + } + }, + "cookie-session": { + "version": "1.1.0", + "from": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.1.0.tgz", + "dependencies": { + "cookies": { + "version": "0.5.0", + "from": "https://registry.npmjs.org/cookies/-/cookies-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.5.0.tgz", + "dependencies": { + "keygrip": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.1.tgz" + } + } + }, + "debug": { + "version": "2.1.3", + "from": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.1.3.tgz", + "dependencies": { + "ms": { + "version": "0.7.0", + "from": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz", + "resolved": "http://registry.npmjs.org/ms/-/ms-0.7.0.tgz" + } + } + }, + "on-headers": { + "version": "1.0.0", + "from": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz" + } + } + }, + "cron": { + "version": "1.0.9", + "from": "http://registry.npmjs.org/cron/-/cron-1.0.9.tgz", + "resolved": "http://registry.npmjs.org/cron/-/cron-1.0.9.tgz", + "dependencies": { + "moment-timezone": { + "version": "0.3.1", + "from": "http://registry.npmjs.org/moment-timezone/-/moment-timezone-0.3.1.tgz", + "resolved": "http://registry.npmjs.org/moment-timezone/-/moment-timezone-0.3.1.tgz", + "dependencies": { + "moment": { + "version": "2.10.3", + "from": "https://registry.npmjs.org/moment/-/moment-2.10.3.tgz", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.10.3.tgz" + } + } + } + } + }, + "csurf": { + "version": "1.8.3", + "from": "https://registry.npmjs.org/csurf/-/csurf-1.8.3.tgz", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.8.3.tgz", + "dependencies": { + "cookie": { + "version": "0.1.3", + "from": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz" + }, + "cookie-signature": { + "version": "1.0.6", + "from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + }, + "csrf": { + "version": "3.0.0", + "from": "https://registry.npmjs.org/csrf/-/csrf-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.0.0.tgz", + "dependencies": { + "base64-url": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz" + }, + "rndm": { + "version": "1.1.0", + "from": "https://registry.npmjs.org/rndm/-/rndm-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.1.0.tgz" + }, + "scmp": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/scmp/-/scmp-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-1.0.0.tgz" + }, + "uid-safe": { + "version": "2.0.0", + "from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz" + } + } + }, + "http-errors": { + "version": "1.3.1", + "from": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz", + "dependencies": { + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "statuses": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.2.1.tgz" + } + } + } + } + }, + "db-migrate": { + "version": "0.9.16", + "from": "https://registry.npmjs.org/db-migrate/-/db-migrate-0.9.16.tgz", + "resolved": "https://registry.npmjs.org/db-migrate/-/db-migrate-0.9.16.tgz", + "dependencies": { + "async": { + "version": "0.9.2", + "from": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz" + }, + "dotenv": { + "version": "0.5.1", + "from": "http://registry.npmjs.org/dotenv/-/dotenv-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/dotenv/-/dotenv-0.5.1.tgz" + }, + "final-fs": { + "version": "1.6.1", + "from": "https://registry.npmjs.org/final-fs/-/final-fs-1.6.1.tgz", + "resolved": "https://registry.npmjs.org/final-fs/-/final-fs-1.6.1.tgz", + "dependencies": { + "node-fs": { + "version": "0.1.7", + "from": "http://registry.npmjs.org/node-fs/-/node-fs-0.1.7.tgz", + "resolved": "http://registry.npmjs.org/node-fs/-/node-fs-0.1.7.tgz" + }, + "when": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/when/-/when-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/when/-/when-2.0.1.tgz" + } + } + }, + "mkdirp": { + "version": "0.5.1", + "from": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "dependencies": { + "minimist": { + "version": "0.0.8", + "from": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + } + } + }, + "moment": { + "version": "2.9.0", + "from": "http://registry.npmjs.org/moment/-/moment-2.9.0.tgz", + "resolved": "http://registry.npmjs.org/moment/-/moment-2.9.0.tgz" + }, + "mongodb": { + "version": "1.4.38", + "from": "https://registry.npmjs.org/mongodb/-/mongodb-1.4.38.tgz", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-1.4.38.tgz", + "dependencies": { + "bson": { + "version": "0.2.21", + "from": "https://registry.npmjs.org/bson/-/bson-0.2.21.tgz", + "resolved": "https://registry.npmjs.org/bson/-/bson-0.2.21.tgz", + "dependencies": { + "nan": { + "version": "1.7.0", + "from": "https://registry.npmjs.org/nan/-/nan-1.7.0.tgz", + "resolved": "https://registry.npmjs.org/nan/-/nan-1.7.0.tgz" + } + } + }, + "kerberos": { + "version": "0.0.11", + "from": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.11.tgz", + "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-0.0.11.tgz", + "dependencies": { + "nan": { + "version": "1.8.4", + "from": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz", + "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz" + } + } + }, + "readable-stream": { + "version": "2.0.0", + "from": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.0.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "process-nextick-args": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.1.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "util-deprecate": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.1.tgz" + } + } + } + } + }, + "mysql": { + "version": "2.5.5", + "from": "https://registry.npmjs.org/mysql/-/mysql-2.5.5.tgz", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.5.5.tgz", + "dependencies": { + "bignumber.js": { + "version": "2.0.0", + "from": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.0.0.tgz" + }, + "readable-stream": { + "version": "1.1.13", + "from": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "require-all": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/require-all/-/require-all-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/require-all/-/require-all-1.0.0.tgz" + } + } + }, + "optimist": { + "version": "0.6.1", + "from": "http://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "resolved": "http://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" + }, + "minimist": { + "version": "0.0.10", + "from": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" + } + } + }, + "parse-database-url": { + "version": "0.2.2", + "from": "http://registry.npmjs.org/parse-database-url/-/parse-database-url-0.2.2.tgz", + "resolved": "http://registry.npmjs.org/parse-database-url/-/parse-database-url-0.2.2.tgz" + }, + "pg": { + "version": "4.2.0", + "from": "http://registry.npmjs.org/pg/-/pg-4.2.0.tgz", + "resolved": "http://registry.npmjs.org/pg/-/pg-4.2.0.tgz", + "dependencies": { + "buffer-writer": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.0.tgz" + }, + "generic-pool": { + "version": "2.1.1", + "from": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.1.1.tgz" + }, + "packet-reader": { + "version": "0.2.0", + "from": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.2.0.tgz", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.2.0.tgz" + }, + "pg-connection-string": { + "version": "0.1.3", + "from": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz" + }, + "pg-types": { + "version": "1.6.0", + "from": "https://registry.npmjs.org/pg-types/-/pg-types-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.6.0.tgz" + }, + "pgpass": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/pgpass/-/pgpass-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-0.0.3.tgz", + "dependencies": { + "split": { + "version": "0.3.3", + "from": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "dependencies": { + "through": { + "version": "2.3.7", + "from": "http://registry.npmjs.org/through/-/through-2.3.7.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.7.tgz" + } + } + } + } + } + } + }, + "pkginfo": { + "version": "0.3.0", + "from": "http://registry.npmjs.org/pkginfo/-/pkginfo-0.3.0.tgz", + "resolved": "http://registry.npmjs.org/pkginfo/-/pkginfo-0.3.0.tgz" + }, + "sqlite3": { + "version": "3.0.8", + "from": "https://registry.npmjs.org/sqlite3/-/sqlite3-3.0.8.tgz", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-3.0.8.tgz", + "dependencies": { + "nan": { + "version": "1.8.4", + "from": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz", + "resolved": "https://registry.npmjs.org/nan/-/nan-1.8.4.tgz" + }, + "node-pre-gyp": { + "version": "0.6.7", + "from": "node-pre-gyp@~0.6.7", + "dependencies": { + "nopt": { + "version": "3.0.1", + "from": "nopt@>=3.0.1 <3.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.1.tgz", + "dependencies": { + "abbrev": { + "version": "1.0.5", + "from": "abbrev@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.5.tgz" + } + } + }, + "npmlog": { + "version": "1.2.0", + "from": "npmlog@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-1.2.0.tgz", + "dependencies": { + "ansi": { + "version": "0.3.0", + "from": "ansi@>=0.3.0 <0.4.0", + "resolved": "https://registry.npmjs.org/ansi/-/ansi-0.3.0.tgz" + }, + "are-we-there-yet": { + "version": "1.0.4", + "from": "are-we-there-yet@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.0.4.tgz", + "dependencies": { + "delegates": { + "version": "0.1.0", + "from": "delegates@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-0.1.0.tgz" + }, + "readable-stream": { + "version": "1.1.13", + "from": "readable-stream@>=1.1.13 <2.0.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "core-util-is@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.1 <2.1.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + } + } + }, + "gauge": { + "version": "1.2.0", + "from": "gauge@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-1.2.0.tgz", + "dependencies": { + "has-unicode": { + "version": "1.0.0", + "from": "has-unicode@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-1.0.0.tgz" + }, + "lodash.pad": { + "version": "3.1.0", + "from": "lodash.pad@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.pad/-/lodash.pad-3.1.0.tgz", + "dependencies": { + "lodash._basetostring": { + "version": "3.0.0", + "from": "lodash._basetostring@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.0.tgz" + }, + "lodash._createpadding": { + "version": "3.6.0", + "from": "lodash._createpadding@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._createpadding/-/lodash._createpadding-3.6.0.tgz", + "dependencies": { + "lodash.repeat": { + "version": "3.0.0", + "from": "lodash.repeat@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-3.0.0.tgz" + } + } + } + } + }, + "lodash.padleft": { + "version": "3.1.1", + "from": "lodash.padleft@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.padleft/-/lodash.padleft-3.1.1.tgz", + "dependencies": { + "lodash._basetostring": { + "version": "3.0.0", + "from": "lodash._basetostring@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.0.tgz" + }, + "lodash._createpadding": { + "version": "3.6.0", + "from": "lodash._createpadding@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._createpadding/-/lodash._createpadding-3.6.0.tgz", + "dependencies": { + "lodash.repeat": { + "version": "3.0.0", + "from": "lodash.repeat@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-3.0.0.tgz" + } + } + } + } + }, + "lodash.padright": { + "version": "3.1.1", + "from": "lodash.padright@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.padright/-/lodash.padright-3.1.1.tgz", + "dependencies": { + "lodash._basetostring": { + "version": "3.0.0", + "from": "lodash._basetostring@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.0.tgz" + }, + "lodash._createpadding": { + "version": "3.6.0", + "from": "lodash._createpadding@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash._createpadding/-/lodash._createpadding-3.6.0.tgz", + "dependencies": { + "lodash.repeat": { + "version": "3.0.0", + "from": "lodash.repeat@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash.repeat/-/lodash.repeat-3.0.0.tgz" + } + } + } + } + } + } + } + } + }, + "request": { + "version": "2.55.0", + "from": "request@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.55.0.tgz", + "dependencies": { + "bl": { + "version": "0.9.4", + "from": "bl@>=0.9.0 <0.10.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-0.9.4.tgz", + "dependencies": { + "readable-stream": { + "version": "1.0.33", + "from": "readable-stream@>=1.0.26 <1.1.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "core-util-is@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.1 <2.1.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + } + } + }, + "caseless": { + "version": "0.9.0", + "from": "caseless@>=0.9.0 <0.10.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.9.0.tgz" + }, + "forever-agent": { + "version": "0.6.1", + "from": "forever-agent@>=0.6.0 <0.7.0", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" + }, + "form-data": { + "version": "0.2.0", + "from": "form-data@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-0.2.0.tgz", + "dependencies": { + "async": { + "version": "0.9.0", + "from": "async@>=0.9.0 <0.10.0", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.0.tgz" + } + } + }, + "json-stringify-safe": { + "version": "5.0.0", + "from": "json-stringify-safe@>=5.0.0 <5.1.0", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.0.tgz" + }, + "mime-types": { + "version": "2.0.10", + "from": "mime-types@>=2.0.1 <2.1.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.0.10.tgz", + "dependencies": { + "mime-db": { + "version": "1.8.0", + "from": "mime-db@>=1.8.0 <1.9.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.8.0.tgz" + } + } + }, + "node-uuid": { + "version": "1.4.3", + "from": "node-uuid@>=1.4.0 <1.5.0", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.3.tgz" + }, + "qs": { + "version": "2.4.1", + "from": "qs@>=2.4.0 <2.5.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.1.tgz" + }, + "tunnel-agent": { + "version": "0.4.0", + "from": "tunnel-agent@>=0.4.0 <0.5.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.0.tgz" + }, + "tough-cookie": { + "version": "1.1.0", + "from": "tough-cookie@>=0.12.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-1.1.0.tgz" + }, + "http-signature": { + "version": "0.10.1", + "from": "http-signature@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.10.1.tgz", + "dependencies": { + "assert-plus": { + "version": "0.1.5", + "from": "assert-plus@>=0.1.5 <0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" + }, + "asn1": { + "version": "0.1.11", + "from": "asn1@0.1.11", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.1.11.tgz" + }, + "ctype": { + "version": "0.5.3", + "from": "ctype@0.5.3", + "resolved": "https://registry.npmjs.org/ctype/-/ctype-0.5.3.tgz" + } + } + }, + "oauth-sign": { + "version": "0.6.0", + "from": "oauth-sign@>=0.6.0 <0.7.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.6.0.tgz" + }, + "hawk": { + "version": "2.3.1", + "from": "hawk@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-2.3.1.tgz", + "dependencies": { + "hoek": { + "version": "2.13.0", + "from": "hoek@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.13.0.tgz" + }, + "boom": { + "version": "2.7.1", + "from": "boom@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.7.1.tgz" + }, + "cryptiles": { + "version": "2.0.4", + "from": "cryptiles@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.4.tgz" + }, + "sntp": { + "version": "1.0.9", + "from": "sntp@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz" + } + } + }, + "aws-sign2": { + "version": "0.5.0", + "from": "aws-sign2@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.5.0.tgz" + }, + "stringstream": { + "version": "0.0.4", + "from": "stringstream@>=0.0.4 <0.1.0", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.4.tgz" + }, + "combined-stream": { + "version": "0.0.7", + "from": "combined-stream@>=0.0.5 <0.1.0", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-0.0.7.tgz", + "dependencies": { + "delayed-stream": { + "version": "0.0.5", + "from": "delayed-stream@0.0.5", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.5.tgz" + } + } + }, + "isstream": { + "version": "0.1.2", + "from": "isstream@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" + }, + "har-validator": { + "version": "1.7.0", + "from": "har-validator@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-1.7.0.tgz", + "dependencies": { + "bluebird": { + "version": "2.9.25", + "from": "bluebird@>=2.9.25 <3.0.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.9.25.tgz" + }, + "chalk": { + "version": "1.0.0", + "from": "chalk@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.0.0.tgz", + "dependencies": { + "ansi-styles": { + "version": "2.0.1", + "from": "ansi-styles@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.0.1.tgz" + }, + "escape-string-regexp": { + "version": "1.0.3", + "from": "escape-string-regexp@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.3.tgz" + }, + "has-ansi": { + "version": "1.0.3", + "from": "has-ansi@>=1.0.3 <2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-1.0.3.tgz", + "dependencies": { + "ansi-regex": { + "version": "1.1.1", + "from": "ansi-regex@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz" + }, + "get-stdin": { + "version": "4.0.1", + "from": "get-stdin@>=4.0.1 <5.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz" + } + } + }, + "strip-ansi": { + "version": "2.0.1", + "from": "strip-ansi@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-2.0.1.tgz", + "dependencies": { + "ansi-regex": { + "version": "1.1.1", + "from": "ansi-regex@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-1.1.1.tgz" + } + } + }, + "supports-color": { + "version": "1.3.1", + "from": "supports-color@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz" + } + } + }, + "commander": { + "version": "2.8.1", + "from": "commander@>=2.8.1 <3.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz", + "dependencies": { + "graceful-readlink": { + "version": "1.0.1", + "from": "graceful-readlink@>=1.0.0", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" + } + } + }, + "is-my-json-valid": { + "version": "2.10.1", + "from": "is-my-json-valid@>=2.10.1 <3.0.0", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.10.1.tgz", + "dependencies": { + "generate-function": { + "version": "2.0.0", + "from": "generate-function@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" + }, + "generate-object-property": { + "version": "1.1.1", + "from": "generate-object-property@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.1.1.tgz", + "dependencies": { + "is-property": { + "version": "1.0.2", + "from": "is-property@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" + } + } + }, + "jsonpointer": { + "version": "1.1.0", + "from": "jsonpointer@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-1.1.0.tgz" + }, + "xtend": { + "version": "4.0.0", + "from": "xtend@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.0.tgz" + } + } + } + } + } + } + }, + "semver": { + "version": "4.3.3", + "from": "semver@>=4.3.2 <4.4.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.3.tgz" + }, + "tar": { + "version": "2.1.0", + "from": "tar@>=2.1.0 <2.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.1.0.tgz", + "dependencies": { + "block-stream": { + "version": "0.0.7", + "from": "block-stream@*", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.7.tgz" + }, + "fstream": { + "version": "1.0.4", + "from": "fstream@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.4.tgz", + "dependencies": { + "graceful-fs": { + "version": "3.0.6", + "from": "graceful-fs@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.6.tgz" + } + } + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "tar-pack": { + "version": "2.0.0", + "from": "tar-pack@>=2.0.0 <2.1.0", + "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-2.0.0.tgz", + "dependencies": { + "uid-number": { + "version": "0.0.3", + "from": "uid-number@0.0.3", + "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.3.tgz" + }, + "once": { + "version": "1.1.1", + "from": "once@>=1.1.1 <1.2.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.1.1.tgz" + }, + "debug": { + "version": "0.7.4", + "from": "debug@>=0.7.2 <0.8.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" + }, + "rimraf": { + "version": "2.2.8", + "from": "rimraf@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz" + }, + "fstream": { + "version": "0.1.31", + "from": "fstream@>=0.1.22 <0.2.0", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-0.1.31.tgz", + "dependencies": { + "graceful-fs": { + "version": "3.0.6", + "from": "graceful-fs@>=3.0.2 <3.1.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.6.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.0 <2.1.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "tar": { + "version": "0.1.20", + "from": "tar@>=0.1.17 <0.2.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-0.1.20.tgz", + "dependencies": { + "block-stream": { + "version": "0.0.7", + "from": "block-stream@*", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.7.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "fstream-ignore": { + "version": "0.0.7", + "from": "fstream-ignore@0.0.7", + "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-0.0.7.tgz", + "dependencies": { + "minimatch": { + "version": "0.2.14", + "from": "minimatch@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz", + "dependencies": { + "lru-cache": { + "version": "2.6.2", + "from": "lru-cache@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.2.tgz" + }, + "sigmund": { + "version": "1.0.0", + "from": "sigmund@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.0.tgz" + } + } + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "readable-stream": { + "version": "1.0.33", + "from": "readable-stream@>=1.0.2 <1.1.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "core-util-is@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.1 <2.1.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "graceful-fs": { + "version": "1.2.3", + "from": "graceful-fs@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" + } + } + }, + "mkdirp": { + "version": "0.5.0", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz", + "dependencies": { + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + } + } + }, + "rc": { + "version": "1.0.1", + "from": "rc@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.0.1.tgz", + "dependencies": { + "minimist": { + "version": "0.0.10", + "from": "minimist@>=0.0.7 <0.1.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" + }, + "deep-extend": { + "version": "0.2.11", + "from": "deep-extend@>=0.2.5 <0.3.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.2.11.tgz" + }, + "strip-json-comments": { + "version": "0.1.3", + "from": "strip-json-comments@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz" + }, + "ini": { + "version": "1.3.3", + "from": "ini@>=1.3.0 <1.4.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.3.tgz" + } + } + }, + "rimraf": { + "version": "2.3.3", + "from": "rimraf@>=2.3.2 <2.4.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.3.3.tgz", + "dependencies": { + "glob": { + "version": "4.5.3", + "from": "glob@>=4.4.2 <5.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz", + "dependencies": { + "inflight": { + "version": "1.0.4", + "from": "inflight@>=1.0.4 <2.0.0", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.4.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.1", + "from": "wrappy@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.1.tgz" + } + } + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "minimatch": { + "version": "2.0.7", + "from": "minimatch@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.7.tgz", + "dependencies": { + "brace-expansion": { + "version": "1.1.0", + "from": "brace-expansion@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.0.tgz", + "dependencies": { + "balanced-match": { + "version": "0.2.0", + "from": "balanced-match@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.2.0.tgz" + }, + "concat-map": { + "version": "0.0.1", + "from": "concat-map@0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + } + } + } + } + }, + "once": { + "version": "1.3.1", + "from": "once@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.1.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.1", + "from": "wrappy@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.1.tgz" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "debug": { + "version": "2.2.0", + "from": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "dependencies": { + "ms": { + "version": "0.7.1", + "from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "dockerode": { + "version": "2.2.2", + "from": "dockerode@2.2.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-2.2.2.tgz", + "dependencies": { + "docker-modem": { + "version": "0.2.6", + "from": "docker-modem@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-0.2.6.tgz", + "dependencies": { + "debug": { + "version": "0.7.4", + "from": "debug@>=0.7.4 <0.8.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" + }, + "follow-redirects": { + "version": "0.0.3", + "from": "follow-redirects@0.0.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.3.tgz" + }, + "JSONStream": { + "version": "0.10.0", + "from": "JSONStream@0.10.0", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-0.10.0.tgz", + "dependencies": { + "jsonparse": { + "version": "0.0.5", + "from": "jsonparse@0.0.5", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-0.0.5.tgz" + }, + "through": { + "version": "2.3.8", + "from": "through@>=2.2.7 <3.0.0", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz" + } + } + }, + "querystring": { + "version": "0.2.0", + "from": "querystring@0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz" + }, + "readable-stream": { + "version": "1.0.33", + "from": "readable-stream@>=1.0.26-4 <1.1.0", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "core-util-is@>=1.0.0 <1.1.0", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@>=2.0.1 <2.1.0", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + } + } + } + } + }, + "ejs": { + "version": "2.3.1", + "from": "https://registry.npmjs.org/ejs/-/ejs-2.3.1.tgz", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-2.3.1.tgz" + }, + "ejs-cli": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/ejs-cli/-/ejs-cli-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/ejs-cli/-/ejs-cli-1.0.1.tgz", + "dependencies": { + "optimist": { + "version": "0.5.2", + "from": "https://registry.npmjs.org/optimist/-/optimist-0.5.2.tgz", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.5.2.tgz", + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" + } + } + }, + "colors": { + "version": "0.6.2", + "from": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz" + }, + "ejs": { + "version": "0.8.8", + "from": "https://registry.npmjs.org/ejs/-/ejs-0.8.8.tgz", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-0.8.8.tgz" + }, + "async": { + "version": "0.2.10", + "from": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" + }, + "glob": { + "version": "3.2.11", + "from": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", + "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.11.tgz", + "dependencies": { + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "minimatch": { + "version": "0.3.0", + "from": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.3.0.tgz", + "dependencies": { + "lru-cache": { + "version": "2.6.4", + "from": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.4.tgz", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.4.tgz" + }, + "sigmund": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" + } + } + } + } + } + } + }, + "express": { + "version": "4.12.4", + "from": "https://registry.npmjs.org/express/-/express-4.12.4.tgz", + "resolved": "https://registry.npmjs.org/express/-/express-4.12.4.tgz", + "dependencies": { + "accepts": { + "version": "1.2.9", + "from": "https://registry.npmjs.org/accepts/-/accepts-1.2.9.tgz", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.2.9.tgz", + "dependencies": { + "mime-types": { + "version": "2.1.1", + "from": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "dependencies": { + "mime-db": { + "version": "1.13.0", + "from": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz" + } + } + }, + "negotiator": { + "version": "0.5.3", + "from": "https://registry.npmjs.org/negotiator/-/negotiator-0.5.3.tgz", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.5.3.tgz" + } + } + }, + "content-disposition": { + "version": "0.5.0", + "from": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.0.tgz" + }, + "content-type": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/content-type/-/content-type-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.1.tgz" + }, + "cookie": { + "version": "0.1.2", + "from": "https://registry.npmjs.org/cookie/-/cookie-0.1.2.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.2.tgz" + }, + "cookie-signature": { + "version": "1.0.6", + "from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + }, + "depd": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz" + }, + "escape-html": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/escape-html/-/escape-html-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/escape-html/-/escape-html-1.0.1.tgz" + }, + "etag": { + "version": "1.6.0", + "from": "https://registry.npmjs.org/etag/-/etag-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.6.0.tgz", + "dependencies": { + "crc": { + "version": "3.2.1", + "from": "http://registry.npmjs.org/crc/-/crc-3.2.1.tgz", + "resolved": "http://registry.npmjs.org/crc/-/crc-3.2.1.tgz" + } + } + }, + "finalhandler": { + "version": "0.3.6", + "from": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.3.6.tgz", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.3.6.tgz" + }, + "fresh": { + "version": "0.2.4", + "from": "http://registry.npmjs.org/fresh/-/fresh-0.2.4.tgz", + "resolved": "http://registry.npmjs.org/fresh/-/fresh-0.2.4.tgz" + }, + "merge-descriptors": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.0.tgz" + }, + "methods": { + "version": "1.1.1", + "from": "https://registry.npmjs.org/methods/-/methods-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.1.tgz" + }, + "on-finished": { + "version": "2.2.1", + "from": "http://registry.npmjs.org/on-finished/-/on-finished-2.2.1.tgz", + "resolved": "http://registry.npmjs.org/on-finished/-/on-finished-2.2.1.tgz", + "dependencies": { + "ee-first": { + "version": "1.1.0", + "from": "http://registry.npmjs.org/ee-first/-/ee-first-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/ee-first/-/ee-first-1.1.0.tgz" + } + } + }, + "parseurl": { + "version": "1.3.0", + "from": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz", + "resolved": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz" + }, + "path-to-regexp": { + "version": "0.1.3", + "from": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.3.tgz" + }, + "proxy-addr": { + "version": "1.0.8", + "from": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.8.tgz", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.0.8.tgz", + "dependencies": { + "forwarded": { + "version": "0.1.0", + "from": "http://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz", + "resolved": "http://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz" + }, + "ipaddr.js": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.0.1.tgz" + } + } + }, + "qs": { + "version": "2.4.2", + "from": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-2.4.2.tgz" + }, + "range-parser": { + "version": "1.0.2", + "from": "http://registry.npmjs.org/range-parser/-/range-parser-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/range-parser/-/range-parser-1.0.2.tgz" + }, + "send": { + "version": "0.12.3", + "from": "https://registry.npmjs.org/send/-/send-0.12.3.tgz", + "resolved": "https://registry.npmjs.org/send/-/send-0.12.3.tgz", + "dependencies": { + "destroy": { + "version": "1.0.3", + "from": "https://registry.npmjs.org/destroy/-/destroy-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.3.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "serve-static": { + "version": "1.9.3", + "from": "https://registry.npmjs.org/serve-static/-/serve-static-1.9.3.tgz", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.9.3.tgz" + }, + "type-is": { + "version": "1.6.3", + "from": "https://registry.npmjs.org/type-is/-/type-is-1.6.3.tgz", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.3.tgz", + "dependencies": { + "media-typer": { + "version": "0.3.0", + "from": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + }, + "mime-types": { + "version": "2.1.1", + "from": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.1.tgz", + "dependencies": { + "mime-db": { + "version": "1.13.0", + "from": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.13.0.tgz" + } + } + } + } + }, + "vary": { + "version": "1.0.0", + "from": "http://registry.npmjs.org/vary/-/vary-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/vary/-/vary-1.0.0.tgz" + }, + "utils-merge": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" + } + } + }, + "express-session": { + "version": "1.11.3", + "from": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz", + "dependencies": { + "cookie": { + "version": "0.1.3", + "from": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz" + }, + "cookie-signature": { + "version": "1.0.6", + "from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + }, + "crc": { + "version": "3.3.0", + "from": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz" + }, + "depd": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz" + }, + "on-headers": { + "version": "1.0.0", + "from": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz" + }, + "parseurl": { + "version": "1.3.0", + "from": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz", + "resolved": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz" + }, + "uid-safe": { + "version": "2.0.0", + "from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz", + "dependencies": { + "base64-url": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz" + } + } + }, + "utils-merge": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" + } + } + }, + "hat": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz" + }, + "json": { + "version": "9.0.3", + "from": "https://registry.npmjs.org/json/-/json-9.0.3.tgz", + "resolved": "https://registry.npmjs.org/json/-/json-9.0.3.tgz" + }, + "ldapjs": { + "version": "0.7.1", + "from": "ldapjs@*", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-0.7.1.tgz", + "dependencies": { + "asn1": { + "version": "0.2.1", + "from": "asn1@0.2.1", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.1.tgz" + }, + "assert-plus": { + "version": "0.1.5", + "from": "assert-plus@0.1.5", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" + }, + "bunyan": { + "version": "0.22.1", + "from": "bunyan@0.22.1", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-0.22.1.tgz", + "dependencies": { + "mv": { + "version": "0.0.5", + "from": "mv@0.0.5", + "resolved": "https://registry.npmjs.org/mv/-/mv-0.0.5.tgz" + } + } + }, + "nopt": { + "version": "2.1.1", + "from": "nopt@2.1.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-2.1.1.tgz", + "dependencies": { + "abbrev": { + "version": "1.0.7", + "from": "abbrev@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.7.tgz" + } + } + }, + "pooling": { + "version": "0.4.6", + "from": "pooling@0.4.6", + "resolved": "https://registry.npmjs.org/pooling/-/pooling-0.4.6.tgz", + "dependencies": { + "once": { + "version": "1.3.0", + "from": "once@1.3.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.0.tgz" + }, + "vasync": { + "version": "1.4.0", + "from": "vasync@1.4.0", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.4.0.tgz", + "dependencies": { + "jsprim": { + "version": "0.3.0", + "from": "jsprim@0.3.0", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-0.3.0.tgz", + "dependencies": { + "extsprintf": { + "version": "1.0.0", + "from": "extsprintf@1.0.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.0.tgz" + }, + "json-schema": { + "version": "0.2.2", + "from": "json-schema@0.2.2", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.2.tgz" + }, + "verror": { + "version": "1.3.3", + "from": "verror@1.3.3", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.3.tgz" + } + } + }, + "verror": { + "version": "1.1.0", + "from": "verror@1.1.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.1.0.tgz", + "dependencies": { + "extsprintf": { + "version": "1.0.0", + "from": "extsprintf@1.0.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.0.tgz" + } + } + } + } + } + } + }, + "dtrace-provider": { + "version": "0.2.8", + "from": "dtrace-provider@0.2.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.2.8.tgz" + } + } + }, + "memorystream": { + "version": "0.3.1", + "from": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz" + }, + "mime": { + "version": "1.3.4", + "from": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" + }, + "morgan": { + "version": "1.6.0", + "from": "https://registry.npmjs.org/morgan/-/morgan-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.6.0.tgz", + "dependencies": { + "basic-auth": { + "version": "1.0.2", + "from": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.0.2.tgz" + }, + "depd": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/depd/-/depd-1.0.1.tgz" + }, + "on-finished": { + "version": "2.3.0", + "from": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "dependencies": { + "ee-first": { + "version": "1.1.1", + "from": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + } + } + }, + "on-headers": { + "version": "1.0.0", + "from": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/on-headers/-/on-headers-1.0.0.tgz" + } + } + }, + "multiparty": { + "version": "4.1.2", + "from": "https://registry.npmjs.org/multiparty/-/multiparty-4.1.2.tgz", + "resolved": "https://registry.npmjs.org/multiparty/-/multiparty-4.1.2.tgz", + "dependencies": { + "fd-slicer": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "dependencies": { + "pend": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" + } + } + } + } + }, + "mysql": { + "version": "2.7.0", + "from": "https://registry.npmjs.org/mysql/-/mysql-2.7.0.tgz", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.7.0.tgz", + "dependencies": { + "bignumber.js": { + "version": "2.0.7", + "from": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.0.7.tgz", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.0.7.tgz" + }, + "readable-stream": { + "version": "1.1.13", + "from": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "require-all": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/require-all/-/require-all-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/require-all/-/require-all-1.0.0.tgz" + } + } + }, + "native-dns": { + "version": "0.7.0", + "from": "https://registry.npmjs.org/native-dns/-/native-dns-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/native-dns/-/native-dns-0.7.0.tgz", + "dependencies": { + "ipaddr.js": { + "version": "0.1.9", + "from": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-0.1.9.tgz", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-0.1.9.tgz" + }, + "native-dns-cache": { + "version": "0.0.2", + "from": "http://registry.npmjs.org/native-dns-cache/-/native-dns-cache-0.0.2.tgz", + "resolved": "http://registry.npmjs.org/native-dns-cache/-/native-dns-cache-0.0.2.tgz", + "dependencies": { + "binaryheap": { + "version": "0.0.3", + "from": "http://registry.npmjs.org/binaryheap/-/binaryheap-0.0.3.tgz", + "resolved": "http://registry.npmjs.org/binaryheap/-/binaryheap-0.0.3.tgz" + } + } + }, + "native-dns-packet": { + "version": "0.1.1", + "from": "http://registry.npmjs.org/native-dns-packet/-/native-dns-packet-0.1.1.tgz", + "resolved": "http://registry.npmjs.org/native-dns-packet/-/native-dns-packet-0.1.1.tgz", + "dependencies": { + "buffercursor": { + "version": "0.0.12", + "from": "http://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", + "resolved": "http://registry.npmjs.org/buffercursor/-/buffercursor-0.0.12.tgz", + "dependencies": { + "verror": { + "version": "1.6.0", + "from": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", + "dependencies": { + "extsprintf": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz" + } + } + } + } + } + } + } + } + }, + "node-uuid": { + "version": "1.4.3", + "from": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.3.tgz", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.3.tgz" + }, + "nodejs-disks": { + "version": "0.2.1", + "from": "https://registry.npmjs.org/nodejs-disks/-/nodejs-disks-0.2.1.tgz", + "resolved": "https://registry.npmjs.org/nodejs-disks/-/nodejs-disks-0.2.1.tgz", + "dependencies": { + "async": { + "version": "0.2.10", + "from": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" + }, + "numeral": { + "version": "1.4.8", + "from": "https://registry.npmjs.org/numeral/-/numeral-1.4.8.tgz", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-1.4.8.tgz" + } + } + }, + "nodemailer": { + "version": "1.3.4", + "from": "https://registry.npmjs.org/nodemailer/-/nodemailer-1.3.4.tgz", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-1.3.4.tgz", + "dependencies": { + "buildmail": { + "version": "1.2.4", + "from": "https://registry.npmjs.org/buildmail/-/buildmail-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/buildmail/-/buildmail-1.2.4.tgz", + "dependencies": { + "addressparser": { + "version": "0.3.2", + "from": "https://registry.npmjs.org/addressparser/-/addressparser-0.3.2.tgz", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.3.2.tgz" + }, + "libbase64": { + "version": "0.1.0", + "from": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz" + }, + "libqp": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/libqp/-/libqp-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.0.0.tgz" + } + } + }, + "hyperquest": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/hyperquest/-/hyperquest-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/hyperquest/-/hyperquest-1.2.0.tgz", + "dependencies": { + "duplexer2": { + "version": "0.0.2", + "from": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", + "dependencies": { + "readable-stream": { + "version": "1.1.13", + "from": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.13.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + } + } + }, + "through2": { + "version": "0.6.5", + "from": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "dependencies": { + "readable-stream": { + "version": "1.0.33", + "from": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.33.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + }, + "xtend": { + "version": "4.0.0", + "from": "https://registry.npmjs.org/xtend/-/xtend-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.0.tgz" + } + } + } + } + }, + "libmime": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/libmime/-/libmime-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-1.0.0.tgz", + "dependencies": { + "iconv-lite": { + "version": "0.4.10", + "from": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.10.tgz", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.10.tgz" + }, + "libbase64": { + "version": "0.1.0", + "from": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz" + }, + "libqp": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/libqp/-/libqp-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-1.0.0.tgz" + } + } + }, + "nodemailer-direct-transport": { + "version": "1.0.2", + "from": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/nodemailer-direct-transport/-/nodemailer-direct-transport-1.0.2.tgz", + "dependencies": { + "smtp-connection": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-1.2.0.tgz" + } + } + } + } + }, + "nodemailer-smtp-transport": { + "version": "1.0.3", + "from": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/nodemailer-smtp-transport/-/nodemailer-smtp-transport-1.0.3.tgz", + "dependencies": { + "clone": { + "version": "1.0.2", + "from": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz" + }, + "nodemailer-wellknown": { + "version": "0.1.5", + "from": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.5.tgz", + "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.5.tgz" + }, + "smtp-connection": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/smtp-connection/-/smtp-connection-1.2.0.tgz" + } + } + }, + "oauth2orize": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/oauth2orize/-/oauth2orize-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/oauth2orize/-/oauth2orize-1.0.1.tgz", + "dependencies": { + "uid2": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz" + }, + "utils-merge": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" + }, + "debug": { + "version": "0.7.4", + "from": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" + } + } + }, + "once": { + "version": "1.3.2", + "from": "https://registry.npmjs.org/once/-/once-1.3.2.tgz", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.2.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/wrappy/-/wrappy-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/wrappy/-/wrappy-1.0.1.tgz" + } + } + }, + "passport": { + "version": "0.2.2", + "from": "https://registry.npmjs.org/passport/-/passport-0.2.2.tgz", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.2.2.tgz", + "dependencies": { + "passport-strategy": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" + }, + "pause": { + "version": "0.0.1", + "from": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz" + } + } + }, + "passport-http": { + "version": "0.2.2", + "from": "https://registry.npmjs.org/passport-http/-/passport-http-0.2.2.tgz", + "resolved": "https://registry.npmjs.org/passport-http/-/passport-http-0.2.2.tgz", + "dependencies": { + "pkginfo": { + "version": "0.2.3", + "from": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.2.3.tgz", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.2.3.tgz" + }, + "passport": { + "version": "0.1.18", + "from": "https://registry.npmjs.org/passport/-/passport-0.1.18.tgz", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.1.18.tgz", + "dependencies": { + "pause": { + "version": "0.0.1", + "from": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz" + } + } + } + } + }, + "passport-http-bearer": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz", + "dependencies": { + "passport-strategy": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" + } + } + }, + "passport-local": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "dependencies": { + "passport-strategy": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" + } + } + }, + "passport-oauth2-client-password": { + "version": "0.1.2", + "from": "https://registry.npmjs.org/passport-oauth2-client-password/-/passport-oauth2-client-password-0.1.2.tgz", + "resolved": "https://registry.npmjs.org/passport-oauth2-client-password/-/passport-oauth2-client-password-0.1.2.tgz", + "dependencies": { + "passport-strategy": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" + } + } + }, + "password-generator": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/password-generator/-/password-generator-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/password-generator/-/password-generator-1.0.0.tgz", + "dependencies": { + "optimist": { + "version": "0.6.1", + "from": "http://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "resolved": "http://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "from": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" + }, + "minimist": { + "version": "0.0.10", + "from": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" + } + } + } + } + }, + "proxy-middleware": { + "version": "0.13.0", + "from": "https://registry.npmjs.org/proxy-middleware/-/proxy-middleware-0.13.0.tgz", + "resolved": "https://registry.npmjs.org/proxy-middleware/-/proxy-middleware-0.13.0.tgz" + }, + "safetydance": { + "version": "0.0.16", + "from": "http://registry.npmjs.org/safetydance/-/safetydance-0.0.16.tgz", + "resolved": "http://registry.npmjs.org/safetydance/-/safetydance-0.0.16.tgz" + }, + "semver": { + "version": "4.3.6", + "from": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz" + }, + "serve-favicon": { + "version": "2.3.0", + "from": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.3.0.tgz", + "dependencies": { + "etag": { + "version": "1.7.0", + "from": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz" + }, + "fresh": { + "version": "0.3.0", + "from": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + }, + "parseurl": { + "version": "1.3.0", + "from": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz", + "resolved": "http://registry.npmjs.org/parseurl/-/parseurl-1.3.0.tgz" + } + } + }, + "split": { + "version": "1.0.0", + "from": "https://registry.npmjs.org/split/-/split-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.0.tgz", + "dependencies": { + "through": { + "version": "2.3.7", + "from": "http://registry.npmjs.org/through/-/through-2.3.7.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.7.tgz" + } + } + }, + "superagent": { + "version": "0.21.0", + "from": "https://registry.npmjs.org/superagent/-/superagent-0.21.0.tgz", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-0.21.0.tgz", + "dependencies": { + "qs": { + "version": "1.2.0", + "from": "https://registry.npmjs.org/qs/-/qs-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/qs/-/qs-1.2.0.tgz" + }, + "formidable": { + "version": "1.0.14", + "from": "https://registry.npmjs.org/formidable/-/formidable-1.0.14.tgz", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.0.14.tgz" + }, + "mime": { + "version": "1.2.11", + "from": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz" + }, + "component-emitter": { + "version": "1.1.2", + "from": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz" + }, + "methods": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/methods/-/methods-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.0.1.tgz" + }, + "cookiejar": { + "version": "2.0.1", + "from": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.0.1.tgz", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.0.1.tgz" + }, + "reduce-component": { + "version": "1.0.1", + "from": "https://registry.npmjs.org/reduce-component/-/reduce-component-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/reduce-component/-/reduce-component-1.0.1.tgz" + }, + "extend": { + "version": "1.2.1", + "from": "https://registry.npmjs.org/extend/-/extend-1.2.1.tgz", + "resolved": "https://registry.npmjs.org/extend/-/extend-1.2.1.tgz" + }, + "form-data": { + "version": "0.1.3", + "from": "http://registry.npmjs.org/form-data/-/form-data-0.1.3.tgz", + "resolved": "http://registry.npmjs.org/form-data/-/form-data-0.1.3.tgz", + "dependencies": { + "combined-stream": { + "version": "0.0.7", + "from": "https://registry.npmjs.org/combined-stream/-/combined-stream-0.0.7.tgz", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-0.0.7.tgz", + "dependencies": { + "delayed-stream": { + "version": "0.0.5", + "from": "http://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.5.tgz", + "resolved": "http://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.5.tgz" + } + } + }, + "async": { + "version": "0.9.2", + "from": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz" + } + } + }, + "readable-stream": { + "version": "1.0.27-1", + "from": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.27-1.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.27-1.tgz", + "dependencies": { + "core-util-is": { + "version": "1.0.1", + "from": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/core-util-is/-/core-util-is-1.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "resolved": "http://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "resolved": "http://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + } + } + } + } + }, + "supererror": { + "version": "0.7.0", + "from": "https://registry.npmjs.org/supererror/-/supererror-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/supererror/-/supererror-0.7.0.tgz", + "dependencies": { + "colors": { + "version": "1.0.3", + "from": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz" + }, + "prettyjson": { + "version": "1.1.0", + "from": "https://registry.npmjs.org/prettyjson/-/prettyjson-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/prettyjson/-/prettyjson-1.1.0.tgz", + "dependencies": { + "colors": { + "version": "0.6.2", + "from": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + } + } + } + } + }, + "supervisord-eventlistener": { + "version": "0.1.0", + "from": "https://registry.npmjs.org/supervisord-eventlistener/-/supervisord-eventlistener-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/supervisord-eventlistener/-/supervisord-eventlistener-0.1.0.tgz" + }, + "tail-stream": { + "version": "0.2.1", + "from": "https://registry.npmjs.org/tail-stream/-/tail-stream-0.2.1.tgz", + "resolved": "https://registry.npmjs.org/tail-stream/-/tail-stream-0.2.1.tgz" + }, + "underscore": { + "version": "1.8.3", + "from": "http://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz", + "resolved": "http://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz" + }, + "valid-url": { + "version": "1.0.9", + "from": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz" + }, + "validator": { + "version": "3.40.1", + "from": "http://registry.npmjs.org/validator/-/validator-3.40.1.tgz", + "resolved": "http://registry.npmjs.org/validator/-/validator-3.40.1.tgz" + } + } +} diff --git a/oauthproxy.js b/oauthproxy.js new file mode 100755 index 000000000..d5acc4042 --- /dev/null +++ b/oauthproxy.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +'use strict'; + +require('supererror')({ splatchError: true }); + +var express = require('express'), + url = require('url'), + uuid = require('node-uuid'), + async = require('async'), + superagent = require('superagent'), + assert = require('assert'), + debug = require('debug')('box:proxy'), + proxy = require('proxy-middleware'), + session = require('cookie-session'), + database = require('./src/database.js'), + appdb = require('./src/appdb.js'), + clientdb = require('./src/clientdb.js'), + config = require('./src/config.js'), + http = require('http'); + +// Allow self signed certs! +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +var gSessions = {}; +var gProxyMiddlewareCache = {}; +var gApp = express(); +var gHttpServer = http.createServer(gApp); + +var CALLBACK_URI = '/callback'; +var PORT = 4000; + +function startServer(callback) { + assert.strictEqual(typeof callback, 'function'); + + gHttpServer.on('error', console.error); + + gApp.use(session({ + keys: ['blue', 'cheese', 'is', 'something'] + })); + + // ensure we have a in memory store for the session to cache client information + gApp.use(function (req, res, next) { + assert.strictEqual(typeof req.session, 'object'); + + if (!req.session.id || !gSessions[req.session.id]) { + req.session.id = uuid.v4(); + gSessions[req.session.id] = {}; + } + + // attach the session data to the requeset + req.sessionData = gSessions[req.session.id]; + + next(); + }); + + gApp.use(function verifySession(req, res, next) { + assert.strictEqual(typeof req.sessionData, 'object'); + + if (!req.sessionData.accessToken) { + req.authenticated = false; + return next(); + } + + superagent.get(config.adminOrigin() + '/api/v1/profile').query({ access_token: req.sessionData.accessToken}).end(function (error, result) { + if (error) { + console.error(error); + req.authenticated = false; + } else if (result.statusCode !== 200) { + req.sessionData.accessToken = null; + req.authenticated = false; + } else { + req.authenticated = true; + } + + next(); + }); + }); + + gApp.use(function (req, res, next) { + // proceed if we are authenticated + if (req.authenticated) return next(); + + if (req.path === CALLBACK_URI && req.sessionData.returnTo) { + // exchange auth code for an access token + var query = { + response_type: 'token', + client_id: req.sessionData.clientId + }; + + var data = { + grant_type: 'authorization_code', + code: req.query.code, + redirect_uri: req.sessionData.returnTo, + client_id: req.sessionData.clientId, + client_secret: req.sessionData.clientSecret + }; + + superagent.post(config.adminOrigin() + '/api/v1/oauth/token').query(query).send(data).end(function (error, result) { + if (error) { + console.error(error); + return res.send(500, 'Unable to contact the oauth server.'); + } + if (result.statusCode !== 200) { + console.error('Failed to exchange auth code for a token.', result.statusCode, result.body); + return res.send(500, 'Failed to exchange auth code for a token.'); + } + + req.sessionData.accessToken = result.body.access_token; + + debug('user verified.'); + + // now redirect to the actual initially requested URL + res.redirect(req.sessionData.returnTo); + }); + } else { + var port = parseInt(req.headers['x-cloudron-proxy-port'], 10); + + if (!Number.isFinite(port)) { + console.error('Failed to parse nginx proxy header to get app port.'); + return res.send(500, 'Routing error. No forwarded port.'); + } + + debug('begin verifying user for app on port %s.', port); + + appdb.getByHttpPort(port, function (error, result) { + if (error) { + console.error('Unknown app.', error); + return res.send(500, 'Unknown app.'); + } + + clientdb.getByAppId('proxy-' + result.id, function (error, result) { + if (error) { + console.error('Unkonwn OAuth client.', error); + return res.send(500, 'Unknown OAuth client.'); + } + + req.sessionData.port = port; + req.sessionData.returnTo = result.redirectURI + req.path; + req.sessionData.clientId = result.id; + req.sessionData.clientSecret = result.clientSecret; + + var callbackUrl = result.redirectURI + CALLBACK_URI; + var scope = 'profile,roleUser'; + var oauthLogin = config.adminOrigin() + '/api/v1/oauth/dialog/authorize?response_type=code&client_id=' + result.id + '&redirect_uri=' + callbackUrl + '&scope=' + scope; + + debug('begin OAuth flow for client %s.', result.name); + + // begin the OAuth flow + res.redirect(oauthLogin); + }); + }); + } + }); + + gApp.use(function (req, res, next) { + var port = req.sessionData.port; + + debug('proxy request for port %s with path %s.', port, req.path); + + var proxyMiddleware = gProxyMiddlewareCache[port]; + if (!proxyMiddleware) { + console.log('Adding proxy middleware for port %d', port); + + proxyMiddleware = proxy(url.parse('http://127.0.0.1:' + port)); + gProxyMiddlewareCache[port] = proxyMiddleware; + } + + proxyMiddleware(req, res, next); + }); + + gHttpServer.listen(PORT, callback); +} + +async.series([ + database.initialize, + startServer +], function (error) { + if (error) { + console.error('Failed to start proxy server.', error); + process.exit(1); + } + + console.log('Proxy server listening...'); +}); diff --git a/package.json b/package.json new file mode 100644 index 000000000..8cfea3831 --- /dev/null +++ b/package.json @@ -0,0 +1,103 @@ +{ + "name": "Cloudron", + "description": "Main code for a cloudron", + "version": "0.0.1", + "private": "true", + "author": { + "name": "Cloudron authors" + }, + "repository": { + "type": "git" + }, + "engines": [ + "node >= 0.12.0" + ], + "bin": { + "cloudron": "./app.js" + }, + "dependencies": { + "async": "^1.2.1", + "body-parser": "^1.13.1", + "cloudron-manifestformat": "^1.4.0", + "connect-ensure-login": "^0.1.1", + "connect-lastmile": "0.0.12", + "connect-timeout": "^1.5.0", + "cookie-parser": "^1.3.5", + "cookie-session": "^1.1.0", + "cron": "^1.0.9", + "csurf": "^1.6.6", + "db-migrate": "^0.9.2", + "debug": "^2.2.0", + "dockerode": "^2.2.2", + "ejs": "^2.2.4", + "ejs-cli": "^1.0.1", + "express": "^4.12.4", + "express-session": "^1.11.3", + "hat": "0.0.3", + "json": "^9.0.3", + "ldapjs": "^0.7.1", + "memorystream": "^0.3.0", + "mime": "^1.3.4", + "morgan": "^1.6.0", + "multiparty": "^4.1.2", + "mysql": "^2.7.0", + "native-dns": "^0.7.0", + "node-uuid": "^1.4.3", + "nodejs-disks": "^0.2.1", + "nodemailer": "^1.3.0", + "nodemailer-smtp-transport": "^1.0.3", + "oauth2orize": "^1.0.1", + "once": "^1.3.2", + "passport": "^0.2.2", + "passport-http": "^0.2.2", + "passport-http-bearer": "^1.0.1", + "passport-local": "^1.0.0", + "passport-oauth2-client-password": "^0.1.2", + "password-generator": "^1.0.0", + "proxy-middleware": "^0.13.0", + "safetydance": "0.0.16", + "semver": "^4.3.6", + "serve-favicon": "^2.2.0", + "split": "^1.0.0", + "superagent": "~0.21.0", + "supererror": "^0.7.0", + "supervisord-eventlistener": "^0.1.0", + "tail-stream": "https://registry.npmjs.org/tail-stream/-/tail-stream-0.2.1.tgz", + "underscore": "^1.7.0", + "valid-url": "^1.0.9", + "validator": "^3.30.0" + }, + "devDependencies": { + "apidoc": "*", + "aws-sdk": "^2.1.10", + "bootstrap-sass": "^3.3.3", + "del": "^1.1.1", + "expect.js": "*", + "gulp": "^3.8.11", + "gulp-autoprefixer": "^2.3.0", + "gulp-concat": "^2.4.3", + "gulp-ejs": "^1.0.0", + "gulp-minify-css": "^1.1.3", + "gulp-sass": "^2.0.1", + "gulp-serve": "^1.0.0", + "gulp-sourcemaps": "^1.5.2", + "gulp-uglify": "^1.1.0", + "hock": "~1.2.0", + "istanbul": "*", + "mocha": "*", + "nock": "^2.6.0", + "node-sass": "^3.0.0-alpha.0", + "redis": "^0.12.1", + "sinon": "^1.12.2", + "yargs": "^3.15.0" + }, + "scripts": { + "migrate_local": "NODE_ENV=local DATABASE_URL=mysql://root:@localhost/box node_modules/.bin/db-migrate up", + "migrate_test": "NODE_ENV=test DATABASE_URL=mysql://root:@localhost/boxtest node_modules/.bin/db-migrate up", + "test": "npm run migrate_test && src/test/setupTest && NODE_ENV=test ./node_modules/istanbul/lib/cli.js test $1 ./node_modules/mocha/bin/_mocha -- -R spec ./src/test ./src/routes/test", + "postmerge": "/bin/true", + "precommit": "/bin/true", + "prepush": "npm test", + "webadmin": "node_modules/.bin/gulp" + } +} diff --git a/setup/DESIGN.md b/setup/DESIGN.md new file mode 100644 index 000000000..1ca9b748d --- /dev/null +++ b/setup/DESIGN.md @@ -0,0 +1,57 @@ +This document gives the design of this setup code. + +box code should be delivered in the form of a (docker) container. +This is not the case currently but we want to do structure the code +in spirit that way. + +### container.sh +This contains code that essential goes into Dockerfile. + +This file contains static configuration over a base image. Currently, +the yellowtent user is created in the installer base image but it +could very well be placed here. + +The idea is that the installer would simply remove the old box container +and replace it with a new one for an update. + +Because we do not package things as Docker yet, we should be careful +about the code here. We have to expect remains of an older setup code. +For example, older supervisor or nginx configs might be around. + +The config directory is _part_ of the container and is not a VOLUME. +Which is to say that the files will be nuked from one update to the next. + +The data directory is a VOLUME. Contents of this directory are expected +to survive an update. This is a good place to place config files that +are "dynamic" and need to survive restarts. For example, the infra +version (see below) or the mysql/postgresql data etc. + +### start.sh + * It is called in 3 modes - new, update, restore. + + * The first thing this does is to do the static container.sh setup. + + * It then downloads any box restore data and restores the box db from the + backup. + + * It then proceeds to call the db-migrate script. + + * It then does dynamic configuration like setting up nginx, collectd. + + * It then setups up the cloud infra (setup_infra.sh) and creates cloudron.conf. + + * supervisor is then started + +setup_infra.sh +This setups containers like graphite, mail and the addons containers. + +Containers are relaunched based on the INFRA_VERSION. The script compares +the version here with the version in the file DATA_DIR/INFRA_VERSION. + +If they match, the containers are not recreated and nothing is to be done. +nginx, collectd configs are part of data already and containers are running. + +If they do not match, it deletes all containers (including app containers) and starts +them all afresh. Important thing here is that, DATA_DIR is never removed across +updates. So, it is only the containers being recreated and not the data. + diff --git a/setup/INFRA_VERSION b/setup/INFRA_VERSION new file mode 100644 index 000000000..633839829 --- /dev/null +++ b/setup/INFRA_VERSION @@ -0,0 +1,6 @@ +#!/bin/bash + +# If you change the infra version, be sure to put a warning +# in the change log + +INFRA_VERSION=4 diff --git a/setup/argparser.sh b/setup/argparser.sh new file mode 100644 index 000000000..8203e8638 --- /dev/null +++ b/setup/argparser.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +json="${script_dir}/../node_modules/.bin/json" + +arg_restore_url="" +arg_restore_key="" +arg_box_versions_url="" +arg_tls_cert="" +arg_tls_key="" +arg_api_server_origin="" +arg_web_server_origin="" +arg_fqdn="" +arg_token="" +arg_version="" +arg_is_custom_domain="false" +arg_developer_mode="" +arg_retire="false" +arg_model="" + +args=$(getopt -o "" -l "data:,retire" -n "$0" -- "$@") +eval set -- "${args}" + +while true; do + case "$1" in + --retire) + arg_retire="true" + shift + ;; + --data) + # only read mandatory non-empty parameters here + read -r arg_api_server_origin arg_web_server_origin arg_fqdn arg_token arg_is_custom_domain arg_box_versions_url arg_version </dev/null || rm -rf /etc/nginx +ln -s "${DATA_DIR}/nginx" /etc/nginx + +########## Enable services +update-rc.d -f collectd defaults + diff --git a/setup/container/logrotate/cloudron b/setup/container/logrotate/cloudron new file mode 100644 index 000000000..71a556df5 --- /dev/null +++ b/setup/container/logrotate/cloudron @@ -0,0 +1,6 @@ +/var/log/cloudron/*log { + missingok + notifempty + size 100k + nocompress +} diff --git a/setup/container/logrotate/supervisor b/setup/container/logrotate/supervisor new file mode 100644 index 000000000..e02233830 --- /dev/null +++ b/setup/container/logrotate/supervisor @@ -0,0 +1,7 @@ +/var/log/supervisor/*log { + missingok + copytruncate + notifempty + size 100k + nocompress +} diff --git a/setup/container/sudoers b/setup/container/sudoers new file mode 100644 index 000000000..b8e7a3656 --- /dev/null +++ b/setup/container/sudoers @@ -0,0 +1,26 @@ +Defaults!/home/yellowtent/box/src/scripts/createappdir.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/createappdir.sh + +Defaults!/home/yellowtent/box/src/scripts/rmappdir.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/rmappdir.sh + +Defaults!/home/yellowtent/box/src/scripts/reloadnginx.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadnginx.sh + +Defaults!/home/yellowtent/box/src/scripts/backupbox.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupbox.sh + +Defaults!/home/yellowtent/box/src/scripts/backupapp.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupapp.sh + +Defaults!/home/yellowtent/box/src/scripts/restoreapp.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/restoreapp.sh + +Defaults!/home/yellowtent/box/src/scripts/reboot.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reboot.sh + +Defaults!/home/yellowtent/box/src/scripts/reloadcollectd.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadcollectd.sh + +Defaults!/home/yellowtent/box/src/scripts/backupswap.sh env_keep="HOME NODE_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupswap.sh diff --git a/setup/container/supervisor/conf.d/apphealthtask.conf b/setup/container/supervisor/conf.d/apphealthtask.conf new file mode 100644 index 000000000..0c806536f --- /dev/null +++ b/setup/container/supervisor/conf.d/apphealthtask.conf @@ -0,0 +1,10 @@ +[program:apphealthtask] +command=/usr/bin/node "/home/yellowtent/box/apphealthtask.js" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/supervisor/apphealthtask.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=2 +user=yellowtent +environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron" diff --git a/setup/container/supervisor/conf.d/box.conf b/setup/container/supervisor/conf.d/box.conf new file mode 100644 index 000000000..2354bdd0a --- /dev/null +++ b/setup/container/supervisor/conf.d/box.conf @@ -0,0 +1,10 @@ +[program:box] +command=/usr/bin/node "/home/yellowtent/box/app.js" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/supervisor/box.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=2 +user=yellowtent +environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*,connect-lastmile",NODE_ENV="cloudron" diff --git a/setup/container/supervisor/conf.d/crashnotifier.conf b/setup/container/supervisor/conf.d/crashnotifier.conf new file mode 100644 index 000000000..c6d56d4df --- /dev/null +++ b/setup/container/supervisor/conf.d/crashnotifier.conf @@ -0,0 +1,11 @@ +[eventlistener:crashnotifier] +command=/usr/bin/node "/home/yellowtent/box/crashnotifier.js" +events=PROCESS_STATE +autostart=true +autorestart=true +redirect_stderr=false +stderr_logfile=/var/log/supervisor/crashnotifier.log +stderr_logfile_maxbytes=50MB +stderr_logfile_backups=2 +user=yellowtent +environment=HOME="/home/yellowtent",USER="yellowtent",NODE_ENV="cloudron" diff --git a/setup/container/supervisor/conf.d/janitor.conf b/setup/container/supervisor/conf.d/janitor.conf new file mode 100644 index 000000000..48681c609 --- /dev/null +++ b/setup/container/supervisor/conf.d/janitor.conf @@ -0,0 +1,10 @@ +[program:janitor] +command=/usr/bin/node "/home/yellowtent/box/janitor.js" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/supervisor/janitor.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=2 +user=yellowtent +environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron" diff --git a/setup/container/supervisor/conf.d/oauthproxy.conf b/setup/container/supervisor/conf.d/oauthproxy.conf new file mode 100644 index 000000000..26782d2ba --- /dev/null +++ b/setup/container/supervisor/conf.d/oauthproxy.conf @@ -0,0 +1,10 @@ +[program:oauthproxy] +command=/usr/bin/node "/home/yellowtent/box/oauthproxy.js" +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/supervisor/oauthproxy.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=2 +user=yellowtent +environment=HOME="/home/yellowtent",USER="yellowtent",DEBUG="box*",NODE_ENV="cloudron" diff --git a/setup/container/supervisor/supervisord.conf b/setup/container/supervisor/supervisord.conf new file mode 100644 index 000000000..e0c30b068 --- /dev/null +++ b/setup/container/supervisor/supervisord.conf @@ -0,0 +1,33 @@ +; supervisor config file + +; http://coffeeonthekeyboard.com/using-supervisorctl-with-linux-permissions-but-without-root-or-sudo-977/ +[inet_http_server] +port = 127.0.0.1:9001 + +[supervisord] +logfile=/var/log/supervisor/supervisord.log ; (main log file;default $CWD/supervisord.log) +pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid) +logfile_maxbytes = 50MB +logfile_backups=10 +loglevel = info +nodaemon = false +childlogdir = /var/log/supervisor/ + +; the below section must remain in the config file for RPC +; (supervisorctl/web interface) to work, additional interfaces may be +; added by defining them in separate rpcinterface: sections +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=http://127.0.0.1:9001 + +; The [include] section can just contain the "files" setting. This +; setting can list multiple files (separated by whitespace or +; newlines). It can also contain wildcards. The filenames are +; interpreted as relative to this file. Included files *cannot* +; include files themselves. + +[include] +files = conf.d/*.conf + diff --git a/setup/splashpage.sh b/setup/splashpage.sh new file mode 100755 index 000000000..ddf435a3b --- /dev/null +++ b/setup/splashpage.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -eu -o pipefail + +readonly SETUP_WEBSITE_DIR="/home/yellowtent/setup/website" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly BOX_SRC_DIR="/home/yellowtent/box" +readonly DATA_DIR="/home/yellowtent/data" + +source "${script_dir}/INFRA_VERSION" # this injects INFRA_VERSION + +echo "Setting up nginx update page" + +source "${script_dir}/argparser.sh" "$@" # this injects the arg_* variables used below + +# copy the website +rm -rf "${SETUP_WEBSITE_DIR}" && mkdir -p "${SETUP_WEBSITE_DIR}" +cp -r "${script_dir}/splash/website/"* "${SETUP_WEBSITE_DIR}" + +# create nginx config +infra_version="none" +[[ -f "${DATA_DIR}/INFRA_VERSION" ]] && infra_version=$(cat "${DATA_DIR}/INFRA_VERSION") +if [[ "${arg_retire}" == "true" || "${infra_version}" != "${INFRA_VERSION}" ]]; then + rm -f ${DATA_DIR}/nginx/applications/* + ${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \ + -O "{ \"vhost\": \"~^(.+)\$\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf" +else + # keep this is sync with config.js appFqdn() + readonly ADMIN_LOCATION="my" # keep this in sync with constants.js + admin_fqdn=$([[ "${arg_is_custom_domain}" == "true" ]] && echo "${ADMIN_LOCATION}.${arg_fqdn}" || echo "${ADMIN_LOCATION}-${arg_fqdn}") + + ${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \ + -O "{ \"vhost\": \"${admin_fqdn}\", \"endpoint\": \"splash\", \"sourceDir\": \"${SETUP_WEBSITE_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf" +fi + +echo '{ "update": { "percent": "10", "message": "Updating cloudron software" }, "backup": null }' > "${SETUP_WEBSITE_DIR}/progress.json" + +nginx -s reload diff --git a/setup/start.sh b/setup/start.sh new file mode 100755 index 000000000..cd9fa94ad --- /dev/null +++ b/setup/start.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +set -eu -o pipefail + +echo "==== Cloudron Start ====" + +readonly USER="yellowtent" +readonly BOX_SRC_DIR="/home/${USER}/box" +readonly DATA_DIR="/home/${USER}/data" +readonly CONFIG_DIR="/home/${USER}/configs" +readonly SETUP_PROGRESS_JSON="/home/yellowtent/setup/website/progress.json" +readonly ADMIN_LOCATION="my" # keep this in sync with constants.js + +readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +source "${script_dir}/argparser.sh" "$@" # this injects the arg_* variables used below + +# keep this is sync with config.js appFqdn() +admin_fqdn=$([[ "${arg_is_custom_domain}" == "true" ]] && echo "${ADMIN_LOCATION}.${arg_fqdn}" || echo "${ADMIN_LOCATION}-${arg_fqdn}") + +readonly is_update=$([[ -d "${DATA_DIR}/box" ]] && echo "true" || echo "false") + +set_progress() { + local percent="$1" + local message="$2" + + echo "==== ${percent} - ${message} ====" + (echo "{ \"update\": { \"percent\": \"${percent}\", \"message\": \"${message}\" }, \"backup\": {} }" > "${SETUP_PROGRESS_JSON}") 2> /dev/null || true # as this will fail in non-update mode +} + +set_progress "1" "Create container" +$script_dir/container.sh + +set_progress "10" "Ensuring directories" +# keep these in sync with paths.js +[[ "${is_update}" == "false" ]] && btrfs subvolume create "${DATA_DIR}/box" +mkdir -p "${DATA_DIR}/box/appicons" +mkdir -p "${DATA_DIR}/box/mail" +mkdir -p "${DATA_DIR}/graphite" + +mkdir -p "${DATA_DIR}/snapshots" +mkdir -p "${DATA_DIR}/addons" +mkdir -p "${DATA_DIR}/collectd/collectd.conf.d" + +# bookkeep the version as part of data +echo "{ \"version\": \"${arg_version}\", \"boxVersionsUrl\": \"${arg_box_versions_url}\" }" > "${DATA_DIR}/box/version" + +# remove old snapshots. if we do want to keep this around, we will have to fix the chown -R below +# which currently fails because these are readonly fs +echo "Cleaning up snapshots" +find "${DATA_DIR}/snapshots" -mindepth 1 -maxdepth 1 | xargs --no-run-if-empty btrfs subvolume delete + +readonly mysql_root_password="password" +mysqladmin -u root -ppassword password password # reset default root password +mysql -u root -p${mysql_root_password} -e 'CREATE DATABASE IF NOT EXISTS box' + +if [[ -n "${arg_restore_url}" ]]; then + set_progress "15" "Downloading restore data" + + echo "Downloading backup: ${arg_restore_url} and key: ${arg_restore_key}" + + while true; do + if $curl -L "${arg_restore_url}" | openssl aes-256-cbc -d -pass "pass:${arg_restore_key}" | tar -zxf - -C "${DATA_DIR}/box"; then break; fi + echo "Failed to download data, trying again" + done + + set_progress "21" "Setting up MySQL" + if [[ -f "${DATA_DIR}/box/box.mysqldump" ]]; then + echo "Importing existing database into MySQL" + mysql -u root -p${mysql_root_password} box < "${DATA_DIR}/box/box.mysqldump" + fi +fi + +set_progress "25" "Migrating data" +sudo -u "${USER}" -H bash < "${DATA_DIR}/nginx/nginx.conf" + +# generate these for update code paths as well to overwrite splash +${BOX_SRC_DIR}/node_modules/.bin/ejs-cli -f "${script_dir}/start/nginx/appconfig.ejs" \ + -O "{ \"vhost\": \"${admin_fqdn}\", \"endpoint\": \"admin\", \"sourceDir\": \"${BOX_SRC_DIR}\" }" > "${DATA_DIR}/nginx/applications/admin.conf" + +mkdir -p "${DATA_DIR}/nginx/cert" +echo "${arg_tls_cert}" > ${DATA_DIR}/nginx/cert/host.cert +echo "${arg_tls_key}" > ${DATA_DIR}/nginx/cert/host.key + +set_progress "33" "Changing ownership" +chown "${USER}:${USER}" -R "${DATA_DIR}/box" "${DATA_DIR}/nginx" "${DATA_DIR}/collectd" "${DATA_DIR}/addons" + +set_progress "40" "Setting up infra" +${script_dir}/start/setup_infra.sh "${arg_fqdn}" + +set_progress "65" "Creating cloudron.conf" +admin_origin="https://${admin_fqdn}" +sudo -u yellowtent -H bash < "${CONFIG_DIR}/cloudron.conf" < "${BOX_SRC_DIR}/webadmin/dist/config.json" < block is encountered. # +# Disabled by default. # +#----------------------------------------------------------------------------# +#AutoLoadPlugin false + +#----------------------------------------------------------------------------# +# Interval at which to query values. This may be overwritten on a per-plugin # +# base by using the 'Interval' option of the LoadPlugin block: # +# # +# Interval 60 # +# # +#----------------------------------------------------------------------------# +# IMPORTANT: changing this value requires a change in whisper schema as well +Interval 20 + +#Timeout 2 +#ReadThreads 5 +#WriteThreads 5 + +# Limit the size of the write queue. Default is no limit. Setting up a limit +# is recommended for servers handling a high volume of traffic. +#WriteQueueLimitHigh 1000000 +#WriteQueueLimitLow 800000 + +############################################################################## +# Logging # +#----------------------------------------------------------------------------# +# Plugins which provide logging functions should be loaded first, so log # +# messages generated when loading or configuring other plugins can be # +# accessed. # +############################################################################## + +LoadPlugin logfile +#LoadPlugin syslog + + + LogLevel "info" + File "/var/log/collectd.log" + Timestamp true + PrintSeverity false + + +# +# LogLevel info +# + +############################################################################## +# LoadPlugin section # +#----------------------------------------------------------------------------# +# Specify what features to activate. # +############################################################################## + +LoadPlugin aggregation +#LoadPlugin amqp +#LoadPlugin apache +#LoadPlugin apcups +#LoadPlugin ascent +#LoadPlugin battery +#LoadPlugin bind +#LoadPlugin cgroups +#LoadPlugin conntrack +#LoadPlugin contextswitch +LoadPlugin cpu +#LoadPlugin cpufreq +#LoadPlugin csv +#LoadPlugin curl +#LoadPlugin curl_json +#LoadPlugin curl_xml +#LoadPlugin dbi +LoadPlugin df +#LoadPlugin disk +#LoadPlugin dns +#LoadPlugin email +#LoadPlugin entropy +#LoadPlugin ethstat +#LoadPlugin exec +#LoadPlugin filecount +#LoadPlugin fscache +#LoadPlugin gmond +#LoadPlugin hddtemp +LoadPlugin interface +#LoadPlugin ipmi +#LoadPlugin iptables +#LoadPlugin ipvs +#LoadPlugin irq +#LoadPlugin java +#LoadPlugin libvirt +LoadPlugin load +#LoadPlugin lvm +#LoadPlugin madwifi +#LoadPlugin mbmon +#LoadPlugin md +#LoadPlugin memcachec +#LoadPlugin memcached +LoadPlugin memory +#LoadPlugin modbus +#LoadPlugin multimeter +#LoadPlugin mysql +#LoadPlugin netlink +#LoadPlugin network +#LoadPlugin nfs +LoadPlugin nginx +#LoadPlugin notify_desktop +#LoadPlugin notify_email +#LoadPlugin ntpd +#LoadPlugin numa +#LoadPlugin nut +#LoadPlugin olsrd +#LoadPlugin openvpn +# +# Globals true +# +#LoadPlugin pinba +LoadPlugin ping +#LoadPlugin postgresql +#LoadPlugin powerdns +LoadPlugin processes +#LoadPlugin protocols +# +# Globals true +# +#LoadPlugin rrdcached +#LoadPlugin rrdtool +#LoadPlugin sensors +#LoadPlugin serial +#LoadPlugin snmp +#LoadPlugin statsd +LoadPlugin swap +#LoadPlugin table +LoadPlugin tail +#LoadPlugin tail_csv +#LoadPlugin tcpconns +#LoadPlugin teamspeak2 +#LoadPlugin ted +#LoadPlugin thermal +#LoadPlugin tokyotyrant +#LoadPlugin unixsock +#LoadPlugin uptime +#LoadPlugin users +#LoadPlugin uuid +#LoadPlugin varnish +LoadPlugin vmem +#LoadPlugin vserver +#LoadPlugin wireless +LoadPlugin write_graphite +#LoadPlugin write_http +#LoadPlugin write_riemann + +############################################################################## +# Plugin configuration # +#----------------------------------------------------------------------------# +# In this section configuration stubs for each plugin are provided. A desc- # +# ription of those options is available in the collectd.conf(5) manual page. # +############################################################################## + + + + Plugin "cpu" + Type "cpu" + + GroupBy "Host" + GroupBy "TypeInstance" + + CalculateNum false + CalculateSum true + CalculateAverage true + CalculateMinimum false + CalculateMaximum true + CalculateStddev false + + + + + Device "/dev/vda1" + Device "/dev/loop0" + Device "/dev/loop1" + + ReportByDevice true + IgnoreSelected false + + ValuesAbsolute true + ValuesPercentage true + + + + Interface "eth0" + IgnoreSelected false + + + + URL "http://127.0.0.1/nginx_status" + + + + Host "google.com" + Interval 1.0 + Timeout 0.9 + TTL 255 + + + + ProcessMatch "app" "node app.js" + + + + ReportByDevice false + ReportBytes true + + + + + Instance "nginx" + + Regex ".*" + DSType "CounterInc" + Type counter + Instance "errors" + + + + Instance "nginx" + + Regex ".*" + DSType "CounterInc" + Type counter + Instance "requests" + + + Regex " \".*\" [0-9]+ [0-9]+ ([0-9]+)" + DSType GaugeAverage + Type delay + Instance "response" + + + + + + Verbose false + + + + + Host "localhost" + Port "2003" + Protocol "tcp" + LogSendErrors true + Prefix "collectd." + StoreRates true + AlwaysAppendDS false + EscapeCharacter "_" + + + + + Filter "*.conf" + + diff --git a/setup/start/nginx/appconfig.ejs b/setup/start/nginx/appconfig.ejs new file mode 100644 index 000000000..89c01f3bf --- /dev/null +++ b/setup/start/nginx/appconfig.ejs @@ -0,0 +1,115 @@ +# http://nginx.org/en/docs/http/websocket.html +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443; + server_name <%= vhost %>; + + ssl on; + # paths are relative to prefix and not to this file + ssl_certificate cert/host.cert; + ssl_certificate_key cert/host.key; + ssl_session_timeout 5m; + ssl_session_cache shared:SSL:50m; + + # https://bettercrypto.org/static/applied-crypto-hardening.pdf + # https://mozilla.github.io/server-side-tls/ssl-config-generator/ + # https://cipherli.st/ + ssl_prefer_server_ciphers on; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # don't use SSLv3 ref: POODLE + ssl_ciphers 'AES128+EECDH:AES128+EDH'; + add_header Strict-Transport-Security "max-age=15768000; includeSubDomains"; + + proxy_http_version 1.1; + proxy_intercept_errors on; + proxy_read_timeout 3500; + proxy_connect_timeout 3250; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + + # upgrade is a hop-by-hop header (http://nginx.org/en/docs/http/websocket.html) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + error_page 500 502 503 504 =200 @appstatus; + location @appstatus { + internal; + root <%= sourceDir %>/webadmin/dist; + rewrite ^/$ /appstatus.html break; + } + + location / { + # increase the proxy buffer sizes to not run into buffer issues (http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffers) + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + # Disable check to allow unlimited body sizes + client_max_body_size 0; + +<% if ( endpoint === 'admin' ) { %> + location /api/ { + proxy_pass http://127.0.0.1:3000; + client_max_body_size 1m; + } + + # graphite paths + location ~ ^/(graphite|content|metrics|dashboard|render|browser|composer)/ { + proxy_pass http://127.0.0.1:8000; + client_max_body_size 1m; + } + + location / { + root <%= sourceDir %>/webadmin/dist; + index index.html index.htm; + } + +<% } else if ( endpoint === 'oauthproxy' ) { %> + proxy_pass http://127.0.0.1:4000; + proxy_set_header X-Cloudron-Proxy-Port <%= port %>; +<% } else if ( endpoint === 'app' ) { %> + proxy_pass http://127.0.0.1:<%= port %>; +<% } else if ( endpoint === 'splash' ) { %> + root <%= sourceDir %>; + + error_page 503 /update.html; + + location /update.html { + add_header Cache-Control no-cache; + } + + location /theme.css { + add_header Cache-Control no-cache; + } + + location /3rdparty/ { + add_header Cache-Control no-cache; + } + + location /js/ { + add_header Cache-Control no-cache; + } + + location /progress.json { + add_header Cache-Control no-cache; + } + + location /api/v1/cloudron/progress { + add_header Cache-Control no-cache; + default_type application/json; + alias <%= sourceDir %>/progress.json; + } + + location / { + return 503; + } +<% } %> + } +} + diff --git a/setup/start/nginx/mime.types b/setup/start/nginx/mime.types new file mode 100644 index 000000000..8a218b22a --- /dev/null +++ b/setup/start/nginx/mime.types @@ -0,0 +1,80 @@ + +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + image/svg+xml svg svgz; + image/webp webp; + + application/java-archive jar war ear; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.wap.wmlc wmlc; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/setup/start/nginx/nginx.ejs b/setup/start/nginx/nginx.ejs new file mode 100644 index 000000000..e91663d05 --- /dev/null +++ b/setup/start/nginx/nginx.ejs @@ -0,0 +1,64 @@ +user www-data; + +worker_processes 1; + +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + # the collectd config depends on this log format + log_format combined2 '$remote_addr - [$time_local] ' + '"$request" $status $body_bytes_sent $request_time ' + '"$http_referer" "$http_user_agent"'; + + access_log access.log combined2; + + sendfile on; + + keepalive_timeout 65; + + # HTTP server + server { + listen 80; + + # collectd + location /nginx_status { + stub_status on; + access_log off; + allow 127.0.0.1; + deny all; + } + + location / { + # redirect everything to HTTPS + return 301 https://$host$request_uri; + } + } + + # We have to enable https for nginx to read in the vhost in http request + # and send a 404. This is a side-effect of using wildcard DNS + server { + listen 443 default_server; + ssl on; + ssl_certificate cert/host.cert; + ssl_certificate_key cert/host.key; + + error_page 404 = @fallback; + location @fallback { + internal; + root <%= sourceDir %>/webadmin/dist; + rewrite ^/$ /nakeddomain.html break; + } + + return 404; + } + + include applications/*.conf; +} + diff --git a/setup/start/setup_infra.sh b/setup/start/setup_infra.sh new file mode 100755 index 000000000..4c40497ec --- /dev/null +++ b/setup/start/setup_infra.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -eu -o pipefail + +readonly DATA_DIR="/home/yellowtent/data" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${script_dir}/../INFRA_VERSION" # this injects INFRA_VERSION + +arg_fqdn="$1" + +# removing containers ensures containers are launched with latest config updates +# restore code in appatask does not delete old containers +infra_version="none" +[[ -f "${DATA_DIR}/INFRA_VERSION" ]] && infra_version=$(cat "${DATA_DIR}/INFRA_VERSION") +if [[ "${infra_version}" == "${INFRA_VERSION}" ]]; then + echo "Infrastructure is upto date" + exit 0 +fi + +echo "Upgrading infrastructure from ${infra_version} to ${INFRA_VERSION}" + +existing_containers=$(docker ps -qa) +echo "Remove containers: ${existing_containers}" +if [[ -n "${existing_containers}" ]]; then + echo "${existing_containers}" | xargs docker rm -f +fi + +# graphite +docker run --restart=always -d --name="graphite" \ + -p 127.0.0.1:2003:2003 \ + -p 127.0.0.1:2004:2004 \ + -p 127.0.0.1:8000:8000 \ + -v "${DATA_DIR}/graphite:/app/data" cloudron/graphite:0.3.1 + +# mail +mail_container_id=$(docker run --restart=always -d --name="mail" \ + -p 127.0.0.1:25:25 \ + -h "${arg_fqdn}" \ + -e "DOMAIN_NAME=${arg_fqdn}" \ + -v "${DATA_DIR}/box/mail:/app/data" \ + cloudron/mail:0.3.0) +echo "Mail container id: ${mail_container_id}" + +# mysql +mysql_addon_root_password=$(pwgen -1 -s) +docker0_ip=$(/sbin/ifconfig docker0 | grep "inet addr" | awk -F: '{print $2}' | awk '{print $1}') +cat > "${DATA_DIR}/addons/mysql_vars.sh" < "${DATA_DIR}/addons/postgresql_vars.sh" < "${DATA_DIR}/addons/mongodb_vars.sh" < "${DATA_DIR}/INFRA_VERSION" + diff --git a/setup/stop.sh b/setup/stop.sh new file mode 100755 index 000000000..3bfed5e4c --- /dev/null +++ b/setup/stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -eu -o pipefail + +echo "Stopping box code" + +service supervisor stop || true + +echo -n "Waiting for supervisord to stop" +while test -e "/var/run/supervisord.pid" && kill -0 `cat /var/run/supervisord.pid`; do + echo -n "." + sleep 1 +done +echo "" + diff --git a/src/addons.js b/src/addons.js new file mode 100644 index 000000000..8d8297944 --- /dev/null +++ b/src/addons.js @@ -0,0 +1,768 @@ +'use strict'; + +exports = module.exports = { + setupAddons: setupAddons, + teardownAddons: teardownAddons, + backupAddons: backupAddons, + restoreAddons: restoreAddons, + + getEnvironment: getEnvironment, + getLinksSync: getLinksSync, + getBindsSync: getBindsSync, + + // exported for testing + _allocateOAuthCredentials: allocateOAuthCredentials, + _removeOAuthCredentials: removeOAuthCredentials +}; + +var appdb = require('./appdb.js'), + assert = require('assert'), + async = require('async'), + child_process = require('child_process'), + clientdb = require('./clientdb.js'), + config = require('./config.js'), + DatabaseError = require('./databaseerror.js'), + debug = require('debug')('box:addons'), + docker = require('./docker.js'), + fs = require('fs'), + generatePassword = require('password-generator'), + hat = require('hat'), + MemoryStream = require('memorystream'), + once = require('once'), + os = require('os'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + shell = require('./shell.js'), + spawn = child_process.spawn, + tokendb = require('./tokendb.js'), + util = require('util'), + uuid = require('node-uuid'), + vbox = require('./vbox.js'), + _ = require('underscore'); + +var NOOP = function (app, callback) { return callback(); }; + +// setup can be called multiple times for the same app (configure crash restart) and existing data must not be lost +// teardown is destructive. app data stored with the addon is lost +var KNOWN_ADDONS = { + oauth: { + setup: allocateOAuthCredentials, + teardown: removeOAuthCredentials, + backup: NOOP, + restore: allocateOAuthCredentials + }, + token: { + setup: allocateAccessToken, + teardown: removeAccessToken, + backup: NOOP, + restore: allocateAccessToken + }, + ldap: { + setup: setupLdap, + teardown: teardownLdap, + backup: NOOP, + restore: setupLdap + }, + sendmail: { + setup: setupSendMail, + teardown: teardownSendMail, + backup: NOOP, + restore: setupSendMail + }, + mysql: { + setup: setupMySql, + teardown: teardownMySql, + backup: backupMySql, + restore: restoreMySql, + }, + postgresql: { + setup: setupPostgreSql, + teardown: teardownPostgreSql, + backup: backupPostgreSql, + restore: restorePostgreSql + }, + mongodb: { + setup: setupMongoDb, + teardown: teardownMongoDb, + backup: backupMongoDb, + restore: restoreMongoDb + }, + redis: { + setup: setupRedis, + teardown: teardownRedis, + backup: NOOP, // no backup because we store redis as part of app's volume + restore: setupRedis // same thing + }, + localstorage: { + setup: NOOP, // docker creates the directory for us + teardown: NOOP, + backup: NOOP, // no backup because it's already inside app data + restore: NOOP + }, + _docker: { + setup: NOOP, + teardown: NOOP, + backup: NOOP, + restore: NOOP + } +}; + +var RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh'); + +function debugApp(app, args) { + assert(!app || typeof app === 'object'); + + var prefix = app ? (app.location || 'naked_domain') : '(no app)'; + debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); +} + +function setupAddons(app, addons, callback) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (!addons) return callback(null); + + async.eachSeries(Object.keys(addons), function iterator(addon, iteratorCallback) { + if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon)); + + debugApp(app, 'Setting up addon %s', addon); + + KNOWN_ADDONS[addon].setup(app, iteratorCallback); + }, callback); +} + +function teardownAddons(app, addons, callback) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (!addons) return callback(null); + + async.eachSeries(Object.keys(addons), function iterator(addon, iteratorCallback) { + if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon)); + + debugApp(app, 'Tearing down addon %s', addon); + + KNOWN_ADDONS[addon].teardown(app, iteratorCallback); + }, callback); +} + +function backupAddons(app, addons, callback) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'backupAddons'); + + if (!addons) return callback(null); + + async.eachSeries(Object.keys(addons), function iterator (addon, iteratorCallback) { + if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon)); + + KNOWN_ADDONS[addon].backup(app, iteratorCallback); + }, callback); +} + +function restoreAddons(app, addons, callback) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'restoreAddons'); + + if (!addons) return callback(null); + + async.eachSeries(Object.keys(addons), function iterator (addon, iteratorCallback) { + if (!(addon in KNOWN_ADDONS)) return iteratorCallback(new Error('No such addon:' + addon)); + + KNOWN_ADDONS[addon].restore(app, iteratorCallback); + }, callback); +} + +function getEnvironment(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + appdb.getAddonConfigByAppId(app.id, callback); +} + +function getLinksSync(app, addons) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + + var links = [ ]; + + if (!addons) return links; + + for (var addon in addons) { + switch (addon) { + case 'mysql': links.push('mysql:mysql'); break; + case 'postgresql': links.push('postgresql:postgresql'); break; + case 'sendmail': links.push('mail:mail'); break; + case 'redis': links.push('redis-' + app.id + ':redis-' + app.id); break; + case 'mongodb': links.push('mongodb:mongodb'); break; + default: break; + } + } + + return links; +} + +function getBindsSync(app, addons) { + assert.strictEqual(typeof app, 'object'); + assert(!addons || typeof addons === 'object'); + + var binds = [ ]; + + if (!addons) return binds; + + for (var addon in addons) { + switch (addon) { + case '_docker': binds.push('/var/run/docker.sock:/var/run/docker.sock:rw'); break; + case 'localstorage': binds.push(path.join(paths.DATA_DIR, app.id, 'data') + ':/app/data:rw'); break; + default: break; + } + } + + return binds; +} + +function allocateOAuthCredentials(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var appId = app.id; + var id = 'cid-addon-' + uuid.v4(); + var clientSecret = hat(256); + var redirectURI = 'https://' + config.appFqdn(app.location); + var scope = 'profile,roleUser'; + + debugApp(app, 'allocateOAuthCredentials: id:%s clientSecret:%s', id, clientSecret); + + clientdb.delByAppId('addon-' + appId, function (error) { // remove existing creds + if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(error); + + clientdb.add(id, 'addon-' + appId, clientSecret, redirectURI, scope, function (error) { + if (error) return callback(error); + + var env = [ + 'OAUTH_CLIENT_ID=' + id, + 'OAUTH_CLIENT_SECRET=' + clientSecret, + 'OAUTH_ORIGIN=' + config.adminOrigin() + ]; + + debugApp(app, 'Setting oauth addon config to %j', env); + + appdb.setAddonConfig(appId, 'oauth', env, callback); + }); + }); +} + +function removeOAuthCredentials(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'removeOAuthCredentials'); + + clientdb.delByAppId('addon-' + app.id, function (error) { + if (error && error.reason !== DatabaseError.NOT_FOUND) console.error(error); + + appdb.unsetAddonConfig(app.id, 'oauth', callback); + }); +} + +function setupLdap(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var env = [ + 'LDAP_SERVER=172.17.42.1', + 'LDAP_PORT=3002', + 'LDAP_URL=ldap://172.17.42.1:3002', + 'LDAP_USERS_BASE_DN=ou=users,dc=cloudron', + 'LDAP_GROUPS_BASE_DN=ou=groups,dc=cloudron' + ]; + + debugApp(app, 'Setting up LDAP'); + + appdb.setAddonConfig(app.id, 'ldap', env, callback); +} + +function teardownLdap(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'Tearing down LDAP'); + + appdb.unsetAddonConfig(app.id, 'ldap', callback); +} + +function setupSendMail(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var env = [ + 'MAIL_SMTP_SERVER=mail', + 'MAIL_SMTP_PORT=25', + 'MAIL_SMTP_USERNAME=' + (app.location || app.id), // use app.id for bare domains + 'MAIL_DOMAIN=' + config.fqdn() + ]; + + debugApp(app, 'Setting up sendmail'); + + appdb.setAddonConfig(app.id, 'sendmail', env, callback); +} + +function teardownSendMail(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'Tearing down sendmail'); + + appdb.unsetAddonConfig(app.id, 'sendmail', callback); +} + +function setupMySql(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'Setting up mysql'); + + var container = docker.getContainer('mysql'); + var cmd = [ '/addons/mysql/service.sh', 'add', app.id ]; + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var stdout = new MemoryStream(); + var stderr = new MemoryStream(); + + execContainer.modem.demuxStream(stream, stdout, stderr); + stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output + + var chunks = [ ]; + stdout.on('data', function (chunk) { chunks.push(chunk); }); + + stream.on('error', callback); + stream.on('end', function () { + var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline + debugApp(app, 'Setting mysql addon config to %j', env); + appdb.setAddonConfig(app.id, 'mysql', env, callback); + }); + }); + }); +} + +function teardownMySql(app, callback) { + var container = docker.getContainer('mysql'); + var cmd = [ '/addons/mysql/service.sh', 'remove', app.id ]; + + debugApp(app, 'Tearing down mysql'); + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var data = ''; + stream.on('error', callback); + stream.on('data', function (d) { data += d.toString('utf8'); }); + stream.on('end', function () { + appdb.unsetAddonConfig(app.id, 'mysql', callback); + }); + }); + }); +} + +function backupMySql(app, callback) { + debugApp(app, 'Backing up mysql'); + + callback = once(callback); // ChildProcess exit may or may not be called after error + + var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mysqldump')); + output.on('error', callback); + + var cp = spawn('/usr/bin/docker', [ 'exec', 'mysql', '/addons/mysql/service.sh', 'backup', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'backupMySql: done. code:%s signal:%s', code, signal); + if (!callback.called) callback(code ? 'backupMySql failed with status ' + code : null); + }); + + cp.stdout.pipe(output); + cp.stderr.pipe(process.stderr); +} + +function restoreMySql(app, callback) { + callback = once(callback); // ChildProcess exit may or may not be called after error + + setupMySql(app, function (error) { + if (error) return callback(error); + + debugApp(app, 'restoreMySql'); + + var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mysqldump')); + input.on('error', callback); + + // cannot get this to work through docker.exec + var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'mysql', '/addons/mysql/service.sh', 'restore', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'restoreMySql: done %s %s', code, signal); + if (!callback.called) callback(code ? 'restoreMySql failed with status ' + code : null); + }); + + cp.stdout.pipe(process.stdout); + cp.stderr.pipe(process.stderr); + input.pipe(cp.stdin).on('error', callback); + }); +} + +function setupPostgreSql(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'Setting up postgresql'); + + var container = docker.getContainer('postgresql'); + var cmd = [ '/addons/postgresql/service.sh', 'add', app.id ]; + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var stdout = new MemoryStream(); + var stderr = new MemoryStream(); + + execContainer.modem.demuxStream(stream, stdout, stderr); + stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output + + var chunks = [ ]; + stdout.on('data', function (chunk) { chunks.push(chunk); }); + + stream.on('error', callback); + stream.on('end', function () { + var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline + debugApp(app, 'Setting postgresql addon config to %j', env); + appdb.setAddonConfig(app.id, 'postgresql', env, callback); + }); + }); + }); +} + +function teardownPostgreSql(app, callback) { + var container = docker.getContainer('postgresql'); + var cmd = [ '/addons/postgresql/service.sh', 'remove', app.id ]; + + debugApp(app, 'Tearing down postgresql'); + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var data = ''; + stream.on('error', callback); + stream.on('data', function (d) { data += d.toString('utf8'); }); + stream.on('end', function () { + appdb.unsetAddonConfig(app.id, 'postgresql', callback); + }); + }); + }); +} + +function backupPostgreSql(app, callback) { + debugApp(app, 'Backing up postgresql'); + + callback = once(callback); // ChildProcess exit may or may not be called after error + + var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump')); + output.on('error', callback); + + var cp = spawn('/usr/bin/docker', [ 'exec', 'postgresql', '/addons/postgresql/service.sh', 'backup', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'backupPostgreSql: done %s %s', code, signal); + if (!callback.called) callback(code ? 'backupPostgreSql failed with status ' + code : null); + }); + + cp.stdout.pipe(output); + cp.stderr.pipe(process.stderr); +} + +function restorePostgreSql(app, callback) { + callback = once(callback); // ChildProcess exit may or may not be called after error + + setupPostgreSql(app, function (error) { + if (error) return callback(error); + + debugApp(app, 'restorePostgreSql'); + + var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'postgresqldump')); + input.on('error', callback); + + // cannot get this to work through docker.exec + var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'postgresql', '/addons/postgresql/service.sh', 'restore', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'restorePostgreSql: done %s %s', code, signal); + if (!callback.called) callback(code ? 'restorePostgreSql failed with status ' + code : null); + }); + + cp.stdout.pipe(process.stdout); + cp.stderr.pipe(process.stderr); + input.pipe(cp.stdin).on('error', callback); + }); +} + +function setupMongoDb(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + debugApp(app, 'Setting up mongodb'); + + var container = docker.getContainer('mongodb'); + var cmd = [ '/addons/mongodb/service.sh', 'add', app.id ]; + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var stdout = new MemoryStream(); + var stderr = new MemoryStream(); + + execContainer.modem.demuxStream(stream, stdout, stderr); + stderr.on('data', function (data) { debugApp(app, data.toString('utf8')); }); // set -e output + + var chunks = [ ]; + stdout.on('data', function (chunk) { chunks.push(chunk); }); + + stream.on('error', callback); + stream.on('end', function () { + var env = Buffer.concat(chunks).toString('utf8').split('\n').slice(0, -1); // remove trailing newline + debugApp(app, 'Setting mongodb addon config to %j', env); + appdb.setAddonConfig(app.id, 'mongodb', env, callback); + }); + }); + }); +} + +function teardownMongoDb(app, callback) { + var container = docker.getContainer('mongodb'); + var cmd = [ '/addons/mongodb/service.sh', 'remove', app.id ]; + + debugApp(app, 'Tearing down mongodb'); + + container.exec({ Cmd: cmd, AttachStdout: true, AttachStderr: true }, function (error, execContainer) { + if (error) return callback(error); + + execContainer.start(function (error, stream) { + if (error) return callback(error); + + var data = ''; + stream.on('error', callback); + stream.on('data', function (d) { data += d.toString('utf8'); }); + stream.on('end', function () { + appdb.unsetAddonConfig(app.id, 'mongodb', callback); + }); + }); + }); +} + +function backupMongoDb(app, callback) { + debugApp(app, 'Backing up mongodb'); + + callback = once(callback); // ChildProcess exit may or may not be called after error + + var output = fs.createWriteStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump')); + output.on('error', callback); + + var cp = spawn('/usr/bin/docker', [ 'exec', 'mongodb', '/addons/mongodb/service.sh', 'backup', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'backupMongoDb: done %s %s', code, signal); + if (!callback.called) callback(code ? 'backupMongoDb failed with status ' + code : null); + }); + + cp.stdout.pipe(output); + cp.stderr.pipe(process.stderr); +} + +function restoreMongoDb(app, callback) { + callback = once(callback); // ChildProcess exit may or may not be called after error + + setupMongoDb(app, function (error) { + if (error) return callback(error); + + debugApp(app, 'restoreMongoDb'); + + var input = fs.createReadStream(path.join(paths.DATA_DIR, app.id, 'mongodbdump')); + input.on('error', callback); + + // cannot get this to work through docker.exec + var cp = spawn('/usr/bin/docker', [ 'exec', '-i', 'mongodb', '/addons/mongodb/service.sh', 'restore', app.id ]); + cp.on('error', callback); + cp.on('exit', function (code, signal) { + debugApp(app, 'restoreMongoDb: done %s %s', code, signal); + if (!callback.called) callback(code ? 'restoreMongoDb failed with status ' + code : null); + }); + + cp.stdout.pipe(process.stdout); + cp.stderr.pipe(process.stderr); + input.pipe(cp.stdin).on('error', callback); + }); +} + + +function forwardRedisPort(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + docker.getContainer('redis-' + appId).inspect(function (error, data) { + if (error) return callback(new Error('Unable to inspect container:' + error)); + + var redisPort = parseInt(safe.query(data, 'NetworkSettings.Ports.6379/tcp[0].HostPort'), 10); + if (!Number.isInteger(redisPort)) return callback(new Error('Unable to get container port mapping')); + + vbox.forwardFromHostToVirtualBox('redis-' + appId, redisPort); + + return callback(null); + }); +} + +// Ensures that app's addon redis container is running. Can be called when named container already exists/running +function setupRedis(app, callback) { + var redisPassword = generatePassword(64, false /* memorable */); + var redisVarsFile = path.join(paths.ADDON_CONFIG_DIR, 'redis-' + app.id + '_vars.sh'); + var redisDataDir = path.join(paths.DATA_DIR, app.id + '/redis'); + + if (!safe.fs.writeFileSync(redisVarsFile, 'REDIS_PASSWORD=' + redisPassword)) { + return callback(new Error('Error writing redis config')); + } + + if (!safe.fs.mkdirSync(redisDataDir) && safe.error.code !== 'EEXIST') return callback(new Error('Error creating redis data dir:' + safe.error)); + + var createOptions = { + name: 'redis-' + app.id, + Hostname: config.appFqdn(app.location), + Tty: true, + Image: 'cloudron/redis:0.3.0', + Cmd: null, + Volumes: {}, + VolumesFrom: [] + }; + + var isMac = os.platform() === 'darwin'; + + var startOptions = { + Binds: [ + redisVarsFile + ':/etc/redis/redis_vars.sh:r', + redisDataDir + ':/var/lib/redis:rw' + ], + // On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work + // On linux, export to localhost only for testing purposes and not for the app itself + PortBindings: { + '6379/tcp': [{ HostPort: '0', HostIp: isMac ? '0.0.0.0' : '127.0.0.1' }] + }, + RestartPolicy: { + 'Name': 'always', + 'MaximumRetryCount': 0 + } + }; + + var env = [ + 'REDIS_URL=redis://redisuser:' + redisPassword + '@redis-' + app.id, + 'REDIS_PASSWORD=' + redisPassword, + 'REDIS_HOST=redis-' + app.id, + 'REDIS_PORT=6379' + ]; + + var redisContainer = docker.getContainer(createOptions.name); + redisContainer.remove({ force: true, v: false }, function (ignoredError) { + docker.createContainer(createOptions, function (error) { + if (error && error.statusCode !== 409) return callback(error); // if not already created + + redisContainer.start(startOptions, function (error) { + if (error && error.statusCode !== 304) return callback(error); // if not already running + + appdb.setAddonConfig(app.id, 'redis', env, function (error) { + if (error) return callback(error); + + forwardRedisPort(app.id, callback); + }); + }); + }); + }); +} + +function teardownRedis(app, callback) { + var container = docker.getContainer('redis-' + app.id); + + var removeOptions = { + force: true, // kill container if it's running + v: false // removes volumes associated with the container + }; + + container.remove(removeOptions, function (error) { + if (error && error.statusCode !== 404) return callback(new Error('Error removing container:' + error)); + + vbox.unforwardFromHostToVirtualBox('redis-' + app.id); + + safe.fs.unlinkSync(paths.ADDON_CONFIG_DIR, 'redis-' + app.id + '_vars.sh'); + + shell.sudo('teardownRedis', [ RMAPPDIR_CMD, app.id + '/redis' ], function (error, stdout, stderr) { + if (error) return callback(new Error('Error removing redis data:' + error)); + + appdb.unsetAddonConfig(app.id, 'redis', callback); + }); + }); +} + +function allocateAccessToken(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var token = tokendb.generateToken(); + var expiresAt = Number.MAX_SAFE_INTEGER; // basically never expire + var scopes = 'profile,users'; // TODO This should be put into the manifest and the user should know those + var clientId = ''; // meaningless for apps so far + + tokendb.delByIdentifier(tokendb.PREFIX_APP + app.id, function (error) { + if (error && error.reason !== DatabaseError.NOT_FOUND) return callback(error); + + tokendb.add(token, tokendb.PREFIX_APP + app.id, clientId, expiresAt, scopes, function (error) { + if (error) return callback(error); + + var env = [ + 'CLOUDRON_TOKEN=' + token + ]; + + debugApp(app, 'Setting token addon config to %j', env); + + appdb.setAddonConfig(appId, 'token', env, callback); + }); + }); +} + +function removeAccessToken(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + tokendb.delByIdentifier(tokendb.PREFIX_APP + app.id, function (error) { + if (error && error.reason !== DatabaseError.NOT_FOUND) console.error(error); + + appdb.unsetAddonConfig(app.id, 'token', callback); + }); +} + diff --git a/src/aes-helper.js b/src/aes-helper.js new file mode 100644 index 000000000..db96d6e7a --- /dev/null +++ b/src/aes-helper.js @@ -0,0 +1,49 @@ +'use strict'; + +var crypto = require('crypto'); + +// This code is taken from https://github.com/fabdrol/node-aes-helper +module.exports = { + algorithm: 'AES-256-CBC', + + key: function (password, salt) { + var key = salt.toString('utf8') + password; + var hash = crypto.createHash('sha1'); + + hash.update(key, 'utf8'); + return hash.digest('hex'); + }, + + encrypt: function (plain, password, salt) { + var key = this.key(password, salt); + var cipher = crypto.createCipher(this.algorithm, key); + var crypted; + + try { + crypted = cipher.update(plain, 'utf8', 'hex'); + crypted += cipher.final('hex'); + } catch (e) { + console.error('Encryption error:', e); + crypted = ''; + } + + return crypted; + }, + + decrypt: function (crypted, password, salt) { + var key = this.key(password, salt); + var decipher = crypto.createDecipher(this.algorithm, key); + var decoded; + + try { + decoded = decipher.update(crypted, 'hex', 'utf8'); + decoded += decipher.final('utf8'); + } catch (e) { + console.error('Decryption error:', e); + decoded = ''; + } + + return decoded; + } +}; + diff --git a/src/appdb.js b/src/appdb.js new file mode 100644 index 000000000..af895237d --- /dev/null +++ b/src/appdb.js @@ -0,0 +1,448 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + get: get, + getBySubdomain: getBySubdomain, + getByHttpPort: getByHttpPort, + add: add, + exists: exists, + del: del, + update: update, + getAll: getAll, + getPortBindings: getPortBindings, + + setAddonConfig: setAddonConfig, + getAddonConfig: getAddonConfig, + getAddonConfigByAppId: getAddonConfigByAppId, + unsetAddonConfig: unsetAddonConfig, + unsetAddonConfigByAppId: unsetAddonConfigByAppId, + + setHealth: setHealth, + setInstallationCommand: setInstallationCommand, + setRunCommand: setRunCommand, + getAppStoreIds: getAppStoreIds, + + // installation codes (keep in sync in UI) + ISTATE_PENDING_INSTALL: 'pending_install', // installs and fresh reinstalls + ISTATE_PENDING_CONFIGURE: 'pending_configure', // config (location, port) changes and on infra update + ISTATE_PENDING_UNINSTALL: 'pending_uninstall', // uninstallation + ISTATE_PENDING_RESTORE: 'pending_restore', // restore to previous backup or on upgrade + ISTATE_PENDING_UPDATE: 'pending_update', // update from installed state preserving data + ISTATE_PENDING_FORCE_UPDATE: 'pending_force_update', // update from any state preserving data + ISTATE_PENDING_BACKUP: 'pending_backup', // backup the app + ISTATE_ERROR: 'error', // error executing last pending_* command + ISTATE_INSTALLED: 'installed', // app is installed + + // run codes (keep in sync in UI) + RSTATE_RUNNING: 'running', + RSTATE_PENDING_START: 'pending_start', + RSTATE_PENDING_STOP: 'pending_stop', + RSTATE_STOPPED: 'stopped', // app stopped by use + + HEALTH_HEALTHY: 'healthy', + HEALTH_UNHEALTHY: 'unhealthy', + HEALTH_ERROR: 'error', + HEALTH_DEAD: 'dead', + + _clear: clear +}; + +var assert = require('assert'), + async = require('async'), + database = require('./database.js'), + DatabaseError = require('./databaseerror'), + safe = require('safetydance'), + util = require('util'); + +var APPS_FIELDS = [ 'id', 'appStoreId', 'installationState', 'installationProgress', 'runState', + 'health', 'containerId', 'manifestJson', 'httpPort', 'location', 'dnsRecordId', + 'accessRestriction', 'lastBackupId', 'lastBackupConfigJson', 'oldConfigJson' ].join(','); + +var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.installationProgress', 'apps.runState', + 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'apps.location', 'apps.dnsRecordId', + 'apps.accessRestriction', 'apps.lastBackupId', 'apps.lastBackupConfigJson', 'apps.oldConfigJson' ].join(','); + +var PORT_BINDINGS_FIELDS = [ 'hostPort', 'environmentVariable', 'appId' ].join(','); + +function postProcess(result) { + assert.strictEqual(typeof result, 'object'); + + assert(result.manifestJson === null || typeof result.manifestJson === 'string'); + result.manifest = safe.JSON.parse(result.manifestJson); + delete result.manifestJson; + + assert(result.lastBackupConfigJson === null || typeof result.lastBackupConfigJson === 'string'); + result.lastBackupConfig = safe.JSON.parse(result.lastBackupConfigJson); + delete result.lastBackupConfigJson; + + assert(result.oldConfigJson === null || typeof result.oldConfigJson === 'string'); + result.oldConfig = safe.JSON.parse(result.oldConfigJson); + delete result.oldConfigJson; + + assert(result.hostPorts === null || typeof result.hostPorts === 'string'); + assert(result.environmentVariables === null || typeof result.environmentVariables === 'string'); + + result.portBindings = { }; + var hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(','); + var environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(','); + + delete result.hostPorts; + delete result.environmentVariables; + + for (var i = 0; i < environmentVariables.length; i++) { + result.portBindings[environmentVariables[i]] = parseInt(hostPorts[i], 10); + } +} + +function get(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APPS_FIELDS_PREFIXED + ',' + + 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables' + + ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE apps.id = ? GROUP BY apps.id', [ id ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + postProcess(result[0]); + + callback(null, result[0]); + }); +} + +function getBySubdomain(subdomain, callback) { + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APPS_FIELDS_PREFIXED + ',' + + 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables' + + ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE location = ? GROUP BY apps.id', [ subdomain ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + postProcess(result[0]); + + callback(null, result[0]); + }); +} + +function getByHttpPort(httpPort, callback) { + assert.strictEqual(typeof httpPort, 'number'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APPS_FIELDS_PREFIXED + ',' + + 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables' + + ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId WHERE httpPort = ? GROUP BY apps.id', [ httpPort ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + postProcess(result[0]); + + callback(null, result[0]); + }); +} + +function getAll(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + APPS_FIELDS_PREFIXED + ',' + + 'GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables' + + ' FROM apps LEFT OUTER JOIN appPortBindings ON apps.id = appPortBindings.appId' + + ' GROUP BY apps.id ORDER BY apps.id', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + results.forEach(postProcess); + + callback(null, results); + }); +} + +function add(id, appStoreId, manifest, location, portBindings, accessRestriction, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof appStoreId, 'string'); + assert(manifest && typeof manifest === 'object'); + assert.strictEqual(typeof manifest.version, 'string'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof portBindings, 'object'); + assert.strictEqual(typeof accessRestriction, 'string'); + assert.strictEqual(typeof callback, 'function'); + + portBindings = portBindings || { }; + + var manifestJson = JSON.stringify(manifest); + + var queries = [ ]; + queries.push({ + query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, location, accessRestriction) VALUES (?, ?, ?, ?, ?, ?)', + args: [ id, appStoreId, manifestJson, exports.ISTATE_PENDING_INSTALL, location, accessRestriction ] + }); + + Object.keys(portBindings).forEach(function (env) { + queries.push({ + query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, appId) VALUES (?, ?, ?)', + args: [ env, portBindings[env], id ] + }); + }); + + database.transaction(queries, function (error) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error.message)); + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function exists(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT 1 FROM apps WHERE id=?', [ id ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null, result.length !== 0); + }); +} + +function getPortBindings(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + PORT_BINDINGS_FIELDS + ' FROM appPortBindings WHERE appId = ?', [ id ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + var portBindings = { }; + for (var i = 0; i < results.length; i++) { + portBindings[results[i].environmentVariable] = results[i].hostPort; + } + + callback(null, portBindings); + }); +} + +function del(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var queries = [ + { query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] }, + { query: 'DELETE FROM apps WHERE id = ?', args: [ id ] } + ]; + + database.transaction(queries, function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (results[1].affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null); + }); +} + +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + + async.series([ + database.query.bind(null, 'DELETE FROM appPortBindings'), + database.query.bind(null, 'DELETE FROM appAddonConfigs'), + database.query.bind(null, 'DELETE FROM apps') + ], function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + return callback(null); + }); +} + +function update(id, app, callback) { + updateWithConstraints(id, app, '', callback); +} + +function updateWithConstraints(id, app, constraints, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof constraints, 'string'); + assert.strictEqual(typeof callback, 'function'); + assert(!('portBindings' in app) || typeof app.portBindings === 'object'); + + var queries = [ ]; + + if ('portBindings' in app) { + var portBindings = app.portBindings || { }; + // replace entries by app id + queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] }); + Object.keys(portBindings).forEach(function (env) { + var values = [ portBindings[env], env, id ]; + queries.push({ query: 'INSERT INTO appPortBindings (hostPort, environmentVariable, appId) VALUES(?, ?, ?)', args: values }); + }); + } + + var fields = [ ], values = [ ]; + for (var p in app) { + if (p === 'manifest') { + fields.push('manifestJson = ?'); + values.push(JSON.stringify(app[p])); + } else if (p === 'lastBackupConfig') { + fields.push('lastBackupConfigJson = ?'); + values.push(JSON.stringify(app[p])); + } else if (p === 'oldConfig') { + fields.push('oldConfigJson = ?'); + values.push(JSON.stringify(app[p])); + } else if (p !== 'portBindings') { + fields.push(p + ' = ?'); + values.push(app[p]); + } + } + + if (values.length !== 0) { + values.push(id); + queries.push({ query: 'UPDATE apps SET ' + fields.join(', ') + ' WHERE id = ? ' + constraints, args: values }); + } + + database.transaction(queries, function (error, results) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error.message)); + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (results[results.length - 1].affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null); + }); +} + +// not sure if health should influence runState +function setHealth(appId, health, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof health, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var values = { health: health }; + + var constraints = 'AND runState NOT LIKE "pending_%" AND installationState = "installed"'; + + updateWithConstraints(appId, values, constraints, callback); +} + +function setInstallationCommand(appId, installationState, values, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof installationState, 'string'); + + if (typeof values === 'function') { + callback = values; + values = { }; + } else { + assert.strictEqual(typeof values, 'object'); + assert.strictEqual(typeof callback, 'function'); + } + + values.installationState = installationState; + values.installationProgress = ''; + + // Rules are: + // uninstall is allowed in any state + // restore is allowed from installed or error state + // update and configure are allowed only in installed state + + if (installationState === exports.ISTATE_PENDING_UNINSTALL || installationState === exports.ISTATE_PENDING_FORCE_UPDATE) { + updateWithConstraints(appId, values, '', callback); + } else if (installationState === exports.ISTATE_PENDING_RESTORE) { + updateWithConstraints(appId, values, 'AND (installationState = "installed" OR installationState = "error")', callback); + } else if (installationState === exports.ISTATE_PENDING_UPDATE || exports.ISTATE_PENDING_CONFIGURE || installationState == exports.ISTATE_PENDING_BACKUP) { + updateWithConstraints(appId, values, 'AND installationState = "installed"', callback); + } else { + callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, 'invalid installationState')); + } +} + +function setRunCommand(appId, runState, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof runState, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var values = { runState: runState }; + updateWithConstraints(appId, values, 'AND runState NOT LIKE "pending_%" AND installationState = "installed"', callback); +} + +function getAppStoreIds(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT id, appStoreId FROM apps', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function setAddonConfig(appId, addonId, env, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof addonId, 'string'); + assert(util.isArray(env)); + assert.strictEqual(typeof callback, 'function'); + + unsetAddonConfig(appId, addonId, function (error) { + if (error) return callback(error); + + if (env.length === 0) return callback(null); + + var query = 'INSERT INTO appAddonConfigs(appId, addonId, value) VALUES '; + var args = [ ], queryArgs = [ ]; + for (var i = 0; i < env.length; i++) { + args.push(appId, addonId, env[i]); + queryArgs.push('(?, ?, ?)'); + } + + database.query(query + queryArgs.join(','), args, function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null); + }); + }); +} + +function unsetAddonConfig(appId, addonId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof addonId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function unsetAddonConfigByAppId(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function getAddonConfig(appId, addonId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof addonId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT value FROM appAddonConfigs WHERE appId = ? AND addonId = ?', [ appId, addonId ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + var config = [ ]; + results.forEach(function (v) { config.push(v.value); }); + + callback(null, config); + }); +} + +function getAddonConfigByAppId(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT value FROM appAddonConfigs WHERE appId = ?', [ appId ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + var config = [ ]; + results.forEach(function (v) { config.push(v.value); }); + + callback(null, config); + }); +} + diff --git a/src/apps.js b/src/apps.js new file mode 100644 index 000000000..f49d4fe1a --- /dev/null +++ b/src/apps.js @@ -0,0 +1,765 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + AppsError: AppsError, + + get: get, + getBySubdomain: getBySubdomain, + getAll: getAll, + purchase: purchase, + install: install, + configure: configure, + uninstall: uninstall, + + restore: restore, + restoreApp: restoreApp, + + update: update, + + backup: backup, + backupApp: backupApp, + + getLogStream: getLogStream, + getLogs: getLogs, + + start: start, + stop: stop, + + exec: exec, + + checkManifestConstraints: checkManifestConstraints, + + setRestorePoint: setRestorePoint, + + autoupdateApps: autoupdateApps, + + // exported for testing + _validateHostname: validateHostname, + _validatePortBindings: validatePortBindings +}; + +var addons = require('./addons.js'), + appdb = require('./appdb.js'), + assert = require('assert'), + async = require('async'), + backups = require('./backups.js'), + BackupsError = require('./backups.js').BackupsError, + config = require('./config.js'), + constants = require('./constants.js'), + DatabaseError = require('./databaseerror.js'), + debug = require('debug')('box:apps'), + docker = require('./docker.js'), + fs = require('fs'), + manifestFormat = require('cloudron-manifestformat'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + semver = require('semver'), + shell = require('./shell.js'), + split = require('split'), + superagent = require('superagent'), + taskmanager = require('./taskmanager.js'), + util = require('util'), + validator = require('validator'); + +var BACKUP_APP_CMD = path.join(__dirname, 'scripts/backupapp.sh'), + RESTORE_APP_CMD = path.join(__dirname, 'scripts/restoreapp.sh'), + BACKUP_SWAP_CMD = path.join(__dirname, 'scripts/backupswap.sh'); + +function debugApp(app, args) { + assert(!app || typeof app === 'object'); + + var prefix = app ? app.location : '(no app)'; + debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); +} + +function ignoreError(func) { + return function (callback) { + func(function (error) { + if (error) console.error('Ignored error:', error); + callback(); + }); + }; +} + +// http://dustinsenos.com/articles/customErrorsInNode +// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi +function AppsError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(AppsError, Error); +AppsError.INTERNAL_ERROR = 'Internal Error'; +AppsError.EXTERNAL_ERROR = 'External Error'; +AppsError.ALREADY_EXISTS = 'Already Exists'; +AppsError.NOT_FOUND = 'Not Found'; +AppsError.BAD_FIELD = 'Bad Field'; +AppsError.BAD_STATE = 'Bad State'; +AppsError.PORT_RESERVED = 'Port Reserved'; +AppsError.PORT_CONFLICT = 'Port Conflict'; +AppsError.BILLING_REQUIRED = 'Billing Required'; + +// Hostname validation comes from RFC 1123 (section 2.1) +// Domain name validation comes from RFC 2181 (Name syntax) +// https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names +// We are validating the validity of the location-fqdn as host name +function validateHostname(location, fqdn) { + var RESERVED_LOCATIONS = [ constants.ADMIN_LOCATION, constants.API_LOCATION ]; + + if (RESERVED_LOCATIONS.indexOf(location) !== -1) return new Error(location + ' is reserved'); + + if (location === '') return null; // bare location + + if ((location.length + 1 /*+ hyphen */ + fqdn.indexOf('.')) > 63) return new Error('Hostname length cannot be greater than 63'); + if (location.match(/^[A-Za-z0-9-]+$/) === null) return new Error('Hostname can only contain alphanumerics and hyphen'); + if (location[0] === '-' || location[location.length-1] === '-') return new Error('Hostname cannot start or end with hyphen'); + if (location.length + 1 /* hyphen */ + fqdn.length > 253) return new Error('FQDN length exceeds 253 characters'); + + return null; +} + +// validate the port bindings +function validatePortBindings(portBindings, tcpPorts) { + // keep the public ports in sync with firewall rules in scripts/initializeBaseUbuntuImage.sh + // these ports are reserved even if we listen only on 127.0.0.1 because we setup HostIp to be 127.0.0.1 + // for custom tcp ports + var RESERVED_PORTS = [ + 22, /* ssh */ + 25, /* smtp */ + 53, /* dns */ + 80, /* http */ + 443, /* https */ + 2003, /* graphite (lo) */ + 2004, /* graphite (lo) */ + 2020, /* install server */ + config.get('port'), /* app server (lo) */ + config.get('internalPort'), /* internal app server (lo) */ + 3306, /* mysql (lo) */ + 8000 /* graphite (lo) */ + ]; + + if (!portBindings) return null; + + var env; + for (env in portBindings) { + if (!/^[a-zA-Z0-9_]+$/.test(env)) return new AppsError(AppsError.BAD_FIELD, env + ' is not valid environment variable'); + + if (!Number.isInteger(portBindings[env])) return new Error(portBindings[env] + ' is not an integer'); + if (portBindings[env] <= 0 || portBindings[env] > 65535) return new Error(portBindings[env] + ' is out of range'); + + if (RESERVED_PORTS.indexOf(portBindings[env]) !== -1) return new AppsError(AppsError.PORT_RESERVED, + portBindings[env]); + } + + // it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and portBindings. missing values implies + // that the user wants the service disabled + tcpPorts = tcpPorts || { }; + for (env in portBindings) { + if (!(env in tcpPorts)) return new AppsError(AppsError.BAD_FIELD, 'Invalid portBindings ' + env); + } + + return null; +} + +function getDuplicateErrorDetails(location, portBindings, error) { + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof portBindings, 'object'); + assert.strictEqual(error.reason, DatabaseError.ALREADY_EXISTS); + + var match = error.message.match(/ER_DUP_ENTRY: Duplicate entry '(.*)' for key/); + if (!match) { + console.error('Unexpected SQL error message.', error); + return new AppsError(AppsError.INTERNAL_ERROR); + } + + // check if the location conflicts + if (match[1] === location) return new AppsError(AppsError.ALREADY_EXISTS); + + // check if any of the port bindings conflict + for (var env in portBindings) { + if (portBindings[env] === parseInt(match[1])) return new AppsError(AppsError.PORT_CONFLICT, match[1]); + } + + return new AppsError(AppsError.ALREADY_EXISTS); +} + +function getIconUrlSync(app) { + var iconPath = paths.APPICONS_DIR + '/' + app.id + '.png'; + return fs.existsSync(iconPath) ? '/api/v1/apps/' + app.id + '/icon' : null; +} + +function get(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + app.iconUrl = getIconUrlSync(app); + app.fqdn = config.appFqdn(app.location); + + callback(null, app); + }); +} + +function getBySubdomain(subdomain, callback) { + assert.strictEqual(typeof subdomain, 'string'); + assert.strictEqual(typeof callback, 'function'); + + appdb.getBySubdomain(subdomain, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + app.iconUrl = getIconUrlSync(app); + app.fqdn = config.appFqdn(app.location); + + callback(null, app); + }); +} + +function getAll(callback) { + assert.strictEqual(typeof callback, 'function'); + + appdb.getAll(function (error, apps) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + apps.forEach(function (app) { + app.iconUrl = getIconUrlSync(app); + app.fqdn = config.appFqdn(app.location); + }); + + callback(null, apps); + }); +} + +function validateAccessRestriction(accessRestriction) { + // TODO: make the values below enumerations in the oauth code + switch (accessRestriction) { + case '': + case 'roleUser': + case 'roleAdmin': + return null; + default: + return new Error('Invalid accessRestriction'); + } +} + +function purchase(appStoreId, callback) { + assert.strictEqual(typeof appStoreId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + // Skip purchase if appStoreId is empty + if (appStoreId === '') return callback(null); + + var url = config.apiServerOrigin() + '/api/v1/apps/' + appStoreId + '/purchase'; + + superagent.post(url).query({ token: config.token() }).end(function (error, res) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + if (res.status === 402) return callback(new AppsError(AppsError.BILLING_REQUIRED)); + if (res.status !== 201 && res.status !== 200) return callback(new Error(util.format('App purchase failed. %s %j', res.status, res.body))); + + callback(null); + }); +} + +function install(appId, appStoreId, manifest, location, portBindings, accessRestriction, icon, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof appStoreId, 'string'); + assert(manifest && typeof manifest === 'object'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof portBindings, 'object'); + assert.strictEqual(typeof accessRestriction, 'string'); + assert(!icon || typeof icon === 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = manifestFormat.parse(manifest); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest error: ' + error.message)); + + error = checkManifestConstraints(manifest); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed: ' + error.message)); + + error = validateHostname(location, config.fqdn()); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + error = validatePortBindings(portBindings, manifest.tcpPorts); + if (error) return callback(error); + + error = validateAccessRestriction(accessRestriction); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + if (icon) { + if (!validator.isBase64(icon)) return callback(new AppsError(AppsError.BAD_FIELD, 'icon is not base64')); + + if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, appId + '.png'), new Buffer(icon, 'base64'))) { + return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving icon:' + safe.error.message)); + } + } + + debug('Will install app with id : ' + appId); + + purchase(appStoreId, function (error) { + if (error) return callback(error); + + appdb.add(appId, appStoreId, manifest, location.toLowerCase(), portBindings, accessRestriction, function (error) { + if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location.toLowerCase(), portBindings, error)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); + }); +} + +function configure(appId, location, portBindings, accessRestriction, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof location, 'string'); + assert.strictEqual(typeof portBindings, 'object'); + assert.strictEqual(typeof accessRestriction, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validateHostname(location, config.fqdn()); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + error = validateAccessRestriction(accessRestriction); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + error = validatePortBindings(portBindings, app.manifest.tcpPorts); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + var values = { + location: location.toLowerCase(), + accessRestriction: accessRestriction, + portBindings: portBindings, + + oldConfig: { + location: app.location, + accessRestriction: app.accessRestriction, + portBindings: app.portBindings + } + }; + + debug('Will configure app with id:%s values:%j', appId, values); + + appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_CONFIGURE, values, function (error) { + if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails(location.toLowerCase(), portBindings, error)); + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); + }); +} + +function update(appId, force, manifest, portBindings, icon, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof force, 'boolean'); + assert(manifest && typeof manifest === 'object'); + assert(!portBindings || typeof portBindings === 'object'); + assert(!icon || typeof icon === 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will update app with id:%s', appId); + + var error = manifestFormat.parse(manifest); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest error:' + error.message)); + + error = checkManifestConstraints(manifest); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed:' + error.message)); + + error = validatePortBindings(portBindings, manifest.tcpPorts); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, error.message)); + + if (icon) { + if (!validator.isBase64(icon)) return callback(new AppsError(AppsError.BAD_FIELD, 'icon is not base64')); + + if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, appId + '.png'), new Buffer(icon, 'base64'))) { + return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Error saving icon:' + safe.error.message)); + } + } + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + var values = { + manifest: manifest, + portBindings: portBindings, + oldConfig: { + manifest: app.manifest, + portBindings: app.portBindings + } + }; + + appdb.setInstallationCommand(appId, force ? appdb.ISTATE_PENDING_FORCE_UPDATE : appdb.ISTATE_PENDING_UPDATE, values, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess + if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(getDuplicateErrorDetails('' /* location cannot conflict */, portBindings, error)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); + }); +} + +function getLogStream(appId, fromLine, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof fromLine, 'number'); // behaves like tail -n + assert.strictEqual(typeof callback, 'function'); + + debug('Getting logs for %s', appId); + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState))); + + var container = docker.getContainer(app.containerId); + var tail = fromLine < 0 ? -fromLine : 'all'; + + // note: cannot access docker file directly because it needs root access + container.logs({ stdout: true, stderr: true, follow: true, timestamps: true, tail: tail }, function (error, logStream) { + if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + var lineCount = 0; + var skipLinesStream = split(function mapper(line) { + if (++lineCount < fromLine) return undefined; + var timestamp = line.substr(0, line.indexOf(' ')); // sometimes this has square brackets around it + return JSON.stringify({ lineNumber: lineCount, timestamp: timestamp.replace(/[[\]]/g,''), log: line.substr(timestamp.length + 1) }); + }); + skipLinesStream.close = logStream.req.abort; + logStream.pipe(skipLinesStream); + return callback(null, skipLinesStream); + }); + }); +} + +function getLogs(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Getting logs for %s', appId); + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + if (app.installationState !== appdb.ISTATE_INSTALLED) return callback(new AppsError(AppsError.BAD_STATE, util.format('App is in %s state.', app.installationState))); + + var container = docker.getContainer(app.containerId); + // note: cannot access docker file directly because it needs root access + container.logs({ stdout: true, stderr: true, follow: false, timestamps: true, tail: 'all' }, function (error, logStream) { + if (error && error.statusCode === 404) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + return callback(null, logStream); + }); + }); +} + +function restore(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will restore app with id:%s', appId); + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + var restoreConfig = app.lastBackupConfig; + if (!restoreConfig) return callback(new AppsError(AppsError.BAD_STATE, 'No restore point')); + + // re-validate because this new box version may not accept old configs. if we restore location, it should be validated here as well + error = checkManifestConstraints(restoreConfig.manifest); + if (error) return callback(new AppsError(AppsError.BAD_FIELD, 'Manifest cannot be installed: ' + error.message)); + + error = validatePortBindings(restoreConfig.portBindings, restoreConfig.manifest.tcpPorts); // maybe new ports got reserved now + if (error) return callback(error); + + // ## should probably query new location, access restriction from user + var values = { + manifest: restoreConfig.manifest, + portBindings: restoreConfig.portBindings, + + oldConfig: { + location: app.location, + accessRestriction: app.accessRestriction, + portBindings: app.portBindings, + manifest: app.manifest + } + }; + + appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_RESTORE, values, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); + }); +} + +function uninstall(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will uninstall app with id:%s', appId); + + appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_UNINSTALL, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); // since uninstall is allowed from any state, kill current task + + callback(null); + }); +} + +function start(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will start app with id:%s', appId); + + appdb.setRunCommand(appId, appdb.RSTATE_PENDING_START, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); +} + +function stop(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Will stop app with id:%s', appId); + + appdb.setRunCommand(appId, appdb.RSTATE_PENDING_STOP, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); +} + +function checkManifestConstraints(manifest) { + if (semver.valid(manifest.maxBoxVersion) && semver.gt(config.version(), manifest.maxBoxVersion)) { + return new Error('Box version exceeds Apps maxBoxVersion'); + } + + if (semver.valid(manifest.minBoxVersion) && semver.gt(manifest.minBoxVersion, config.version())) { + return new Error('minBoxVersion exceeds Box version'); + } + + return null; +} + +function exec(appId, options, callback) { + assert.strictEqual(typeof appId, 'string'); + assert(options && typeof options === 'object'); + assert.strictEqual(typeof callback, 'function'); + + var cmd = options.cmd || [ '/bin/bash' ]; + assert(util.isArray(cmd) && cmd.length > 0); + + appdb.get(appId, function (error, app) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + var container = docker.getContainer(app.containerId); + + var execOptions = { + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: cmd + }; + + container.exec(execOptions, function (error, exec) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + var startOptions = { + Detach: false, + Tty: true, + stdin: true // this is a dockerode option that enabled openStdin in the modem + }; + exec.start(startOptions, function(error, stream) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + if (options.rows && options.columns) { + exec.resize({ h: options.rows, w: options.columns }, function (error) { if (error) debug('Error resizing console', error); }); + } + + return callback(null, stream); + }); + }); + }); +} + +function setRestorePoint(appId, lastBackupId, lastBackupConfig, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof lastBackupId, 'string'); + assert.strictEqual(typeof lastBackupConfig, 'object'); + assert.strictEqual(typeof callback, 'function'); + + appdb.update(appId, { lastBackupId: lastBackupId, lastBackupConfig: lastBackupConfig }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + return callback(null); + }); +} + +function autoupdateApps(updateInfo, callback) { // updateInfo is { appId -> { manifest } } + assert.strictEqual(typeof updateInfo, 'object'); + assert.strictEqual(typeof callback, 'function'); + + function canAutoupdateApp(app, newManifest) { + // TODO: maybe check the description as well? + if (!newManifest.tcpPorts && !app.portBindings) return true; + if (!newManifest.tcpPorts || !app.portBindings) return false; + + for (var env in newManifest.tcpPorts) { + if (!(env in app.portBindings)) return false; + } + + return true; + } + + if (!updateInfo) return callback(null); + + async.eachSeries(Object.keys(updateInfo), function iterator(appId, iteratorDone) { + get(appId, function (error, app) { + if (!canAutoupdateApp(app, updateInfo.manifest)) { + return iteratorDone(); + } + + update(appId, updateInfo.manifest, app.portBindings, null /* icon */, function (error) { + if (error) debug('Error initiating autoupdate of %s', appId); + + iteratorDone(null); + }); + }); + }, callback); +} + +function backupApp(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + function canBackupApp(app) { + // only backup apps that are installed or pending configure. Rest of them are in some + // state not good for consistent backup + + return (app.installationState === appdb.ISTATE_INSTALLED && app.health === appdb.HEALTH_HEALTHY) + || app.installationState === appdb.ISTATE_PENDING_CONFIGURE + || app.installationState === appdb.ISTATE_PENDING_BACKUP + || app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask + } + + if (!canBackupApp(app)) return callback(new AppsError(AppsError.BAD_STATE, 'App not healthy')); + + var appConfig = { + manifest: app.manifest, + location: app.location, + portBindings: app.portBindings, + accessRestriction: app.accessRestriction + }; + + if (!safe.fs.writeFileSync(path.join(paths.DATA_DIR, app.id + '/config.json'), JSON.stringify(appConfig), 'utf8')) { + return callback(safe.error); + } + + backups.getBackupUrl(app, null, function (error, result) { + if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + debugApp(app, 'backupApp: backup url:%s backup id:%s', result.url, result.id); + + async.series([ + ignoreError(shell.sudo.bind(null, 'mountSwap', [ BACKUP_SWAP_CMD, '--on' ])), + addons.backupAddons.bind(null, app, app.manifest.addons), + shell.sudo.bind(null, 'backupApp', [ BACKUP_APP_CMD, app.id, result.url, result.backupKey ]), + ignoreError(shell.sudo.bind(null, 'unmountSwap', [ BACKUP_SWAP_CMD, '--off' ])), + ], function (error) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + debugApp(app, 'backupApp: successful id:%s', result.id); + + setRestorePoint(app.id, result.id, appConfig, function (error) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + return callback(null, result.id); + }); + }); + }); +} + +function backup(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + get(appId, function (error, app) { + if (error && error.reason === AppsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + appdb.setInstallationCommand(appId, appdb.ISTATE_PENDING_BACKUP, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.BAD_STATE)); // might be a bad guess + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + taskmanager.restartAppTask(appId); + + callback(null); + }); + }); +} + +function restoreApp(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + assert(app.lastBackupId); + + backups.getRestoreUrl(app.lastBackupId, function (error, result) { + if (error && error.reason == BackupsError.EXTERNAL_ERROR) return callback(new AppsError(AppsError.EXTERNAL_ERROR, error.message)); + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + debugApp(app, 'restoreApp: restoreUrl:%s', result.url); + + shell.sudo('restoreApp', [ RESTORE_APP_CMD, app.id, result.url, result.backupKey ], function (error) { + if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); + + addons.restoreAddons(app, app.manifest.addons, callback); + }); + }); +} + diff --git a/src/apptask.js b/src/apptask.js new file mode 100644 index 000000000..d1d999295 --- /dev/null +++ b/src/apptask.js @@ -0,0 +1,929 @@ +#!/usr/bin/env node + +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + initialize: initialize, + startTask: startTask, + + // exported for testing + _getFreePort: getFreePort, + _configureNginx: configureNginx, + _unconfigureNginx: unconfigureNginx, + _createVolume: createVolume, + _deleteVolume: deleteVolume, + _allocateOAuthProxyCredentials: allocateOAuthProxyCredentials, + _removeOAuthProxyCredentials: removeOAuthProxyCredentials, + _verifyManifest: verifyManifest, + _registerSubdomain: registerSubdomain, + _unregisterSubdomain: unregisterSubdomain, + _reloadNginx: reloadNginx, + _waitForDnsPropagation: waitForDnsPropagation +}; + +require('supererror')({ splatchError: true }); + +var addons = require('./addons.js'), + appdb = require('./appdb.js'), + apps = require('./apps.js'), + assert = require('assert'), + async = require('async'), + clientdb = require('./clientdb.js'), + config = require('./config.js'), + database = require('./database.js'), + DatabaseError = require('./databaseerror.js'), + debug = require('debug')('box:apptask'), + docker = require('./docker.js'), + ejs = require('ejs'), + fs = require('fs'), + hat = require('hat'), + manifestFormat = require('cloudron-manifestformat'), + net = require('net'), + os = require('os'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + shell = require('./shell.js'), + superagent = require('superagent'), + sysinfo = require('./sysinfo.js'), + util = require('util'), + uuid = require('node-uuid'), + vbox = require('./vbox.js'), + _ = require('underscore'); + +var NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/../setup/start/nginx/appconfig.ejs', { encoding: 'utf8' }), + COLLECTD_CONFIG_EJS = fs.readFileSync(__dirname + '/collectd.config.ejs', { encoding: 'utf8' }), + RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'), + RELOAD_COLLECTD_CMD = path.join(__dirname, 'scripts/reloadcollectd.sh'), + RMAPPDIR_CMD = path.join(__dirname, 'scripts/rmappdir.sh'), + CREATEAPPDIR_CMD = path.join(__dirname, 'scripts/createappdir.sh'); + +function initialize(callback) { + database.initialize(callback); +} + +function debugApp(app, args) { + assert(!app || typeof app === 'object'); + + var prefix = app ? (app.location || '(bare)') : '(no app)'; + debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); +} + +// We expect conflicts to not happen despite closing the port (parallel app installs, app update does not reconfigure nginx etc) +// https://tools.ietf.org/html/rfc6056#section-3.5 says linux uses random ephemeral port allocation +function getFreePort(callback) { + var server = net.createServer(); + server.listen(0, function () { + var port = server.address().port; + server.close(function () { + return callback(null, port); + }); + }); +} + +function reloadNginx(callback) { + shell.sudo('reloadNginx', [ RELOAD_NGINX_CMD ], callback); +} + +function configureNginx(app, callback) { + getFreePort(function (error, freePort) { + if (error) return callback(error); + + var sourceDir = path.resolve(__dirname, '..'); + var endpoint = app.accessRestriction ? 'oauthproxy' : 'app'; + var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, { sourceDir: sourceDir, vhost: config.appFqdn(app.location), port: freePort, endpoint: endpoint }); + + var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf'); + debugApp(app, 'writing config to %s', nginxConfigFilename); + + if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) { + debugApp(app, 'Error creating nginx config : %s', safe.error.message); + return callback(safe.error); + } + + async.series([ + exports._reloadNginx, + updateApp.bind(null, app, { httpPort: freePort }) + ], callback); + + vbox.forwardFromHostToVirtualBox(app.id + '-http', freePort); + }); +} + +function unconfigureNginx(app, callback) { + var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf'); + if (!safe.fs.unlinkSync(nginxConfigFilename)) { + debugApp(app, 'Error removing nginx configuration : %s', safe.error.message); + return callback(null); + } + + exports._reloadNginx(callback); + + vbox.unforwardFromHostToVirtualBox(app.id + '-http'); +} + +function downloadImage(app, callback) { + debugApp(app, 'downloadImage %s', app.manifest.dockerImage); + + docker.pull(app.manifest.dockerImage, function (err, stream) { + if (err) return callback(new Error('Error connecting to docker')); + + // https://github.com/dotcloud/docker/issues/1074 says each status message + // is emitted as a chunk + stream.on('data', function (chunk) { + var data = safe.JSON.parse(chunk) || { }; + debugApp(app, 'downloadImage data: %j', data); + + // The information here is useless because this is per layer as opposed to per image + if (data.status) { + debugApp(app, 'progress: %s', data.status); // progressDetail { current, total } + } else if (data.error) { + debugApp(app, 'error detail: %s', data.errorDetail.message); + } + }); + + stream.on('end', function () { + debugApp(app, 'download image successfully'); + + var image = docker.getImage(app.manifest.dockerImage); + + image.inspect(function (err, data) { + if (err) { + return callback(new Error('Error inspecting image:' + err.message)); + } + + if (!data || !data.Config) { + return callback(new Error('Missing Config in image:' + JSON.stringify(data, null, 4))); + } + + if (!data.Config.Entrypoint && !data.Config.Cmd) { + return callback(new Error('Only images with entry point are allowed')); + } + + debugApp(app, 'This image exposes ports: %j', data.Config.ExposedPorts); + return callback(null); + }); + }); + }); +} + +function createContainer(app, callback) { + appdb.getPortBindings(app.id, function (error, portBindings) { + if (error) return callback(error); + + var manifest = app.manifest; + var exposedPorts = {}; + var env = []; + + // docker portBindings requires ports to be exposed + exposedPorts[manifest.httpPort + '/tcp'] = {}; + + for (var e in portBindings) { + var hostPort = portBindings[e]; + var containerPort = manifest.tcpPorts[e].containerPort || hostPort; + exposedPorts[containerPort + '/tcp'] = {}; + + env.push(e + '=' + hostPort); + } + + env.push('CLOUDRON=1'); + env.push('ADMIN_ORIGIN' + '=' + config.adminOrigin()); // ## remove + env.push('WEBADMIN_ORIGIN' + '=' + config.adminOrigin()); + env.push('API_ORIGIN' + '=' + config.adminOrigin()); + + addons.getEnvironment(app, function (error, addonEnv) { + if (error) return callback(new Error('Error getting addon env: ' + error)); + + var containerOptions = { + name: app.id, + Hostname: config.appFqdn(app.location), + Tty: true, + Image: app.manifest.dockerImage, + Cmd: null, + Volumes: {}, + VolumesFrom: [], + Env: env.concat(addonEnv), + ExposedPorts: exposedPorts + }; + + debugApp(app, 'Creating container for %s', app.manifest.dockerImage); + + docker.createContainer(containerOptions, function (error, container) { + if (error) return callback(new Error('Error creating container: ' + error)); + + updateApp(app, { containerId: container.id }, callback); + }); + }); + }); +} + +function deleteContainer(app, callback) { + if (app.containerId === null) return callback(null); + + var container = docker.getContainer(app.containerId); + + var removeOptions = { + force: true, // kill container if it's running + v: false // removes volumes associated with the container + }; + + container.remove(removeOptions, function (error) { + if (error && error.statusCode === 404) return updateApp(app, { containerId: null }, callback); + + if (error) debugApp(app, 'Error removing container', error); + callback(error); + }); +} + +function deleteImage(app, manifest, callback) { + var dockerImage = manifest ? manifest.dockerImage : null; + if (!dockerImage) return callback(null); + + docker.getImage(dockerImage).inspect(function (error, result) { + if (error && error.statusCode === 404) return callback(null); + + if (error) return callback(error); + + var removeOptions = { + force: true, + noprune: false + }; + + // delete image by id because docker pull pulls down all the tags and this is the only way to delete all tags + docker.getImage(result.Id).remove(removeOptions, function (error) { + if (error && error.statusCode === 404) return callback(null); + if (error && error.statusCode === 409) return callback(null); // another container using the image + + if (error) debugApp(app, 'Error removing image', error); + + callback(error); + }); + }); +} + +function createVolume(app, callback) { + shell.sudo('createVolume', [ CREATEAPPDIR_CMD, app.id ], callback); +} + +function deleteVolume(app, callback) { + shell.sudo('deleteVolume', [ RMAPPDIR_CMD, app.id ], callback); +} + +function allocateOAuthProxyCredentials(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (!app.accessRestriction) return callback(null); + + var appId = 'proxy-' + app.id; + var id = 'cid-proxy-' + uuid.v4(); + var clientSecret = hat(256); + var redirectURI = 'https://' + config.appFqdn(app.location); + var scope = 'profile,' + app.accessRestriction; + + clientdb.add(id, appId, clientSecret, redirectURI, scope, callback); +} + +function removeOAuthProxyCredentials(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + clientdb.delByAppId('proxy-' + app.id, function (error) { + if (error && error.reason !== DatabaseError.NOT_FOUND) { + debugApp(app, 'Error removing OAuth client id', error); + return callback(error); + } + + callback(null); + }); +} + +function addCollectdProfile(app, callback) { + var collectdConf = ejs.render(COLLECTD_CONFIG_EJS, { appId: app.id, containerId: app.containerId }); + fs.writeFile(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), collectdConf, function (error) { + if (error) return callback(error); + shell.sudo('addCollectdProfile', [ RELOAD_COLLECTD_CMD ], callback); + }); +} + +function removeCollectdProfile(app, callback) { + fs.unlink(path.join(paths.COLLECTD_APPCONFIG_DIR, app.id + '.conf'), function (error) { + if (error && error.code !== 'ENOENT') debugApp(app, 'Error removing collectd profile', error); + shell.sudo('removeCollectdProfile', [ RELOAD_COLLECTD_CMD ], callback); + }); +} + +function startContainer(app, callback) { + appdb.getPortBindings(app.id, function (error, portBindings) { + if (error) return callback(error); + + var manifest = app.manifest; + + var dockerPortBindings = { }; + var isMac = os.platform() === 'darwin'; + + // On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work + dockerPortBindings[manifest.httpPort + '/tcp'] = [ { HostIp: isMac ? '0.0.0.0' : '127.0.0.1', HostPort: app.httpPort + '' } ]; + + for (var env in portBindings) { + var hostPort = portBindings[env]; + var containerPort = manifest.tcpPorts[env].containerPort || hostPort; + dockerPortBindings[containerPort + '/tcp'] = [ { HostIp: '0.0.0.0', HostPort: hostPort + '' } ]; + vbox.forwardFromHostToVirtualBox(app.id + '-tcp' + containerPort, hostPort); + } + + var startOptions = { + Binds: addons.getBindsSync(app, app.manifest.addons), + PortBindings: dockerPortBindings, + PublishAllPorts: false, + Links: addons.getLinksSync(app, app.manifest.addons), + RestartPolicy: { + "Name": "always", + "MaximumRetryCount": 0 + } + }; + + var container = docker.getContainer(app.containerId); + debugApp(app, 'Starting container %s with options: %j', container.id, JSON.stringify(startOptions)); + + container.start(startOptions, function (error, data) { + if (error && error.statusCode !== 304) return callback(new Error('Error starting container:' + error)); + + return callback(null); + }); + }); +} + +function stopContainer(app, callback) { + var container = docker.getContainer(app.containerId); + debugApp(app, 'Stopping container %s', container.id); + + var options = { + t: 10 // wait for 10 seconds before killing it + }; + + container.stop(options, function (error) { + if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error stopping container:' + error)); + + var tcpPorts = safe.query(app, 'manifest.tcpPorts', { }); + for (var containerPort in tcpPorts) { + vbox.unforwardFromHostToVirtualBox(app.id + '-tcp' + containerPort); + } + + debugApp(app, 'Waiting for container ' + container.id); + + container.wait(function (error, data) { + if (error && (error.statusCode !== 304 && error.statusCode !== 404)) return callback(new Error('Error waiting on container:' + error)); + + debugApp(app, 'Container stopped with status code [%s]', data ? String(data.StatusCode) : ''); + + return callback(null); + }); + }); +} + +function verifyManifest(app, callback) { + debugApp(app, 'Verifying manifest'); + + var manifest = app.manifest; + var error = manifestFormat.parse(manifest); + if (error) return callback(new Error(util.format('Manifest error: %s', error.message))); + + error = apps.checkManifestConstraints(manifest); + if (error) return callback(error); + + return callback(null); +} + +function downloadIcon(app, callback) { + debugApp(app, 'Downloading icon of %s@%s', app.appStoreId, app.manifest.version); + + var iconUrl = config.apiServerOrigin() + '/api/v1/apps/' + app.appStoreId + '/versions/' + app.manifest.version + '/icon'; + + superagent + .get(iconUrl) + .buffer(true) + .end(function (error, res) { + if (error) return callback(new Error('Error downloading icon:' + error.message)); + if (res.status !== 200) return callback(null); // ignore error. this can also happen for apps installed with cloudron-cli + + if (!safe.fs.writeFileSync(path.join(paths.APPICONS_DIR, app.id + '.png'), res.body)) return callback(new Error('Error saving icon:' + safe.error.message)); + + callback(null); + }); +} + +function registerSubdomain(app, callback) { + debugApp(app, 'Registering subdomain location [%s]', app.location); + + // even though the bare domain is already registered in the appstore, we still + // need to register it so that we have a dnsRecordId to wait for it to complete + var record = { subdomain: app.location, type: 'A', value: sysinfo.getIp() }; + + superagent + .post(config.apiServerOrigin() + '/api/v1/subdomains') + .set('Accept', 'application/json') + .query({ token: config.token() }) + .send({ records: [ record ] }) + .end(function (error, res) { + if (error) return callback(error); + + debugApp(app, 'Registered subdomain status: %s', res.status); + + if (res.status === 409) return callback(null); // already registered + if (res.status !== 201) return callback(new Error(util.format('Subdomain Registration failed. %s %j', res.status, res.body))); + + updateApp(app, { dnsRecordId: res.body.ids[0] }, callback); + }); +} + +function unregisterSubdomain(app, callback) { + debugApp(app, 'Unregistering subdomain: dnsRecordId=%s', app.dnsRecordId); + + if (!app.dnsRecordId) return callback(null); + + // do not unregister bare domain because we show a error/cloudron info page there + if (app.location === '') return updateApp(app, { dnsRecordId: null }, callback); + + superagent + .del(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId) + .query({ token: config.token() }) + .end(function (error, res) { + if (error) { + debugApp(app, 'Error making request: %s', error); + } else if (res.status !== 204) { + debugApp(app, 'Error unregistering subdomain:', res.status, res.body); + } + + updateApp(app, { dnsRecordId: null }, callback); + }); +} + +function removeIcon(app, callback) { + fs.unlink(path.join(paths.APPICONS_DIR, app.id + '.png'), function (error) { + if (error && error.code !== 'ENOENT') debugApp(app, 'cannot remove icon : %s', error); + callback(null); + }); +} + +function waitForDnsPropagation(app, callback) { + if (!config.CLOUDRON) { + debugApp(app, 'Skipping dns propagation check for development'); + return callback(null); + } + + function retry(error) { + debugApp(app, 'waitForDnsPropagation: ', error); + setTimeout(waitForDnsPropagation.bind(null, app, callback), 5000); + } + + superagent + .get(config.apiServerOrigin() + '/api/v1/subdomains/' + app.dnsRecordId + '/status') + .set('Accept', 'application/json') + .query({ token: config.token() }) + .end(function (error, res) { + if (error) return retry(new Error('Failed to get dns record status : ' + error.message)); + + debugApp(app, 'waitForDnsPropagation: dnsRecordId:%s status:%s', app.dnsRecordId, res.status); + + if (res.status !== 200) return retry(new Error(util.format('Error getting record status: %s %j', res.status, res.body))); + + if (res.body.status !== 'done') return retry(new Error(util.format('app:%s not ready yet: %s', app.id, res.body.status))); + + callback(null); + }); +} + +// updates the app object and the database +function updateApp(app, values, callback) { + debugApp(app, 'installationState: %s progress: %s', app.installationState, app.installationProgress); + + appdb.update(app.id, values, function (error) { + if (error) return callback(error); + + for (var value in values) { + app[value] = values[value]; + } + + return callback(null); + }); +} + +// Ordering is based on the following rationale: +// - configure nginx, icon, oauth +// - register subdomain. +// at this point, the user can visit the site and the above nginx config can show some install screen. +// the icon can be displayed in this nginx page and oauth proxy means the page can be protected +// - download image +// - setup volumes +// - setup addons (requires the above volume) +// - setup the container (requires image, volumes, addons) +// - setup collectd (requires container id) +function install(app, callback) { + async.series([ + verifyManifest.bind(null, app), + + // teardown for re-installs + updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }), + removeCollectdProfile.bind(null, app), + stopApp.bind(null, app), + deleteContainer.bind(null, app), + addons.teardownAddons.bind(null, app, app.manifest.addons), + deleteVolume.bind(null, app), + unregisterSubdomain.bind(null, app), + removeOAuthProxyCredentials.bind(null, app), + removeIcon.bind(null, app), + unconfigureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '15, Configure nginx' }), + configureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '20, Downloading icon' }), + downloadIcon.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '25, Creating OAuth proxy credentials' }), + allocateOAuthProxyCredentials.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '30, Registering subdomain' }), + registerSubdomain.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '40, Downloading image' }), + downloadImage.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '50, Creating volume' }), + createVolume.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '60, Setting up addons' }), + addons.setupAddons.bind(null, app, app.manifest.addons), + + updateApp.bind(null, app, { installationProgress: '70, Creating container' }), + createContainer.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }), + addCollectdProfile.bind(null, app), + + runApp.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }), + exports._waitForDnsPropagation.bind(null, app), + + // done! + function (callback) { + debugApp(app, 'installed'); + updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback); + } + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error installing app: %s', error); + return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + +function backup(app, callback) { + async.series([ + updateApp.bind(null, app, { installationProgress: '10, Backing up' }), + apps.backupApp.bind(null, app), + + // done! + function (callback) { + debugApp(app, 'installed'); + updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '' }, callback); + } + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error backing up app: %s', error); + return updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: error.message }, callback.bind(null, error)); // return to installed state intentionally + } + callback(null); + }); +} + +// restore is also called for upgrades and infra updates. note that in those cases it is possible there is no backup +function restore(app, callback) { + // we don't have a backup, same as re-install + if (!app.lastBackupId) return install(app, callback); + + async.series([ + updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }), + removeCollectdProfile.bind(null, app), + stopApp.bind(null, app), + deleteContainer.bind(null, app), + // oldConfig can be null during upgrades + addons.teardownAddons.bind(null, app, app.oldConfig ? app.oldConfig.manifest.addons : null), + deleteVolume.bind(null, app), + deleteImage.bind(null, app, app.manifest), + removeOAuthProxyCredentials.bind(null, app), + removeIcon.bind(null, app), + unconfigureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '30, Configuring Nginx' }), + configureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '40, Downloading icon' }), + downloadIcon.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '50, Create OAuth proxy credentials' }), + allocateOAuthProxyCredentials.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '55, Registering subdomain' }), // ip might change during upgrades + registerSubdomain.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '60, Downloading image' }), + downloadImage.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '65, Creating volume' }), + createVolume.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '70, Download backup and restore addons' }), + apps.restoreApp.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '75, Creating container' }), + createContainer.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '80, Setting up collectd profile' }), + addCollectdProfile.bind(null, app), + + runApp.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '90, Waiting for DNS propagation' }), + exports._waitForDnsPropagation.bind(null, app), + + // done! + function (callback) { + debugApp(app, 'restored'); + updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback); + } + ], function seriesDone(error) { + if (error) { + debugApp(app, 'Error installing app: %s', error); + return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error)); + } + + callback(null); + }); +} + +// note that configure is called after an infra update as well +function configure(app, callback) { + var locationChanged = app.oldConfig.location !== app.location; + + async.series([ + updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }), + removeCollectdProfile.bind(null, app), + stopApp.bind(null, app), + deleteContainer.bind(null, app), + function (next) { + if (!locationChanged) return next(); + unregisterSubdomain(app, next); + }, + removeOAuthProxyCredentials.bind(null, app), + unconfigureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '25, Configuring Nginx' }), + configureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '30, Create OAuth proxy credentials' }), + allocateOAuthProxyCredentials.bind(null, app), + + function (next) { + if (!locationChanged) return next(); + + async.series([ + updateApp.bind(null, app, { installationProgress: '35, Registering subdomain' }), + registerSubdomain.bind(null, app) + ], next); + }, + + // re-setup addons since they rely on the app's fqdn (e.g oauth) + updateApp.bind(null, app, { installationProgress: '50, Setting up addons' }), + addons.setupAddons.bind(null, app, app.manifest.addons), + + updateApp.bind(null, app, { installationProgress: '60, Creating container' }), + createContainer.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '70, Add collectd profile' }), + addCollectdProfile.bind(null, app), + + runApp.bind(null, app), + + function (next) { + if (!locationChanged) return next(); + + async.series([ + updateApp.bind(null, app, { installationProgress: '80, Waiting for DNS propagation' }), + exports._waitForDnsPropagation.bind(null, app) + ], next); + }, + + // done! + function (callback) { + debugApp(app, 'configured'); + updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback); + } + ], function seriesDone(error) { + if (error) { + debugApp(app, 'error reconfiguring : %s', error); + return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + +// nginx configuration is skipped because app.httpPort is expected to be available +function update(app, callback) { + debugApp(app, 'Updating to %s', safe.query(app, 'manifest.version')); + + // app does not want these addons anymore + var unusedAddons = _.difference(Object.keys(app.oldConfig.manifest.addons), Object.keys(app.manifest.addons)); + + async.series([ + updateApp.bind(null, app, { installationProgress: '0, Verify manifest' }), + verifyManifest.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '10, Backup app' }), + function (done) { + apps.backupApp(app, function (error) { + if (error) error.backupError = true; + done(error); + }); + }, + + updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }), + removeCollectdProfile.bind(null, app), + stopApp.bind(null, app), + deleteContainer.bind(null, app), + addons.teardownAddons.bind(null, app, unusedAddons), + deleteImage.bind(null, app, app.manifest), // delete image even if did not change (see df158b111f) + removeIcon.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '35, Downloading icon' }), + downloadIcon.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '45, Downloading image' }), + downloadImage.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '70, Updating addons' }), + addons.setupAddons.bind(null, app, app.manifest.addons), + + updateApp.bind(null, app, { installationProgress: '80, Creating container' }), + createContainer.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '90, Add collectd profile' }), + addCollectdProfile.bind(null, app), + + runApp.bind(null, app), + + // done! + function (callback) { + debugApp(app, 'updated'); + updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback); + } + ], function seriesDone(error) { + if (error && error.backupError) { + // on a backup error, just abort the update + debugApp(app, 'Error backing up app: %s', error.backupError); + return updateApp(app, { installationState: appdb.ISTATE_INSTALLED, installationProgress: '', health: null }, callback.bind(null, error)); + } else if (error) { + debugApp(app, 'Error updating app: %s', error); + return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error)); + } + callback(null); + }); +} + +function uninstall(app, callback) { + debugApp(app, 'uninstalling'); + + async.series([ + updateApp.bind(null, app, { installationProgress: '0, Remove collectd profile' }), + removeCollectdProfile.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '10, Stopping app' }), + stopApp.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '20, Deleting container' }), + deleteContainer.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '30, Teardown addons' }), + addons.teardownAddons.bind(null, app, app.manifest.addons), + + updateApp.bind(null, app, { installationProgress: '40, Deleting volume' }), + deleteVolume.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '50, Deleting image' }), + deleteImage.bind(null, app, app.manifest), + + updateApp.bind(null, app, { installationProgress: '60, Unregistering subdomain' }), + unregisterSubdomain.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '70, Remove OAuth credentials' }), + removeOAuthProxyCredentials.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '80, Cleanup icon' }), + removeIcon.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '90, Unconfiguring Nginx' }), + unconfigureNginx.bind(null, app), + + updateApp.bind(null, app, { installationProgress: '95, Remove app from database' }), + appdb.del.bind(null, app.id) + ], callback); +} + +function runApp(app, callback) { + startContainer(app, function (error) { + if (error) { + debugApp(app, 'Error starting container : %s', error); + return updateApp(app, { runState: appdb.RSTATE_ERROR }, callback); + } + + updateApp(app, { runState: appdb.RSTATE_RUNNING }, callback); + }); +} + +function stopApp(app, callback) { + stopContainer(app, function (error) { + if (error) return callback(error); + + updateApp(app, { runState: appdb.RSTATE_STOPPED }, callback); + }); +} + +function handleRunCommand(app, callback) { + if (app.runState === appdb.RSTATE_PENDING_STOP) { + return stopApp(app, callback); + } + + if (app.runState === appdb.RSTATE_PENDING_START || app.runState === appdb.RSTATE_RUNNING) { + debugApp(app, 'Resuming app with state : %s', app.runState); + return runApp(app, callback); + } + + debugApp(app, 'handleRunCommand - doing nothing: %s', app.runState); + + return callback(null); +} + +function startTask(appId, callback) { + // determine what to do + appdb.get(appId, function (error, app) { + if (error) return callback(error); + + debugApp(app, 'startTask installationState: %s runState: %s', app.installationState, app.runState); + + if (app.installationState === appdb.ISTATE_PENDING_UNINSTALL) { + return uninstall(app, callback); + } + + if (app.installationState === appdb.ISTATE_PENDING_CONFIGURE) { + return configure(app, callback); + } + + if (app.installationState === appdb.ISTATE_PENDING_UPDATE) { + return update(app, callback); + } + + if (app.installationState === appdb.ISTATE_PENDING_RESTORE) { + return restore(app, callback); + } + + if (app.installationState === appdb.ISTATE_PENDING_BACKUP) { + return backup(app, callback); + } + + if (app.installationState === appdb.ISTATE_INSTALLED) { + return handleRunCommand(app, callback); + } + + if (app.installationState === appdb.ISTATE_PENDING_INSTALL) { + return install(app, callback); + } + + debugApp(app, 'Apptask launched but nothing to do.'); + return callback(null); + }); +} + +if (require.main === module) { + assert.strictEqual(process.argv.length, 3, 'Pass the appid as argument'); + + debug('Apptask for %s', process.argv[2]); + + initialize(function (error) { + if (error) throw error; + + startTask(process.argv[2], function (error) { + if (error) console.error(error); + + debug('Apptask completed for %s', process.argv[2]); + // https://nodejs.org/api/process.html are exit codes used by node. apps.js uses the value below + // to check apptask crashes + process.exit(error ? 50 : 0); + }); + }); +} + diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 000000000..0af5474af --- /dev/null +++ b/src/auth.js @@ -0,0 +1,139 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + initialize: initialize, + uninitialize: uninitialize +}; + +var assert = require('assert'), + BasicStrategy = require('passport-http').BasicStrategy, + BearerStrategy = require('passport-http-bearer').Strategy, + clientdb = require('./clientdb'), + ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy, + DatabaseError = require('./databaseerror'), + debug = require('debug')('box:auth'), + LocalStrategy = require('passport-local').Strategy, + crypto = require('crypto'), + passport = require('passport'), + tokendb = require('./tokendb'), + user = require('./user'), + userdb = require('./userdb'), + UserError = user.UserError, + _ = require('underscore'); + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + passport.serializeUser(function (user, callback) { + callback(null, user.username); + }); + + passport.deserializeUser(function(username, callback) { + userdb.get(username, function (error, result) { + if (error) return callback(error); + + var md5 = crypto.createHash('md5').update(result.email.toLowerCase()).digest('hex'); + result.gravatar = 'https://www.gravatar.com/avatar/' + md5 + '.jpg?s=24&d=mm'; + + callback(null, result); + }); + }); + + passport.use(new LocalStrategy(function (username, password, callback) { + if (username.indexOf('@') === -1) { + user.verify(username, password, function (error, result) { + if (error && error.reason === UserError.NOT_FOUND) return callback(null, false); + if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false); + if (error) return callback(error); + if (!result) return callback(null, false); + callback(null, _.pick(result, 'id', 'username', 'email', 'admin')); + }); + } else { + user.verifyWithEmail(username, password, function (error, result) { + if (error && error.reason === UserError.NOT_FOUND) return callback(null, false); + if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false); + if (error) return callback(error); + if (!result) return callback(null, false); + callback(null, _.pick(result, 'id', 'username', 'email', 'admin')); + }); + } + })); + + passport.use(new BasicStrategy(function (username, password, callback) { + if (username.indexOf('cid-') === 0) { + debug('BasicStrategy: detected client id %s instead of username:password', username); + // username is actually client id here + // password is client secret + clientdb.get(username, function (error, client) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) return callback(error); + if (client.clientSecret != password) return callback(null, false); + return callback(null, client); + }); + } else { + user.verify(username, password, function (error, result) { + if (error && error.reason === UserError.NOT_FOUND) return callback(null, false); + if (error && error.reason === UserError.WRONG_PASSWORD) return callback(null, false); + if (error) return callback(error); + if (!result) return callback(null, false); + callback(null, result); + }); + } + })); + + passport.use(new ClientPasswordStrategy(function (clientId, clientSecret, callback) { + clientdb.get(clientId, function(error, client) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) { return callback(error); } + if (client.clientSecret != clientSecret) { return callback(null, false); } + return callback(null, client); + }); + })); + + passport.use(new BearerStrategy(function (accessToken, callback) { + tokendb.get(accessToken, function (error, token) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) return callback(error); + + // scopes here can define what capabilities that token carries + // passport put the 'info' object into req.authInfo, where we can further validate the scopes + var info = { scope: token.scope }; + var tokenType; + + if (token.identifier.indexOf(tokendb.PREFIX_DEV) === 0) { + token.identifier = token.identifier.slice(tokendb.PREFIX_DEV.length); + tokenType = tokendb.TYPE_DEV; + } else if (token.identifier.indexOf(tokendb.PREFIX_APP) === 0) { + tokenType = tokendb.TYPE_APP; + return callback(null, { id: token.identifier.slice(tokendb.PREFIX_APP.length), tokenType: tokenType }, info); + } else if (token.identifier.indexOf(tokendb.PREFIX_USER) === 0) { + tokenType = tokendb.TYPE_USER; + token.identifier = token.identifier.slice(tokendb.PREFIX_USER.length); + } else { + // legacy tokens assuming a user access token + tokenType = tokendb.TYPE_USER; + } + + userdb.get(token.identifier, function (error, user) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) return callback(error); + + // amend the tokenType of the token owner + user.tokenType = tokenType; + + callback(null, user, info); + }); + }); + })); + + callback(null); +} + +function uninitialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + callback(null); +} + diff --git a/src/authcodedb.js b/src/authcodedb.js new file mode 100644 index 000000000..836417b40 --- /dev/null +++ b/src/authcodedb.js @@ -0,0 +1,78 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + get: get, + add: add, + del: del, + delExpired: delExpired, + + _clear: clear +}; + +var assert = require('assert'), + database = require('./database.js'), + DatabaseError = require('./databaseerror'); + +var AUTHCODES_FIELDS = [ 'authCode', 'userId', 'clientId', 'expiresAt' ].join(','); + +function get(authCode, callback) { + assert.strictEqual(typeof authCode, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + AUTHCODES_FIELDS + ' FROM authcodes WHERE authCode = ? AND expiresAt > ?', [ authCode, Date.now() ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function add(authCode, clientId, userId, expiresAt, callback) { + assert.strictEqual(typeof authCode, 'string'); + assert.strictEqual(typeof clientId, 'string'); + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof expiresAt, 'number'); + assert.strictEqual(typeof callback, 'function'); + + database.query('INSERT INTO authcodes (authCode, clientId, userId, expiresAt) VALUES (?, ?, ?, ?)', + [ authCode, clientId, userId, expiresAt ], function (error, result) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS)); + if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function del(authCode, callback) { + assert.strictEqual(typeof authCode, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM authcodes WHERE authCode = ?', [ authCode ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null); + }); +} + +function delExpired(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM authcodes WHERE expiresAt <= ?', [ Date.now() ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + return callback(null, result.affectedRows); + }); +} + +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM authcodes', function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + diff --git a/src/backups.js b/src/backups.js new file mode 100644 index 000000000..6c0331e65 --- /dev/null +++ b/src/backups.js @@ -0,0 +1,95 @@ +'use strict'; + +exports = module.exports = { + BackupsError: BackupsError, + + getAllPaged: getAllPaged, + + getBackupUrl: getBackupUrl, + getRestoreUrl: getRestoreUrl +}; + +var assert = require('assert'), + config = require('./config.js'), + debug = require('debug')('box:backups'), + superagent = require('superagent'), + util = require('util'); + +function BackupsError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(BackupsError, Error); +BackupsError.EXTERNAL_ERROR = 'external error'; +BackupsError.INTERNAL_ERROR = 'internal error'; + +function getAllPaged(page, perPage, callback) { + assert.strictEqual(typeof page, 'number'); + assert.strictEqual(typeof perPage, 'number'); + assert.strictEqual(typeof callback, 'function'); + + var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/backups'; + + superagent.get(url).query({ token: config.token() }).end(function (error, result) { + if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error)); + if (result.statusCode !== 200) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text)); + if (!result.body || !util.isArray(result.body.backups)) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response')); + + // [ { creationTime, boxVersion, restoreKey, dependsOn: [ ] } ] sorted by time (latest first) + return callback(null, result.body.backups); + }); +} + +function getBackupUrl(app, appBackupIds, callback) { + assert(!app || typeof app === 'object'); + assert(!appBackupIds || util.isArray(appBackupIds)); + assert.strictEqual(typeof callback, 'function'); + + var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/backupurl'; + + var data = { + boxVersion: config.version(), + appId: app ? app.id : null, + appVersion: app ? app.manifest.version : null, + appBackupIds: appBackupIds + }; + + superagent.put(url).query({ token: config.token() }).send(data).end(function (error, result) { + if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error)); + if (result.statusCode !== 201) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text)); + if (!result.body || !result.body.url) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response')); + + return callback(null, result.body); + }); +} + +function getRestoreUrl(backupId, callback) { + assert.strictEqual(typeof backupId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/restoreurl'; + + superagent.put(url).query({ token: config.token(), backupId: backupId }).end(function (error, result) { + if (error) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, error)); + if (result.statusCode !== 201) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, result.text)); + if (!result.body || !result.body.url) return callback(new BackupsError(BackupsError.EXTERNAL_ERROR, 'Unexpected response')); + + return callback(null, result.body); + }); +} + + diff --git a/src/clientdb.js b/src/clientdb.js new file mode 100644 index 000000000..1162a37ed --- /dev/null +++ b/src/clientdb.js @@ -0,0 +1,139 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + get: get, + getAll: getAll, + getAllWithTokenCountByIdentifier: getAllWithTokenCountByIdentifier, + add: add, + del: del, + update: update, + getByAppId: getByAppId, + delByAppId: delByAppId, + + _clear: clear +}; + +var assert = require('assert'), + database = require('./database.js'), + DatabaseError = require('./databaseerror.js'); + +var CLIENTS_FIELDS = [ 'id', 'appId', 'clientSecret', 'redirectURI', 'scope' ].join(','); +var CLIENTS_FIELDS_PREFIXED = [ 'clients.id', 'clients.appId', 'clients.clientSecret', 'clients.redirectURI', 'clients.scope' ].join(','); + +function get(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients WHERE id = ?', [ id ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function getAll(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients ORDER BY appId', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function getAllWithTokenCountByIdentifier(identifier, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + CLIENTS_FIELDS_PREFIXED + ',COUNT(tokens.clientId) AS tokenCount FROM clients LEFT OUTER JOIN tokens ON clients.id=tokens.clientId WHERE tokens.identifier=? GROUP BY clients.id', [ identifier ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + callback(null, results); + + }); +} + +function getByAppId(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + CLIENTS_FIELDS + ' FROM clients WHERE appId = ? LIMIT 1', [ appId ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null, result[0]); + }); +} + +function add(id, appId, clientSecret, redirectURI, scope, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof clientSecret, 'string'); + assert.strictEqual(typeof redirectURI, 'string'); + assert.strictEqual(typeof scope, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var data = [ id, appId, clientSecret, redirectURI, scope ]; + + database.query('INSERT INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (?, ?, ?, ?, ?)', data, function (error, result) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS)); + if (error || result.affectedRows === 0) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function update(id, appId, clientSecret, redirectURI, scope, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof clientSecret, 'string'); + assert.strictEqual(typeof redirectURI, 'string'); + assert.strictEqual(typeof scope, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var data = [ appId, clientSecret, redirectURI, scope, id ]; + + database.query('UPDATE clients SET appId = ?, clientSecret = ?, redirectURI = ?, scope = ? WHERE id = ?', data, function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null); + }); +} + +function del(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM clients WHERE id = ?', [ id ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null); + }); +} + +function delByAppId(appId, callback) { + assert.strictEqual(typeof appId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM clients WHERE appId=?', [ appId ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null); + }); +} + +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM clients WHERE appId!="webadmin"', function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null); + }); +} + diff --git a/src/clients.js b/src/clients.js new file mode 100644 index 000000000..625e85339 --- /dev/null +++ b/src/clients.js @@ -0,0 +1,226 @@ +'use strict'; + +exports = module.exports = { + ClientsError: ClientsError, + + add: add, + get: get, + update: update, + del: del, + getAllWithDetailsByUserId: getAllWithDetailsByUserId, + getClientTokensByUserId: getClientTokensByUserId, + delClientTokensByUserId: delClientTokensByUserId +}; + +var assert = require('assert'), + util = require('util'), + hat = require('hat'), + appdb = require('./appdb.js'), + tokendb = require('./tokendb.js'), + constants = require('./constants.js'), + async = require('async'), + clientdb = require('./clientdb.js'), + DatabaseError = require('./databaseerror.js'), + uuid = require('node-uuid'); + +function ClientsError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(ClientsError, Error); +ClientsError.INVALID_SCOPE = 'Invalid scope'; + +function validateScope(scope) { + assert.strictEqual(typeof scope, 'string'); + + if (scope === '') return new ClientsError(ClientsError.INVALID_SCOPE); + if (scope === '*') return null; + + // TODO maybe validate all individual scopes if they exist + + return null; +} + +function add(appIdentifier, redirectURI, scope, callback) { + assert.strictEqual(typeof appIdentifier, 'string'); + assert.strictEqual(typeof redirectURI, 'string'); + assert.strictEqual(typeof scope, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validateScope(scope); + if (error) return callback(error); + + var id = 'cid-' + uuid.v4(); + var clientSecret = hat(256); + + clientdb.add(id, appIdentifier, clientSecret, redirectURI, scope, function (error) { + if (error) return callback(error); + + var client = { + id: id, + appId: appIdentifier, + clientSecret: clientSecret, + redirectURI: redirectURI, + scope: scope + }; + + callback(null, client); + }); +} + +function get(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + clientdb.get(id, function (error, result) { + if (error) return callback(error); + callback(null, result); + }); +} + +// we only allow appIdentifier and redirectURI to be updated +function update(id, appIdentifier, redirectURI, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof appIdentifier, 'string'); + assert.strictEqual(typeof redirectURI, 'string'); + assert.strictEqual(typeof callback, 'function'); + + clientdb.get(id, function (error, result) { + if (error) return callback(error); + + clientdb.update(id, appIdentifier, result.clientSecret, redirectURI, result.scope, function (error, result) { + if (error) return callback(error); + callback(null, result); + }); + }); +} + +function del(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + clientdb.del(id, function (error, result) { + if (error) return callback(error); + callback(null, result); + }); +} + +function getAllWithDetailsByUserId(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + clientdb.getAllWithTokenCountByIdentifier(tokendb.PREFIX_USER + userId, function (error, results) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, []); + if (error) return callback(error); + + // We have several types of records here + // 1) webadmin has an app id of 'webadmin' + // 2) oauth proxy records are always the app id prefixed with 'proxy-' + // 3) addon oauth records for apps prefixed with 'addon-' + // 4) external app records prefixed with 'external-' + // 5) normal apps on the cloudron without a prefix + + var tmp = []; + async.each(results, function (record, callback) { + if (record.appId === constants.ADMIN_CLIENT_ID) { + record.name = constants.ADMIN_NAME; + record.location = constants.ADMIN_LOCATION; + record.type = 'webadmin'; + + tmp.push(record); + + return callback(null); + } else if (record.appId === constants.TEST_CLIENT_ID) { + record.name = constants.TEST_NAME; + record.location = constants.TEST_LOCATION; + record.type = 'test'; + + tmp.push(record); + + return callback(null); + } + + var appId = record.appId; + var type = 'app'; + + // Handle our different types of oauth clients + if (record.appId.indexOf('addon-') === 0) { + appId = record.appId.slice('addon-'.length); + type = 'addon'; + } else if (record.appId.indexOf('proxy-') === 0) { + appId = record.appId.slice('proxy-'.length); + type = 'proxy'; + } + + appdb.get(appId, function (error, result) { + if (error) { + console.error('Failed to get app details for oauth client', result, error); + return callback(null); // ignore error so we continue listing clients + } + + record.name = result.manifest.title + (record.appId.indexOf('proxy-') === 0 ? 'OAuth Proxy' : ''); + record.location = result.location; + record.type = type; + + tmp.push(record); + + callback(null); + }); + }, function (error) { + if (error) return callback(error); + callback(null, tmp); + }); + }); +} + +function getClientTokensByUserId(clientId, userId, callback) { + assert.strictEqual(typeof clientId, 'string'); + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + tokendb.getByIdentifierAndClientId(tokendb.PREFIX_USER + userId, clientId, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) { + // this can mean either that there are no tokens or the clientId is actually unknown + clientdb.get(clientId, function (error/*, result*/) { + if (error) return callback(error); + callback(null, []); + }); + return; + } + if (error) return callback(error); + callback(null, result || []); + }); +} + +function delClientTokensByUserId(clientId, userId, callback) { + assert.strictEqual(typeof clientId, 'string'); + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + tokendb.delByIdentifierAndClientId(tokendb.PREFIX_USER + userId, clientId, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) { + // this can mean either that there are no tokens or the clientId is actually unknown + clientdb.get(clientId, function (error/*, result*/) { + if (error) return callback(error); + callback(null); + }); + return; + } + if (error) return callback(error); + callback(null); + }); +} diff --git a/src/cloudron.js b/src/cloudron.js new file mode 100644 index 000000000..30df982ed --- /dev/null +++ b/src/cloudron.js @@ -0,0 +1,615 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + CloudronError: CloudronError, + + initialize: initialize, + uninitialize: uninitialize, + activate: activate, + getConfig: getConfig, + getStatus: getStatus, + + setCertificate: setCertificate, + + sendHeartbeat: sendHeartbeat, + + update: update, + reboot: reboot, + migrate: migrate, + backup: backup, + ensureBackup: ensureBackup +}; + +var apps = require('./apps.js'), + AppsError = require('./apps.js').AppsError, + assert = require('assert'), + async = require('async'), + backups = require('./backups.js'), + BackupsError = require('./backups.js').BackupsError, + clientdb = require('./clientdb.js'), + config = require('./config.js'), + debug = require('debug')('box:cloudron'), + fs = require('fs'), + locker = require('./locker.js'), + path = require('path'), + paths = require('./paths.js'), + progress = require('./progress.js'), + safe = require('safetydance'), + settings = require('./settings.js'), + SettingsError = settings.SettingsError, + shell = require('./shell.js'), + superagent = require('superagent'), + sysinfo = require('./sysinfo.js'), + tokendb = require('./tokendb.js'), + updateChecker = require('./updatechecker.js'), + user = require('./user.js'), + UserError = user.UserError, + userdb = require('./userdb.js'), + util = require('util'); + +var RELOAD_NGINX_CMD = path.join(__dirname, 'scripts/reloadnginx.sh'), + REBOOT_CMD = path.join(__dirname, 'scripts/reboot.sh'), + BACKUP_BOX_CMD = path.join(__dirname, 'scripts/backupbox.sh'), + BACKUP_SWAP_CMD = path.join(__dirname, 'scripts/backupswap.sh'), + INSTALLER_UPDATE_URL = 'http://127.0.0.1:2020/api/v1/installer/update'; + +var gAddMailDnsRecordsTimerId = null, + gCloudronDetails = null; // cached cloudron details like region,size... + +function debugApp(app, args) { + assert(!app || typeof app === 'object'); + + var prefix = app ? app.location : '(no app)'; + debug(prefix + ' ' + util.format.apply(util, Array.prototype.slice.call(arguments, 1))); +} + +function ignoreError(func) { + return function (callback) { + func(function (error) { + if (error) console.error('Ignored error:', error); + callback(); + }); + }; +} + + +function CloudronError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(CloudronError, Error); +CloudronError.BAD_FIELD = 'Field error'; +CloudronError.INTERNAL_ERROR = 'Internal Error'; +CloudronError.EXTERNAL_ERROR = 'External Error'; +CloudronError.ALREADY_PROVISIONED = 'Already Provisioned'; +CloudronError.BAD_USERNAME = 'Bad username'; +CloudronError.BAD_EMAIL = 'Bad email'; +CloudronError.BAD_PASSWORD = 'Bad password'; +CloudronError.BAD_NAME = 'Bad name'; +CloudronError.BAD_STATE = 'Bad state'; +CloudronError.NOT_FOUND = 'Not found'; + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (process.env.NODE_ENV !== 'test') { + addMailDnsRecords(); + } + + // Send heartbeat once we are up and running, this speeds up the Cloudron creation, as otherwise we are bound to the cron.js settings + sendHeartbeat(); + + callback(null); +} + +function uninitialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + clearTimeout(gAddMailDnsRecordsTimerId); + gAddMailDnsRecordsTimerId = null; + + callback(null); +} + +function setTimeZone(ip, callback) { + assert.strictEqual(typeof ip, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('setTimeZone ip:%s', ip); + + superagent.get('http://www.telize.com/geoip/' + ip).end(function (error, result) { + if (error || result.statusCode !== 200) { + debug('Failed to get geo location', error); + return callback(null); + } + + if (!result.body.timezone) { + debug('No timezone in geoip response'); + return callback(null); + } + + debug('Setting timezone to ', result.body.timezone); + + settings.setTimeZone(result.body.timezone, callback); + }); +} + +function activate(username, password, email, name, ip, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof ip, 'string'); + assert(!name || typeof name, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('activating user:%s email:%s', username, email); + + setTimeZone(ip, function () { }); + + if (!name) name = settings.getDefaultSync(settings.CLOUDRON_NAME_KEY); + + settings.setCloudronName(name, function (error) { + if (error && error.reason === SettingsError.BAD_FIELD) return callback(new CloudronError(CloudronError.BAD_NAME)); + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + user.createOwner(username, password, email, function (error, userObject) { + if (error && error.reason === UserError.ALREADY_EXISTS) return callback(new CloudronError(CloudronError.ALREADY_PROVISIONED)); + if (error && error.reason === UserError.BAD_USERNAME) return callback(new CloudronError(CloudronError.BAD_USERNAME)); + if (error && error.reason === UserError.BAD_PASSWORD) return callback(new CloudronError(CloudronError.BAD_PASSWORD)); + if (error && error.reason === UserError.BAD_EMAIL) return callback(new CloudronError(CloudronError.BAD_EMAIL)); + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + clientdb.getByAppId('webadmin', function (error, result) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + // Also generate a token so the admin creation can also act as a login + var token = tokendb.generateToken(); + var expires = Date.now() + 24 * 60 * 60 * 1000; // 1 day + + tokendb.add(token, tokendb.PREFIX_USER + userObject.id, result.id, expires, '*', function (error) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + callback(null, { token: token, expires: expires }); + }); + }); + }); + }); +} + +function getStatus(callback) { + assert.strictEqual(typeof callback, 'function'); + + userdb.count(function (error, count) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + settings.getCloudronName(function (error, cloudronName) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + callback(null, { + activated: count !== 0, + version: config.version(), + cloudronName: cloudronName + }); + }); + }); +} + +function getCloudronDetails(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (gCloudronDetails) return callback(null, gCloudronDetails); + + superagent + .get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn()) + .query({ token: config.token() }) + .end(function (error, result) { + if (error) return callback(error); + if (result.status !== 200) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body))); + + gCloudronDetails = result.body.box; + + return callback(null, gCloudronDetails); + }); +} + +function getConfig(callback) { + assert.strictEqual(typeof callback, 'function'); + + getCloudronDetails(function (error, result) { + if (error) { + console.error('Failed to fetch cloudron details.', error); + + // set fallback values to avoid dependency on appstore + result = { + region: result ? result.region : null, + size: result ? result.size : null + }; + } + + settings.getCloudronName(function (error, cloudronName) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + callback(null, { + apiServerOrigin: config.apiServerOrigin(), + webServerOrigin: config.webServerOrigin(), + isDev: config.isDev(), + fqdn: config.fqdn(), + ip: sysinfo.getIp(), + version: config.version(), + update: updateChecker.getUpdateInfo(), + progress: progress.get(), + isCustomDomain: config.isCustomDomain(), + developerMode: config.developerMode(), + region: result.region, + size: result.size, + cloudronName: cloudronName + }); + }); + }); +} + +function sendHeartbeat() { + var url = config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/heartbeat'; + debug('Sending heartbeat ' + url); + + // TODO: this must be a POST + superagent.get(url).query({ token: config.token(), version: config.version() }).timeout(10000).end(function (error, result) { + if (error) debug('Error sending heartbeat.', error); + else if (result.statusCode !== 200) debug('Server responded to heartbeat with %s %s', result.statusCode, result.text); + else debug('Heartbeat successful'); + }); +} + +function sendMailDnsRecordsRequest(callback) { + assert.strictEqual(typeof callback, 'function'); + + var DKIM_SELECTOR = 'mail'; + var DMARC_REPORT_EMAIL = 'dmarc-report@cloudron.io'; + + var dkimPublicKeyFile = path.join(paths.MAIL_DATA_DIR, 'dkim/' + config.fqdn() + '/public'); + var publicKey = safe.fs.readFileSync(dkimPublicKeyFile, 'utf8'); + + if (publicKey === null) return callback(new Error('Error reading dkim public key')); + + // remove header, footer and new lines + publicKey = publicKey.split('\n').slice(1, -2).join(''); + + // note that dmarc requires special DNS records for external RUF and RUA + var records = [ + // softfail all mails not from our IP. Note that this uses IP instead of 'a' should we use a load balancer in the future + { subdomain: '', type: 'TXT', value: '"v=spf1 ip4:' + sysinfo.getIp() + ' ~all"' }, + // t=s limits the domainkey to this domain and not it's subdomains + { subdomain: DKIM_SELECTOR + '._domainkey', type: 'TXT', value: '"v=DKIM1; t=s; p=' + publicKey + '"' }, + // DMARC requires special setup if report email id is in different domain + { subdomain: '_dmarc', type: 'TXT', value: '"v=DMARC1; p=none; pct=100; rua=mailto:' + DMARC_REPORT_EMAIL + '; ruf=' + DMARC_REPORT_EMAIL + '"' } + ]; + + debug('sendMailDnsRecords request:%s', JSON.stringify(records)); + + superagent + .post(config.apiServerOrigin() + '/api/v1/subdomains') + .set('Accept', 'application/json') + .query({ token: config.token() }) + .send({ records: records }) + .end(function (error, res) { + if (error) return callback(error); + + debug('sendMailDnsRecords status: %s', res.status); + + if (res.status === 409) return callback(null); // already registered + + if (res.status !== 201) return callback(new Error(util.format('Failed to add Mail DNS records: %s %j', res.status, res.body))); + + return callback(null, res.body.ids); + }); +} + +function addMailDnsRecords() { + if (config.get('mailDnsRecordIds').length !== 0) return; // already registered + + sendMailDnsRecordsRequest(function (error, ids) { + if (error) { + console.error('Mail DNS record addition failed', error); + gAddMailDnsRecordsTimerId = setTimeout(addMailDnsRecords, 30000); + return; + } + + debug('Added Mail DNS records successfully'); + config.set('mailDnsRecordIds', ids); + }); +} + +function setCertificate(certificate, key, callback) { + assert.strictEqual(typeof certificate, 'string'); + assert.strictEqual(typeof key, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('Updating certificates'); + + if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), certificate)) { + return callback(new CloudronError(CloudronError.INTERNAL_ERROR, safe.error.message)); + } + + if (!safe.fs.writeFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), key)) { + return callback(new CloudronError(CloudronError.INTERNAL_ERROR, safe.error.message)); + } + + shell.sudo('setCertificate', [ RELOAD_NGINX_CMD ], function (error) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + return callback(null); + }); +} + +function reboot(callback) { + shell.sudo('reboot', [ REBOOT_CMD ], callback); +} + +function migrate(size, region, callback) { + assert.strictEqual(typeof size, 'string'); + assert.strictEqual(typeof region, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = locker.lock(locker.OP_MIGRATE); + if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message)); + + function unlock(error) { + if (error) { + debug('Failed to migrate', error); + locker.unlock(locker.OP_MIGRATE); + } else { + debug('Migration initiated successfully'); + // do not unlock; cloudron is migrating + } + + return; + } + + // initiate the migration in the background + backupBoxAndApps(function (error, restoreKey) { + if (error) return unlock(error); + + debug('migrate: size %s region %s restoreKey %s', size, region, restoreKey); + + superagent + .post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/migrate') + .query({ token: config.token() }) + .send({ size: size, region: region, restoreKey: restoreKey }) + .end(function (error, result) { + if (error) return unlock(error); + if (result.status === 409) return unlock(new CloudronError(CloudronError.BAD_STATE)); + if (result.status === 404) return unlock(new CloudronError(CloudronError.NOT_FOUND)); + if (result.status !== 202) return unlock(new CloudronError(CloudronError.EXTERNAL_ERROR, util.format('%s %j', result.status, result.body))); + + return unlock(null); + }); + }); + + callback(null); +} + +function update(boxUpdateInfo, callback) { + assert.strictEqual(typeof boxUpdateInfo, 'object'); + assert.strictEqual(typeof callback, 'function'); + + if (!boxUpdateInfo) return callback(null); + + var error = locker.lock(locker.OP_BOX_UPDATE); + if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message)); + + progress.set(progress.UPDATE, 0, 'Begin ' + (boxUpdateInfo.update ? 'upgrade': 'update')); + + // initiate the update/upgrade but do not wait for it + if (boxUpdateInfo.upgrade) { + doUpgrade(boxUpdateInfo, function (error) { + locker.unlock(locker.OP_BOX_UPDATE); + }); + } else { + doUpdate(boxUpdateInfo, function (error) { + locker.unlock(locker.OP_BOX_UPDATE); + }); + } + + callback(null); +} + +function doUpgrade(boxUpdateInfo, callback) { + assert(boxUpdateInfo !== null && typeof boxUpdateInfo === 'object'); + + progress.set(progress.UPDATE, 5, 'Create app and box backup'); + + backupBoxAndApps(function (error) { + if (error) return callback(error); + + superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/upgrade') + .query({ token: config.token() }) + .send({ version: boxUpdateInfo.version }) + .end(function (error, result) { + if (error) return callback(new Error('Error making upgrade request: ' + error)); + if (result.status !== 202) return callback(new Error('Server not ready to upgrade: ' + result.body)); + + progress.set(progress.UPDATE, 10, 'Updating base system'); + + // no need to unlock since this is the last thing we ever do on this box + + callback(null); + }); + }); +} + +function doUpdate(boxUpdateInfo, callback) { + assert(boxUpdateInfo && typeof boxUpdateInfo === 'object'); + + progress.set(progress.UPDATE, 5, 'Create box backup'); + + backupBox(function (error) { + if (error) return callback(error); + + // fetch a signed sourceTarballUrl + superagent.get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/sourcetarballurl') + .query({ token: config.token(), boxVersion: boxUpdateInfo.version }) + .end(function (error, result) { + if (error) return callback(new Error('Error fetching sourceTarballUrl: ' + error)); + if (result.status !== 200) return callback(new Error('Error fetching sourceTarballUrl status: ' + result.status)); + if (!safe.query(result, 'body.url')) return callback(new Error('Error fetching sourceTarballUrl response: ' + result.body)); + + // NOTE: the args here are tied to the installer revision, box code and appstore provisioning logic + var args = { + sourceTarballUrl: result.body.url, + + // this data is opaque to the installer + data: { + boxVersionsUrl: config.get('boxVersionsUrl'), + version: boxUpdateInfo.version, + apiServerOrigin: config.apiServerOrigin(), + webServerOrigin: config.webServerOrigin(), + fqdn: config.fqdn(), + token: config.token(), + tlsCert: fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert'), 'utf8'), + tlsKey: fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key'), 'utf8'), + isCustomDomain: config.isCustomDomain(), + restoreUrl: null, + restoreKey: null, + developerMode: config.developerMode() // this survives updates but not upgrades + } + }; + + debug('updating box %j', args); + + superagent.post(INSTALLER_UPDATE_URL).send(args).end(function (error, result) { + if (error) return callback(error); + if (result.status !== 202) return callback(new Error('Error initiating update: ' + result.body)); + + progress.set(progress.UPDATE, 10, 'Updating cloudron software'); + + callback(null); + }); + }); + + // Do not add any code here. The installer script will stop the box code any instant + }); +} + +function backup(callback) { + assert.strictEqual(typeof callback, 'function'); + + var error = locker.lock(locker.OP_FULL_BACKUP); + if (error) return callback(new CloudronError(CloudronError.BAD_STATE, error.message)); + + // start the backup operation in the background + backupBoxAndApps(function (error) { + if (error) console.error('backup failed.', error); + + locker.unlock(locker.OP_FULL_BACKUP); + }); + + callback(null); +} + +function ensureBackup(callback) { + callback = callback || function () { }; + + backups.getAllPaged(1, 1, function (error, backups) { + if (error) { + debug('Unable to list backups', error); + return callback(error); // no point trying to backup if appstore is down + } + + if (backups.length !== 0 && (new Date() - new Date(backups[0].creationTime) < 23 * 60 * 60 * 1000)) { // ~1 day ago + debug('Previous backup was %j, no need to backup now', backups[0]); + return callback(null); + } + + backup(callback); + }); +} + +function backupBoxWithAppBackupIds(appBackupIds, callback) { + assert(util.isArray(appBackupIds)); + + backups.getBackupUrl(null /* app */, appBackupIds, function (error, result) { + if (error && error.reason === BackupsError.EXTERNAL_ERROR) return callback(new CloudronError(CloudronError.EXTERNAL_ERROR, error.message)); + if (error) return callback(new CloudronError.INTERNAL_ERROR, error); + + debug('backup: url %s', result.url); + + async.series([ + ignoreError(shell.sudo.bind(null, 'mountSwap', [ BACKUP_SWAP_CMD, '--on' ])), + shell.sudo.bind(null, 'backupBox', [ BACKUP_BOX_CMD, result.url, result.backupKey ]), + ignoreError(shell.sudo.bind(null, 'unmountSwap', [ BACKUP_SWAP_CMD, '--off' ])), + ], function (error) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + debug('backup: successful'); + + callback(null, result.id); + }); + }); +} + +// this function expects you to have a lock +function backupBox(callback) { + apps.getAll(function (error, allApps) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + var appBackupIds = allApps.map(function (app) { return app.lastBackupId; }); + appBackupIds = appBackupIds.filter(function (id) { return id !== null }); // remove apps that were never backed up + + backupBoxWithAppBackupIds(appBackupIds, callback); + }); +} + +// this function expects you to have a lock +function backupBoxAndApps(callback) { + callback = callback || function () { }; // callback can be empty for timer triggered backup + + apps.getAll(function (error, allApps) { + if (error) return callback(new CloudronError(CloudronError.INTERNAL_ERROR, error)); + + var processed = 0; + var step = 100/(allApps.length+1); + + progress.set(progress.BACKUP, processed, ''); + + async.mapSeries(allApps, function iterator(app, iteratorCallback) { + ++processed; + + apps.backupApp(app, function (error, backupId) { + progress.set(progress.BACKUP, step * processed, app.location); + + if (error && error.reason === AppsError.BAD_STATE) { + debugApp(app, 'Skipping backup (istate:%s health%s). Reusing %s', app.installationState, app.health, app.lastBackupId); + backupId = app.lastBackupId; + } + + return iteratorCallback(null, backupId); + }); + }, function appsBackedUp(error, backupIds) { + if (error) return callback(error); + + backupIds = backupIds.filter(function (id) { return id !== null; }); // remove apps that were never backed up + + backupBoxWithAppBackupIds(backupIds, function (error, restoreKey) { + progress.set(progress.BACKUP, 100, ''); + callback(error, restoreKey); + }); + }); + }); +} + diff --git a/src/collectd.config.ejs b/src/collectd.config.ejs new file mode 100644 index 000000000..a3ffc0b16 --- /dev/null +++ b/src/collectd.config.ejs @@ -0,0 +1,32 @@ +LoadPlugin "table" + + /memory.stat"> + Instance "<%= appId %>-memory" + Separator " \\n" + + Type gauge + InstancesFrom 0 + ValuesFrom 1 + +
+ + /memory.max_usage_in_bytes"> + Instance "<%= appId %>-memory" + Separator "\\n" + + Type gauge + InstancePrefix "max_usage_in_bytes" + ValuesFrom 0 + +
+ + /cpuacct.stat"> + Instance "<%= appId %>-cpu" + Separator " \\n" + + Type gauge + InstancesFrom 0 + ValuesFrom 1 + +
+
diff --git a/src/config.js b/src/config.js new file mode 100644 index 000000000..0bb7079f0 --- /dev/null +++ b/src/config.js @@ -0,0 +1,181 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + baseDir: baseDir, + + // values set here will be lost after a upgrade/update. use the sqlite database + // for persistent values that need to be backed up + get: get, + set: set, + + // ifdefs to check environment + CLOUDRON: process.env.NODE_ENV === 'cloudron', + TEST: process.env.NODE_ENV === 'test', + + // convenience getters + apiServerOrigin: apiServerOrigin, + webServerOrigin: webServerOrigin, + fqdn: fqdn, + token: token, + version: version, + isCustomDomain: isCustomDomain, + database: database, + developerMode: developerMode, + + // these values are derived + adminOrigin: adminOrigin, + appFqdn: appFqdn, + zoneName: zoneName, + + isDev: isDev, + + // for testing resets to defaults + _reset: initConfig +}; + +var assert = require('assert'), + constants = require('./constants.js'), + fs = require('fs'), + path = require('path'), + safe = require('safetydance'), + _ = require('underscore'); + +var homeDir = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; + +var data = { }; + +function baseDir() { + if (exports.CLOUDRON) return homeDir; + if (exports.TEST) return path.join(homeDir, '.cloudron_test'); +} + +var cloudronConfigFileName = path.join(baseDir(), 'configs/cloudron.conf'); + +function saveSync() { + fs.writeFileSync(cloudronConfigFileName, JSON.stringify(data, null, 4)); // functions are ignored by JSON.stringify +} + +function initConfig() { + // setup defaults + data.fqdn = 'localhost'; + + data.token = null; + data.mailServer = null; + data.adminEmail = null; + data.mailDnsRecordIds = [ ]; + data.boxVersionsUrl = null; + data.version = null; + data.isCustomDomain = false; + data.webServerOrigin = null; + data.internalPort = 3001; + data.ldapPort = 3002; + + if (exports.CLOUDRON) { + data.port = 3000; + data.apiServerOrigin = null; + data.database = null; + data.developerMode = false; + } else if (exports.TEST) { + data.port = 5454; + data.apiServerOrigin = 'http://localhost:6060'; // hock doesn't support https + data.database = { + hostname: 'localhost', + username: 'root', + password: '', + port: 3306, + name: 'boxtest' + }; + data.token = 'APPSTORE_TOKEN'; + data.developerMode = false; + } else { + assert(false, 'Unknown environment. This should not happen!'); + } + + if (safe.fs.existsSync(cloudronConfigFileName)) { + var existingData = safe.JSON.parse(safe.fs.readFileSync(cloudronConfigFileName, 'utf8')); + _.extend(data, existingData); // overwrite defaults with saved config + return; + } + + saveSync(); +} + +initConfig(); + +// set(obj) or set(key, value) +function set(key, value) { + if (typeof key === 'object') { + var obj = key; + for (var k in obj) { + assert(k in data, 'config.js is missing key "' + k + '"'); + data[k] = obj[k]; + } + } else { + data = safe.set(data, key, value); + } + saveSync(); +} + +function get(key) { + assert.strictEqual(typeof key, 'string'); + + return safe.query(data, key); +} + +function apiServerOrigin() { + return get('apiServerOrigin'); +} + +function webServerOrigin() { + return get('webServerOrigin'); +} + +function fqdn() { + return get('fqdn'); +} + +// keep this in sync with start.sh admin.conf generation code +function appFqdn(location) { + assert.strictEqual(typeof location, 'string'); + + if (location === '') return fqdn(); + return isCustomDomain() ? location + '.' + fqdn() : location + '-' + fqdn(); +} + +function adminOrigin() { + return 'https://' + appFqdn(constants.ADMIN_LOCATION); +} + +function token() { + return get('token'); +} + +function version() { + return get('version'); +} + +function isCustomDomain() { + return get('isCustomDomain'); +} + +function zoneName() { + if (isCustomDomain()) return fqdn(); // the appstore sets up the custom domain as a zone + + // for shared domain name, strip out the hostname + return fqdn().substr(fqdn().indexOf('.') + 1); +} + +function database() { + return get('database'); +} + +function developerMode() { + return get('developerMode'); +} + +function isDev() { + return /dev/i.test(get('boxVersionsUrl')); +} + diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..5c455355d --- /dev/null +++ b/src/constants.js @@ -0,0 +1,16 @@ +'use strict'; + + // default admin installation location. keep in sync with ADMIN_LOCATION in setup/start.sh and BOX_ADMIN_LOCATION in appstore constants.js +exports = module.exports = { + ADMIN_LOCATION: 'my', + API_LOCATION: 'api', // this is unused but reserved for future use (#403) + ADMIN_NAME: 'Settings', + + ADMIN_CLIENT_ID: 'webadmin', // oauth client id + ADMIN_APPID: 'admin', // admin appid (settingsdb) + + TEST_NAME: 'Test', + TEST_LOCATION: '', + TEST_CLIENT_ID: 'test' +}; + diff --git a/src/cron.js b/src/cron.js new file mode 100644 index 000000000..f33c45fd8 --- /dev/null +++ b/src/cron.js @@ -0,0 +1,138 @@ +'use strict'; + +exports = module.exports = { + initialize: initialize, + uninitialize: uninitialize +}; + +var apps = require('./apps.js'), + assert = require('assert'), + cloudron = require('./cloudron.js'), + CronJob = require('cron').CronJob, + debug = require('debug')('box:cron'), + settings = require('./settings.js'), + updateChecker = require('./updatechecker.js'); + +var gAutoupdaterJob = null, + gBoxUpdateCheckerJob = null, + gAppUpdateCheckerJob = null, + gHeartbeatJob = null, + gBackupJob = null; + +var gInitialized = false; + +var NOOP_CALLBACK = function (error) { console.error(error); }; + +// cron format +// Seconds: 0-59 +// Minutes: 0-59 +// Hours: 0-23 +// Day of Month: 1-31 +// Months: 0-11 +// Day of Week: 0-6 + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (gInitialized) return callback(); + + settings.events.on(settings.TIME_ZONE_KEY, recreateJobs); + settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, autoupdatePatternChanged); + + gInitialized = true; + + recreateJobs(callback); +} + +function recreateJobs(unusedTimeZone, callback) { + if (typeof unusedTimeZone === 'function') callback = unusedTimeZone; + + settings.getAll(function (error, allSettings) { + if (gHeartbeatJob) gHeartbeatJob.stop(); + gHeartbeatJob = new CronJob({ + cronTime: '00 */1 * * * *', // every minute + onTick: cloudron.sendHeartbeat, + start: true, + timeZone: allSettings[settings.TIME_ZONE_KEY] + }); + + if (gBackupJob) gBackupJob.stop(); + gBackupJob = new CronJob({ + cronTime: '00 00 */4 * * *', // every 4 hours + onTick: cloudron.ensureBackup, + start: true, + timeZone: allSettings[settings.TIME_ZONE_KEY] + }); + + if (gBoxUpdateCheckerJob) gBoxUpdateCheckerJob.stop(); + gBoxUpdateCheckerJob = new CronJob({ + cronTime: '00 */10 * * * *', // every 10 minutes + onTick: updateChecker.checkBoxUpdates, + start: true, + timeZone: allSettings[settings.TIME_ZONE_KEY] + }); + + if (gAppUpdateCheckerJob) gAppUpdateCheckerJob.stop(); + gAppUpdateCheckerJob = new CronJob({ + cronTime: '00 */10 * * * *', // every 10 minutes + onTick: updateChecker.checkAppUpdates, + start: true, + timeZone: allSettings[settings.TIME_ZONE_KEY] + }); + + autoupdatePatternChanged(allSettings[settings.AUTOUPDATE_PATTERN_KEY]); + + if (callback) callback(); + }); +} + +function autoupdatePatternChanged(pattern) { + assert.strictEqual(typeof pattern, 'string'); + + debug('Auto update pattern changed to %s', pattern); + + if (gAutoupdaterJob) gAutoupdaterJob.stop(); + + if (pattern === 'never') return; + + gAutoupdaterJob = new CronJob({ + cronTime: pattern, + onTick: function() { + debug('Starting autoupdate'); + var updateInfo = updateChecker.getUpdateInfo(); + if (updateInfo.box) { + cloudron.update(updateInfo.box, NOOP_CALLBACK); + } else if (updateInfo.apps) { + apps.autoupdateApps(updateInfo.apps, NOOP_CALLBACK); + } + }, + start: true, + timeZone: gBoxUpdateCheckerJob.cronTime.timeZone // hack + }); +} + +function uninitialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (!gInitialized) return callback(); + + if (gAutoupdaterJob) gAutoupdaterJob.stop(); + gAutoupdaterJob = null; + + gBoxUpdateCheckerJob.stop(); + gBoxUpdateCheckerJob = null; + + gAppUpdateCheckerJob.stop(); + gAppUpdateCheckerJob = null; + + gHeartbeatJob.stop(); + gHeartbeatJob = null; + + gBackupJob.stop(); + gBackupJob = null; + + gInitialized = false; + + callback(); +} + diff --git a/src/database.js b/src/database.js new file mode 100644 index 000000000..6856f353b --- /dev/null +++ b/src/database.js @@ -0,0 +1,202 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + initialize: initialize, + uninitialize: uninitialize, + query: query, + transaction: transaction, + + beginTransaction: beginTransaction, + rollback: rollback, + commit: commit, + + _clear: clear +}; + +var assert = require('assert'), + async = require('async'), + once = require('once'), + config = require('./config.js'), + mysql = require('mysql'), + util = require('util'); + +var gConnectionPool = null, + gDefaultConnection = null; + +function initialize(options, callback) { + if (typeof options === 'function') { + callback = options; + options = { + connectionLimit: 5 + }; + } + + assert.strictEqual(typeof options.connectionLimit, 'number'); + assert.strictEqual(typeof callback, 'function'); + + if (gConnectionPool !== null) return callback(null); + + gConnectionPool = mysql.createPool({ + connectionLimit: options.connectionLimit, + host: config.database().hostname, + user: config.database().username, + password: config.database().password, + port: config.database().port, + database: config.database().name, + multipleStatements: false, + ssl: false + }); + + reconnect(callback); +} + +function uninitialize(callback) { + if (gConnectionPool) { + gConnectionPool.end(callback); + gConnectionPool = null; + } else { + callback(null); + } +} + +function setupConnection(connection, callback) { + assert.strictEqual(typeof connection, 'object'); + assert.strictEqual(typeof callback, 'function'); + + connection.on('error', console.error); + + async.series([ + connection.query.bind(connection, 'USE ' + config.database().name), + connection.query.bind(connection, 'SET SESSION sql_mode = \'strict_all_tables\'') + ], function (error) { + connection.removeListener('error', console.error); + + if (error) connection.release(); + + callback(error); + }); +} + +function reconnect(callback) { + callback = callback ? once(callback) : function () {}; + + gConnectionPool.getConnection(function (error, connection) { + if (error) { + console.error('Unable to reestablish connection to database. Try again in a bit.', error.message); + return setTimeout(reconnect.bind(null, callback), 1000); + } + + connection.on('error', function (error) { + // by design, we catch all normal errors by providing callbacks. + // this function should be invoked only when we have no callbacks pending and we have a fatal error + assert(error.fatal, 'Non-fatal error on connection object'); + + console.error('Unhandled mysql connection error.', error); + + // This is most likely an issue an can cause double callbacks from reconnect() + setTimeout(reconnect.bind(null, callback), 1000); + }); + + setupConnection(connection, function (error) { + if (error) return setTimeout(reconnect.bind(null, callback), 1000); + + gDefaultConnection = connection; + + callback(null); + }); + }); +} + +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + + // the clear funcs don't completely clear the db, they leave the migration code defaults + async.series([ + require('./appdb.js')._clear, + require('./authcodedb.js')._clear, + require('./clientdb.js')._clear, + require('./tokendb.js')._clear, + require('./userdb.js')._clear, + require('./settingsdb.js')._clear + ], callback); +} + +function beginTransaction(callback) { + assert.strictEqual(typeof callback, 'function'); + + gConnectionPool.getConnection(function (error, connection) { + if (error) return callback(error); + + setupConnection(connection, function (error) { + if (error) return callback(error); + + connection.beginTransaction(function (error) { + if (error) return callback(error); + + return callback(null, connection); + }); + }); + }); +} + +function rollback(connection, callback) { + assert.strictEqual(typeof callback, 'function'); + + connection.rollback(function (error) { + if (error) console.error(error); // can this happen? + + connection.release(); + callback(null); + }); +} + +// FIXME: if commit fails, is it supposed to return an error ? +function commit(connection, callback) { + assert.strictEqual(typeof callback, 'function'); + + connection.commit(function (error) { + if (error) return rollback(connection, callback); + + connection.release(); + return callback(null); + }); +} + +function query() { + var args = Array.prototype.slice.call(arguments); + var callback = args[args.length - 1]; + assert.strictEqual(typeof callback, 'function'); + + if (gDefaultConnection === null) return callback(new Error('No connection to database')); + + args[args.length -1 ] = function (error, result) { + if (error && error.fatal) { + gDefaultConnection = null; + setTimeout(reconnect, 1000); + } + + callback(error, result); + }; + + gDefaultConnection.query.apply(gDefaultConnection, args); +} + +function transaction(queries, callback) { + assert(util.isArray(queries)); + assert.strictEqual(typeof callback, 'function'); + + beginTransaction(function (error, conn) { + if (error) return callback(error); + + async.mapSeries(queries, function iterator(query, done) { + conn.query(query.query, query.args, done); + }, function seriesDone(error, results) { + if (error) return rollback(conn, callback.bind(null, error)); + + commit(conn, callback.bind(null, null, results)); + }); + }); +} + diff --git a/src/databaseerror.js b/src/databaseerror.js new file mode 100644 index 000000000..72475766a --- /dev/null +++ b/src/databaseerror.js @@ -0,0 +1,32 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = DatabaseError; + +var assert = require('assert'), + util = require('util'); + +function DatabaseError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined' || errorOrMessage === null); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.reason = reason; + if (typeof errorOrMessage === 'undefined' || errorOrMessage === null) { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(DatabaseError, Error); + +DatabaseError.INTERNAL_ERROR = 'Internal error'; +DatabaseError.ALREADY_EXISTS = 'Entry already exist'; +DatabaseError.NOT_FOUND = 'Record not found'; +DatabaseError.BAD_FIELD = 'Invalid field'; diff --git a/src/developer.js b/src/developer.js new file mode 100644 index 000000000..0d5a7ff3d --- /dev/null +++ b/src/developer.js @@ -0,0 +1,66 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + DeveloperError: DeveloperError, + + enabled: enabled, + setEnabled: setEnabled, + issueDeveloperToken: issueDeveloperToken +}; + +var assert = require('assert'), + tokendb = require('./tokendb.js'), + config = require('./config.js'), + util = require('util'); + +function DeveloperError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(DeveloperError, Error); +DeveloperError.INTERNAL_ERROR = 'Internal Error'; + +function enabled(callback) { + assert.strictEqual(typeof callback, 'function'); + + callback(null, config.developerMode()); +} + +function setEnabled(enabled, callback) { + assert.strictEqual(typeof enabled, 'boolean'); + assert.strictEqual(typeof callback, 'function'); + + config.set('developerMode', enabled); + + callback(null); +} + +function issueDeveloperToken(user, callback) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var token = tokendb.generateToken(); + var expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 1 day + + tokendb.add(token, tokendb.PREFIX_DEV + user.id, '', expiresAt, 'apps,settings,roleDeveloper', function (error) { + if (error) return callback(new DeveloperError(DeveloperError.INTERNAL_ERROR, error)); + + callback(null, { token: token, expiresAt: expiresAt }); + }); +} diff --git a/src/digitalocean.js b/src/digitalocean.js new file mode 100644 index 000000000..294e02ba6 --- /dev/null +++ b/src/digitalocean.js @@ -0,0 +1,46 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + checkPtrRecord: checkPtrRecord +}; + +var assert = require('assert'), + debug = require('debug')('box:digitalocean'), + dns = require('native-dns'); + +function checkPtrRecord(ip, fqdn, callback) { + assert(ip === null || typeof ip === 'string'); + assert.strictEqual(typeof fqdn, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('checkPtrRecord: ' + ip); + + if (!ip) return callback(new Error('Network down')); + + dns.resolve4('ns1.digitalocean.com', function (error, rdnsIps) { + if (error || rdnsIps.length === 0) return callback(new Error('Failed to query DO DNS')); + + var reversedIp = ip.split('.').reverse().join('.'); + + var req = dns.Request({ + question: dns.Question({ name: reversedIp + '.in-addr.arpa', type: 'PTR' }), + server: { address: rdnsIps[0] }, + timeout: 5000 + }); + + req.on('timeout', function () { return callback(new Error('Timedout')); }); + + req.on('message', function (error, message) { + if (error || !message.answer || message.answer.length === 0) return callback(new Error('Failed to query PTR')); + + debug('checkPtrRecord: Actual:%s Expecting:%s', message.answer[0].data, fqdn); + callback(null, message.answer[0].data === fqdn); + }); + + req.send(); + }); +} + + diff --git a/src/docker.js b/src/docker.js new file mode 100644 index 000000000..c0ccbbca7 --- /dev/null +++ b/src/docker.js @@ -0,0 +1,42 @@ +'use strict'; + +var Docker = require('dockerode'), + fs = require('fs'), + os = require('os'), + path = require('path'), + url = require('url'); + +exports = module.exports = (function () { + var docker; + var options = connectOptions(); // the real docker + + if (process.env.NODE_ENV === 'test') { + // test code runs a docker proxy on this port + docker = new Docker({ host: 'http://localhost', port: 5687 }); + } else { + docker = new Docker(options); + } + + // proxy code uses this to route to the real docker + docker.options = options; + + return docker; +})(); + +function connectOptions() { + if (os.platform() === 'linux') return { socketPath: '/var/run/docker.sock' }; + + // boot2docker configuration + var DOCKER_CERT_PATH = process.env.DOCKER_CERT_PATH || path.join(process.env.HOME, '.boot2docker/certs/boot2docker-vm'); + var DOCKER_HOST = process.env.DOCKER_HOST || 'tcp://192.168.59.103:2376'; + + return { + protocol: 'https', + host: url.parse(DOCKER_HOST).hostname, + port: url.parse(DOCKER_HOST).port, + ca: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'ca.pem')), + cert: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'cert.pem')), + key: fs.readFileSync(path.join(DOCKER_CERT_PATH, 'key.pem')) + }; +} + diff --git a/src/ldap.js b/src/ldap.js new file mode 100644 index 000000000..b41100011 --- /dev/null +++ b/src/ldap.js @@ -0,0 +1,106 @@ +'use strict'; + +exports = module.exports = { + start: start +}; + +var assert = require('assert'), + config = require('./config.js'), + debug = require('debug')('box:ldap'), + user = require('./user.js'), + UserError = user.UserError, + ldap = require('ldapjs'); + +var gServer = null; + +var NOOP = function () {}; + +var gLogger = { + trace: NOOP, + debug: NOOP, + info: debug, + warn: debug, + error: console.error, + fatal: console.error +}; + +function start(callback) { + assert(typeof callback === 'function'); + + gServer = ldap.createServer({ log: gLogger }); + + gServer.search('ou=users,dc=cloudron', function (req, res, next) { + debug('ldap user search: dn %s, scope %s, filter %s', req.dn.toString(), req.scope, req.filter.toString()); + + user.list(function (error, result){ + if (error) return next(new ldap.OperationsError(error.toString())); + + // send user objects + result.forEach(function (entry) { + var dn = ldap.parseDN('cn=' + entry.id + ',ou=users,dc=cloudron'); + + var tmp = { + dn: dn.toString(), + attributes: { + objectclass: ['user'], + cn: entry.id, + uid: entry.id, + mail: entry.email, + displayname: entry.username, + username: entry.username + } + }; + + if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) { + res.send(tmp); + debug('ldap user send:', tmp); + } + }); + + res.end(); + }); + }); + + gServer.search('ou=groups,dc=cloudron', function (req, res, next) { + debug('ldap group search: dn %s, scope %s, filter %s', req.dn.toString(), req.scope, req.filter.toString()); + + user.list(function (error, result){ + if (error) return next(new ldap.OperationsError(error.toString())); + + // we only have an admin group + var dn = ldap.parseDN('cn=admin,ou=groups,dc=cloudron'); + + var tmp = { + dn: dn.toString(), + attributes: { + objectclass: ['group'], + cn: 'admin', + memberuid: result.filter(function (entry) { return entry.admin; }).map(function(entry) { return entry.id; }) + } + }; + + if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) { + res.send(tmp); + debug('ldap group send:', tmp); + } + + res.end(); + }); + }); + + gServer.bind('dc=cloudron', function(req, res, next) { + debug('ldap bind: %s', req.dn.toString()); + + if (!req.dn.rdns[0].cn) return next(new ldap.NoSuchObjectError(req.dn.toString())); + + user.verify(req.dn.rdns[0].cn, req.credentials || '', function (error, result) { + if (error && error.reason === UserError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString())); + if (error && error.reason === UserError.WRONG_PASSWORD) return next(new ldap.InvalidCredentialsError(req.dn.toString())); + if (error) return next(new ldap.OperationsError(error)); + + res.end(); + }); + }); + + gServer.listen(config.get('ldapPort'), callback); +} diff --git a/src/locker.js b/src/locker.js new file mode 100644 index 000000000..216bde06a --- /dev/null +++ b/src/locker.js @@ -0,0 +1,55 @@ +'use strict'; + +var assert = require('assert'), + debug = require('debug')('box:locker'), + EventEmitter = require('events').EventEmitter, + util = require('util'); + +function Locker() { + this._operation = null; + this._timestamp = null; + this._watcherId = -1; +} +util.inherits(Locker, EventEmitter); + +// these are mutually exclusive operations +Locker.prototype.OP_BOX_UPDATE = 'box_update'; +Locker.prototype.OP_FULL_BACKUP = 'full_backup'; +Locker.prototype.OP_APPTASK = 'apptask'; +Locker.prototype.OP_MIGRATE = 'migrate'; + +Locker.prototype.lock = function (operation) { + assert.strictEqual(typeof operation, 'string'); + + if (this._operation !== null) return new Error('Already locked for ' + this._operation); + + this._operation = operation; + this._timestamp = new Date(); + var that = this; + this._watcherId = setInterval(function () { debug('Lock unreleased %s', that._operation); }, 1000 * 60 * 5); + + debug('Acquired : %s', this._operation); + + this.emit('locked', this._operation); + + return null; +}; + +Locker.prototype.unlock = function (operation) { + assert.strictEqual(typeof operation, 'string'); + + if (this._operation !== operation) throw new Error('Mismatched unlock. Current lock is for ' + this._operation); // throw because this is a programming error + + debug('Released : %s', this._operation); + + this._operation = null; + this._timestamp = null; + clearInterval(this._watcherId); + this._watcherId = -1; + + this.emit('unlocked', operation); + + return null; +} + +exports = module.exports = new Locker(); diff --git a/src/mail_templates/app_down.ejs b/src/mail_templates/app_down.ejs new file mode 100644 index 000000000..a94385f40 --- /dev/null +++ b/src/mail_templates/app_down.ejs @@ -0,0 +1,19 @@ +<%if (format === 'text') { %> + +Dear Admin, + +The application titled '<%= title %>' that you installed at <%= appFqdn %> +is not responding. + +This is most likely a problem in the application. Please report this issue to +support@cloudron.io (by forwarding this email). + +You are receiving this email because you are an Admin of the Cloudron at <%= fqdn %>. + +Thank you, +Application WatchDog + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/app_update_available.ejs b/src/mail_templates/app_update_available.ejs new file mode 100644 index 000000000..dac317ef6 --- /dev/null +++ b/src/mail_templates/app_update_available.ejs @@ -0,0 +1,15 @@ +<%if (format === 'text') { %> + +Dear Admin, + +A new version of the app '<%= app.manifest.title %>' installed at <%= app.fqdn %> is available! + +Please update at your convenience at <%= webadminUrl %>. + +Thank you, +Update Manager + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/box_update_available.ejs b/src/mail_templates/box_update_available.ejs new file mode 100644 index 000000000..f4ca233ce --- /dev/null +++ b/src/mail_templates/box_update_available.ejs @@ -0,0 +1,20 @@ +<%if (format === 'text') { %> + +Dear Admin, + +A new version of Cloudron <%= fqdn %> is available! + +Please update at your convenience at <%= webadminUrl %>. + +Changelog: +<% for (var i = 0; i < changelog.length; i++) { %> + * <%- changelog[i] %> +<% } %> + +Thank you, +Update Manager + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/crash_notification.ejs b/src/mail_templates/crash_notification.ejs new file mode 100644 index 000000000..a5f8dc871 --- /dev/null +++ b/src/mail_templates/crash_notification.ejs @@ -0,0 +1,19 @@ +<%if (format === 'text') { %> + +Dear Cloudron Team, + +unfortunately the <%= program %> on <%= fqdn %> crashed unexpectedly! + +Please see some excerpt of the logs below. + +Thank you, +Your Cloudron + +------------------------------------- + +<%= context %> + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/password_reset.ejs b/src/mail_templates/password_reset.ejs new file mode 100644 index 000000000..ddc50d6b4 --- /dev/null +++ b/src/mail_templates/password_reset.ejs @@ -0,0 +1,20 @@ +<%if (format === 'text') { %> + +Dear <%= username %>, + +Someone, hopefully you, has requested your <%= fqdn %>'s account password +be reset. If you did not request this reset, please ignore this message. + +To reset your password, please visit the following page: +<%= resetLink %> + +When you visit the above page, you will be prompted to enter a new password. +After you have submitted the form, you can login using the new password. + +Thank you, +<%= fqdn %> Admin + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/user_event.ejs b/src/mail_templates/user_event.ejs new file mode 100644 index 000000000..4f9ddd388 --- /dev/null +++ b/src/mail_templates/user_event.ejs @@ -0,0 +1,15 @@ +<%if (format === 'text') { %> + +Dear Admin, + +User with name '<%= username %>' (<%= email %>) <%= event %> in the Cloudron at <%= fqdn %>. + +You are receiving this email because you are an Admin of the Cloudron at <%= fqdn %>. + +Thank you, +User Manager + +<% } else { %> + +<% } %> + diff --git a/src/mail_templates/welcome_user.ejs b/src/mail_templates/welcome_user.ejs new file mode 100644 index 000000000..d909f4f43 --- /dev/null +++ b/src/mail_templates/welcome_user.ejs @@ -0,0 +1,24 @@ +<%if (format === 'text') { %> + +Dear <%= user.username %>, + +I am excited to welcome you to my Cloudron <%= fqdn %>! + +The Cloudron is our own Private Cloud. You can read more about it +at https://www.cloudron.io. + + You username is '<%= user.username %>' + +To get started, create your account by visiting the following page: +<%= setupLink %> + +When you visit the above page, you will be prompted to enter a new password. +After you have submitted the form, you can login using the new password. + +Thank you, +<%= invitor.email %> + +<% } else { %> + +<% } %> + diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 000000000..1639a5e68 --- /dev/null +++ b/src/mailer.js @@ -0,0 +1,279 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + initialize: initialize, + uninitialize: uninitialize, + + userAdded: userAdded, + userRemoved: userRemoved, + adminChanged: adminChanged, + passwordReset: passwordReset, + boxUpdateAvailable: boxUpdateAvailable, + appUpdateAvailable: appUpdateAvailable, + + sendCrashNotification: sendCrashNotification, + + appDied: appDied +}; + +var assert = require('assert'), + async = require('async'), + config = require('./config.js'), + debug = require('debug')('box:mailer'), + digitalocean = require('./digitalocean.js'), + docker = require('./docker.js'), + ejs = require('ejs'), + nodemailer = require('nodemailer'), + path = require('path'), + safe = require('safetydance'), + smtpTransport = require('nodemailer-smtp-transport'), + sysinfo = require('./sysinfo.js'), + userdb = require('./userdb.js'), + util = require('util'), + _ = require('underscore'); + +var MAIL_TEMPLATES_DIR = path.join(__dirname, 'mail_templates'); + +var gMailQueue = [ ], + gDnsReady = false, + gCheckDnsTimerId = null; + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + checkDns(); + callback(null); +} + +function uninitialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + // TODO: interrupt processQueue as well + clearTimeout(gCheckDnsTimerId); + gCheckDnsTimerId = null; + + debug(gMailQueue.length + ' mail items dropped'); + gMailQueue = [ ]; + + callback(null); +} + +function checkDns() { + digitalocean.checkPtrRecord(sysinfo.getIp(), config.fqdn(), function (error, ok) { + if (error || !ok) { + debug('PTR record not setup yet'); + gCheckDnsTimerId = setTimeout(checkDns, 10000); + return; + } + + gDnsReady = true; + processQueue(); + }); +} + +function processQueue() { + docker.getContainer('mail').inspect(function (error, data) { + if (error) return console.error(error); + + var mailServerIp = safe.query(data, 'NetworkSettings.IPAddress'); + if (!mailServerIp) return debug('Error querying mail server IP'); + + var transport = nodemailer.createTransport(smtpTransport({ + host: mailServerIp, + port: 25 + })); + + var mailQueueCopy = gMailQueue; + gMailQueue = [ ]; + + debug('Processing mail queue of size %d', mailQueueCopy.length); + + async.mapSeries(mailQueueCopy, function iterator(mailOptions, callback) { + transport.sendMail(mailOptions, function (error) { + if (error) return console.error(error); // TODO: requeue? + debug('Email sent to ' + mailOptions.to); + }); + callback(null); + }, function done() { + debug('Done processing mail queue'); + }); + }); +} + +function enqueue(mailOptions) { + assert.strictEqual(typeof mailOptions, 'object'); + + debug('Queued mail for ' + mailOptions.from + ' to ' + mailOptions.to); + gMailQueue.push(mailOptions); + + if (gDnsReady) processQueue(); +} + +function render(templateFile, params) { + assert.strictEqual(typeof templateFile, 'string'); + assert.strictEqual(typeof params, 'object'); + + return ejs.render(safe.fs.readFileSync(path.join(MAIL_TEMPLATES_DIR, templateFile), 'utf8'), params); +} + +function getAdminEmails(callback) { + userdb.getAllAdmins(function (error, admins) { + if (error) return callback(error); + + var adminEmails = [ ]; + admins.forEach(function (admin) { adminEmails.push(admin.email); }); + + callback(null, adminEmails); + }); +} + +function mailUserEventToAdmins(user, event) { + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof event, 'string'); + + getAdminEmails(function (error, adminEmails) { + if (error) return console.log('Error getting admins', error); + + adminEmails = _.difference(adminEmails, [ user.email ]); + + var mailOptions = { + from: config.get('adminEmail'), + to: adminEmails.join(', '), + subject: util.format('%s %s in Cloudron %s', user.username, event, config.fqdn()), + text: render('user_event.ejs', { fqdn: config.fqdn(), username: user.username, email: user.email, event: event, format: 'text' }), + }; + + enqueue(mailOptions); + }); +} + +function userAdded(user, invitor) { + assert.strictEqual(typeof user, 'object'); + assert(typeof invitor === 'object'); + + debug('Sending mail for userAdded'); + + var templateData = { + user: user, + webadminUrl: config.adminOrigin(), + setupLink: config.adminOrigin() + '/api/v1/session/password/setup.html?reset_token=' + user.resetToken, + format: 'text', + fqdn: config.fqdn(), + invitor: invitor + }; + + var mailOptions = { + from: config.get('adminEmail'), + to: user.email, + subject: util.format('Welcome to Cloudron %s', config.fqdn()), + text: render('welcome_user.ejs', templateData) + }; + + enqueue(mailOptions); + + mailUserEventToAdmins(user, 'was added'); +} + +function userRemoved(username) { + assert.strictEqual(typeof username, 'string'); + + debug('Sending mail for userRemoved'); + + mailUserEventToAdmins({ username: username }, 'was removed'); +} + +function adminChanged(user) { + assert.strictEqual(typeof user, 'object'); + + debug('Sending mail for adminChanged'); + + mailUserEventToAdmins(user, user.admin ? 'is now an admin' : 'is no more an admin'); +} + +function passwordReset(user) { + assert.strictEqual(typeof user, 'object'); + + debug('Sending mail for password reset for user %s.', user.username); + + var resetLink = config.adminOrigin() + '/api/v1/session/password/reset.html?reset_token=' + user.resetToken; + + var mailOptions = { + from: config.get('adminEmail'), + to: user.email, + subject: 'Password Reset Request', + text: render('password_reset.ejs', { fqdn: config.fqdn(), username: user.username, resetLink: resetLink, format: 'text' }) + }; + + enqueue(mailOptions); +} + +function appDied(app) { + assert.strictEqual(typeof app, 'object'); + + debug('Sending mail for app %s @ %s died', app.id, app.location); + + getAdminEmails(function (error, adminEmails) { + if (error) return console.log('Error getting admins', error); + + var mailOptions = { + from: config.get('adminEmail'), + to: adminEmails.join(', '), + subject: util.format('App %s is down', app.location), + text: render('app_down.ejs', { fqdn: config.fqdn(), title: app.manifest.title, appFqdn: config.appFqdn(app.location), format: 'text' }) + }; + + enqueue(mailOptions); + }); +} + +function boxUpdateAvailable(newBoxVersion, changelog) { + assert.strictEqual(typeof newBoxVersion, 'string'); + assert(util.isArray(changelog)); + + getAdminEmails(function (error, adminEmails) { + if (error) return console.log('Error getting admins', error); + + var mailOptions = { + from: config.get('adminEmail'), + to: adminEmails.join(', '), + subject: util.format('%s has a new update available', config.fqdn()), + text: render('box_update_available.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), newBoxVersion: newBoxVersion, changelog: changelog, format: 'text' }) + }; + + enqueue(mailOptions); + }); +} + +function appUpdateAvailable(app, updateInfo) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof updateInfo, 'object'); + + getAdminEmails(function (error, adminEmails) { + if (error) return console.log('Error getting admins', error); + + var mailOptions = { + from: config.get('adminEmail'), + to: adminEmails.join(', '), + subject: util.format('%s has a new update available', app.fqdn), + text: render('app_update_available.ejs', { fqdn: config.fqdn(), webadminUrl: config.adminOrigin(), app: app, format: 'text' }) + }; + + enqueue(mailOptions); + }); +} + +function sendCrashNotification(program, context) { + assert.strictEqual(typeof program, 'string'); + assert.strictEqual(typeof context, 'string'); + + var mailOptions = { + from: config.get('adminEmail'), + to: 'admin@cloudron.io', + subject: util.format('[%s] %s exited unexpectedly', config.fqdn(), program), + text: render('crash_notification.ejs', { fqdn: config.fqdn(), program: program, context: context, format: 'text' }) + }; + + enqueue(mailOptions); +} diff --git a/src/middleware/contentType.js b/src/middleware/contentType.js new file mode 100644 index 000000000..9a819b7d1 --- /dev/null +++ b/src/middleware/contentType.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = function contentType(type) { + return function (req, res, next) { + res.setHeader('Content-Type', type); + next(); + }; +}; diff --git a/src/middleware/cors.js b/src/middleware/cors.js new file mode 100644 index 000000000..7a9461b89 --- /dev/null +++ b/src/middleware/cors.js @@ -0,0 +1,55 @@ +/* jshint node:true */ + +'use strict'; + +var url = require('url'); + +/* + * CORS middleware + * + * options can contains a list of origins + */ +module.exports = function cors(options) { + options = options || { }; + var maxAge = options.maxAge || 60 * 60 * 25 * 5; // 5 days + var origins = options.origins || [ '*' ]; + var allowCredentials = options.allowCredentials || false; // cookies + + return function (req, res, next) { + var requestOrigin = req.headers.origin; + if (!requestOrigin) return next(); + + requestOrigin = url.parse(requestOrigin); + + var hostname = requestOrigin.host.split(':')[0]; // remove any port + var originAllowed = origins.some(function (o) { return o === '*' || o === hostname; }); + if (!originAllowed) { + return res.status(405).send('CORS not allowed from this domain'); + } + + // respond back with req.headers.origin which might contain the scheme + res.header('Access-Control-Allow-Origin', req.headers.origin); + res.header('Access-Control-Allow-Credentials', allowCredentials); + + // handle preflighted requests + if (req.method === 'OPTIONS') { + if (req.headers['access-control-request-method']) { + res.header('Access-Control-Allow-Methods', 'GET, PUT, DELETE, POST, OPTIONS'); + } + + if (req.headers['access-control-request-headers']) { + res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']); + } + + res.header('Access-Control-Max-Age', maxAge); + + return res.status(200).send(); + } + + if (req.headers['access-control-request-headers']) { + res.header('Access-Control-Allow-Headers', req.headers['access-control-request-headers']); + } + + next(); + }; +}; diff --git a/src/middleware/index.js b/src/middleware/index.js new file mode 100644 index 000000000..6875d7967 --- /dev/null +++ b/src/middleware/index.js @@ -0,0 +1,17 @@ +'use strict'; + +exports = module.exports = { + contentType: require('./contentType'), + cookieParser: require('cookie-parser'), + cors: require('./cors'), + csrf: require('csurf'), + favicon: require('serve-favicon'), + json: require('body-parser').json, + morgan: require('morgan'), + proxy: require('proxy-middleware'), + lastMile: require('connect-lastmile'), + multipart: require('./multipart.js'), + session: require('express-session'), + timeout: require('connect-timeout'), + urlencoded: require('body-parser').urlencoded +}; diff --git a/src/middleware/multipart.js b/src/middleware/multipart.js new file mode 100644 index 000000000..9d1ca6216 --- /dev/null +++ b/src/middleware/multipart.js @@ -0,0 +1,47 @@ +/* jshint node:true */ + +'use strict'; + +var multiparty = require('multiparty'), + timeout = require('connect-timeout'); + +function _mime(req) { + var str = req.headers['content-type'] || ''; + return str.split(';')[0]; +} + +module.exports = function multipart(options) { + return function (req, res, next) { + if (_mime(req) !== 'multipart/form-data') return res.status(400).send('Invalid content-type. Expecting multipart'); + + var form = new multiparty.Form({ + uploadDir: '/tmp', + keepExtensions: true, + maxFieldsSize: options.maxFieldsSize || (2 * 1024), // only field size, not files + limit: options.limit || '8mb', // file sizes + autoFiles: true + }); + + // increase timeout of file uploads by default to 3 mins + if (req.clearTimeout) req.clearTimeout(); // clear any previous installed timeout middleware + + timeout(options.timeout || (3 * 60 * 1000))(req, res, function () { + req.fields = { }; + req.files = { }; + + form.parse(req, function (err, fields, files) { + if (err) return res.status(400).send('Error parsing request'); + next(null); + }); + + form.on('file', function (name, file) { + req.files[name] = file; + }); + + form.on('field', function (name, value) { + req.fields[name] = value; // otherwise fields.name is an array + }); + }); + }; +}; + diff --git a/src/oauth2views/callback.ejs b/src/oauth2views/callback.ejs new file mode 100644 index 000000000..7d93ba32f --- /dev/null +++ b/src/oauth2views/callback.ejs @@ -0,0 +1,47 @@ +<% include header %> + + + +<% include footer %>; \ No newline at end of file diff --git a/src/oauth2views/dialog.ejs b/src/oauth2views/dialog.ejs new file mode 100644 index 000000000..adafc8540 --- /dev/null +++ b/src/oauth2views/dialog.ejs @@ -0,0 +1,38 @@ +<% include header %> + +
+ + + +
+
+
+
+
+
+ Hi <%= user.username %>! +
+
+
+
+ <%= client.name %> is requesting access to your account. +
+
+
+
+ Do you approve? +
+
+
+
+ + +
+
+
+
+
+
+
+ +<% include footer %> \ No newline at end of file diff --git a/src/oauth2views/error.ejs b/src/oauth2views/error.ejs new file mode 100644 index 000000000..678aa33ec --- /dev/null +++ b/src/oauth2views/error.ejs @@ -0,0 +1,24 @@ +<% include header %> + +
+ +
+
+
+
+
+ <%- message %> +
+
+
+
+
+
+
+ Back +
+
+
+
+ +<% include footer %> diff --git a/src/oauth2views/footer.ejs b/src/oauth2views/footer.ejs new file mode 100644 index 000000000..5179adefc --- /dev/null +++ b/src/oauth2views/footer.ejs @@ -0,0 +1,3 @@ + + + diff --git a/src/oauth2views/header.ejs b/src/oauth2views/header.ejs new file mode 100644 index 000000000..337e6af6b --- /dev/null +++ b/src/oauth2views/header.ejs @@ -0,0 +1,28 @@ + + + + + + + Cloudron Login + + + + + + + + + + + + + + + + + + + + + diff --git a/src/oauth2views/login.ejs b/src/oauth2views/login.ejs new file mode 100644 index 000000000..29ddf3970 --- /dev/null +++ b/src/oauth2views/login.ejs @@ -0,0 +1,44 @@ +<% include header %> + +
+

Login to <%= applicationName %>

+
+ +<% if (error) { %> +
+

+

<%= error %>

+
+<% } %> + +
+
+
+
+ +
+ + +
+
+ + +
+ +
+ Reset your password +
+
+
+ + + +<% include footer %> diff --git a/src/oauth2views/password_reset.ejs b/src/oauth2views/password_reset.ejs new file mode 100644 index 000000000..fe41b598c --- /dev/null +++ b/src/oauth2views/password_reset.ejs @@ -0,0 +1,40 @@ +<% include header %> + + + + + +
+

Hello <%= user.username %> create a new password

+
+ +
+
+
+
+ + + +
+ + +
+
+ + +
+ +
+
+
+
+ +<% include footer %> diff --git a/src/oauth2views/password_reset_request.ejs b/src/oauth2views/password_reset_request.ejs new file mode 100644 index 000000000..1f7716d8a --- /dev/null +++ b/src/oauth2views/password_reset_request.ejs @@ -0,0 +1,25 @@ +<% include header %> + + + +
+

Reset your password

+
+ +
+
+
+
+ +
+ + +
+ +
+ Login +
+
+
+ +<% include footer %> diff --git a/src/oauth2views/password_reset_sent.ejs b/src/oauth2views/password_reset_sent.ejs new file mode 100644 index 000000000..29be50323 --- /dev/null +++ b/src/oauth2views/password_reset_sent.ejs @@ -0,0 +1,18 @@ +<% include header %> + + + +
+

Reset your password successful

+
+ +
+
+
+

An email was sent to you with a link to create a new password.

+ If you have not received any email after some time, maybe you have misspelled your email address, simply try again here. +
+
+
+ +<% include footer %> diff --git a/src/oauth2views/password_setup.ejs b/src/oauth2views/password_setup.ejs new file mode 100644 index 000000000..6e90c0998 --- /dev/null +++ b/src/oauth2views/password_setup.ejs @@ -0,0 +1,40 @@ +<% include header %> + + + + + +
+

Hello <%= user.username %> create a password

+
+ +
+
+
+
+ + + +
+ + +
+
+ + +
+ +
+
+
+
+ +<% include footer %> diff --git a/src/paths.js b/src/paths.js new file mode 100644 index 000000000..c9467495b --- /dev/null +++ b/src/paths.js @@ -0,0 +1,28 @@ +/* jslint node:true */ + +'use strict'; + +var config = require('./config.js'), + path = require('path'); + +// keep these values in sync with start.sh +exports = module.exports = { + NGINX_CONFIG_DIR: path.join(config.baseDir(), 'data/nginx'), + NGINX_APPCONFIG_DIR: path.join(config.baseDir(), 'data/nginx/applications'), + NGINX_CERT_DIR: path.join(config.baseDir(), 'data/nginx/cert'), + + ADDON_CONFIG_DIR: path.join(config.baseDir(), 'data/addons'), + + COLLECTD_APPCONFIG_DIR: path.join(config.baseDir(), 'data/collectd/collectd.conf.d'), + + DATA_DIR: path.join(config.baseDir(), 'data'), + BOX_DATA_DIR: path.join(config.baseDir(), 'data/box'), + // this is not part of appdata because an icon may be set before install + APPICONS_DIR: path.join(config.baseDir(), 'data/box/appicons'), + MAIL_DATA_DIR: path.join(config.baseDir(), 'data/box/mail'), + + CLOUDRON_AVATAR_FILE: path.join(config.baseDir(), 'data/box/avatar.png'), + CLOUDRON_DEFAULT_AVATAR_FILE: path.join(__dirname + '/../assets/avatar.png'), + + FAVICON_FILE: path.join(__dirname + '/../assets/favicon.ico') +}; diff --git a/src/progress.js b/src/progress.js new file mode 100644 index 000000000..6e3be7d9e --- /dev/null +++ b/src/progress.js @@ -0,0 +1,47 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + set: set, + clear: clear, + get: get, + + UPDATE: 'update', + BACKUP: 'backup' +}; + +var assert = require('assert'), + debug = require('debug')('box:progress'); + +// if progress.update or progress.backup are object, they will contain 'percent' and 'message' properties +// otherwise no such operation is currently ongoing +var progress = { + update: null, + backup: null +}; + +function set(tag, percent, message) { + assert(tag === exports.UPDATE || tag === exports.BACKUP); + assert.strictEqual(typeof percent, 'number'); + assert.strictEqual(typeof message, 'string'); + + progress[tag] = { + percent: percent, + message: message + }; + + debug('%s: %s %s', tag, percent, message); +} + +function clear(tag) { + assert(tag === exports.UPDATE || tag === exports.BACKUP); + + progress[tag] = null; + + debug('clearing %s', tag); +} + +function get() { + return progress; +} diff --git a/src/routes/apps.js b/src/routes/apps.js new file mode 100644 index 000000000..e99550daa --- /dev/null +++ b/src/routes/apps.js @@ -0,0 +1,356 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + getApp: getApp, + getAppBySubdomain: getAppBySubdomain, + getApps: getApps, + getAppIcon: getAppIcon, + installApp: installApp, + configureApp: configureApp, + uninstallApp: uninstallApp, + restoreApp: restoreApp, + backupApp: backupApp, + updateApp: updateApp, + getLogs: getLogs, + getLogStream: getLogStream, + + stopApp: stopApp, + startApp: startApp, + exec: exec +}; + +var apps = require('../apps.js'), + AppsError = apps.AppsError, + assert = require('assert'), + debug = require('debug')('box:routes/apps'), + fs = require('fs'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + paths = require('../paths.js'), + safe = require('safetydance'), + util = require('util'), + uuid = require('node-uuid'); + +function removeInternalAppFields(app) { + return { + id: app.id, + appStoreId: app.appStoreId, + installationState: app.installationState, + installationProgress: app.installationProgress, + runState: app.runState, + health: app.health, + location: app.location, + accessRestriction: app.accessRestriction, + lastBackupId: app.lastBackupId, + manifest: app.manifest, + portBindings: app.portBindings, + iconUrl: app.iconUrl, + fqdn: app.fqdn + }; +} + +function getApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + apps.get(req.params.id, function (error, app) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, removeInternalAppFields(app))); + }); +} + +function getAppBySubdomain(req, res, next) { + assert.strictEqual(typeof req.params.subdomain, 'string'); + + apps.getBySubdomain(req.params.subdomain, function (error, app) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such subdomain')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, removeInternalAppFields(app))); + }); +} + +function getApps(req, res, next) { + apps.getAll(function (error, allApps) { + if (error) return next(new HttpError(500, error)); + + allApps = allApps.map(removeInternalAppFields); + + next(new HttpSuccess(200, { apps: allApps })); + }); +} + +function getAppIcon(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + var iconPath = paths.APPICONS_DIR + '/' + req.params.id + '.png'; + fs.exists(iconPath, function (exists) { + if (!exists) return next(new HttpError(404, 'No such icon')); + res.sendFile(iconPath); + }); +} + +/* + * Installs an app + * @bodyparam {string} appStoreId The id of the app to be installed + * @bodyparam {manifest} manifest The app manifest + * @bodyparam {string} password The user's password + * @bodyparam {string} location The subdomain where the app is to be installed + * @bodyparam {object} portBindings map from environment variable name to (public) host port. can be null. + If a value in manifest.tcpPorts is missing in portBindings, the port/service is disabled + * @bodyparam {icon} icon Base64 encoded image + */ +function installApp(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + var data = req.body; + + if (!data) return next(new HttpError(400, 'Cannot parse data field')); + if (!data.manifest || typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest is required')); + if (typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId is required')); + if (typeof data.location !== 'string') return next(new HttpError(400, 'location is required')); + if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + if (typeof data.accessRestriction !== 'string') return next(new HttpError(400, 'accessRestriction is required')); + if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string')); + + // allow tests to provide an appId for testing + var appId = (process.env.NODE_ENV === 'test' && typeof data.appId === 'string') ? data.appId : uuid.v4(); + + debug('Installing app id:%s storeid:%s loc:%s port:%j restrict:%s manifest:%j', appId, data.appStoreId, data.location, data.portBindings, data.accessRestriction, data.manifest); + + apps.install(appId, data.appStoreId, data.manifest, data.location, data.portBindings || null, data.accessRestriction, data.icon || null, function (error) { + if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.')); + if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.')); + if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === AppsError.BILLING_REQUIRED) return next(new HttpError(402, 'Billing required')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { id: appId } )); + }); +} + +/* + * Configure an app + * @bodyparam {string} password The user's password + * @bodyparam {string} location The subdomain where the app is to be installed + * @bodyparam {object} portBindings map from env to (public) host port. can be null. + If a value in manifest.tcpPorts is missing in portBindings, the port/service is disabled + */ +function configureApp(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.params.id, 'string'); + + var data = req.body; + + if (!data) return next(new HttpError(400, 'Cannot parse data field')); + if (typeof data.location !== 'string') return next(new HttpError(400, 'location is required')); + if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + if (typeof data.accessRestriction !== 'string') return next(new HttpError(400, 'accessRestriction is required')); + + debug('Configuring app id:%s location:%s bindings:%j', req.params.id, data.location, data.portBindings); + + apps.configure(req.params.id, data.location, data.portBindings || null, data.accessRestriction, function (error) { + if (error && error.reason === AppsError.ALREADY_EXISTS) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.PORT_RESERVED) return next(new HttpError(409, 'Port ' + error.message + ' is reserved.')); + if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.')); + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +function restoreApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Restore app id:%s', req.params.id); + + apps.restore(req.params.id, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +function backupApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Backup app id:%s', req.params.id); + + apps.backup(req.params.id, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.EXTERNAL_ERROR) return next(new HttpError(503, error)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +/* + * Uninstalls an app + * @bodyparam {string} id The id of the app to be uninstalled + */ +function uninstallApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Uninstalling app id:%s', req.params.id); + + apps.uninstall(req.params.id, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +function startApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Start app id:%s', req.params.id); + + apps.start(req.params.id, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +function stopApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Stop app id:%s', req.params.id); + + apps.stop(req.params.id, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +function updateApp(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + assert.strictEqual(typeof req.body, 'object'); + + var data = req.body; + + if (!data) return next(new HttpError(400, 'Cannot parse data field')); + if (!data.manifest || typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest is required')); + if (('portBindings' in data) && typeof data.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + if ('icon' in data && typeof data.icon !== 'string') return next(new HttpError(400, 'icon is not a string')); + if ('force' in data && typeof data.force !== 'boolean') return next(new HttpError(400, 'force must be a boolean')); + + debug('Update app id:%s to manifest:%j', req.params.id, data.manifest); + + apps.update(req.params.id, data.force || false, data.manifest, data.portBindings, data.icon, function (error) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error && error.reason === AppsError.PORT_CONFLICT) return next(new HttpError(409, 'Port ' + error.message + ' is already in use.')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, { })); + }); +} + +// this route is for streaming logs +function getLogStream(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Getting logstream of app id:%s', req.params.id); + + var fromLine = req.query.fromLine ? parseInt(req.query.fromLine, 10) : -10; // we ignore last-event-id + if (isNaN(fromLine)) return next(new HttpError(400, 'fromLine must be a valid number')); + + function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; } + + if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream')); + + apps.getLogStream(req.params.id, fromLine, function (error, logStream) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message)); + if (error) return next(new HttpError(500, error)); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // disable nginx buffering + 'Access-Control-Allow-Origin': '*' + }); + res.write('retry: 3000\n'); + res.on('close', logStream.close); + logStream.on('data', function (data) { + var obj = JSON.parse(data); + res.write(sse(obj.lineNumber, JSON.stringify(obj))); + }); + logStream.on('end', res.end.bind(res)); + logStream.on('error', res.end.bind(res, null)); + }); +} + +function getLogs(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Getting logs of app id:%s', req.params.id); + + apps.getLogs(req.params.id, function (error, logStream) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(412, error.message)); + if (error) return next(new HttpError(500, error)); + + res.writeHead(200, { + 'Content-Type': 'application/x-logs', + 'Content-Disposition': 'attachment; filename="log.txt"', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' // disable nginx buffering + }); + logStream.pipe(res); + }); +} + +function exec(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + + debug('Execing into app id:%s', req.params.id); + + var cmd = null; + if (req.query.cmd) { + cmd = safe.JSON.parse(req.query.cmd); + if (!util.isArray(cmd) && cmd.length < 1) return next(new HttpError(400, 'cmd must be array with atleast size 1')); + } + + var columns = req.query.columns ? parseInt(req.query.columns, 10) : null; + if (isNaN(columns)) return next(new HttpError(400, 'columns must be a number')); + + var rows = req.query.rows ? parseInt(req.query.rows, 10) : null; + if (isNaN(rows)) return next(new HttpError(400, 'rows must be a number')); + + apps.exec(req.params.id, { cmd: cmd, rows: rows, columns: columns }, function (error, duplexStream) { + if (error && error.reason === AppsError.NOT_FOUND) return next(new HttpError(404, 'No such app')); + if (error && error.reason === AppsError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + if (req.headers['upgrade'] !== 'tcp') return next(new HttpError(404, 'exec requires TCP upgrade')); + + req.clearTimeout(); + res.sendUpgradeHandshake(); + + duplexStream.pipe(res.socket); + res.socket.pipe(duplexStream); + }); +} + diff --git a/src/routes/backups.js b/src/routes/backups.js new file mode 100644 index 000000000..11dd9de2c --- /dev/null +++ b/src/routes/backups.js @@ -0,0 +1,36 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + get: get, + create: create +}; + +var backups = require('../backups.js'), + BackupsError = require('../backups.js').BackupsError, + cloudron = require('../cloudron.js'), + CloudronError = require('../cloudron.js').CloudronError, + debug = require('debug')('box:routes/backups'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess; + +function get(req, res, next) { + backups.getAllPaged(1, 5, function (error, result) { + if (error && error.reason === BackupsError.EXTERNAL_ERROR) return next(new HttpError(503, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { backups: result })); + }); +} + +function create(req, res, next) { + // note that cloudron.backup only waits for backup initiation and not for backup to complete + // backup progress can be checked up ny polling the progress api call + cloudron.backup(function (error) { + if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} diff --git a/src/routes/clients.js b/src/routes/clients.js new file mode 100644 index 000000000..b5b79be64 --- /dev/null +++ b/src/routes/clients.js @@ -0,0 +1,105 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + add: add, + get: get, + update: update, + del: del, + getAllByUserId: getAllByUserId, + getClientTokens: getClientTokens, + delClientTokens: delClientTokens +}; + +var assert = require('assert'), + validUrl = require('valid-url'), + clients = require('../clients.js'), + ClientsError = clients.ClientsError, + DatabaseError = require('../databaseerror.js'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess; + +function add(req, res, next) { + var data = req.body; + + if (!data) return next(new HttpError(400, 'Cannot parse data field')); + if (typeof data.appId !== 'string' || !data.appId) return next(new HttpError(400, 'appId is required')); + if (typeof data.redirectURI !== 'string' || !data.redirectURI) return next(new HttpError(400, 'redirectURI is required')); + if (typeof data.scope !== 'string' || !data.scope) return next(new HttpError(400, 'scope is required')); + if (!validUrl.isWebUri(data.redirectURI)) return next(new HttpError(400, 'redirectURI must be a valid uri')); + + // prefix as this route only allows external apps for developers + var appId = 'external-' + data.appId; + + clients.add(appId, data.redirectURI, data.scope, function (error, result) { + if (error && error.reason === ClientsError.INVALID_SCOPE) return next(new HttpError(400, 'Invalid scope')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(201, result)); + }); +} + +function get(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + + clients.get(req.params.clientId, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'No such client')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, result)); + }); +} + +function update(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + + var data = req.body; + + if (!data) return next(new HttpError(400, 'Cannot parse data field')); + if (typeof data.appId !== 'string' || !data.appId) return next(new HttpError(400, 'appId is required')); + if (typeof data.redirectURI !== 'string' || !data.redirectURI) return next(new HttpError(400, 'redirectURI is required')); + if (!validUrl.isWebUri(data.redirectURI)) return next(new HttpError(400, 'redirectURI must be a valid uri')); + + clients.update(req.params.clientId, data.appId, data.redirectURI, function (error, result) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(202, result)); + }); +} + +function del(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + + clients.del(req.params.clientId, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(204, result)); + }); +} + +function getAllByUserId(req, res, next) { + clients.getAllWithDetailsByUserId(req.user.id, function (error, result) { + if (error && error.reason !== DatabaseError.NOT_FOUND) return next(new HttpError(500, error)); + next(new HttpSuccess(200, { clients: result })); + }); +} + +function getClientTokens(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + assert.strictEqual(typeof req.user, 'object'); + + clients.getClientTokensByUserId(req.params.clientId, req.user.id, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, { tokens: result })); + }); +} + +function delClientTokens(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + assert.strictEqual(typeof req.user, 'object'); + + clients.delClientTokensByUserId(req.params.clientId, req.user.id, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return next(new HttpError(404, 'no such client')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(204)); + }); +} diff --git a/src/routes/cloudron.js b/src/routes/cloudron.js new file mode 100644 index 000000000..d2a9b58c7 --- /dev/null +++ b/src/routes/cloudron.js @@ -0,0 +1,159 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + activate: activate, + setupTokenAuth: setupTokenAuth, + getStatus: getStatus, + reboot: reboot, + getProgress: getProgress, + getConfig: getConfig, + update: update, + migrate: migrate, + setCertificate: setCertificate +}; + +var assert = require('assert'), + cloudron = require('../cloudron.js'), + config = require('../config.js'), + progress = require('../progress.js'), + CloudronError = cloudron.CloudronError, + debug = require('debug')('box:routes/cloudron'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + superagent = require('superagent'), + safe = require('safetydance'), + updateChecker = require('../updatechecker.js'); + +/** + * Creating an admin user and activate the cloudron. + * + * @apiParam {string} username The administrator's user name + * @apiParam {string} password The administrator's password + * @apiParam {string} email The administrator's email address + * + * @apiSuccess (Created 201) {string} token A valid access token + */ +function activate(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.query.setupToken, 'string'); + + if (typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be string')); + if (typeof req.body.password !== 'string') return next(new HttpError(400, 'password must be string')); + if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string')); + if ('name' in req.body && typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be a string')); + + var username = req.body.username; + var password = req.body.password; + var email = req.body.email; + var name = req.body.name || null; + + var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + debug('activate: username:%s ip:%s', username, ip); + + cloudron.activate(username, password, email, name, ip, function (error, info) { + if (error && error.reason === CloudronError.ALREADY_PROVISIONED) return next(new HttpError(409, 'Already setup')); + if (error && error.reason === CloudronError.BAD_USERNAME) return next(new HttpError(400, 'Bad username')); + if (error && error.reason === CloudronError.BAD_PASSWORD) return next(new HttpError(400, 'Bad password')); + if (error && error.reason === CloudronError.BAD_EMAIL) return next(new HttpError(400, 'Bad email')); + if (error && error.reason === CloudronError.BAD_NAME) return next(new HttpError(400, 'Bad name')); + if (error) return next(new HttpError(500, error)); + + // Now let the api server know we got activated + superagent.post(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/setup/done').query({ setupToken:req.query.setupToken }).end(function (error, result) { + if (error) return next(new HttpError(500, error)); + if (result.statusCode === 403) return next(new HttpError(403, 'Invalid token')); + if (result.statusCode === 409) return next(new HttpError(409, 'Already setup')); + if (result.statusCode !== 201) return next(new HttpError(500, result.text ? result.text.message : 'Internal error')); + + next(new HttpSuccess(201, info)); + }); + }); +} + +function setupTokenAuth(req, res, next) { + assert.strictEqual(typeof req.query, 'object'); + + if (typeof req.query.setupToken !== 'string') return next(new HttpError(400, 'no setupToken provided')); + + superagent.get(config.apiServerOrigin() + '/api/v1/boxes/' + config.fqdn() + '/setup/verify').query({ setupToken:req.query.setupToken }).end(function (error, result) { + if (error) return next(new HttpError(500, error)); + if (result.statusCode === 403) return next(new HttpError(403, 'Invalid token')); + if (result.statusCode === 409) return next(new HttpError(409, 'Already setup')); + if (result.statusCode !== 200) return next(new HttpError(500, result.text ? result.text.message : 'Internal error')); + + next(); + }); +} + +function getStatus(req, res, next) { + cloudron.getStatus(function (error, status) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, status)); + }); +} + +function getProgress(req, res, next) { + return next(new HttpSuccess(200, progress.get())); +} + +function reboot(req, res, next) { + // Finish the request, to let the appstore know we triggered the restore it + next(new HttpSuccess(202, {})); + + cloudron.reboot(); +} + +function getConfig(req, res, next) { + cloudron.getConfig(function (error, cloudronConfig) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, cloudronConfig)); + }); +} + +function update(req, res, next) { + var boxUpdateInfo = updateChecker.getUpdateInfo().box; + if (!boxUpdateInfo) return next(new HttpError(422, 'No update available')); + + // this only initiates the update, progress can be checked via the progress route + cloudron.update(boxUpdateInfo, function (error) { + if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} + +function migrate(req, res, next) { + if (typeof req.body.size !== 'string') return next(new HttpError(400, 'size must be string')); + if (typeof req.body.region !== 'string') return next(new HttpError(400, 'region must be string')); + + debug('Migration requested', req.body.size, req.body.region); + + cloudron.migrate(req.body.size, req.body.region, function (error) { + if (error && error.reason === CloudronError.BAD_STATE) return next(new HttpError(409, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} + +function setCertificate(req, res, next) { + assert.strictEqual(typeof req.files, 'object'); + + if (!req.files.certificate) return next(new HttpError(400, 'certificate must be provided')); + var certificate = safe.fs.readFileSync(req.files.certificate.path, 'utf8'); + + if (!req.files.key) return next(new HttpError(400, 'key must be provided')); + var key = safe.fs.readFileSync(req.files.key.path, 'utf8'); + + cloudron.setCertificate(certificate, key, function (error) { + if (error && error.reason === CloudronError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(202, {})); + }); +} diff --git a/src/routes/developer.js b/src/routes/developer.js new file mode 100644 index 000000000..8fc1b1f45 --- /dev/null +++ b/src/routes/developer.js @@ -0,0 +1,48 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + enabled: enabled, + setEnabled: setEnabled, + status: status, + login: login +}; + +var developer = require('../developer.js'), + passport = require('passport'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess; + +function enabled(req, res, next) { + developer.enabled(function (error, enabled) { + if (enabled) return next(); + next(new HttpError(412, 'Developer mode not enabled')); + }); +} + +function setEnabled(req, res, next) { + if (typeof req.body.enabled !== 'boolean') return next(new HttpError(400, 'enabled must be boolean')); + + developer.setEnabled(req.body.enabled, function (error) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, {})); + }); +} + +function status(req, res, next) { + next(new HttpSuccess(200, {})); +} + +function login(req, res, next) { + passport.authenticate('local', function (error, user) { + if (error) return next(new HttpError(500, error)); + if (!user) return next(new HttpError(401, 'Invalid credentials')); + + developer.issueDeveloperToken(user, function (error, result) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { token: result.token, expiresAt: result.expiresAt })); + }); + })(req, res, next); +} diff --git a/src/routes/graphs.js b/src/routes/graphs.js new file mode 100644 index 000000000..34185756d --- /dev/null +++ b/src/routes/graphs.js @@ -0,0 +1,21 @@ +'use strict'; + +exports = module.exports = { + getGraphs: getGraphs +}; + +var middleware = require('../middleware/index.js'), + url = require('url'); + +var graphiteProxy = middleware.proxy(url.parse('http://127.0.0.1:8000')); + +function getGraphs(req, res, next) { + var parsedUrl = url.parse(req.url, true /* parseQueryString */); + delete parsedUrl.query['access_token']; + delete req.headers['authorization']; + delete req.headers['cookies']; + req.url = url.format({ pathname: 'render', query: parsedUrl.query }); + + graphiteProxy(req, res, next); +} + diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 000000000..19165cca8 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,15 @@ +'use strict'; + +exports = module.exports = { + apps: require('./apps.js'), + cloudron: require('./cloudron.js'), + developer: require('./developer.js'), + graphs: require('./graphs.js'), + oauth2: require('./oauth2.js'), + settings: require('./settings.js'), + clients: require('./clients.js'), + backups: require('./backups.js'), + internal: require('./internal.js'), + user: require('./user.js') +}; + diff --git a/src/routes/internal.js b/src/routes/internal.js new file mode 100644 index 000000000..dec0dd1bc --- /dev/null +++ b/src/routes/internal.js @@ -0,0 +1,23 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + backup: backup +}; + +var cloudron = require('../cloudron.js'), + debug = require('debug')('box:routes/internal'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess; + +function backup(req, res, next) { + debug('trigger backup'); + + cloudron.backup(function (error) { + if (error) debug('Internal route backup failed', error); + }); + + // we always succeed to trigger a backup + next(new HttpSuccess(202, {})); +} diff --git a/src/routes/oauth2.js b/src/routes/oauth2.js new file mode 100644 index 000000000..3ea8fb8de --- /dev/null +++ b/src/routes/oauth2.js @@ -0,0 +1,503 @@ +/* jslint node:true */ + +'use strict'; + +var assert = require('assert'), + authcodedb = require('../authcodedb'), + clientdb = require('../clientdb'), + config = require('../config.js'), + constants = require('../constants.js'), + DatabaseError = require('../databaseerror'), + debug = require('debug')('box:routes/oauth2'), + HttpError = require('connect-lastmile').HttpError, + middleware = require('../middleware/index.js'), + oauth2orize = require('oauth2orize'), + passport = require('passport'), + querystring = require('querystring'), + util = require('util'), + session = require('connect-ensure-login'), + tokendb = require('../tokendb'), + appdb = require('../appdb'), + url = require('url'), + user = require('../user.js'), + UserError = user.UserError, + hat = require('hat'); + +// create OAuth 2.0 server +var gServer = oauth2orize.createServer(); + +// Register serialialization and deserialization functions. +// +// When a client redirects a user to user authorization endpoint, an +// authorization transaction is initiated. To complete the transaction, the +// user must authenticate and approve the authorization request. Because this +// may involve multiple HTTP request/response exchanges, the transaction is +// stored in the session. +// +// An application must supply serialization functions, which determine how the +// client object is serialized into the session. Typically this will be a +// simple matter of serializing the client's ID, and deserializing by finding +// the client by ID from the database. + +gServer.serializeClient(function (client, callback) { + debug('server serialize:', client); + + return callback(null, client.id); +}); + +gServer.deserializeClient(function (id, callback) { + debug('server deserialize:', id); + + clientdb.get(id, function (error, client) { + if (error) { return callback(error); } + return callback(null, client); + }); +}); + +// Register supported grant types. + +// Grant authorization codes. The callback takes the `client` requesting +// authorization, the `redirectURI` (which is used as a verifier in the +// subsequent exchange), the authenticated `user` granting access, and +// their response, which contains approved scope, duration, etc. as parsed by +// the application. The application issues a code, which is bound to these +// values, and will be exchanged for an access token. + +// we use , (comma) as scope separator +gServer.grant(oauth2orize.grant.code({ scopeSeparator: ',' }, function (client, redirectURI, user, ares, callback) { + debug('grant code:', client, redirectURI, user.id, ares); + + var code = hat(256); + var expiresAt = Date.now() + 60 * 60000; // 1 hour + var scopes = client.scope ? client.scope.split(',') : ['profile','roleUser']; + + if (scopes.indexOf('roleAdmin') !== -1 && !user.admin) { + debug('grant code: not allowed, you need to be admin'); + return callback(new Error('Admin capabilities required')); + } + + authcodedb.add(code, client.id, user.username, expiresAt, function (error) { + if (error) return callback(error); + callback(null, code); + }); +})); + + +gServer.grant(oauth2orize.grant.token({ scopeSeparator: ',' }, function (client, user, ares, callback) { + debug('grant token:', client.id, user.id, ares); + + var token = tokendb.generateToken(); + var expires = Date.now() + 24 * 60 * 60 * 1000; // 1 day + + tokendb.add(token, tokendb.PREFIX_USER + user.id, client.id, expires, client.scope, function (error) { + if (error) return callback(error); + + debug('new access token for client ' + client.id + ' token ' + token); + + callback(null, token); + }); +})); + + +// Exchange authorization codes for access tokens. The callback accepts the +// `client`, which is exchanging `code` and any `redirectURI` from the +// authorization request for verification. If these values are validated, the +// application issues an access token on behalf of the user who authorized the +// code. + +gServer.exchange(oauth2orize.exchange.code(function (client, code, redirectURI, callback) { + debug('exchange:', client, code, redirectURI); + + authcodedb.get(code, function (error, authCode) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) return callback(error); + if (client.id !== authCode.clientId) return callback(null, false); + + authcodedb.del(code, function (error) { + if(error) return callback(error); + + var token = tokendb.generateToken(); + var expires = Date.now() + 24 * 60 * 60 * 1000; // 1 day + + tokendb.add(token, tokendb.PREFIX_USER + authCode.userId, authCode.clientId, expires, client.scope, function (error) { + if (error) return callback(error); + + debug('new access token for client ' + client.id + ' token ' + token); + + callback(null, token); + }); + }); + }); +})); + +// overwrite the session.ensureLoggedIn to not use res.redirect() due to a chrome bug not sending cookies on redirects +session.ensureLoggedIn = function (redirectTo) { + assert.strictEqual(typeof redirectTo, 'string'); + + return function (req, res, next) { + if (!req.isAuthenticated || !req.isAuthenticated()) { + if (req.session) { + req.session.returnTo = req.originalUrl || req.url; + } + + res.status(200).send(util.format('', redirectTo)); + } else { + next(); + } + }; +}; + +function sendErrorPageOrRedirect(req, res, message) { + assert.strictEqual(typeof req, 'object'); + assert.strictEqual(typeof res, 'object'); + assert.strictEqual(typeof message, 'string'); + + debug('sendErrorPageOrRedirect: returnTo "%s".', req.query.returnTo, message); + + if (typeof req.query.returnTo !== 'string') { + res.render('error', { + adminOrigin: config.adminOrigin(), + message: message + }); + } else { + var u = url.parse(req.query.returnTo); + if (!u.protocol || !u.host) return res.render('error', { + adminOrigin: config.adminOrigin(), + message: 'Invalid request. returnTo query is not a valid URI. ' + message + }); + + res.redirect(util.format('%s//%s', u.protocol, u.host)); + } +} + +function sendError(req, res, message) { + assert.strictEqual(typeof req, 'object'); + assert.strictEqual(typeof res, 'object'); + assert.strictEqual(typeof message, 'string'); + + res.render('error', { + adminOrigin: config.adminOrigin(), + message: message + }); +} + +// Main login form username and password +function loginForm(req, res) { + if (typeof req.session.returnTo !== 'string') return sendErrorPageOrRedirect(req, res, 'Invalid login request. No returnTo provided.'); + + var u = url.parse(req.session.returnTo, true); + if (!u.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid login request. No client_id provided.'); + + function render(applicationName) { + res.render('login', { + adminOrigin: config.adminOrigin(), + csrf: req.csrfToken(), + applicationName: applicationName, + error: req.query.error || null + }); + } + + clientdb.get(u.query.client_id, function (error, result) { + if (error) return sendError(req, res, 'Unknown OAuth client'); + + // Handle our different types of oauth clients + var appId = result.appId; + if (appId === constants.ADMIN_CLIENT_ID) { + return render(constants.ADMIN_NAME); + } else if (appId === constants.TEST_CLIENT_ID) { + return render(constants.TEST_NAME); + } else if (appId.indexOf('external-') === 0) { + return render('External Application'); + } else if (appId.indexOf('addon-') === 0) { + appId = appId.slice('addon-'.length); + } else if (appId.indexOf('proxy-') === 0) { + appId = appId.slice('proxy-'.length); + } + + appdb.get(appId, function (error, result) { + if (error) return sendErrorPageOrRedirect(req, res, 'Unknown Application for those OAuth credentials'); + + var applicationName = result.location || config.fqdn(); + render(applicationName); + }); + }); +} + +// performs the login POST from the login form +function login(req, res) { + var returnTo = req.session.returnTo || req.query.returnTo; + + var failureQuery = querystring.stringify({ error: 'Invalid username or password', returnTo: returnTo }); + passport.authenticate('local', { + failureRedirect: '/api/v1/session/login?' + failureQuery + })(req, res, function () { + res.redirect(returnTo); + }); +} + +// ends the current session +function logout(req, res) { + req.logout(); + + if (req.query && req.query.redirect) res.redirect(req.query.redirect); + else res.redirect('/'); +} + +// Form to enter email address to send a password reset request mail +// -> GET /api/v1/session/password/resetRequest.html +function passwordResetRequestSite(req, res) { + res.render('password_reset_request', { adminOrigin: config.adminOrigin(), csrf: req.csrfToken() }); +} + +// This route is used for above form submission +// -> POST /api/v1/session/password/resetRequest +function passwordResetRequest(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.identifier !== 'string') return next(new HttpError(400, 'Missing identifier')); + + debug('passwordResetRequest: email or username %s.', req.body.identifier); + + user.resetPasswordByIdentifier(req.body.identifier, function (error) { + if (error && error.reason !== UserError.NOT_FOUND) { + console.error(error); + return sendErrorPageOrRedirect(req, res, 'User not found'); + } + + res.redirect('/api/v1/session/password/sent.html'); + }); +} + +// -> GET /api/v1/session/password/sent.html +function passwordSentSite(req, res) { + res.render('password_reset_sent', { adminOrigin: config.adminOrigin() }); +} + +// -> GET /api/v1/session/password/setup.html +function passwordSetupSite(req, res, next) { + if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token')); + + debug('passwordSetupSite: with token %s.', req.query.reset_token); + + user.getByResetToken(req.query.reset_token, function (error, user) { + if (error) return next(new HttpError(401, 'Invalid reset_token')); + + res.render('password_setup', { adminOrigin: config.adminOrigin(), user: user, csrf: req.csrfToken(), resetToken: req.query.reset_token }); + }); +} + +// -> GET /api/v1/session/password/reset.html +function passwordResetSite(req, res, next) { + if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token')); + + debug('passwordResetSite: with token %s.', req.query.reset_token); + + user.getByResetToken(req.query.reset_token, function (error, user) { + if (error) return next(new HttpError(401, 'Invalid reset_token')); + + res.render('password_reset', { adminOrigin: config.adminOrigin(), user: user, csrf: req.csrfToken(), resetToken: req.query.reset_token }); + }); +} + +// -> POST /api/v1/session/password/reset +function passwordReset(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken')); + if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password')); + + debug('passwordReset: with token %s.', req.body.resetToken); + + user.getByResetToken(req.body.resetToken, function (error, userObject) { + if (error) return next(new HttpError(401, 'Invalid resetToken')); + + // setPassword clears the resetToken + user.setPassword(userObject.id, req.body.password, function (error, result) { + if (error) return next(new HttpError(500, error)); + + res.redirect(util.format('%s?accessToken=%s&expiresAt=%s', config.adminOrigin(), result.token, result.expiresAt)); + }); + }); +} + + +/* + + The callback page takes the redirectURI and the authCode and redirects the browser accordingly + +*/ +var callback = [ + session.ensureLoggedIn('/api/v1/session/login'), + function (req, res) { + debug('callback: with callback server ' + req.query.redirectURI); + res.render('callback', { adminOrigin: config.adminOrigin(), callbackServer: req.query.redirectURI }); + } +]; + + +/* + + This indicates a missing OAuth client session or invalid client ID + +*/ +var error = [ + session.ensureLoggedIn('/api/v1/session/login'), + function (req, res) { + sendErrorPageOrRedirect(req, res, 'Invalid OAuth Client'); + } +]; + + +/* + + The authorization endpoint is the entry point for an OAuth login. + + Each app would start OAuth by redirecting the user to: + + /api/v1/oauth/dialog/authorize?response_type=code&client_id=&redirect_uri=&scope= + + - First, this will ensure the user is logged in. + - Then in normal OAuth it would ask the user for permissions to the scopes, which we will do on app installation + - Then it will redirect the browser to the given containing the authcode in the query + + Scopes are set by the app during installation, the ones given on OAuth transaction start are simply ignored. + +*/ +var authorization = [ + // extract the returnTo origin and set as query param + function (req, res, next) { + if (!req.query.redirect_uri) return sendErrorPageOrRedirect(req, res, 'Invalid request. redirect_uri query param is not set.'); + if (!req.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid request. client_id query param is not set.'); + if (!req.query.response_type) return sendErrorPageOrRedirect(req, res, 'Invalid request. response_type query param is not set.'); + if (req.query.response_type !== 'code' && req.query.response_type !== 'token') return sendErrorPageOrRedirect(req, res, 'Invalid request. Only token and code response types are supported.'); + + session.ensureLoggedIn('/api/v1/session/login?returnTo=' + req.query.redirect_uri)(req, res, next); + }, + gServer.authorization(function (clientID, redirectURI, callback) { + debug('authorization: client %s with callback to %s.', clientID, redirectURI); + + clientdb.get(clientID, function (error, client) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); + if (error) return callback(error); + + // ignore the origin passed into form the client, but use the one from the clientdb + var redirectPath = url.parse(redirectURI).path; + var redirectOrigin = client.redirectURI; + + callback(null, client, '/api/v1/session/callback?redirectURI=' + url.resolve(redirectOrigin, redirectPath)); + }); + }), + // Until we have OAuth scopes, skip decision dialog + // OAuth sopes skip START + function (req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.oauth2, 'object'); + + var scopes = req.oauth2.client.scope ? req.oauth2.client.scope.split(',') : ['profile','roleUser']; + + if (scopes.indexOf('roleAdmin') !== -1 && !req.user.admin) { + return sendErrorPageOrRedirect(req, res, 'Admin capabilities required'); + } + + req.body.transaction_id = req.oauth2.transactionID; + next(); + }, + gServer.decision(function(req, done) { + debug('decision: with scope', req.oauth2.req.scope); + return done(null, { scope: req.oauth2.req.scope }); + }) + // OAuth sopes skip END + // function (req, res) { + // res.render('dialog', { transactionID: req.oauth2.transactionID, user: req.user, client: req.oauth2.client, csrf: req.csrfToken() }); + // } +]; + +// this triggers the above grant middleware and handles the user's decision if he accepts the access +var decision = [ + session.ensureLoggedIn('/api/v1/session/login'), + gServer.decision() +]; + + +/* + + The token endpoint allows an OAuth client to exchange an authcode with an accesstoken. + + Authcodes are obtained using the authorization endpoint. The route is authenticated by + providing a Basic auth with clientID as username and clientSecret as password. + An authcode is only good for one such exchange to an accesstoken. + +*/ +var token = [ + passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), + gServer.token(), + gServer.errorHandler() +]; + + +/* + + The scope middleware provides an auth middleware for routes. + + It is used for API routes, which are authenticated using accesstokens. + Those accesstokens carry OAuth scopes and the middleware takes the required + scope as an argument and will verify the accesstoken against it. + + See server.js: + var profileScope = routes.oauth2.scope('profile'); + +*/ +function scope(requestedScope) { + assert.strictEqual(typeof requestedScope, 'string'); + + var requestedScopes = requestedScope.split(','); + debug('scope: add routes with requested scopes', requestedScopes); + + return [ + passport.authenticate(['bearer'], { session: false }), + function (req, res, next) { + if (!req.authInfo || !req.authInfo.scope) return next(new HttpError(401, 'No scope found')); + if (req.authInfo.scope === '*') return next(); + + var scopes = req.authInfo.scope.split(','); + + for (var i = 0; i < requestedScopes.length; ++i) { + if (scopes.indexOf(requestedScopes[i]) === -1) { + debug('scope: missing scope "%s".', requestedScopes[i]); + return next(new HttpError(401, 'Missing required scope "' + requestedScopes[i] + '"')); + } + } + + next(); + } + ]; +} + +// Cross-site request forgery protection middleware for login form +var csrf = [ + middleware.csrf(), + function (err, req, res, next) { + if (err.code !== 'EBADCSRFTOKEN') return next(err); + + sendErrorPageOrRedirect(req, res, 'Form expired'); + } +]; + +exports = module.exports = { + loginForm: loginForm, + login: login, + logout: logout, + callback: callback, + error: error, + passwordResetRequestSite: passwordResetRequestSite, + passwordResetRequest: passwordResetRequest, + passwordSentSite: passwordSentSite, + passwordResetSite: passwordResetSite, + passwordSetupSite: passwordSetupSite, + passwordReset: passwordReset, + authorization: authorization, + decision: decision, + token: token, + scope: scope, + csrf: csrf +}; diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 000000000..81e6bf004 --- /dev/null +++ b/src/routes/settings.js @@ -0,0 +1,82 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + getAutoupdatePattern: getAutoupdatePattern, + setAutoupdatePattern: setAutoupdatePattern, + + getCloudronName: getCloudronName, + setCloudronName: setCloudronName, + + getCloudronAvatar: getCloudronAvatar, + setCloudronAvatar: setCloudronAvatar +}; + +var assert = require('assert'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + safe = require('safetydance'), + settings = require('../settings.js'), + SettingsError = settings.SettingsError; + +function getAutoupdatePattern(req, res, next) { + settings.getAutoupdatePattern(function (error, pattern) { + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { pattern: pattern })); + }); +} + +function setAutoupdatePattern(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.pattern !== 'string') return next(new HttpError(400, 'pattern is required')); + + settings.setAutoupdatePattern(req.body.pattern, function (error) { + if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, 'Invalid pattern')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200)); + }); +} + +function setCloudronName(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name is required')); + + settings.setCloudronName(req.body.name, function (error) { + if (error && error.reason === SettingsError.BAD_FIELD) return next(new HttpError(400, 'Invalid name')); + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200)); + }); +} + +function getCloudronName(req, res, next) { + settings.getCloudronName(function (error, name) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, { name: name })); + }); +} + +function setCloudronAvatar(req, res, next) { + assert.strictEqual(typeof req.files, 'object'); + + if (!req.files.avatar) return next(new HttpError(400, 'avatar must be provided')); + var avatar = safe.fs.readFileSync(req.files.avatar.path); + + settings.setCloudronAvatar(avatar, function (error) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(202, {})); + }); +} + +function getCloudronAvatar(req, res, next) { + settings.getCloudronAvatar(function (error, avatar) { + if (error) return next(new HttpError(500, error)); + + res.set('Content-Type', 'image/png'); + res.status(200).send(avatar); + }); +} diff --git a/src/routes/test/apps-test.js b/src/routes/test/apps-test.js new file mode 100644 index 000000000..4b669cede --- /dev/null +++ b/src/routes/test/apps-test.js @@ -0,0 +1,1319 @@ +'use strict'; + +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +var appdb = require('../../appdb.js'), + apps = require('../../apps.js'), + assert = require('assert'), + path = require('path'), + async = require('async'), + child_process = require('child_process'), + clientdb = require('../../clientdb.js'), + config = require('../../config.js'), + constants = require('../../constants.js'), + database = require('../../database.js'), + docker = require('../../docker.js'), + expect = require('expect.js'), + fs = require('fs'), + hock = require('hock'), + http = require('http'), + https = require('https'), + net = require('net'), + nock = require('nock'), + os = require('os'), + paths = require('../../paths.js'), + redis = require('redis'), + request = require('superagent'), + safe = require('safetydance'), + server = require('../../server.js'), + sysinfo = require('../../sysinfo.js'), + tokendb = require('../../tokendb.js'), + url = require('url'), + util = require('util'), + uuid = require('node-uuid'), + _ = require('underscore'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var APP_STORE_ID = 'test', APP_ID; +var APP_LOCATION = 'appslocation'; +var APP_LOCATION_2 = 'appslocationtwo'; +var APP_LOCATION_NEW = 'appslocationnew'; +var APP_MANIFEST = JSON.parse(fs.readFileSync(__dirname + '/../../../../test-app/CloudronManifest.json', 'utf8')); +APP_MANIFEST.dockerImage = 'girish/test:0.2.0'; +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='admin@me.com'; +var USERNAME_1 = 'user', PASSWORD_1 = 'password', EMAIL_1 ='user@me.com'; +var token = null; // authentication token +var token_1 = null; + +function startDockerProxy(interceptor, callback) { + assert.strictEqual(typeof interceptor, 'function'); + + return http.createServer(function (req, res) { + if (interceptor(req, res)) return; + + // rejectUnauthorized should not be required but it doesn't work without it + var options = _.extend({ }, docker.options, { method: req.method, path: req.url, headers: req.headers, rejectUnauthorized: false }); + delete options.protocol; // https module doesn't like this key + var proto = docker.options.protocol === 'https' ? https : http; + var dockerRequest = proto.request(options, function (dockerResponse) { + res.writeHead(dockerResponse.statusCode, dockerResponse.headers); + dockerResponse.on('error', console.error); + dockerResponse.pipe(res, { end: true }); + }); + + req.on('error', console.error); + if (!req.readable) { + dockerRequest.end(); + } else { + req.pipe(dockerRequest, { end: true }); + } + + }).listen(5687, callback); +} + +function setup(done) { + async.series([ + server.start.bind(server), + + database._clear, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.eql(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash for further use + token = result.body.token; + + callback(); + }); + }, + + child_process.exec.bind(null, __dirname + '/start_addons.sh'), + + function (callback) { + callback(null); + }, + function (callback) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_1, email: EMAIL_1 }) + .end(function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(201); + + callback(null); + }); + }, function (callback) { + token_1 = tokendb.generateToken(); + + // HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...) + tokendb.add(token_1, tokendb.PREFIX_USER + USERNAME_1, 'test-client-id', Date.now() + 100000, '*', callback); + } + ], done); +} + +function cleanup(done) { + async.series([ + database._clear, + + server.stop, + + child_process.exec.bind(null, 'docker rm -f mysql; docker rm -f postgresql; docker rm -f mongodb') + ], done); +} + +describe('App API', function () { + this.timeout(50000); + var dockerProxy; + + before(function (done) { + dockerProxy = startDockerProxy(function interceptor() { return false; }, function () { + setup(done); + }); + }); + after(function (done) { + APP_ID = null; + cleanup(function () { + dockerProxy.close(done); + }); + }); + + it('app install fails - missing manifest', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('manifest is required'); + done(err); + }); + }); + + it('app install fails - missing appId', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ manifest: APP_MANIFEST, password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('appStoreId is required'); + done(err); + }); + }); + + it('app install fails - invalid json', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send('garbage') + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('app install fails - invalid location', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: '!awesome', accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('Hostname can only contain alphanumerics and hyphen'); + done(err); + }); + }); + + it('app install fails - invalid location type', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: 42, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('location is required'); + done(err); + }); + }); + + it('app install fails - reserved admin location', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: constants.ADMIN_LOCATION, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql(constants.ADMIN_LOCATION + ' is reserved'); + done(err); + }); + }); + + it('app install fails - reserved api location', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: constants.API_LOCATION, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql(constants.API_LOCATION + ' is reserved'); + done(err); + }); + }); + + it('app install fails - portBindings must be object', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: 23, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('portBindings must be an object'); + done(err); + }); + }); + + it('app install fails - accessRestriction is required', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: {} }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(res.body.message).to.eql('accessRestriction is required'); + done(err); + }); + }); + + it('app install fails for non admin', function (done) { + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token_1 }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('app install fails due to purchase failure', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(402, {}); + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(402); + expect(fake.isDone()).to.be.ok(); + done(err); + }); + }); + + it('app install succeeds with purchase', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(res.body.id).to.be.a('string'); + APP_ID = res.body.id; + expect(fake.isDone()).to.be.ok(); + done(err); + }); + }); + + it('app install fails because of conflicting location', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(409); + expect(fake.isDone()).to.be.ok(); + done(); + }); + }); + + it('can get app status', function (done) { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.id).to.eql(APP_ID); + expect(res.body.installationState).to.be.ok(); + done(err); + }); + }); + + it('cannot get invalid app status', function (done) { + request.get(SERVER_URL + '/api/v1/apps/kubachi') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(err); + }); + }); + + it('can get all apps', function (done) { + request.get(SERVER_URL + '/api/v1/apps') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.apps).to.be.an('array'); + expect(res.body.apps[0].id).to.eql(APP_ID); + expect(res.body.apps[0].installationState).to.be.ok(); + done(err); + }); + }); + + it('non admin can get all apps', function (done) { + request.get(SERVER_URL + '/api/v1/apps') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.apps).to.be.an('array'); + expect(res.body.apps[0].id).to.eql(APP_ID); + expect(res.body.apps[0].installationState).to.be.ok(); + done(err); + }); + }); + + it('can get appBySubdomain', function (done) { + request.get(SERVER_URL + '/api/v1/subdomains/' + APP_LOCATION) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.id).to.eql(APP_ID); + expect(res.body.installationState).to.be.ok(); + done(err); + }); + }); + + it('cannot get invalid app by Subdomain', function (done) { + request.get(SERVER_URL + '/api/v1/subdomains/tikaloma') + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(err); + }); + }); + + it('cannot uninstall invalid app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/whatever/uninstall') + .send({ password: PASSWORD }) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(err); + }); + }); + + it('cannot uninstall app without password', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('cannot uninstall app with wrong password', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .send({ password: PASSWORD+PASSWORD }) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('non admin cannot uninstall app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .send({ password: PASSWORD }) + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('can uninstall app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .send({ password: PASSWORD }) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(err); + }); + }); + + it('app install succeeds already purchased', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION_2, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(res.body.id).to.be.a('string'); + APP_ID = res.body.id; + expect(fake.isDone()).to.be.ok(); + done(err); + }); + }); + + it('app install succeeds without password but developer token', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(201, {}); + + config.set('developerMode', true); + + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME, password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.expiresAt).to.be.a('number'); + expect(result.body.token).to.be.a('string'); + + // overwrite non dev token + token = result.body.token; + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, location: APP_LOCATION+APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(res.body.id).to.be.a('string'); + expect(fake.isDone()).to.be.ok(); + APP_ID = res.body.id; + done(err); + }); + }); + }); + + it('can uninstall app without password but developer token', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(err); + }); + }); +}); + +describe('App installation', function () { + this.timeout(50000); + + var hockInstance = hock.createHock({ throwOnUnmatched: false }), hockServer, dockerProxy; + var imageDeleted = false, imageCreated = false; + + before(function (done) { + APP_ID = uuid.v4(); + + async.series([ + function (callback) { + dockerProxy = startDockerProxy(function interceptor(req, res) { + if (req.method === 'POST' && req.url === '/images/create?fromImage=girish%2Ftest&tag=0.2.0') { + imageCreated = true; + res.writeHead(200); + res.end(); + return true; + } else if (req.method === 'DELETE' && req.url === '/images/c7ddfc8fb7cd8a14d4d70153a199ff0c6e9b709807aeec5a7b799d60618731d1?force=true&noprune=false') { + imageDeleted = true; + res.writeHead(200); + res.end(); + return true; + } + return false; + }, callback); + }, + + setup, + + function (callback) { + hockInstance + .get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon') + .replyWithFile(200, path.resolve(__dirname, '../../../webadmin/src/img/appicon_fallback.png')) + .post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION, type: 'A', value: sysinfo.getIp() } ] }) + .reply(201, { ids: [ 'dnsrecordid' ] }, { 'Content-Type': 'application/json' }) + .delete('/api/v1/subdomains/dnsrecordid?token=' + config.token()) + .reply(204, { }, { 'Content-Type': 'application/json' }); + + var port = parseInt(url.parse(config.apiServerOrigin()).port, 10); + hockServer = http.createServer(hockInstance.handler).listen(port, callback); + } + ], done); + }); + + after(function (done) { + APP_ID = null; + cleanup(function (error) { + if (error) return done(error); + hockServer.close(function () { + dockerProxy.close(done); + }); + }); + }); + + var appResult = null /* the json response */, appEntry = null /* entry from database */; + + it('can install test app', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(201, {}); + + var count = 0; + function checkInstallStatus() { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + if (res.body.installationState === appdb.ISTATE_INSTALLED) { appResult = res.body; return done(null); } + if (res.body.installationState === appdb.ISTATE_ERROR) return done(new Error('Install error')); + if (++count > 50) return done(new Error('Timedout')); + setTimeout(checkInstallStatus, 1000); + }); + } + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appId: APP_ID, appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(fake.isDone()).to.be.ok(); + expect(res.body.id).to.be.a('string'); + expect(res.body.id).to.be.eql(APP_ID); + checkInstallStatus(); + }); + }); + + it('installation - image created', function (done) { + expect(imageCreated).to.be.ok(); + done(); + }); + + it('installation - can get app', function (done) { + apps.get(appResult.id, function (error, app) { + expect(!error).to.be.ok(); + expect(app).to.be.an('object'); + appEntry = app; + done(); + }); + }); + + it('installation - container created', function (done) { + expect(appResult.containerId).to.be(undefined); + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Config.ExposedPorts['7777/tcp']).to.eql({ }); + expect(data.Config.Env).to.contain('ADMIN_ORIGIN=' + config.adminOrigin()); + expect(data.Config.Env).to.contain('CLOUDRON=1'); + clientdb.getByAppId('addon-' + appResult.id, function (error, client) { + expect(error).to.not.be.ok(); + expect(client.id.length).to.be(46); // cid-addon- + 32 hex chars (128 bits) + 4 hyphens + expect(client.clientSecret.length).to.be(64); // 32 hex chars (256 bits) + expect(data.Config.Env).to.contain('OAUTH_CLIENT_ID=' + client.id); + expect(data.Config.Env).to.contain('OAUTH_CLIENT_SECRET=' + client.clientSecret); + done(); + }); + }); + }); + + it('installation - nginx config', function (done) { + expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('installation - registered subdomain', function (done) { + // this is checked in unregister subdomain testcase + done(); + }); + + it('installation - volume created', function (done) { + expect(fs.existsSync(paths.DATA_DIR + '/' + APP_ID)); + done(); + }); + + it('installation - is up and running', function (done) { + expect(appResult.httpPort).to.be(undefined); + setTimeout(function () { + request.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) + .end(function (err, res) { + expect(!err).to.be.ok(); + expect(res.statusCode).to.equal(200); + done(); + }); + }, 2000); // give some time for docker to settle + }); + + it('installation - running container has volume mounted', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Volumes['/app/data']).to.eql(paths.DATA_DIR + '/' + APP_ID + '/data'); + done(); + }); + }); + + var redisIp, exportedRedisPort; + + it('installation - redis addon created', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data).to.be.ok(); + + redisIp = safe.query(data, 'NetworkSettings.IPAddress'); + expect(redisIp).to.be.ok(); + + exportedRedisPort = safe.query(data, 'NetworkSettings.Ports.6379/tcp[0].HostPort'); + expect(exportedRedisPort).to.be.ok(); + + done(); + }); + }); + + it('installation - redis addon config', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + var redisUrl = null; + data.Config.Env.forEach(function (env) { if (env.indexOf('REDIS_URL=') === 0) redisUrl = env.split('=')[1]; }); + expect(redisUrl).to.be.ok(); + + var urlp = url.parse(redisUrl); + var password = urlp.auth.split(':')[1]; + + expect(data.Config.Env).to.contain('REDIS_PORT=6379'); + expect(data.Config.Env).to.contain('REDIS_HOST=redis-' + APP_ID); + expect(data.Config.Env).to.contain('REDIS_PASSWORD=' + password); + + expect(urlp.hostname).to.be('redis-' + APP_ID); + + var isMac = os.platform() === 'darwin'; + var client = + isMac ? redis.createClient(parseInt(exportedRedisPort, 10), '127.0.0.1', { auth_pass: password }) + : redis.createClient(parseInt(urlp.port, 10), redisIp, { auth_pass: password }); + client.on('error', done); + client.set('key', 'value'); + client.get('key', function (err, reply) { + expect(err).to.not.be.ok(); + expect(reply.toString()).to.be('value'); + client.end(); + done(); + }); + }); + }); + + it('installation - mysql addon config', function (done) { + var appContainer = docker.getContainer(appEntry.containerId); + appContainer.inspect(function (error, data) { + var mysqlUrl = null; + data.Config.Env.forEach(function (env) { if (env.indexOf('MYSQL_URL=') === 0) mysqlUrl = env.split('=')[1]; }); + expect(mysqlUrl).to.be.ok(); + + var urlp = url.parse(mysqlUrl); + var username = urlp.auth.split(':')[0]; + var password = urlp.auth.split(':')[1]; + var dbname = urlp.path.substr(1); + + expect(data.Config.Env).to.contain('MYSQL_PORT=3306'); + expect(data.Config.Env).to.contain('MYSQL_HOST=mysql'); + expect(data.Config.Env).to.contain('MYSQL_USERNAME=' + username); + expect(data.Config.Env).to.contain('MYSQL_PASSWORD=' + password); + expect(data.Config.Env).to.contain('MYSQL_DATABASE=' + dbname); + + var cmd = util.format('mysql -h %s -u%s -p%s --database=%s -e "CREATE TABLE IF NOT EXISTS foo (id INT);"', + 'mysql', username, password, dbname); + + child_process.exec('docker exec ' + appContainer.id + ' ' + cmd, { timeout: 5000 }, function (error, stdout, stderr) { + expect(!error).to.be.ok(); + expect(stdout.length).to.be(0); + expect(stderr.length).to.be(0); + done(); + }); + }); + }); + + it('installation - postgresql addon config', function (done) { + var appContainer = docker.getContainer(appEntry.containerId); + appContainer.inspect(function (error, data) { + var postgresqlUrl = null; + data.Config.Env.forEach(function (env) { if (env.indexOf('POSTGRESQL_URL=') === 0) postgresqlUrl = env.split('=')[1]; }); + expect(postgresqlUrl).to.be.ok(); + + var urlp = url.parse(postgresqlUrl); + var username = urlp.auth.split(':')[0]; + var password = urlp.auth.split(':')[1]; + var dbname = urlp.path.substr(1); + + expect(data.Config.Env).to.contain('POSTGRESQL_PORT=5432'); + expect(data.Config.Env).to.contain('POSTGRESQL_HOST=postgresql'); + expect(data.Config.Env).to.contain('POSTGRESQL_USERNAME=' + username); + expect(data.Config.Env).to.contain('POSTGRESQL_PASSWORD=' + password); + expect(data.Config.Env).to.contain('POSTGRESQL_DATABASE=' + dbname); + + var cmd = util.format('bash -c "PGPASSWORD=%s psql -q -h %s -U%s --dbname=%s -e \'CREATE TABLE IF NOT EXISTS foo (id INT);\'"', + password, 'postgresql', username, dbname); + + child_process.exec('docker exec ' + appContainer.id + ' ' + cmd, { timeout: 5000 }, function (error, stdout, stderr) { + expect(!error).to.be.ok(); + expect(stdout.length).to.be(0); + expect(stderr.length).to.be(0); + done(); + }); + }); + }); + + it('logs - stdout and stderr', function (done) { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logs') + .query({ access_token: token }) + .end(function (err, res) { + var data = ''; + res.on('data', function (d) { data += d.toString('utf8'); }); + res.on('end', function () { + expect(data.length).to.not.be(0); + done(); + }); + res.on('error', done); + }); + }); + + it('logStream - requires event-stream accept header', function (done) { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID + '/logstream') + .query({ access_token: token, fromLine: 0 }) + .end(function (err, res) { + expect(res.statusCode).to.be(400); + done(); + }); + }); + + + it('logStream - stream logs', function (done) { + var options = { + port: config.get('port'), host: 'localhost', path: '/api/v1/apps/' + APP_ID + '/logstream?access_token=' + token, + headers: { 'Accept': 'text/event-stream', 'Connection': 'keep-alive' } + }; + + // superagent doesn't work. maybe https://github.com/visionmedia/superagent/issues/420 + var req = http.get(options, function (res) { + var data = ''; + res.on('data', function (d) { data += d.toString('utf8'); }); + setTimeout(function checkData() { + expect(data.length).to.not.be(0); + var lineNumber = 1; + data.split('\n').forEach(function (line) { + if (line.indexOf('id: ') !== 0) return; + expect(parseInt(line.substr(4), 10)).to.be(lineNumber); // line number + ++lineNumber; + }); + + req.abort(); + expect(lineNumber).to.be.above(1); + done(); + }, 1000); + res.on('error', done); + }); + + req.on('error', done); + }); + + it('non admin cannot stop app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); + + it('can stop app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(); + }); + }); + + it('did stop the app', function (done) { + // give the app a couple of seconds to die + setTimeout(function () { + request.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) + .end(function (err, res) { + expect(err).to.be.ok(); + done(); + }); + }, 2000); + }); + + it('nonadmin cannot start app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') + .query({ access_token: token_1 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); + + it('can start app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/start') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(); + }); + }); + + it('did start the app', function (done) { + setTimeout(function () { + request.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) + .end(function (err, res) { + expect(!err).to.be.ok(); + expect(res.statusCode).to.equal(200); + done(); + }); + }, 2000); // give some time for docker to settle + }); + + it('can uninstall app', function (done) { + var count = 0; + function checkUninstallStatus() { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + if (res.statusCode === 404) return done(null); + if (++count > 50) return done(new Error('Timedout')); + setTimeout(checkUninstallStatus, 1000); + }); + } + + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .send({ password: PASSWORD }) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + checkUninstallStatus(); + }); + }); + + it('uninstalled - container destroyed', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + if (data) { + console.log('Container is still alive', data); + } + expect(error).to.be.ok(); + done(); + }); + }); + + it('uninstalled - image destroyed', function (done) { + expect(imageDeleted).to.be.ok(); + done(); + }); + + it('uninstalled - volume destroyed', function (done) { + expect(!fs.existsSync(paths.DATA_DIR + '/' + APP_ID)); + done(); + }); + + it('uninstalled - unregistered subdomain', function (done) { + hockInstance.done(function (error) { // checks if all the hockServer APIs were called + expect(!error).to.be.ok(); + done(); + }); + }); + + it('uninstalled - removed nginx', function (done) { + expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('uninstalled - removed redis addon', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error, data) { + expect(error).to.be.ok(); + done(); + }); + }); +}); + +describe('App installation - port bindings', function () { + this.timeout(50000); + + var hockInstance = hock.createHock({ throwOnUnmatched: false }), hockServer, dockerProxy; + var imageDeleted = false, imageCreated = false; + + before(function (done) { + APP_ID = uuid.v4(); + async.series([ + function (callback) { + dockerProxy = startDockerProxy(function interceptor(req, res) { + if (req.method === 'POST' && req.url === '/images/create?fromImage=girish%2Ftest&tag=0.2.0') { + imageCreated = true; + res.writeHead(200); + res.end(); + return true; + } else if (req.method === 'DELETE' && req.url === '/images/c7ddfc8fb7cd8a14d4d70153a199ff0c6e9b709807aeec5a7b799d60618731d1?force=true&noprune=false') { + imageDeleted = true; + res.writeHead(200); + res.end(); + return true; + } + return false; + }, callback); + }, + + setup, + + function (callback) { + hockInstance + // app install + .get('/api/v1/apps/' + APP_STORE_ID + '/versions/' + APP_MANIFEST.version + '/icon') + .replyWithFile(200, path.resolve(__dirname, '../../../webadmin/src/img/appicon_fallback.png')) + .post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION, type: 'A', value: sysinfo.getIp() } ] }) + .reply(201, { ids: [ 'dnsrecordid' ] }, { 'Content-Type': 'application/json' }) + // app configure + .delete('/api/v1/subdomains/dnsrecordid?token=' + config.token()) + .reply(204, { }, { 'Content-Type': 'application/json' }) + .post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP_LOCATION_NEW, type: 'A', value: sysinfo.getIp() } ] }) + .reply(201, { ids: [ 'anotherdnsid' ] }, { 'Content-Type': 'application/json' }) + // app remove + .delete('/api/v1/subdomains/anotherdnsid?token=' + config.token()) + .reply(204, { }, { 'Content-Type': 'application/json' }); + + var port = parseInt(url.parse(config.apiServerOrigin()).port, 10); + hockServer = http.createServer(hockInstance.handler).listen(port, callback); + } + ], done); + }); + + after(function (done) { + APP_ID = null; + cleanup(function (error) { + if (error) return done(error); + hockServer.close(function () { + dockerProxy.close(done); + }); + }); + }); + + var appResult = null, appEntry = null; + + it('can install test app', function (done) { + var fake = nock(config.apiServerOrigin()).post('/api/v1/apps/test/purchase?token=APPSTORE_TOKEN').reply(201, {}); + + var count = 0; + function checkInstallStatus() { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + if (res.body.installationState === appdb.ISTATE_INSTALLED) { appResult = res.body; return done(null); } + if (res.body.installationState === appdb.ISTATE_ERROR) return done(new Error('Install error')); + if (++count > 50) return done(new Error('Timedout')); + setTimeout(checkInstallStatus, 1000); + }); + } + + request.post(SERVER_URL + '/api/v1/apps/install') + .query({ access_token: token }) + .send({ appId: APP_ID, appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: { ECHO_SERVER_PORT: 7171 }, accessRestriction: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + expect(fake.isDone()).to.be.ok(); + expect(res.body.id).to.equal(APP_ID); + checkInstallStatus(); + }); + }); + + it('installation - image created', function (done) { + expect(imageCreated).to.be.ok(); + done(); + }); + + it('installation - can get app', function (done) { + apps.get(appResult.id, function (error, app) { + expect(!error).to.be.ok(); + expect(app).to.be.an('object'); + appEntry = app; + done(); + }); + }); + + it('installation - container created', function (done) { + expect(appResult.containerId).to.be(undefined); + expect(appEntry.containerId).to.be.ok(); + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Config.ExposedPorts['7777/tcp']).to.eql({ }); + expect(data.Config.Env).to.contain('ECHO_SERVER_PORT=7171'); + expect(data.HostConfig.PortBindings['7778/tcp'][0].HostPort).to.eql('7171'); + done(); + }); + }); + + it('installation - nginx config', function (done) { + expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('installation - registered subdomain', function (done) { + // this is checked in unregister subdomain testcase + done(); + }); + + it('installation - volume created', function (done) { + expect(fs.existsSync(paths.DATA_DIR + '/' + APP_ID)); + done(); + }); + + it('installation - http is up and running', function (done) { + var tryCount = 20; + expect(appResult.httpPort).to.be(undefined); + (function healthCheck() { + request.get('http://localhost:' + appEntry.httpPort + appResult.manifest.healthCheckPath) + .end(function (err, res) { + if (err || res.statusCode !== 200) { + if (--tryCount === 0) return done(new Error('Timedout')); + return setTimeout(healthCheck, 2000); + } + + expect(!err).to.be.ok(); + expect(res.statusCode).to.equal(200); + done(); + }); + })(); + }); + + it('installation - tcp port mapping works', function (done) { + var client = net.connect(7171); + client.on('data', function (data) { + expect(data.toString()).to.eql('ECHO_SERVER_PORT=7171'); + done(); + }); + client.on('error', done); + }); + + it('installation - running container has volume mounted', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data.Volumes['/app/data']).to.eql(paths.DATA_DIR + '/' + APP_ID + '/data'); + done(); + }); + }); + + var redisIp, exportedRedisPort; + + it('installation - redis addon created', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data).to.be.ok(); + + redisIp = safe.query(data, 'NetworkSettings.IPAddress'); + expect(redisIp).to.be.ok(); + + exportedRedisPort = safe.query(data, 'NetworkSettings.Ports.6379/tcp[0].HostPort'); + expect(exportedRedisPort).to.be.ok(); + + done(); + }); + }); + + it('installation - redis addon config', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + var redisUrl = null; + data.Config.Env.forEach(function (env) { if (env.indexOf('REDIS_URL=') === 0) redisUrl = env.split('=')[1]; }); + expect(redisUrl).to.be.ok(); + + var urlp = url.parse(redisUrl); + expect(urlp.hostname).to.be('redis-' + APP_ID); + + var password = urlp.auth.split(':')[1]; + + expect(data.Config.Env).to.contain('REDIS_PORT=6379'); + expect(data.Config.Env).to.contain('REDIS_HOST=redis-' + APP_ID); + expect(data.Config.Env).to.contain('REDIS_PASSWORD=' + password); + + function checkRedis() { + var isMac = os.platform() === 'darwin'; + var client = + isMac ? redis.createClient(parseInt(exportedRedisPort, 10), '127.0.0.1', { auth_pass: password }) + : redis.createClient(parseInt(urlp.port, 10), redisIp, { auth_pass: password }); + client.on('error', done); + client.set('key', 'value'); + client.get('key', function (err, reply) { + expect(err).to.not.be.ok(); + expect(reply.toString()).to.be('value'); + client.end(); + done(); + }); + } + + setTimeout(checkRedis, 1000); // the bridge network takes time to come up? + }); + }); + + function checkConfigureStatus(count, done) { + assert.strictEqual(typeof count, 'number'); + assert.strictEqual(typeof done, 'function'); + + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + if (res.body.installationState === appdb.ISTATE_INSTALLED) { appResult = res.body; expect(appResult).to.be.ok(); return done(null); } + if (res.body.installationState === appdb.ISTATE_ERROR) return done(new Error('Install error')); + if (++count > 50) return done(new Error('Timedout')); + setTimeout(checkConfigureStatus.bind(null, count, done), 1000); + }); + } + + it('cannot reconfigure app with missing location', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') + .query({ access_token: token }) + .send({ appId: APP_ID, password: PASSWORD, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: 'roleAdmin' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot reconfigure app with missing accessRestriction', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') + .query({ access_token: token }) + .send({ appId: APP_ID, password: PASSWORD, location: APP_LOCATION_NEW, portBindings: { ECHO_SERVER_PORT: 7172 } }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('non admin cannot reconfigure app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') + .query({ access_token: token_1 }) + .send({ appId: APP_ID, password: PASSWORD, location: APP_LOCATION_NEW, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: 'roleAdmin' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(); + }); + }); + + it('can reconfigure app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure') + .query({ access_token: token }) + .send({ appId: APP_ID, password: PASSWORD, location: APP_LOCATION_NEW, portBindings: { ECHO_SERVER_PORT: 7172 }, accessRestriction: 'roleAdmin' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + checkConfigureStatus(0, done); + }); + }); + + it('changed container id after reconfigure', function (done) { + var oldContainerId = appEntry.containerId; + apps.get(appResult.id, function (error, app) { + expect(!error).to.be.ok(); + expect(app).to.be.an('object'); + appEntry = app; + expect(appEntry.containerid).to.not.be(oldContainerId); + done(); + }); + }); + + it('port mapping works after reconfiguration', function (done) { + setTimeout(function () { + var client = net.connect(7172); + client.on('data', function (data) { + expect(data.toString()).to.eql('ECHO_SERVER_PORT=7172'); + done(); + }); + client.on('error', done); + }, 2000); + }); + + it('reconfiguration - redis addon recreated', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error, data) { + expect(error).to.not.be.ok(); + expect(data).to.be.ok(); + + redisIp = safe.query(data, 'NetworkSettings.IPAddress'); + expect(redisIp).to.be.ok(); + + exportedRedisPort = safe.query(data, 'NetworkSettings.Ports.6379/tcp[0].HostPort'); + expect(exportedRedisPort).to.be.ok(); + + done(); + }); + }); + + it('redis addon works after reconfiguration', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + var redisUrl = null; + data.Config.Env.forEach(function (env) { if (env.indexOf('REDIS_URL=') === 0) redisUrl = env.split('=')[1]; }); + expect(redisUrl).to.be.ok(); + + var urlp = url.parse(redisUrl); + var password = urlp.auth.split(':')[1]; + + expect(urlp.hostname).to.be('redis-' + APP_ID); + + expect(data.Config.Env).to.contain('REDIS_PORT=6379'); + expect(data.Config.Env).to.contain('REDIS_HOST=redis-' + APP_ID); + expect(data.Config.Env).to.contain('REDIS_PASSWORD=' + password); + + var isMac = os.platform() === 'darwin'; + var client = + isMac ? redis.createClient(parseInt(exportedRedisPort, 10), '127.0.0.1', { auth_pass: password }) + : redis.createClient(parseInt(urlp.port, 10), redisIp, { auth_pass: password }); + client.on('error', done); + client.set('key', 'value'); + client.get('key', function (err, reply) { + expect(err).to.not.be.ok(); + expect(reply.toString()).to.be('value'); + client.end(); + done(); + }); + }); + }); + + it('can stop app', function (done) { + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/stop') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(); + }); + }); + + // osx: if this test is failing, it is probably because of a stray port binding in boot2docker + it('did stop the app', function (done) { + setTimeout(function () { + var client = net.connect(7171); + client.setTimeout(2000); + client.on('connect', function () { done(new Error('Got connected')); }); + client.on('timeout', function () { done(); }); + client.on('error', function (error) { done(); }); + client.on('data', function (data) { + done(new Error('Expected connection to fail!')); + }); + }, 3000); // give the app some time to die + }); + + it('can uninstall app', function (done) { + var count = 0; + function checkUninstallStatus() { + request.get(SERVER_URL + '/api/v1/apps/' + APP_ID) + .query({ access_token: token }) + .end(function (err, res) { + if (res.statusCode === 404) return done(null); + if (++count > 20) return done(new Error('Timedout')); + setTimeout(checkUninstallStatus, 400); + }); + } + + request.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/uninstall') + .send({ password: PASSWORD }) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + checkUninstallStatus(); + }); + }); + + it('uninstalled - container destroyed', function (done) { + docker.getContainer(appEntry.containerId).inspect(function (error, data) { + expect(error).to.be.ok(); + expect(data).to.not.be.ok(); + done(); + }); + }); + + it('uninstalled - image destroyed', function (done) { + expect(imageDeleted).to.be.ok(); + done(); + }); + + it('uninstalled - volume destroyed', function (done) { + expect(!fs.existsSync(paths.DATA_DIR + '/' + APP_ID)); + done(); + }); + + it('uninstalled - unregistered subdomain', function (done) { + hockInstance.done(function (error) { // checks if all the hockServer APIs were called + expect(!error).to.be.ok(); + done(); + }); + }); + + it('uninstalled - removed nginx', function (done) { + expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP_LOCATION + '.conf')); + done(); + }); + + it('uninstalled - removed redis addon', function (done) { + docker.getContainer('redis-' + APP_ID).inspect(function (error, data) { + expect(error).to.be.ok(); + done(); + }); + }); +}); + diff --git a/src/routes/test/backups-test.js b/src/routes/test/backups-test.js new file mode 100644 index 000000000..3dceb9aeb --- /dev/null +++ b/src/routes/test/backups-test.js @@ -0,0 +1,144 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var appdb = require('../../appdb.js'), + async = require('async'), + config = require('../../config.js'), + database = require('../../database.js'), + expect = require('expect.js'), + request = require('superagent'), + server = require('../../server.js'), + nock = require('nock'), + userdb = require('../../userdb.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com'; +var token = null; + +var server; +function setup(done) { + async.series([ + server.start.bind(server), + + userdb._clear, + + function createAdmin(callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.eql(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function addApp(callback) { + var manifest = { version: '0.0.1', manifestVersion: 1, dockerImage: 'foo', healthCheckPath: '/', httpPort: 3, title: 'ok' }; + appdb.add('appid', 'appStoreId', manifest, 'location', [ ] /* portBindings */, '' /* accessRestriction */, callback); + } + ], done); +} + +function cleanup(done) { + database._clear(function (error) { + expect(!error).to.be.ok(); + + server.stop(done); + }); +} + +describe('Backups API', function () { + before(setup); + after(cleanup); + + describe('get', function () { + it('cannot get backups with appstore request failing', function (done) { + var req = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/backups?token=APPSTORE_TOKEN').reply(401, {}); + + request.get(SERVER_URL + '/api/v1/backups') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(503); + expect(req.isDone()).to.be.ok(); + done(err); + }); + }); + + it('can get backups', function (done) { + var req = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/backups?token=APPSTORE_TOKEN').reply(200, { backups: ['foo', 'bar']}); + + request.get(SERVER_URL + '/api/v1/backups') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(req.isDone()).to.be.ok(); + expect(res.body.backups).to.be.an(Array); + expect(res.body.backups[0]).to.eql('foo'); + expect(res.body.backups[1]).to.eql('bar'); + done(err); + }); + }); + }); + + describe('create', function () { + it('fails due to mising token', function (done) { + request.post(SERVER_URL + '/api/v1/backups') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to wrong token', function (done) { + request.post(SERVER_URL + '/api/v1/backups') + .query({ access_token: token.toUpperCase() }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('succeeds', function (done) { + var scope = nock(config.apiServerOrigin()) + .put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }) + .reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' }); + + request.post(SERVER_URL + '/api/v1/backups') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(202); + + function checkAppstoreServerCalled() { + if (scope.isDone()) { + return done(); + } + + setTimeout(checkAppstoreServerCalled, 100); + } + + checkAppstoreServerCalled(); + }); + }); + }); +}); + diff --git a/src/routes/test/clients-test.js b/src/routes/test/clients-test.js new file mode 100644 index 000000000..1f77e2218 --- /dev/null +++ b/src/routes/test/clients-test.js @@ -0,0 +1,817 @@ +'use strict'; + +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +var async = require('async'), + config = require('../../config.js'), + database = require('../../database.js'), + oauth2 = require('../oauth2.js'), + expect = require('expect.js'), + uuid = require('node-uuid'), + nock = require('nock'), + hat = require('hat'), + superagent = require('superagent'), + server = require('../../server.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com'; +var token = null; // authentication token + +function cleanup(done) { + database._clear(function (error) { + expect(error).to.not.be.ok(); + + server.stop(done); + }); +} + +describe('OAuth Clients API', function () { + describe('add', function () { + before(function (done) { + async.series([ + server.start.bind(null), + database._clear.bind(null), + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.equal(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if not in developerMode', function (done) { + config.set('developerMode', false); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + + it('fails without appId', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ redirectURI: 'http://foobar.com', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with empty appId', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: '', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails without scope', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: 'http://foobar.com' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with empty scope', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails without redirectURI', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with empty redirectURI', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: '', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with malformed redirectURI', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: 'foobar', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('succeeds', function (done) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: 'someApp', redirectURI: 'http://foobar.com', scope: 'profile,roleUser' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(201); + expect(result.body.id).to.be.a('string'); + expect(result.body.appId).to.be.a('string'); + expect(result.body.redirectURI).to.be.a('string'); + expect(result.body.clientSecret).to.be.a('string'); + expect(result.body.scope).to.be.a('string'); + done(); + }); + }); + }); + + describe('get', function () { + var CLIENT_0 = { + id: '', + appId: 'someAppId-0', + redirectURI: 'http://some.callback0', + scope: 'profile,roleUser' + }; + + before(function (done) { + async.series([ + server.start.bind(null), + database._clear.bind(null), + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function (callback) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(201); + + CLIENT_0 = result.body; + + callback(); + }); + } + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + config.set('developerMode', true); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if not in developerMode', function (done) { + config.set('developerMode', false); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + + it('fails with unknown id', function (done) { + config.set('developerMode', true); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id.toUpperCase()) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + config.set('developerMode', true); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body).to.eql(CLIENT_0); + done(); + }); + }); + }); + + describe('update', function () { + var CLIENT_0 = { + id: '', + appId: 'someAppId-0', + redirectURI: 'http://some.callback0', + scope: 'profile,roleUser' + }; + var CLIENT_1 = { + id: '', + appId: 'someAppId-1', + redirectURI: 'http://some.callback1', + scope: 'profile,roleUser' + }; + + before(function (done) { + async.series([ + server.start.bind(null), + database._clear.bind(null), + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.equal(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function (callback) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(201); + + CLIENT_0 = result.body; + + callback(); + }); + } + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .send({ appId: 'someApp', redirectURI: 'http://foobar.com' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if not in developerMode', function (done) { + config.set('developerMode', false); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: CLIENT_1.appId, redirectURI: CLIENT_1.redirectURI }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + + it('fails without appId', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ redirectURI: CLIENT_1.redirectURI }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with empty appId', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: '', redirectURI: CLIENT_1.redirectURI }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails without redirectURI', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: CLIENT_1.appId }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with empty redirectURI', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: CLIENT_1.appId, redirectURI: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with malformed redirectURI', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: CLIENT_1.appId, redirectURI: 'foobar' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('succeeds', function (done) { + config.set('developerMode', true); + + superagent.put(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .send({ appId: CLIENT_1.appId, redirectURI: CLIENT_1.redirectURI }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(202); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.be(null); + expect(result.statusCode).to.equal(200); + expect(result.body.appId).to.equal(CLIENT_1.appId); + expect(result.body.redirectURI).to.equal(CLIENT_1.redirectURI); + + done(); + }); + }); + }); + }); + + describe('del', function () { + var CLIENT_0 = { + id: '', + appId: 'someAppId-0', + redirectURI: 'http://some.callback0', + scope: 'profile,roleUser' + }; + + before(function (done) { + async.series([ + server.start.bind(null), + database._clear.bind(null), + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function (callback) { + config.set('developerMode', true); + + superagent.post(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .send({ appId: CLIENT_0.appId, redirectURI: CLIENT_0.redirectURI, scope: CLIENT_0.scope }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(201); + + CLIENT_0 = result.body; + + callback(); + }); + } + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + config.set('developerMode', true); + + superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails if not in developerMode', function (done) { + config.set('developerMode', false); + + superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + + it('fails with unknown id', function (done) { + config.set('developerMode', true); + + superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id.toUpperCase()) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + config.set('developerMode', true); + + superagent.del(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(204); + + superagent.get(SERVER_URL + '/api/v1/oauth/clients/' + CLIENT_0.id) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.be(null); + expect(result.statusCode).to.equal(404); + + done(); + }); + }); + }); + }); +}); + +describe('Clients', function () { + var USER_0 = { + userId: uuid.v4(), + username: 'someusername', + password: 'somepassword', + email: 'some@email.com', + admin: true, + salt: 'somesalt', + createdAt: (new Date()).toUTCString(), + modifiedAt: (new Date()).toUTCString(), + resetToken: hat(256) + }; + + // make csrf always succeed for testing + oauth2.csrf = function (req, res, next) { + req.csrfToken = function () { return hat(256); }; + next(); + }; + + function setup(done) { + async.series([ + server.start.bind(server), + database._clear.bind(null), + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + superagent.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USER_0.username, password: USER_0.password, email: USER_0.email }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.eql(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash for further use + token = result.body.token; + + callback(); + }); + } + ], done); + } + + function cleanup(done) { + database._clear(function (error) { + expect(error).to.not.be.ok(); + + server.stop(done); + }); + } + + describe('get', function () { + before(setup); + after(cleanup); + + it('fails due to missing token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to empty token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to wrong token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token.toUpperCase() }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + + expect(result.body.clients.length).to.eql(1); + expect(result.body.clients[0].tokenCount).to.eql(1); + + done(); + }); + }); + }); + + describe('get tokens by client', function () { + before(setup); + after(cleanup); + + it('fails due to missing token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to empty token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to wrong token', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: token.toUpperCase() }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to unkown client', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/CID-WEBADMIN/tokens') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + + expect(result.body.tokens.length).to.eql(1); + expect(result.body.tokens[0].identifier).to.eql('user-' + USER_0.username); + + done(); + }); + }); + }); + + describe('delete tokens by client', function () { + before(setup); + after(cleanup); + + it('fails due to missing token', function (done) { + superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to empty token', function (done) { + superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to wrong token', function (done) { + superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: token.toUpperCase() }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to unkown client', function (done) { + superagent.del(SERVER_URL + '/api/v1/oauth/clients/CID-WEBADMIN/tokens') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(404); + done(); + }); + }); + + it('succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + + expect(result.body.tokens.length).to.eql(1); + expect(result.body.tokens[0].identifier).to.eql('user-' + USER_0.username); + + superagent.del(SERVER_URL + '/api/v1/oauth/clients/cid-webadmin/tokens') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(204); + + // further calls with this token should not work + superagent.get(SERVER_URL + '/api/v1/profile') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + }); + }); + }); +}); diff --git a/src/routes/test/cloudron-test.js b/src/routes/test/cloudron-test.js new file mode 100644 index 000000000..70615f2ec --- /dev/null +++ b/src/routes/test/cloudron-test.js @@ -0,0 +1,506 @@ +'use strict'; + +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +var async = require('async'), + config = require('../../config.js'), + database = require('../../database.js'), + expect = require('expect.js'), + fs = require('fs'), + os = require('os'), + path = require('path'), + nock = require('nock'), + paths = require('../../paths.js'), + request = require('superagent'), + server = require('../../server.js'), + shell = require('../../shell.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com'; +var token = null; // authentication token + +var server; +function setup(done) { + config.set('version', '0.5.0'); + server.start(done); +} + +function cleanup(done) { + database._clear(function (error) { + expect(error).to.not.be.ok(); + + server.stop(done); + }); +} + +var gSudoOriginal = null; +function injectShellMock() { + gSudoOriginal = shell.sudo; + shell.sudo = function (tag, options, callback) { callback(null); }; +} + +function restoreShellMock() { + shell.sudo = gSudoOriginal; +} + +describe('Cloudron', function () { + + describe('activate', function () { + + before(setup); + after(cleanup); + + it('fails due to missing setupToken', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .send({ username: '', password: 'somepassword', email: 'admin@foo.bar' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to empty username', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: '', password: 'somepassword', email: 'admin@foo.bar' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('fails due to empty password', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: '', email: 'admin@foo.bar' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('fails due to empty email', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: 'somepassword', email: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('fails due to empty name', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: '', email: 'admin@foo.bar', name: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('fails due to invalid email', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: 'somepassword', email: 'invalidemail' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('succeeds', function (done) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: 'somepassword', email: 'admin@foo.bar', name: 'tester' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + done(); + }); + }); + + it('fails the second time', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: 'someuser', password: 'somepassword', email: 'admin@foo.bar' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(409); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + }); + + describe('Certificates API', function () { + var certFile, keyFile; + + before(function (done) { + certFile = path.join(os.tmpdir(), 'host.cert'); + fs.writeFileSync(certFile, 'test certificate'); + + keyFile = path.join(os.tmpdir(), 'host.key'); + fs.writeFileSync(keyFile, 'test key'); + + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(function (done) { + fs.unlinkSync(certFile); + fs.unlinkSync(keyFile); + + cleanup(done); + }); + + it('cannot set certificate without token', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/certificate') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('cannot set certificate without certificate', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/certificate') + .query({ access_token: token }) + .attach('key', keyFile, 'key') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot set certificate without key', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/certificate') + .query({ access_token: token }) + .attach('certificate', certFile, 'certificate') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('can set certificate', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/certificate') + .query({ access_token: token }) + .attach('key', keyFile, 'key') + .attach('certificate', certFile, 'certificate') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(202); + done(); + }); + }); + + it('did set the certificate', function (done) { + var cert = fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.cert')); + expect(cert).to.eql(fs.readFileSync(certFile)); + + var key = fs.readFileSync(path.join(paths.NGINX_CERT_DIR, 'host.key')); + expect(key).to.eql(fs.readFileSync(keyFile)); + done(); + }); + }); + + describe('get config', function () { + before(function (done) { + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + config._reset(); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('cannot get without token', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/config') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('succeeds without appstore', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/config') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.apiServerOrigin).to.eql('http://localhost:6060'); + expect(result.body.webServerOrigin).to.eql(null); + expect(result.body.fqdn).to.eql('localhost'); + expect(result.body.isCustomDomain).to.eql(false); + expect(result.body.progress).to.be.an('object'); + expect(result.body.update).to.be.an('object'); + expect(result.body.version).to.eql('0.5.0'); + expect(result.body.developerMode).to.be.a('boolean'); + expect(result.body.size).to.eql(null); + expect(result.body.region).to.eql(null); + expect(result.body.cloudronName).to.be.a('string'); + + done(); + }); + }); + + it('succeeds', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/localhost?token=' + config.token()).reply(200, { box: { region: 'sfo', size: 'small' }}); + + request.get(SERVER_URL + '/api/v1/cloudron/config') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.apiServerOrigin).to.eql('http://localhost:6060'); + expect(result.body.webServerOrigin).to.eql(null); + expect(result.body.fqdn).to.eql('localhost'); + expect(result.body.isCustomDomain).to.eql(false); + expect(result.body.progress).to.be.an('object'); + expect(result.body.update).to.be.an('object'); + expect(result.body.version).to.eql('0.5.0'); + expect(result.body.developerMode).to.be.a('boolean'); + expect(result.body.size).to.eql('small'); + expect(result.body.region).to.eql('sfo'); + expect(result.body.cloudronName).to.be.a('string'); + + expect(scope.isDone()).to.be.ok(); + + done(); + }); + }); + + }); + + describe('migrate', function () { + before(function (done) { + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + config._reset(); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo'}) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails without password', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo'}) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with missing size', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with wrong size type', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 4, region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails with missing region', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + + it('fails with wrong region type', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 4, password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails when in wrong state', function (done) { + var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { size: 'small', region: 'sfo', restoreKey: 'someid' }).reply(409, {}); + var scope2 = nock(config.apiServerOrigin()).put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }).reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' }); + + injectShellMock(); + + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(202); + + function checkAppstoreServerCalled() { + if (scope1.isDone() && scope2.isDone()) { + restoreShellMock(); + return done(); + } + + setTimeout(checkAppstoreServerCalled, 100); + } + + checkAppstoreServerCalled(); + }); + }); + + + it('succeeds', function (done) { + var scope1 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/migrate?token=APPSTORE_TOKEN', { size: 'small', region: 'sfo', restoreKey: 'someid' }).reply(202, {}); + var scope2 = nock(config.apiServerOrigin()).put('/api/v1/boxes/' + config.fqdn() + '/backupurl?token=APPSTORE_TOKEN', { boxVersion: '0.5.0', appId: null, appVersion: null, appBackupIds: [] }).reply(201, { id: 'someid', url: 'http://foobar', backupKey: 'somerestorekey' }); + + injectShellMock(); + + request.post(SERVER_URL + '/api/v1/cloudron/migrate') + .send({ size: 'small', region: 'sfo', password: PASSWORD }) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(202); + + function checkAppstoreServerCalled() { + if (scope1.isDone() && scope2.isDone()) { + restoreShellMock(); + return done(); + } + + setTimeout(checkAppstoreServerCalled, 100); + } + + checkAppstoreServerCalled(); + }); + }); + }); +}); + + diff --git a/src/routes/test/developer-test.js b/src/routes/test/developer-test.js new file mode 100644 index 000000000..023b437d8 --- /dev/null +++ b/src/routes/test/developer-test.js @@ -0,0 +1,356 @@ +'use strict'; + +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +var async = require('async'), + config = require('../../config.js'), + database = require('../../database.js'), + expect = require('expect.js'), + nock = require('nock'), + request = require('superagent'), + server = require('../../server.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com'; +var token = null; // authentication token + +var server; +function setup(done) { + server.start(done); +} + +function cleanup(done) { + database._clear(function (error) { + expect(error).to.not.be.ok(); + + server.stop(done); + }); +} + +describe('Developer API', function () { + describe('isEnabled', function () { + before(function (done) { + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + config.set('developerMode', true); + + request.get(SERVER_URL + '/api/v1/developer') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('succeeds (enabled)', function (done) { + config.set('developerMode', true); + + request.get(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + + it('succeeds (not enabled)', function (done) { + config.set('developerMode', false); + + request.get(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + }); + + describe('setEnabled', function () { + before(function (done) { + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('fails without token', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .send({ enabled: true }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to missing password', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ enabled: true }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to empty password', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: '', enabled: true }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(403); + done(); + }); + }); + + it('fails due to wrong password', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: PASSWORD.toUpperCase(), enabled: true }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(403); + done(); + }); + }); + + it('fails due to missing enabled property', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to wrong enabled property type', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: PASSWORD, enabled: 'true' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('succeeds enabling', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: PASSWORD, enabled: true }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + + request.get(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + }); + + it('succeeds disabling', function (done) { + request.post(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .send({ password: PASSWORD, enabled: false }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + + request.get(SERVER_URL + '/api/v1/developer') + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(412); + done(); + }); + }); + }); + }); + + describe('login', function () { + before(function (done) { + config.set('developerMode', true); + + async.series([ + setup, + + function (callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + ], done); + }); + + after(cleanup); + + it('fails without body', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails without username', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails without password', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails with empty username', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: '', password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails with empty password', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME, password: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails with unknown username', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME.toUpperCase(), password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails with wrong password', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME, password: PASSWORD.toUpperCase() }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('with username succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: USERNAME, password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.expiresAt).to.be.a('number'); + expect(result.body.token).to.be.a('string'); + done(); + }); + }); + + it('with email succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/developer/login') + .send({ username: EMAIL, password: PASSWORD }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.expiresAt).to.be.a('number'); + expect(result.body.token).to.be.a('string'); + done(); + }); + }); + }); +}); diff --git a/src/routes/test/oauth2-test.js b/src/routes/test/oauth2-test.js new file mode 100644 index 000000000..7e27f972e --- /dev/null +++ b/src/routes/test/oauth2-test.js @@ -0,0 +1,334 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var expect = require('expect.js'), + uuid = require('node-uuid'), + hat = require('hat'), + nock = require('nock'), + HttpError = require('connect-lastmile').HttpError, + oauth2 = require('../oauth2.js'), + server = require('../../server.js'), + database = require('../../database.js'), + userdb = require('../../userdb.js'), + config = require('../../config.js'), + superagent = require('superagent'), + passport = require('passport'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +describe('OAuth2', function () { + var passportAuthenticateSave = null; + + before(function () { + passportAuthenticateSave = passport.authenticate; + passport.authenticate = function () { + return function (req, res, next) { next(); }; + }; + }); + + after(function () { + passport.authenticate = passportAuthenticateSave; + }); + + describe('scopes middleware', function () { + it('fails due to missing authInfo', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = {}; + + mw(req, null, function (error) { + expect(error).to.be.a(HttpError); + done(); + }); + }); + + it('fails due to missing scope property in authInfo', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = { authInfo: {} }; + + mw(req, null, function (error) { + expect(error).to.be.a(HttpError); + done(); + }); + }); + + it('fails due to missing scope in request', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = { authInfo: { scope: '' } }; + + mw(req, null, function (error) { + expect(error).to.be.a(HttpError); + done(); + }); + }); + + it('fails due to wrong scope in request', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = { authInfo: { scope: 'foobar,something' } }; + + mw(req, null, function (error) { + expect(error).to.be.a(HttpError); + done(); + }); + }); + + it('fails due to wrong scope in request', function (done) { + var mw = oauth2.scope('admin,users')[1]; + var req = { authInfo: { scope: 'foobar,admin' } }; + + mw(req, null, function (error) { + expect(error).to.be.a(HttpError); + done(); + }); + }); + + it('succeeds with one requested scope and one provided scope', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = { authInfo: { scope: 'admin' } }; + + mw(req, null, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('succeeds with one requested scope and two provided scopes', function (done) { + var mw = oauth2.scope('admin')[1]; + var req = { authInfo: { scope: 'foobar,admin' } }; + + mw(req, null, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('succeeds with two requested scope and two provided scopes', function (done) { + var mw = oauth2.scope('admin,foobar')[1]; + var req = { authInfo: { scope: 'foobar,admin' } }; + + mw(req, null, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('succeeds with two requested scope and provided wildcard scope', function (done) { + var mw = oauth2.scope('admin,foobar')[1]; + var req = { authInfo: { scope: '*' } }; + + mw(req, null, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + }); + +}); + +describe('Password', function () { + var USER_0 = { + userId: uuid.v4(), + username: 'someusername', + password: 'somepassword', + email: 'some@email.com', + admin: true, + salt: 'somesalt', + createdAt: (new Date()).toUTCString(), + modifiedAt: (new Date()).toUTCString(), + resetToken: hat(256) + }; + + // make csrf always succeed for testing + oauth2.csrf = function (req, res, next) { + req.csrfToken = function () { return hat(256); }; + next(); + }; + + function setup(done) { + server.start(function (error) { + expect(error).to.not.be.ok(); + database._clear(function (error) { + expect(error).to.not.be.ok(); + + userdb.add(USER_0.userId, USER_0, done); + }); + }); + } + + function cleanup(done) { + database._clear(function (error) { + expect(error).to.not.be.ok(); + + server.stop(done); + }); + } + + describe('pages', function () { + before(setup); + after(cleanup); + + it('reset request succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/resetRequest.html') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.text.indexOf('')).to.not.equal(-1); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + + it('setup fails due to missing reset_token', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/setup.html') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('setup fails due to invalid reset_token', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/setup.html') + .query({ reset_token: hat(256) }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('setup succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/setup.html') + .query({ reset_token: USER_0.resetToken }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.text.indexOf('')).to.not.equal(-1); + done(); + }); + }); + + it('reset fails due to missing reset_token', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/reset.html') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('reset fails due to invalid reset_token', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/reset.html') + .query({ reset_token: hat(256) }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('reset succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/reset.html') + .query({ reset_token: USER_0.resetToken }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.text.indexOf('')).to.not.equal(-1); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + + it('sent succeeds', function (done) { + superagent.get(SERVER_URL + '/api/v1/session/password/sent.html') + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.text.indexOf('')).to.not.equal(-1); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + }); + + describe('reset request handler', function () { + before(setup); + after(cleanup); + + it('succeeds', function (done) { + superagent.post(SERVER_URL + '/api/v1/session/password/resetRequest') + .send({ identifier: USER_0.email }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.text.indexOf('')).to.not.equal(-1); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + }); + + describe('reset handler', function () { + before(setup); + after(cleanup); + + it('fails due to missing resetToken', function (done) { + superagent.post(SERVER_URL + '/api/v1/session/password/reset') + .send({ password: 'somepassword' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to missing password', function (done) { + superagent.post(SERVER_URL + '/api/v1/session/password/reset') + .send({ resetToken: hat(256) }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('fails due to empty password', function (done) { + superagent.post(SERVER_URL + '/api/v1/session/password/reset') + .send({ password: '', resetToken: hat(256) }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('fails due to empty resetToken', function (done) { + superagent.post(SERVER_URL + '/api/v1/session/password/reset') + .send({ password: '', resetToken: '' }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }); + + it('succeeds', function (done) { + var scope = nock(config.adminOrigin()) + .filteringPath(function (path) { + path = path.replace(/accessToken=[^&]*/, 'accessToken=token'); + path = path.replace(/expiresAt=[^&]*/, 'expiresAt=1234'); + return path; + }) + .get('/?accessToken=token&expiresAt=1234').reply(200, {}); + + superagent.post(SERVER_URL + '/api/v1/session/password/reset') + .send({ password: 'somepassword', resetToken: USER_0.resetToken }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(scope.isDone()).to.be.ok(); + expect(result.statusCode).to.equal(200); + done(); + }); + }); + }); +}); diff --git a/src/routes/test/settings-test.js b/src/routes/test/settings-test.js new file mode 100644 index 000000000..e366c6499 --- /dev/null +++ b/src/routes/test/settings-test.js @@ -0,0 +1,233 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var appdb = require('../../appdb.js'), + async = require('async'), + config = require('../../config.js'), + database = require('../../database.js'), + expect = require('expect.js'), + paths = require('../../paths.js'), + request = require('superagent'), + server = require('../../server.js'), + settings = require('../../settings.js'), + fs = require('fs'), + nock = require('nock'), + userdb = require('../../userdb.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME = 'admin', PASSWORD = 'password', EMAIL ='silly@me.com'; +var token = null; + +var server; +function setup(done) { + async.series([ + server.start.bind(server), + + userdb._clear, + + function createAdmin(callback) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME, password: PASSWORD, email: EMAIL }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.statusCode).to.eql(201); + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + + // stash token for further use + token = result.body.token; + + callback(); + }); + }, + + function addApp(callback) { + var manifest = { version: '0.0.1', manifestVersion: 1, dockerImage: 'foo', healthCheckPath: '/', httpPort: 3, title: 'ok' }; + appdb.add('appid', 'appStoreId', manifest, 'location', [ ] /* portBindings */, '' /* accessRestriction */, callback); + } + ], done); +} + +function cleanup(done) { + database._clear(function (error) { + expect(!error).to.be.ok(); + + server.stop(done); + }); +} + +describe('Settings API', function () { + this.timeout(10000); + + before(setup); + after(cleanup); + + describe('autoupdate_pattern', function () { + it('can get auto update pattern (default)', function (done) { + request.get(SERVER_URL + '/api/v1/settings/autoupdate_pattern') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.pattern).to.be.ok(); + done(err); + }); + }); + + it('cannot set autoupdate_pattern without pattern', function (done) { + request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set autoupdate_pattern', function (done) { + var eventPattern = null; + settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, function (pattern) { + eventPattern = pattern; + }); + + request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern') + .query({ access_token: token }) + .send({ pattern: '00 30 11 * * 1-5' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(eventPattern === '00 30 11 * * 1-5').to.be.ok(); + done(); + }); + }); + + it('can set autoupdate_pattern to never', function (done) { + var eventPattern = null; + settings.events.on(settings.AUTOUPDATE_PATTERN_KEY, function (pattern) { + eventPattern = pattern; + }); + + request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern') + .query({ access_token: token }) + .send({ pattern: 'never' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(eventPattern).to.eql('never'); + done(); + }); + }); + + it('cannot set invalid autoupdate_pattern', function (done) { + request.post(SERVER_URL + '/api/v1/settings/autoupdate_pattern') + .query({ access_token: token }) + .send({ pattern: '1 3 x 5 6' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + }); + + describe('cloudron_name', function () { + var name = 'foobar'; + + it('get default succeeds', function (done) { + request.get(SERVER_URL + '/api/v1/settings/cloudron_name') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.name).to.be.ok(); + done(err); + }); + }); + + it('cannot set without name', function (done) { + request.post(SERVER_URL + '/api/v1/settings/cloudron_name') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('cannot set empty name', function (done) { + request.post(SERVER_URL + '/api/v1/settings/cloudron_name') + .query({ access_token: token }) + .send({ name: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('set succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/settings/cloudron_name') + .query({ access_token: token }) + .send({ name: name }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('get succeeds', function (done) { + request.get(SERVER_URL + '/api/v1/settings/cloudron_name') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.name).to.eql(name); + done(err); + }); + }); + }); + + describe('cloudron_avatar', function () { + it('get default succeeds', function (done) { + request.get(SERVER_URL + '/api/v1/settings/cloudron_avatar') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body).to.be.a(Buffer); + done(err); + }); + }); + + it('cannot set without data', function (done) { + request.post(SERVER_URL + '/api/v1/settings/cloudron_avatar') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('set succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/settings/cloudron_avatar') + .query({ access_token: token }) + .attach('avatar', paths.FAVICON_FILE) + .end(function (err, res) { + expect(res.statusCode).to.equal(202); + done(); + }); + }); + + it('get succeeds', function (done) { + request.get(SERVER_URL + '/api/v1/settings/cloudron_avatar') + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.toString()).to.eql(fs.readFileSync(paths.FAVICON_FILE, 'utf-8')); + done(err); + }); + }); + }); +}); + diff --git a/src/routes/test/start_addons.sh b/src/routes/test/start_addons.sh new file mode 100755 index 000000000..fd5a4a296 --- /dev/null +++ b/src/routes/test/start_addons.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +set -eu -o pipefail + +readonly mysqldatadir="/tmp/mysqldata-$(date +%s)" +readonly postgresqldatadir="/tmp/postgresqldata-$(date +%s)" +readonly mongodbdatadir="/tmp/mongodbdata-$(date +%s)" +root_password=secret + +start_postgresql() { + postgresql_vars="POSTGRESQL_ROOT_PASSWORD=${root_password}; POSTGRESQL_ROOT_HOST=172.17.0.0/255.255.0.0" + + if which boot2docker >/dev/null; then + boot2docker ssh "sudo rm -rf /tmp/postgresql_vars.sh" + boot2docker ssh "echo \"${postgresql_vars}\" > /tmp/postgresql_vars.sh" + else + rm -rf /tmp/postgresql_vars.sh + echo "${postgresql_vars}" > /tmp/postgresql_vars.sh + fi + + docker rm -f postgresql 2>/dev/null 1>&2 || true + + docker run -dtP --name=postgresql -v "${postgresqldatadir}:/var/lib/postgresql" -v /tmp/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh cloudron/postgresql:0.3.0 >/dev/null +} + +start_mysql() { + local mysql_vars="MYSQL_ROOT_PASSWORD=${root_password}; MYSQL_ROOT_HOST=172.17.0.0/255.255.0.0" + + if which boot2docker >/dev/null; then + boot2docker ssh "sudo rm -rf /tmp/mysql_vars.sh" + boot2docker ssh "echo \"${mysql_vars}\" > /tmp/mysql_vars.sh" + else + rm -rf /tmp/mysql_vars.sh + echo "${mysql_vars}" > /tmp/mysql_vars.sh + fi + + docker rm -f mysql 2>/dev/null 1>&2 || true + + docker run -dP --name=mysql -v "${mysqldatadir}:/var/lib/mysql" -v /tmp/mysql_vars.sh:/etc/mysql/mysql_vars.sh cloudron/mysql:0.3.0 >/dev/null +} + +start_mongodb() { + local mongodb_vars="MONGODB_ROOT_PASSWORD=${root_password}" + + if which boot2docker >/dev/null; then + boot2docker ssh "sudo rm -rf /tmp/mongodb_vars.sh" + boot2docker ssh "echo \"${mongodb_vars}\" > /tmp/mongodb_vars.sh" + else + rm -rf /tmp/mongodb_vars.sh + echo "${mongodb_vars}" > /tmp/mongodb_vars.sh + fi + + docker rm -f mongodb 2>/dev/null 1>&2 || true + + docker run -dP --name=mongodb -v "${mongodbdatadir}:/var/lib/mongodb" -v /tmp/mongodb_vars.sh:/etc/mongodb_vars.sh cloudron/mongodb:0.3.0 >/dev/null +} + +start_mysql +start_postgresql +start_mongodb + +echo -n "Waiting for addons to start" +for i in {1..10}; do + echo -n "." + sleep 1 +done +echo "" + diff --git a/src/routes/test/user-test.js b/src/routes/test/user-test.js new file mode 100644 index 000000000..4bde4fccb --- /dev/null +++ b/src/routes/test/user-test.js @@ -0,0 +1,528 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var config = require('../../config.js'), + database = require('../../database.js'), + tokendb = require('../../tokendb.js'), + expect = require('expect.js'), + request = require('superagent'), + nock = require('nock'), + server = require('../../server.js'), + userdb = require('../../userdb.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +var USERNAME_0 = 'admin', PASSWORD = 'password', EMAIL = 'silly@me.com', EMAIL_0_NEW = 'stupid@me.com'; +var USERNAME_1 = 'userTheFirst', EMAIL_1 = 'tao@zen.mac'; +var USERNAME_2 = 'userTheSecond', EMAIL_2 = 'user@foo.bar'; +var USERNAME_3 = 'userTheThird', EMAIL_3 = 'user3@foo.bar'; + +var server; +function setup(done) { + server.start(function (error) { + expect(!error).to.be.ok(); + userdb._clear(done); + }); +} + +function cleanup(done) { + database._clear(function (error) { + expect(!error).to.be.ok(); + + server.stop(done); + }); +} + +describe('User API', function () { + this.timeout(5000); + + var user_0 = null; + var token = null; + var token_1 = tokendb.generateToken(); + var token_2 = tokendb.generateToken(); + + before(setup); + after(cleanup); + + it('device is in first time mode', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/status') + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.activated).to.not.be.ok(); + done(err); + }); + }); + + it('create admin fails due to missing parameters', function (done) { + var scope = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME_0 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + expect(scope.isDone()).to.be.ok(); + done(err); + }); + }); + + it('create admin fails because only POST is allowed', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/activate') + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + done(err); + }); + }); + + it('create admin', function (done) { + var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {}); + var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {}); + + request.post(SERVER_URL + '/api/v1/cloudron/activate') + .query({ setupToken: 'somesetuptoken' }) + .send({ username: USERNAME_0, password: PASSWORD, email: EMAIL }) + .end(function (err, res) { + expect(res.statusCode).to.equal(201); + + // stash for later use + token = res.body.token; + + expect(scope1.isDone()).to.be.ok(); + expect(scope2.isDone()).to.be.ok(); + done(err); + }); + }); + + it('device left first time mode', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/status') + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.activated).to.be.ok(); + done(err); + }); + }); + + it('can get userInfo with token', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.username).to.equal(USERNAME_0); + expect(res.body.email).to.equal(EMAIL); + expect(res.body.admin).to.be.ok(); + + // stash for further use + user_0 = res.body; + + done(err); + }); + }); + + it('cannot get userInfo with expired token', function (done) { + var token = tokendb.generateToken(); + var expires = Date.now() + 2000; // 1 sec + + tokendb.add(token, tokendb.PREFIX_USER + user_0.id, null, expires, '*', function (error) { + expect(error).to.not.be.ok(); + + setTimeout(function () { + request.get(SERVER_URL + '/api/v1/users/' + user_0.username) + .query({ access_token: token }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(401); + done(); + }); + }, 2000); + }); + }); + + it('can get userInfo with token', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.username).to.equal(USERNAME_0); + expect(res.body.email).to.equal(EMAIL); + expect(res.body.admin).to.be.ok(); + done(err); + }); + }); + + it('cannot get userInfo only with basic auth', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .auth(USERNAME_0, PASSWORD) + .end(function (err, res) { + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('cannot get userInfo with invalid token (token length)', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: 'x' + token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('cannot get userInfo with invalid token (wrong token)', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token.toUpperCase() }) + .end(function (err, res) { + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('can get userInfo with token in auth header', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .set('Authorization', 'Bearer ' + token) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + expect(res.body.username).to.equal(USERNAME_0); + expect(res.body.email).to.equal(EMAIL); + expect(res.body.admin).to.be.ok(); + expect(res.body.password).to.not.be.ok(); + expect(res.body.salt).to.not.be.ok(); + done(err); + }); + }); + + it('cannot get userInfo with invalid token in auth header', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .set('Authorization', 'Bearer ' + 'x' + token) + .end(function (err, res) { + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('cannot get userInfo with invalid token (wrong token)', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .set('Authorization', 'Bearer ' + 'x' + token.toUpperCase()) + .end(function (err, res) { + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('create second user succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_1, email: EMAIL_1 }) + .end(function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(201); + + // HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...) + tokendb.add(token_1, tokendb.PREFIX_USER + USERNAME_1, 'test-client-id', Date.now() + 10000, '*', done); + }); + }); + + it('set second user as admin succeeds', function (done) { + // TODO is USERNAME_1 in body and url redundant? + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_1 + '/admin') + .query({ access_token: token }) + .send({ username: USERNAME_1, admin: true }) + .end(function (err, res) { + expect(res.statusCode).to.equal(204); + done(err); + }); + }); + + it('remove first user from admins succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/admin') + .query({ access_token: token_1 }) + .send({ username: USERNAME_0, admin: false }) + .end(function (err, res) { + expect(res.statusCode).to.equal(204); + done(err); + }); + }); + + it('remove second user by first, now normal, user fails', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_1) + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('remove second user from admins and thus last admin fails', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_1 + '/admin') + .query({ access_token: token_1 }) + .send({ username: USERNAME_1, admin: false }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('reset first user as admin succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/admin') + .query({ access_token: token_1 }) + .send({ username: USERNAME_0, admin: true }) + .end(function (err, res) { + expect(res.statusCode).to.equal(204); + done(err); + }); + }); + + it('create user missing username fails', function (done) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ email: EMAIL_2 }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('create user missing email fails', function (done) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_2 }) + .end(function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(400); + done(); + }); + }); + + it('create second and third user', function (done) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_2, email: EMAIL_2 }) + .end(function (error, res) { + expect(error).to.not.be.ok(); + expect(res.statusCode).to.equal(201); + + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_3, email: EMAIL_3 }) + .end(function (error, res) { + expect(error).to.not.be.ok(); + expect(res.statusCode).to.equal(201); + + // HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...) + tokendb.add(token_2, tokendb.PREFIX_USER + USERNAME_2, 'test-client-id', Date.now() + 10000, '*', done); + }); + }); + }); + + it('second user userInfo', function (done) { + request.get(SERVER_URL + '/api/v1/users/' + USERNAME_2) + .query({ access_token: token_1 }) + .end(function (error, result) { + expect(error).to.be(null); + expect(result.statusCode).to.equal(200); + expect(result.body.username).to.equal(USERNAME_2); + expect(result.body.email).to.equal(EMAIL_2); + expect(result.body.admin).to.not.be.ok(); + + done(); + }); + }); + + it('create user with same username should fail', function (done) { + request.post(SERVER_URL + '/api/v1/users') + .query({ access_token: token }) + .send({ username: USERNAME_2, email: EMAIL }) + .end(function (err, res) { + expect(res.statusCode).to.equal(409); + done(err); + }); + }); + + it('list users', function (done) { + request.get(SERVER_URL + '/api/v1/users') + .query({ access_token: token_2 }) + .end(function (error, res) { + expect(error).to.be(null); + expect(res.statusCode).to.equal(200); + expect(res.body.users).to.be.an('array'); + expect(res.body.users.length).to.equal(4); + + res.body.users.forEach(function (user) { + expect(user).to.be.an('object'); + expect(user.id).to.be.ok(); + expect(user.username).to.be.ok(); + expect(user.email).to.be.ok(); + expect(user.password).to.not.be.ok(); + expect(user.salt).to.not.be.ok(); + }); + + done(); + }); + }); + + it('user removes himself is not allowed', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('admin cannot remove normal user without giving a password', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3) + .query({ access_token: token }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('admin cannot remove normal user with empty password', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3) + .query({ access_token: token }) + .send({ password: '' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('admin cannot remove normal user with giving wrong password', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3) + .query({ access_token: token }) + .send({ password: PASSWORD + PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('admin removes normal user', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_3) + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(204); + done(err); + }); + }); + + it('admin removes himself should not be allowed', function (done) { + request.del(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + // Change email + it('change email fails due to missing token', function (done) { + request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .send({ password: PASSWORD, email: EMAIL_0_NEW }) + .end(function (error, result) { + expect(result.statusCode).to.equal(401); + done(error); + }); + }); + + it('change email fails due to missing password', function (done) { + request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ email: EMAIL_0_NEW }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(error); + }); + }); + + it('change email fails due to wrong password', function (done) { + request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ password: PASSWORD+PASSWORD, email: EMAIL_0_NEW }) + .end(function (error, result) { + expect(result.statusCode).to.equal(403); + done(error); + }); + }); + + it('change email fails due to invalid email', function (done) { + request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ password: PASSWORD, email: 'foo@bar' }) + .end(function (error, result) { + expect(result.statusCode).to.equal(400); + done(error); + }); + }); + + it('change email succeeds', function (done) { + request.put(SERVER_URL + '/api/v1/users/' + USERNAME_0) + .query({ access_token: token }) + .send({ password: PASSWORD, email: EMAIL_0_NEW }) + .end(function (error, result) { + expect(result.statusCode).to.equal(204); + done(error); + }); + }); + + // Change password + it('change password fails due to missing current password', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password') + .query({ access_token: token }) + .send({ newPassword: 'some wrong password' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('change password fails due to missing new password', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password') + .query({ access_token: token }) + .send({ password: PASSWORD }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('change password fails due to wrong password', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password') + .query({ access_token: token }) + .send({ password: 'some wrong password', newPassword: 'newpassword' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(403); + done(err); + }); + }); + + it('change password fails due to invalid password', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password') + .query({ access_token: token }) + .send({ password: PASSWORD, newPassword: 'five' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(err); + }); + }); + + it('change password succeeds', function (done) { + request.post(SERVER_URL + '/api/v1/users/' + USERNAME_0 + '/password') + .query({ access_token: token }) + .send({ password: PASSWORD, newPassword: 'new_password' }) + .end(function (err, res) { + expect(res.statusCode).to.equal(204); + done(err); + }); + }); +}); diff --git a/src/routes/user.js b/src/routes/user.js new file mode 100644 index 000000000..a265b073a --- /dev/null +++ b/src/routes/user.js @@ -0,0 +1,200 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + profile: profile, + info: info, + update: update, + list: listUser, + create: createUser, + changePassword: changePassword, + changeAdmin: changeAdmin, + remove: removeUser, + verifyPassword: verifyPassword, + requireAdmin: requireAdmin +}; + +var assert = require('assert'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + user = require('../user.js'), + tokendb = require('../tokendb.js'), + UserError = user.UserError; + +// http://stackoverflow.com/questions/1497481/javascript-password-generator#1497512 +function generatePassword() { + var length = 8, + charset = 'abcdefghijklnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + retVal = ''; + for (var i = 0, n = charset.length; i < length; ++i) { + retVal += charset.charAt(Math.floor(Math.random() * n)); + } + return retVal; +} + +function profile(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + var result = {}; + result.id = req.user.id; + result.tokenType = req.user.tokenType; + + if (req.user.tokenType === tokendb.TYPE_USER || req.user.tokenType === tokendb.TYPE_DEV) { + result.username = req.user.username; + result.email = req.user.email; + result.admin = req.user.admin; + } + + next(new HttpSuccess(200, result)); +} + +function createUser(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.username !== 'string') return next(new HttpError(400, 'username must be string')); + if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string')); + + var username = req.body.username; + var password = generatePassword(); + var email = req.body.email; + + user.create(username, password, email, false /* admin */, req.user /* creator */, function (error, user) { + if (error && error.reason === UserError.BAD_USERNAME) return next(new HttpError(400, 'Invalid username')); + if (error && error.reason === UserError.BAD_EMAIL) return next(new HttpError(400, 'Invalid email')); + if (error && error.reason === UserError.BAD_PASSWORD) return next(new HttpError(400, 'Invalid password')); + if (error && error.reason === UserError.BAD_FIELD) return next(new HttpError(400, error.message)); + if (error && error.reason === UserError.ALREADY_EXISTS) return next(new HttpError(409, 'Already exists')); + if (error) return next(new HttpError(500, error)); + + var userInfo = { + id: user.id, + username: user.username, + email: user.email, + admin: user.admin, + resetToken: user.resetToken + }; + + next(new HttpSuccess(201, { userInfo: userInfo })); + }); +} + +function update(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.email !== 'string') return next(new HttpError(400, 'email must be string')); + + if (req.user.tokenType !== tokendb.TYPE_USER) return next(new HttpError(403, 'Token type not allowed')); + + user.update(req.user.id, req.user.username, req.body.email, function (error) { + if (error && error.reason === UserError.BAD_EMAIL) return next(new HttpError(400, error.message)); + if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'User not found')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(204)); + }); +} + +function changeAdmin(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.username !== 'string') return next(new HttpError(400, 'API call requires a username.')); + if (typeof req.body.admin !== 'boolean') return next(new HttpError(400, 'API call requires an admin setting.')); + + user.changeAdmin(req.body.username, req.body.admin, function (error) { + if (error && error.reason === UserError.NOT_ALLOWED) return next(new HttpError(403, 'Last admin')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(204)); + }); +} + +function changePassword(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.user, 'object'); + + if (typeof req.body.password !== 'string') return next(new HttpError(400, 'API call requires the users old password.')); + if (typeof req.body.newPassword !== 'string') return next(new HttpError(400, 'API call requires the users new password.')); + + if (req.user.tokenType !== tokendb.TYPE_USER) return next(new HttpError(403, 'Token type not allowed')); + + user.changePassword(req.user.username, req.body.password, req.body.newPassword, function (error) { + if (error && error.reason === UserError.BAD_PASSWORD) return next(new HttpError(400, error.message)); + if (error && error.reason === UserError.WRONG_PASSWORD) return next(new HttpError(403, 'Wrong password')); + if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(403, 'Wrong password')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(204)); + }); +} + +function listUser(req, res, next) { + user.list(function (error, result) { + if (error) return next(new HttpError(500, error)); + next(new HttpSuccess(200, { users: result })); + }); +} + +function info(req, res, next) { + assert.strictEqual(typeof req.params.userId, 'string'); + + user.get(req.params.userId, function (error, result) { + if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'No such user')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(200, { + id: result.id, + username: result.username, + email: result.email, + admin: result.admin + })); + }); +} + +function removeUser(req, res, next) { + assert.strictEqual(typeof req.params.userId, 'string'); + + // rules: + // - admin can remove any user + // - admin cannot remove admin + // - user cannot remove himself <- TODO should this actually work? + + if (req.user.id === req.params.userId) return next(new HttpError(403, 'Not allowed to remove yourself.')); + + user.remove(req.params.userId, function (error) { + if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'User not found')); + if (error) return next(new HttpError(500, error)); + + next(new HttpSuccess(204)); + }); +} + +function verifyPassword(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + // developers are allowed to through without password + if (req.user.tokenType === tokendb.TYPE_DEV) return next(); + + if (typeof req.body.password !== 'string') return next(new HttpError(400, 'API call requires user password')); + + user.verify(req.user.username, req.body.password, function (error) { + if (error && error.reason === UserError.WRONG_PASSWORD) return next(new HttpError(403, 'Password incorrect')); + if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(403, 'Password incorrect')); + if (error) return next(new HttpError(500, error)); + + next(); + }); +} + +/* + Middleware which makes the route only accessable for the admin user. +*/ +function requireAdmin(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + + if (!req.user.admin) return next(new HttpError(403, 'API call requires admin rights.')); + + next(); +} + diff --git a/src/scripts/backupapp.sh b/src/scripts/backupapp.sh new file mode 100755 index 000000000..ed08313e0 --- /dev/null +++ b/src/scripts/backupapp.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script should be run as root." >&2 + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [ $# -lt 3 ]; then + echo "Usage: backup.sh " + exit 1 +fi + +readonly DATA_DIR="${HOME}/data" + +app_id="$1" +backup_url="$2" +backup_key="$3" +readonly now=$(date "+%Y-%m-%dT%H:%M:%S") +readonly app_data_dir="${DATA_DIR}/${app_id}" +readonly app_data_snapshot="${DATA_DIR}/snapshots/${app_id}-${now}" + +btrfs subvolume snapshot -r "${app_data_dir}" "${app_data_snapshot}" + +for try in `seq 1 5`; do + echo "Uploading backup to ${backup_url} (try ${try})" + error_log=$(mktemp) + if tar -cvzf - -C "${app_data_snapshot}" . \ + | openssl aes-256-cbc -e -pass "pass:${backup_key}" \ + | curl --fail -H "Content-Type:" -X PUT --data-binary @- "${backup_url}" 2>"${error_log}"; then + break + fi + cat "${error_log}" && rm "${error_log}" +done + +btrfs subvolume delete "${app_data_snapshot}" + +if [[ ${try} -eq 5 ]]; then + echo "Backup failed" + exit 1 +else + echo "Backup successful" +fi + diff --git a/src/scripts/backupbox.sh b/src/scripts/backupbox.sh new file mode 100755 index 000000000..74f12efcb --- /dev/null +++ b/src/scripts/backupbox.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script should be run as root." >&2 + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [ $# -lt 2 ]; then + echo "Usage: backupbox.sh " + exit 1 +fi + +backup_url="$1" +backup_key="$2" +now=$(date "+%Y-%m-%dT%H:%M:%S") +BOX_DATA_DIR="${HOME}/data/box" +box_snapshot_dir="${HOME}/data/snapshots/box-${now}" + +echo "Creating MySQL dump" +mysqldump -u root -ppassword --single-transaction --routines --triggers box > "${BOX_DATA_DIR}/box.mysqldump" + +echo "Snapshoting backup as backup-${now}" +btrfs subvolume snapshot -r "${BOX_DATA_DIR}" "${box_snapshot_dir}" + +for try in `seq 1 5`; do + echo "Uploading backup to ${backup_url} (try ${try})" + error_log=$(mktemp) + if tar -cvzf - -C "${box_snapshot_dir}" . \ + | openssl aes-256-cbc -e -pass "pass:${backup_key}" \ + | curl --fail -H "Content-Type:" -X PUT --data-binary @- "${backup_url}" 2>"${error_log}"; then + break + fi + cat "${error_log}" && rm "${error_log}" +done + +echo "Deleting backup snapshot" +btrfs subvolume delete "${box_snapshot_dir}" + +if [[ ${try} -eq 5 ]]; then + echo "Backup failed" + exit 1 +else + echo "Backup successful" +fi + diff --git a/src/scripts/backupswap.sh b/src/scripts/backupswap.sh new file mode 100755 index 000000000..7f2eef53c --- /dev/null +++ b/src/scripts/backupswap.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# -eq 0 ]]; then + echo "No arguments supplied" + exit 1 +fi + +if [[ "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +BACKUP_SWAP_FILE="/backup.swap" + +if [[ "$1" == "--on" ]]; then + echo "Mounting backup swap" + + if ! swapon -s | grep -q "${BACKUP_SWAP_FILE}"; then + swapon "${BACKUP_SWAP_FILE}" + else + echo "Backup swap already mounted" + fi +fi + +if [[ "$1" == "--off" ]]; then + echo "Unmounting backup swap" + + if swapon -s | grep -q "${BACKUP_SWAP_FILE}"; then + swapoff "${BACKUP_SWAP_FILE}" + else + echo "Backup swap was not mounted" + fi +fi + diff --git a/src/scripts/createappdir.sh b/src/scripts/createappdir.sh new file mode 100755 index 000000000..2d9c87f04 --- /dev/null +++ b/src/scripts/createappdir.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# -eq 0 ]]; then + echo "No arguments supplied" + exit 1 +fi + +if [[ "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [[ "${NODE_ENV}" == "cloudron" ]]; then + readonly app_data_dir="${HOME}/data/$1" + btrfs subvolume create "${app_data_dir}" + mkdir -p "${app_data_dir}/data" + chown -R yellowtent:yellowtent "${app_data_dir}" +else + readonly app_data_dir="${HOME}/.cloudron_test/data/$1" + mkdir -p "${app_data_dir}/data" + chown -R ${SUDO_USER}:${SUDO_USER} "${app_data_dir}" +fi + diff --git a/src/scripts/reboot.sh b/src/scripts/reboot.sh new file mode 100755 index 000000000..580eb5907 --- /dev/null +++ b/src/scripts/reboot.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [[ "${NODE_ENV}" == "cloudron" ]]; then + shutdown -r now +fi + diff --git a/src/scripts/reloadcollectd.sh b/src/scripts/reloadcollectd.sh new file mode 100755 index 000000000..0fbd1055b --- /dev/null +++ b/src/scripts/reloadcollectd.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [[ "${NODE_ENV}" == "cloudron" ]]; then + /etc/init.d/collectd restart +fi + diff --git a/src/scripts/reloadnginx.sh b/src/scripts/reloadnginx.sh new file mode 100755 index 000000000..57d1f90e8 --- /dev/null +++ b/src/scripts/reloadnginx.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [[ "${OSTYPE}" == "darwin"* ]]; then + # On Mac, brew installs supervisor in /usr/local/bin + export PATH=$PATH:/usr/local/bin +fi + +if [[ "${NODE_ENV}" == "cloudron" ]]; then + nginx -s reload +fi + diff --git a/src/scripts/restoreapp.sh b/src/scripts/restoreapp.sh new file mode 100755 index 000000000..8050d609d --- /dev/null +++ b/src/scripts/restoreapp.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ $EUID -ne 0 ]]; then + echo "This script should be run as root." >&2 + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [ $# -lt 3 ]; then + echo "Usage: restoreapp.sh " + exit 1 +fi + +readonly DATA_DIR="${HOME}/data" +readonly curl="curl --fail --connect-timeout 20 --retry 10 --retry-delay 2 --max-time 2400" + +app_id="$1" +restore_url="$2" +restore_key="$3" + +echo "Downloading backup: ${restore_url} and key: ${restore_key}" + +for try in `seq 1 5`; do + echo "Download backup from ${restore_url} (try ${try})" + error_log=$(mktemp) + + if $curl -L "${restore_url}" \ + | openssl aes-256-cbc -d -pass "pass:${restore_key}" \ + | tar -zxf - -C "${DATA_DIR}/${app_id}" 2>"${error_log}"; then + chown -R yellowtent:yellowtent "${DATA_DIR}/${app_id}" + break + fi + cat "${error_log}" && rm "${error_log}" +done + +if [[ ${try} -eq 5 ]]; then + echo "restore failed" + exit 1 +else + echo "restore successful" +fi + diff --git a/src/scripts/rmappdir.sh b/src/scripts/rmappdir.sh new file mode 100755 index 000000000..ab04e2df5 --- /dev/null +++ b/src/scripts/rmappdir.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# -eq 0 ]]; then + echo "No arguments supplied" + exit 1 +fi + +if [[ "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +if [[ "${NODE_ENV}" == "cloudron" ]]; then + readonly app_data_dir="${HOME}/data/$1" + if [[ -d "${app_data_dir}" ]]; then + find "${app_data_dir}" -mindepth 1 -delete + rm -rf "${app_data_dir}" || btrfs subvolume delete "${app_data_dir}" + fi +else + readonly app_data_dir="${HOME}/.cloudron_test/data/$1" + rm -rf "${app_data_dir}" +fi + diff --git a/src/server.js b/src/server.js new file mode 100644 index 000000000..9d14b3f60 --- /dev/null +++ b/src/server.js @@ -0,0 +1,267 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + start: start, + stop: stop +}; + +var assert = require('assert'), + async = require('async'), + auth = require('./auth.js'), + cloudron = require('./cloudron.js'), + cron = require('./cron.js'), + config = require('./config.js'), + database = require('./database.js'), + express = require('express'), + http = require('http'), + mailer = require('./mailer.js'), + middleware = require('./middleware'), + passport = require('passport'), + path = require('path'), + paths = require('./paths.js'), + routes = require('./routes/index.js'), + taskmanager = require('./taskmanager.js'); + +var gHttpServer = null; +var gInternalHttpServer = null; + +function initializeExpressSync() { + var app = express(); + var httpServer = http.createServer(app); + + var QUERY_LIMIT = '10mb', // max size for json and urlencoded queries + FIELD_LIMIT = 2 * 1024 * 1024; // max fields that can appear in multipart + + var REQUEST_TIMEOUT = 10000; // timeout for all requests + + var json = middleware.json({ strict: true, limit: QUERY_LIMIT }), // application/json + urlencoded = middleware.urlencoded({ extended: false, limit: QUERY_LIMIT }); // application/x-www-form-urlencoded + + app.set('views', path.join(__dirname, 'oauth2views')); + app.set('view options', { layout: true, debug: true }); + app.set('view engine', 'ejs'); + + if (process.env.NODE_ENV === 'test') { + app.use(express.static(path.join(__dirname, '/../webadmin'))); + } else { + app.use(middleware.morgan('dev', { immediate: false })); + } + + var router = new express.Router(); + router.del = router.delete; // amend router.del for readability further on + + app + .use(middleware.timeout(REQUEST_TIMEOUT)) + .use(json) + .use(urlencoded) + .use(middleware.cookieParser()) + .use(middleware.favicon(paths.FAVICON_FILE)) // used when serving oauth login page + .use(middleware.cors({ origins: [ '*' ], allowCredentials: true })) + .use(middleware.session({ secret: 'yellow is blue', resave: true, saveUninitialized: true, cookie: { path: '/', httpOnly: true, secure: false, maxAge: 600000 } })) + .use(passport.initialize()) + .use(passport.session()) + .use(router) + .use(middleware.lastMile()); + + // NOTE: these limits have to be in sync with nginx limits + var FILE_SIZE_LIMIT = '1mb', // max file size that can be uploaded + FILE_TIMEOUT = 60 * 1000; // increased timeout for file uploads (1 min) + + var multipart = middleware.multipart({ maxFieldsSize: FIELD_LIMIT, limit: FILE_SIZE_LIMIT, timeout: FILE_TIMEOUT }); + + // scope middleware implicitly also adds bearer token verification + var rootScope = routes.oauth2.scope('root'); + var profileScope = routes.oauth2.scope('profile'); + var usersScope = routes.oauth2.scope('users'); + var appsScope = routes.oauth2.scope('apps'); + var developerScope = routes.oauth2.scope('developer'); + var settingsScope = routes.oauth2.scope('settings'); + + // csrf protection + var csrf = routes.oauth2.csrf; + + // public routes + router.post('/api/v1/cloudron/activate', routes.cloudron.setupTokenAuth, routes.cloudron.activate); + router.get ('/api/v1/cloudron/progress', routes.cloudron.getProgress); + router.get ('/api/v1/cloudron/status', routes.cloudron.getStatus); + router.get ('/api/v1/cloudron/avatar', routes.settings.getCloudronAvatar); // this is a public alias for /api/v1/settings/cloudron_avatar + + // developer routes + router.post('/api/v1/developer', developerScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.developer.setEnabled); + router.get ('/api/v1/developer', developerScope, routes.developer.enabled, routes.developer.status); + router.post('/api/v1/developer/login', routes.developer.enabled, routes.developer.login); + + // private routes + router.get ('/api/v1/cloudron/config', rootScope, routes.cloudron.getConfig); + router.post('/api/v1/cloudron/update', rootScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.update); + router.get ('/api/v1/cloudron/reboot', rootScope, routes.cloudron.reboot); + router.post('/api/v1/cloudron/migrate', rootScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.cloudron.migrate); + router.post('/api/v1/cloudron/certificate', rootScope, multipart, routes.cloudron.setCertificate); + router.get ('/api/v1/cloudron/graphs', rootScope, routes.graphs.getGraphs); + + router.get ('/api/v1/profile', profileScope, routes.user.profile); + + router.get ('/api/v1/users', usersScope, routes.user.list); + router.post('/api/v1/users', usersScope, routes.user.requireAdmin, routes.user.create); + router.get ('/api/v1/users/:userId', usersScope, routes.user.info); + router.put ('/api/v1/users/:userId', usersScope, routes.user.verifyPassword, routes.user.update); + router.del ('/api/v1/users/:userId', usersScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.user.remove); + router.post('/api/v1/users/:userId/password', usersScope, routes.user.changePassword); // changePassword verifies password + router.post('/api/v1/users/:userId/admin', usersScope, routes.user.requireAdmin, routes.user.changeAdmin); + + // form based login routes used by oauth2 frame + router.get ('/api/v1/session/login', csrf, routes.oauth2.loginForm); + router.post('/api/v1/session/login', csrf, routes.oauth2.login); + router.get ('/api/v1/session/logout', routes.oauth2.logout); + router.get ('/api/v1/session/callback', routes.oauth2.callback); + router.get ('/api/v1/session/error', routes.oauth2.error); + router.get ('/api/v1/session/password/resetRequest.html', csrf, routes.oauth2.passwordResetRequestSite); + router.post('/api/v1/session/password/resetRequest', csrf, routes.oauth2.passwordResetRequest); + router.get ('/api/v1/session/password/sent.html', routes.oauth2.passwordSentSite); + router.get ('/api/v1/session/password/setup.html', csrf, routes.oauth2.passwordSetupSite); + router.get ('/api/v1/session/password/reset.html', csrf, routes.oauth2.passwordResetSite); + router.post('/api/v1/session/password/reset', csrf, routes.oauth2.passwordReset); + + // oauth2 routes + router.get ('/api/v1/oauth/dialog/authorize', routes.oauth2.authorization); + router.post('/api/v1/oauth/dialog/authorize/decision', csrf, routes.oauth2.decision); + router.post('/api/v1/oauth/token', routes.oauth2.token); + router.get ('/api/v1/oauth/clients', settingsScope, routes.clients.getAllByUserId); + router.post('/api/v1/oauth/clients', routes.developer.enabled, settingsScope, routes.clients.add); + router.get ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.get); + router.post('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.add); + router.put ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.update); + router.del ('/api/v1/oauth/clients/:clientId', routes.developer.enabled, settingsScope, routes.clients.del); + router.get ('/api/v1/oauth/clients/:clientId/tokens', settingsScope, routes.clients.getClientTokens); + router.del ('/api/v1/oauth/clients/:clientId/tokens', settingsScope, routes.clients.delClientTokens); + + // app routes + router.get ('/api/v1/apps', appsScope, routes.apps.getApps); + router.get ('/api/v1/apps/:id', appsScope, routes.apps.getApp); + router.get ('/api/v1/apps/:id/icon', appsScope, routes.apps.getAppIcon); + + router.post('/api/v1/apps/install', appsScope, routes.user.requireAdmin, routes.apps.installApp); + router.post('/api/v1/apps/:id/uninstall', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.uninstallApp); + router.post('/api/v1/apps/:id/configure', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.configureApp); + router.post('/api/v1/apps/:id/update', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.updateApp); + router.post('/api/v1/apps/:id/restore', appsScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.apps.restoreApp); + router.post('/api/v1/apps/:id/backup', appsScope, routes.user.requireAdmin, routes.apps.backupApp); + router.post('/api/v1/apps/:id/stop', appsScope, routes.user.requireAdmin, routes.apps.stopApp); + router.post('/api/v1/apps/:id/start', appsScope, routes.user.requireAdmin, routes.apps.startApp); + router.get ('/api/v1/apps/:id/logstream', appsScope, routes.user.requireAdmin, routes.apps.getLogStream); + router.get ('/api/v1/apps/:id/logs', appsScope, routes.user.requireAdmin, routes.apps.getLogs); + router.get ('/api/v1/apps/:id/exec', routes.developer.enabled, appsScope, routes.user.requireAdmin, routes.apps.exec); + + // subdomain routes + router.get ('/api/v1/subdomains/:subdomain', routes.apps.getAppBySubdomain); + + // settings routes + router.get ('/api/v1/settings/autoupdate_pattern', settingsScope, routes.settings.getAutoupdatePattern); + router.post('/api/v1/settings/autoupdate_pattern', settingsScope, routes.settings.setAutoupdatePattern); + router.get ('/api/v1/settings/cloudron_name', settingsScope, routes.settings.getCloudronName); + router.post('/api/v1/settings/cloudron_name', settingsScope, routes.settings.setCloudronName); + router.get ('/api/v1/settings/cloudron_avatar', settingsScope, routes.settings.getCloudronAvatar); + router.post('/api/v1/settings/cloudron_avatar', settingsScope, multipart, routes.settings.setCloudronAvatar); + + // backup routes + router.get ('/api/v1/backups', settingsScope, routes.backups.get); + router.post('/api/v1/backups', settingsScope, routes.backups.create); + + // upgrade handler + httpServer.on('upgrade', function (req, socket, head) { + if (req.headers['upgrade'] !== 'tcp') return req.end('Only TCP upgrades are possible'); + + // create a node response object for express + var res = new http.ServerResponse({}); + res.assignSocket(socket); + res.sendUpgradeHandshake = function () { // could extend express.response as well + socket.write('HTTP/1.1 101 TCP Handshake\r\n' + + 'Upgrade: tcp\r\n' + + 'Connection: Upgrade\r\n' + + '\r\n'); + }; + + // route through express middleware + app(req, res, function (error) { + if (error) { + console.error(error); + socket.destroy(); + } + }); + }); + + return httpServer; +} + +function initializeInternalExpressSync() { + var app = express(); + var httpServer = http.createServer(app); + + var QUERY_LIMIT = '10mb'; // max size for json and urlencoded queries + var REQUEST_TIMEOUT = 10000; // timeout for all requests + + var json = middleware.json({ strict: true, limit: QUERY_LIMIT }), // application/json + urlencoded = middleware.urlencoded({ extended: false, limit: QUERY_LIMIT }); // application/x-www-form-urlencoded + + app.use(middleware.morgan('dev', { immediate: false })); + + var router = new express.Router(); + router.del = router.delete; // amend router.del for readability further on + + app + .use(middleware.timeout(REQUEST_TIMEOUT)) + .use(json) + .use(urlencoded) + .use(router) + .use(middleware.lastMile()); + + // internal routes + router.post('/api/v1/backup', routes.internal.backup); + + return httpServer; +} + +function start(callback) { + assert.strictEqual(typeof callback, 'function'); + assert.strictEqual(gHttpServer, null, 'Server is already up and running.'); + + gHttpServer = initializeExpressSync(); + gInternalHttpServer = initializeInternalExpressSync(); + + async.series([ + auth.initialize, + database.initialize, + taskmanager.initialize, + cloudron.initialize, + mailer.initialize, + cron.initialize, + gHttpServer.listen.bind(gHttpServer, config.get('port'), '127.0.0.1'), + gInternalHttpServer.listen.bind(gInternalHttpServer, config.get('internalPort'), '127.0.0.1') + ], callback); +} + +function stop(callback) { + assert.strictEqual(typeof callback, 'function'); + + if (!gHttpServer) return callback(null); + + async.series([ + auth.uninitialize, + cloudron.uninitialize, + taskmanager.uninitialize, + cron.uninitialize, + mailer.uninitialize, + database.uninitialize, + gHttpServer.close.bind(gHttpServer), + gInternalHttpServer.close.bind(gInternalHttpServer) + ], function (error) { + if (error) console.error(error); + + gHttpServer = null; + gInternalHttpServer = null; + + callback(null); + }); +} diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 000000000..b7671b7b1 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,199 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + SettingsError: SettingsError, + + getAutoupdatePattern: getAutoupdatePattern, + setAutoupdatePattern: setAutoupdatePattern, + + getTimeZone: getTimeZone, + setTimeZone: setTimeZone, + + getCloudronName: getCloudronName, + setCloudronName: setCloudronName, + + getCloudronAvatar: getCloudronAvatar, + setCloudronAvatar: setCloudronAvatar, + + getDefaultSync: getDefaultSync, + getAll: getAll, + + AUTOUPDATE_PATTERN_KEY: 'autoupdate_pattern', + TIME_ZONE_KEY: 'time_zone', + CLOUDRON_NAME_KEY: 'cloudron_name', + + events: new (require('events').EventEmitter)() +}; + +var assert = require('assert'), + config = require('./config.js'), + CronJob = require('cron').CronJob, + DatabaseError = require('./databaseerror.js'), + paths = require('./paths.js'), + safe = require('safetydance'), + settingsdb = require('./settingsdb.js'), + util = require('util'), + _ = require('underscore'); + +var gDefaults = (function () { + var tz = safe.fs.readFileSync('/etc/timezone', 'utf8'); + tz = tz ? tz.trim() : 'America/Los_Angeles'; + + var result = { }; + result[exports.AUTOUPDATE_PATTERN_KEY] = '00 00 1,3,5,23 * * *'; + result[exports.TIME_ZONE_KEY] = tz; + result[exports.CLOUDRON_NAME_KEY] = 'Cloudron'; + + return result; +})(); + +if (config.TEST) { + // avoid noisy warnings during npm test + exports.events.setMaxListeners(100); +} + +function SettingsError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(SettingsError, Error); +SettingsError.INTERNAL_ERROR = 'Internal Error'; +SettingsError.NOT_FOUND = 'Not Found'; +SettingsError.BAD_FIELD = 'Bad Field'; + +function setAutoupdatePattern(pattern, callback) { + assert.strictEqual(typeof pattern, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (pattern !== 'never') { // check if pattern is valid + var job = safe.safeCall(function () { return new CronJob(pattern); }); + if (!job) return callback(new SettingsError(SettingsError.BAD_FIELD, 'Invalid pattern')); + } + + settingsdb.set(exports.AUTOUPDATE_PATTERN_KEY, pattern, function (error) { + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + exports.events.emit(exports.AUTOUPDATE_PATTERN_KEY, pattern); + + return callback(null); + }); +} + +function getAutoupdatePattern(callback) { + assert.strictEqual(typeof callback, 'function'); + + settingsdb.get(exports.AUTOUPDATE_PATTERN_KEY, function (error, pattern) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.AUTOUPDATE_PATTERN_KEY]); + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + callback(null, pattern); + }); +} + +function setTimeZone(tz, callback) { + assert.strictEqual(typeof tz, 'string'); + assert.strictEqual(typeof callback, 'function'); + + settingsdb.set(exports.TIME_ZONE_KEY, tz, function (error) { + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + exports.events.emit(exports.TIME_ZONE_KEY, tz); + + return callback(null); + }); +} + +function getTimeZone(callback) { + assert.strictEqual(typeof callback, 'function'); + + settingsdb.get(exports.TIME_ZONE_KEY, function (error, tz) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.AUTOUPDATE_PATTERN_KEY]); + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + callback(null, tz); + }); +} + +function getCloudronName(callback) { + assert.strictEqual(typeof callback, 'function'); + + settingsdb.get(exports.CLOUDRON_NAME_KEY, function (error, name) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, gDefaults[exports.CLOUDRON_NAME_KEY]); + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + callback(null, name); + }); +} + +function setCloudronName(name, callback) { + assert.strictEqual(typeof name, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (!name) return callback(new SettingsError(SettingsError.BAD_FIELD)); + + settingsdb.set(exports.CLOUDRON_NAME_KEY, name, function (error) { + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + exports.events.emit(exports.CLOUDRON_NAME_KEY, name); + + return callback(null); + }); +} + +function getCloudronAvatar(callback) { + assert.strictEqual(typeof callback, 'function'); + + var avatar = safe.fs.readFileSync(paths.CLOUDRON_AVATAR_FILE); + if (avatar) return callback(null, avatar); + + // try default fallback + avatar = safe.fs.readFileSync(paths.CLOUDRON_DEFAULT_AVATAR_FILE); + if (avatar) return callback(null, avatar); + + callback(new SettingsError(SettingsError.INTERNAL_ERROR, safe.error)); +} + +function setCloudronAvatar(avatar, callback) { + assert(util.isBuffer(avatar)); + assert.strictEqual(typeof callback, 'function'); + + if (!safe.fs.writeFileSync(paths.CLOUDRON_AVATAR_FILE, avatar)) { + return callback(new SettingsError(SettingsError.INTERNAL_ERROR, safe.error)); + } + + return callback(null); +} + +function getDefaultSync(name) { + assert.strictEqual(typeof name, 'string'); + + return gDefaults[name]; +} + +function getAll(callback) { + assert.strictEqual(typeof callback, 'function'); + + settingsdb.getAll(function (error, settings) { + if (error) return callback(new SettingsError(SettingsError.INTERNAL_ERROR, error)); + + var result = _.extend({ }, gDefaults); + settings.forEach(function (setting) { result[setting.name] = setting.value; }); + + callback(null, result); + }); +} diff --git a/src/settingsdb.js b/src/settingsdb.js new file mode 100644 index 000000000..6af39424a --- /dev/null +++ b/src/settingsdb.js @@ -0,0 +1,54 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + get: get, + getAll: getAll, + set: set, + _clear: clear +}; + +var assert = require('assert'), + database = require('./database.js'), + DatabaseError = require('./databaseerror'); + +function get(key, callback) { + assert.strictEqual(typeof key, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT * FROM settings WHERE name = ?', [ key ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0].value); + }); +} + +function getAll(callback) { + database.query('SELECT * FROM settings ORDER BY name', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function set(key, value, callback) { + assert.strictEqual(typeof key, 'string'); + assert(value === null || typeof value === 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('INSERT INTO settings (name, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value=VALUES(value)', [ key, value ], function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); // don't rely on affectedRows here since it gives 2 + + callback(null); + }); +} + +function clear(callback) { + database.query('DELETE FROM settings', function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(error); + }); +} diff --git a/src/shell.js b/src/shell.js new file mode 100644 index 000000000..47644cfc5 --- /dev/null +++ b/src/shell.js @@ -0,0 +1,58 @@ +'use strict'; + +exports = module.exports = { + sudo: sudo, + exec: exec +}; + +var assert = require('assert'), + child_process = require('child_process'), + debug = require('debug')('box:shell.js'), + once = require('once'), + util = require('util'); + +var SUDO = '/usr/bin/sudo'; + +function exec(tag, file, args, callback) { + assert.strictEqual(typeof tag, 'string'); + assert.strictEqual(typeof file, 'string'); + assert(util.isArray(args)); + assert.strictEqual(typeof callback, 'function'); + + callback = once(callback); // exit may or may not be called after an 'error' + + debug(tag + ' execFile: %s %s', file, args.join(' ')); + + var cp = child_process.spawn(file, args); + cp.stdout.on('data', function (data) { + debug(tag + ' (stdout): %s', data.toString('utf8')); + }); + + cp.stderr.on('data', function (data) { + debug(tag + ' (stderr): %s', data.toString('utf8')); + }); + + cp.on('exit', function (code, signal) { + if (code || signal) debug(tag + ' code: %s, signal: %s', code, signal); + + callback(code === 0 ? null : new Error(util.format('Exited with error %s signal %s', code, signal))); + }); + + cp.on('error', function (error) { + debug(tag + ' code: %s, signal: %s', error.code, error.signal); + callback(error); + }); + + return cp; +} + +function sudo(tag, args, callback) { + assert.strictEqual(typeof tag, 'string'); + assert(util.isArray(args)); + assert.strictEqual(typeof callback, 'function'); + + // -S makes sudo read stdin for password + var cp = exec(tag, SUDO, [ '-S' ].concat(args), callback); + cp.stdin.end(); +} + diff --git a/src/sysinfo.js b/src/sysinfo.js new file mode 100644 index 000000000..54caa1f48 --- /dev/null +++ b/src/sysinfo.js @@ -0,0 +1,28 @@ +'use strict'; + +exports = module.exports = { + getIp: getIp +}; + +var os = require('os'); + +var gCachedIp = null; + +function getIp() { + if (gCachedIp) return gCachedIp; + + var ifaces = os.networkInterfaces(); + for (var dev in ifaces) { + if (dev.match(/^(en|eth|wlp).*/) === null) continue; + + for (var i = 0; i < ifaces[dev].length; i++) { + if (ifaces[dev][i].family === 'IPv4') { + gCachedIp = ifaces[dev][i].address; + return gCachedIp; + } + } + } + + return null; +} + diff --git a/src/taskmanager.js b/src/taskmanager.js new file mode 100644 index 000000000..20f71cef4 --- /dev/null +++ b/src/taskmanager.js @@ -0,0 +1,102 @@ +'use strict'; + +exports = module.exports = { + initialize: initialize, + uninitialize: uninitialize, + + restartAppTask: restartAppTask +}; + +var appdb = require('./appdb.js'), + assert = require('assert'), + child_process = require('child_process'), + debug = require('debug')('box:taskmanager'), + locker = require('./locker.js'), + _ = require('underscore'); + +var gActiveTasks = { }; +var gPendingTasks = [ ]; + +// Task concurrency is 1 for two reasons: +// 1. The backup scripts (app and box) turn off swap after finish disregarding other backup processes +// 2. apptask getFreePort has race with multiprocess +var TASK_CONCURRENCY = 1; +var NOOP_CALLBACK = function (error) { console.error(error); }; + +function initialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + // resume app installs and uninstalls + appdb.getAll(function (error, apps) { + if (error) return callback(error); + + apps.forEach(function (app) { + debug('Creating process for %s (%s) with state %s', app.location, app.id, app.installationState); + startAppTask(app.id); + }); + + callback(null); + }); + + locker.on('unlocked', startNextTask); +} + +function uninitialize(callback) { + assert.strictEqual(typeof callback, 'function'); + + gPendingTasks = [ ]; // clear this first, otherwise stopAppTask will resume them + for (var appId in gActiveTasks) { + stopAppTask(appId); + } + + callback(null); +} + +function startNextTask() { + if (gPendingTasks.length === 0) return; + assert(Object.keys(gActiveTasks).length === 0); // since we allow only one task at a time + + startAppTask(gPendingTasks.shift()); +} + +function startAppTask(appId) { + assert.strictEqual(typeof appId, 'string'); + assert(!(appId in gActiveTasks)); + + var lockError = locker.lock(locker.OP_APPTASK); + + if (lockError || Object.keys(gActiveTasks).length >= TASK_CONCURRENCY) { + debug('Reached concurrency limit, queueing task for %s', appId); + gPendingTasks.push(appId); + return; + } + + gActiveTasks[appId] = child_process.fork(__dirname + '/apptask.js', [ appId ]); + gActiveTasks[appId].once('exit', function (code) { + debug('Task for %s completed with status %s', appId, code); + if (code && code !== 50) { // apptask crashed + appdb.update(appId, { installationState: appdb.ISTATE_ERROR, installationProgress: 'Apptask crashed with code ' + code }, NOOP_CALLBACK); + } + delete gActiveTasks[appId]; + locker.unlock(locker.OP_APPTASK); // unlock event will trigger next task + }); +} + +function stopAppTask(appId) { + assert.strictEqual(typeof appId, 'string'); + + if (gActiveTasks[appId]) { + debug('stopAppTask : Killing existing task of %s with pid %s: ', appId, gActiveTasks[appId].pid); + gActiveTasks[appId].kill(); // this will end up calling the 'exit' handler + delete gActiveTasks[appId]; + } else if (gPendingTasks.indexOf(appId) !== -1) { + debug('stopAppTask: Removing existing pending task : %s', appId); + gPendingTasks = _.without(gPendingTasks, appId); + } +} + +function restartAppTask(appId) { + stopAppTask(appId); + startAppTask(appId); +} + diff --git a/src/test/apps-test.js b/src/test/apps-test.js new file mode 100644 index 000000000..3f8a2482c --- /dev/null +++ b/src/test/apps-test.js @@ -0,0 +1,161 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var appdb = require('../appdb.js'), + apps = require('../apps.js'), + AppsError = apps.AppsError, + async = require('async'), + config = require('../config.js'), + constants = require('../constants.js'), + database = require('../database.js'), + expect = require('expect.js'); + +describe('Apps', function () { + var APP_0 = { + id: 'appid-0', + appStoreId: 'appStoreId-0', + installationState: appdb.ISTATE_PENDING_INSTALL, + installationProgress: null, + runState: null, + location: 'some-location-0', + manifest: { + version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0', + tcpPorts: { + PORT: { + description: 'this is a port that i expose', + containerPort: '1234' + } + } + }, + httpPort: null, + containerId: null, + portBindings: { PORT: 5678 }, + healthy: null, + accessRestriction: '' + }; + + before(function (done) { + async.series([ + database.initialize, + database._clear, + appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction) + ], done); + }); + + after(function (done) { + database._clear(done); + }); + + describe('validateHostname', function () { + it('does not allow admin subdomain', function () { + expect(apps._validateHostname(constants.ADMIN_LOCATION, 'cloudron.us')).to.be.an(Error); + }); + + it('cannot have >63 length subdomains', function () { + var s = ''; + for (var i = 0; i < 64; i++) s += 's'; + expect(apps._validateHostname(s, 'cloudron.us')).to.be.an(Error); + }); + + it('allows only alphanumerics and hypen', function () { + expect(apps._validateHostname('#2r', 'cloudron.us')).to.be.an(Error); + expect(apps._validateHostname('a%b', 'cloudron.us')).to.be.an(Error); + expect(apps._validateHostname('ab_', 'cloudron.us')).to.be.an(Error); + expect(apps._validateHostname('a.b', 'cloudron.us')).to.be.an(Error); + expect(apps._validateHostname('-ab', 'cloudron.us')).to.be.an(Error); + expect(apps._validateHostname('ab-', 'cloudron.us')).to.be.an(Error); + }); + + it('total length cannot exceed 255', function () { + var s = ''; + for (var i = 0; i < (255 - 'cloudron.us'.length); i++) s += 's'; + + expect(apps._validateHostname(s, 'cloudron.us')).to.be.an(Error); + }); + + it('allow valid domains', function () { + expect(apps._validateHostname('a', 'cloudron.us')).to.be(null); + expect(apps._validateHostname('a0-x', 'cloudron.us')).to.be(null); + expect(apps._validateHostname('01', 'cloudron.us')).to.be(null); + }); + }); + + describe('validatePortBindings', function () { + it('does not allow invalid host port', function () { + expect(apps._validatePortBindings({ port: -1 })).to.be.an(Error); + expect(apps._validatePortBindings({ port: 0 })).to.be.an(Error); + expect(apps._validatePortBindings({ port: 'text' })).to.be.an(Error); + expect(apps._validatePortBindings({ port: 65536 })).to.be.an(Error); + expect(apps._validatePortBindings({ port: 1024 })).to.be.an(Error); + }); + + it('does not allow ports not as part of manifest', function () { + expect(apps._validatePortBindings({ port: 1567 })).to.be.an(Error); + expect(apps._validatePortBindings({ port: 1567 }, { port3: null })).to.be.an(Error); + }); + + it('allows valid bindings', function () { + expect(apps._validatePortBindings({ port: 1025 }, { port: null })).to.be(null); + expect(apps._validatePortBindings({ + port1: 4033, + port2: 3242, + port3: 1234 + }, { port1: null, port2: null, port3: null })).to.be(null); + }); + }); + + describe('getters', function () { + it('cannot get invalid app', function (done) { + apps.get('nope', function (error, app) { + expect(error).to.be.ok(); + expect(error.reason).to.be(AppsError.NOT_FOUND); + done(); + }); + }); + + it('can get valid app', function (done) { + apps.get(APP_0.id, function (error, app) { + expect(error).to.be(null); + expect(app).to.be.ok(); + expect(app.iconUrl).to.be(null); + expect(app.fqdn).to.eql(APP_0.location + '-' + config.fqdn()); + done(); + }); + }); + + it('cannot getBySubdomain', function (done) { + apps.getBySubdomain('moang', function (error, app) { + expect(error).to.be.ok(); + expect(error.reason).to.be(AppsError.NOT_FOUND); + done(); + }); + }); + + it('can getBySubdomain', function (done) { + apps.getBySubdomain(APP_0.location, function (error, app) { + expect(error).to.be(null); + expect(app).to.be.ok(); + expect(app.iconUrl).to.eql(null); + expect(app.fqdn).to.eql(APP_0.location + '-' + config.fqdn()); + done(); + }); + }); + + it('can getAll', function (done) { + apps.getAll(function (error, apps) { + expect(error).to.be(null); + expect(apps).to.be.an(Array); + expect(apps[0].id).to.be(APP_0.id); + expect(apps[0].iconUrl).to.be(null); + expect(apps[0].fqdn).to.eql(APP_0.location + '-' + config.fqdn()); + done(); + }); + }); + }); +}); + diff --git a/src/test/apptask-test.js b/src/test/apptask-test.js new file mode 100644 index 000000000..81de7f0b6 --- /dev/null +++ b/src/test/apptask-test.js @@ -0,0 +1,212 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var addons = require('../addons.js'), + appdb = require('../appdb.js'), + apptask = require('../apptask.js'), + config = require('../config.js'), + database = require('../database.js'), + expect = require('expect.js'), + fs = require('fs'), + net = require('net'), + nock = require('nock'), + paths = require('../paths.js'), + sysinfo = require('../sysinfo.js'), + _ = require('underscore'); + +var MANIFEST = { + "id": "io.cloudron.test", + "author": "The Presidents Of the United States Of America", + "title": "test title", + "description": "test description", + "tagline": "test rocks", + "website": "http://test.cloudron.io", + "contactEmail": "support@cloudron.io", + "version": "0.1.0", + "manifestVersion": 1, + "dockerImage": "girish/test:0.2.0", + "healthCheckPath": "/", + "httpPort": 7777, + "tcpPorts": { + "ECHO_SERVER_PORT": { + "title": "Echo Server Port", + "description": "Echo server", + "containerPort": 7778 + } + }, + "addons": { + "oauth": { }, + "redis": { }, + "mysql": { }, + "postgresql": { } + } +}; + +var APP = { + id: 'appid', + appStoreId: 'appStoreId', + installationState: appdb.ISTATE_PENDING_INSTALL, + runState: null, + location: 'applocation', + manifest: MANIFEST, + containerId: null, + httpPort: 4567, + portBindings: null, + accessRestriction: '', + dnsRecordId: 'someDnsRecordId' +}; + +describe('apptask', function () { + before(function (done) { + config.set('version', '0.5.0'); + database.initialize(function (error) { + expect(error).to.be(null); + appdb.add(APP.id, APP.appStoreId, APP.manifest, APP.location, APP.portBindings, APP.accessRestriction, done); + }); + }); + + after(function (done) { + database._clear(done); + }); + + it('initializes succesfully', function (done) { + apptask.initialize(done); + }); + + it('free port', function (done) { + apptask._getFreePort(function (error, port) { + expect(error).to.be(null); + expect(port).to.be.a('number'); + var client = net.connect(port); + client.on('connect', function () { done(new Error('Port is not free:' + port)); }); + client.on('error', function (error) { done(); }); + }); + }); + + it('configure nginx correctly', function (done) { + apptask._configureNginx(APP, function (error) { + expect(fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP.id + '.conf')); + // expect(error).to.be(null); // this fails because nginx cannot be restarted + done(); + }); + }); + + it('unconfigure nginx', function (done) { + apptask._unconfigureNginx(APP, function (error) { + expect(!fs.existsSync(paths.NGINX_APPCONFIG_DIR + '/' + APP.id + '.conf')); + // expect(error).to.be(null); // this fails because nginx cannot be restarted + done(); + }); + }); + + it('create volume', function (done) { + apptask._createVolume(APP, function (error) { + expect(fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true); + expect(error).to.be(null); + done(); + }); + }); + + it('delete volume', function (done) { + apptask._deleteVolume(APP, function (error) { + expect(!fs.existsSync(paths.DATA_DIR + '/' + APP.id + '/data')).to.be(true); + expect(error).to.be(null); + done(); + }); + }); + + it('allocate OAuth credentials', function (done) { + addons._allocateOAuthCredentials(APP, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('remove OAuth credentials', function (done) { + addons._removeOAuthCredentials(APP, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('remove OAuth credentials twice succeeds', function (done) { + addons._removeOAuthCredentials(APP, function (error) { + expect(!error).to.be.ok(); + done(); + }); + }); + + it('barfs on empty manifest', function (done) { + var badApp = _.extend({ }, APP); + badApp.manifest = { }; + + apptask._verifyManifest(badApp, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('barfs on bad manifest', function (done) { + var badApp = _.extend({ }, APP); + badApp.manifest = _.extend({ }, APP.manifest); + delete badApp.manifest['id']; + + apptask._verifyManifest(badApp, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('barfs on incompatible manifest', function (done) { + var badApp = _.extend({ }, APP); + badApp.manifest = _.extend({ }, APP.manifest); + badApp.manifest.maxBoxVersion = '0.0.0'; // max box version is too small + + apptask._verifyManifest(badApp, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('verifies manifest', function (done) { + var goodApp = _.extend({ }, APP); + + apptask._verifyManifest(goodApp, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('registers subdomain', function (done) { + nock.cleanAll(); + var scope = nock(config.apiServerOrigin()) + .post('/api/v1/subdomains?token=' + config.token(), { records: [ { subdomain: APP.location, type: 'A', value: sysinfo.getIp() } ] }) + .reply(201, { ids: [ APP.dnsRecordId ] }); + + apptask._registerSubdomain(APP, function (error) { + expect(error).to.be(null); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); + + it('unregisters subdomain', function (done) { + nock.cleanAll(); + var scope = nock(config.apiServerOrigin()) + .delete('/api/v1/subdomains/' + APP.dnsRecordId + '?token=' + config.token()) + .reply(204, {}); + + apptask._unregisterSubdomain(APP, function (error) { + expect(error).to.be(null); + expect(scope.isDone()).to.be.ok(); + done(); + }); + }); +}); + + diff --git a/src/test/checkInstall b/src/test/checkInstall new file mode 100755 index 000000000..76cdfe4af --- /dev/null +++ b/src/test/checkInstall @@ -0,0 +1,58 @@ +#!/bin/bash + +set -eu + +readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# reset sudo timestamp to avoid wrong success +sudo -k || sudo --reset-timestamp + +# checks if all scripts are sudo access +scripts=("${SOURCE_DIR}/scripts/rmappdir.sh" \ + "${SOURCE_DIR}/scripts/createappdir.sh" \ + "${SOURCE_DIR}/scripts/reloadnginx.sh" \ + "${SOURCE_DIR}/scripts/backupbox.sh" \ + "${SOURCE_DIR}/scripts/backupapp.sh" \ + "${SOURCE_DIR}/scripts/restoreapp.sh" \ + "${SOURCE_DIR}/scripts/reboot.sh" \ + "${SOURCE_DIR}/scripts/backupswap.sh" \ + "${SOURCE_DIR}/scripts/reloadcollectd.sh") + +for script in "${scripts[@]}"; do + if [[ $(sudo -n "${script}" --check 2>/dev/null) != "OK" ]]; then + echo "" + echo "${script} does not have sudo access." + echo "You have to add the lines below to /etc/sudoers.d/yellowtent." + echo "" + echo "Defaults!${script} env_keep=\"HOME NODE_ENV\"" + echo "${USER} ALL=(ALL) NOPASSWD: ${script}" + echo "" + exit 1 + fi +done + +if ! docker inspect girish/test:0.2.0 >/dev/null 2>/dev/null; then + echo "docker pull girish/test:0.2.0 for tests to run" + exit 1 +fi + +if ! docker inspect cloudron/redis:0.3.0 >/dev/null 2>/dev/null; then + echo "docker pull cloudron/redis:0.3.0 for tests to run" + exit 1 +fi + +if ! docker inspect cloudron/mysql:0.3.0 >/dev/null 2>/dev/null; then + echo "docker pull cloudron/mysql:0.3.0 for tests to run" + exit 1 +fi + +if ! docker inspect cloudron/postgresql:0.3.0 >/dev/null 2>/dev/null; then + echo "docker pull cloudron/postgresql:0.3.0 for tests to run" + exit 1 +fi + +if ! docker inspect cloudron/mongodb:0.3.0 >/dev/null 2>/dev/null; then + echo "docker pull cloudron/mongodb:0.3.0 for tests to run" + exit 1 +fi + diff --git a/src/test/config-test.js b/src/test/config-test.js new file mode 100644 index 000000000..8eb268660 --- /dev/null +++ b/src/test/config-test.js @@ -0,0 +1,95 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global after:false */ +/* global before:false */ + +'use strict'; + +var constants = require('../constants.js'), + expect = require('expect.js'), + fs = require('fs'), + path = require('path'); + +var config = null; + +describe('config', function () { + before(function () { + delete require.cache[require.resolve('../config.js')]; + config = require('../config.js'); + }); + + after(function () { + delete require.cache[require.resolve('../config.js')]; + }); + + it('baseDir() is set', function (done) { + expect(config.baseDir()).to.be.ok(); + done(); + }); + + it('cloudron.conf generated automatically', function (done) { + expect(fs.existsSync(path.join(config.baseDir(), 'configs/cloudron.conf'))).to.be.ok(); + done(); + }); + + it('did set default values', function () { + expect(config.isCustomDomain()).to.equal(false); + expect(config.fqdn()).to.equal('localhost'); + expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '-localhost'); + expect(config.appFqdn('app')).to.equal('app-localhost'); + expect(config.zoneName()).to.equal('localhost'); + }); + + it('set saves value in file', function (done) { + config.set('foobar', 'somevalue'); + expect(JSON.parse(fs.readFileSync(path.join(config.baseDir(), 'configs/cloudron.conf'))).foobar).to.eql('somevalue'); + done(); + }); + + it('set - simple key value', function (done) { + config.set('foobar', 'somevalue2'); + expect(config.get('foobar')).to.eql('somevalue2'); + done(); + }); + + it('set - object', function (done) { + config.set( { fqdn: 'something.com' } ); + expect(config.fqdn()).to.eql('something.com'); + done(); + }); + + it('uses dotted locations with custom domain', function () { + config.set('fqdn', 'example.com'); + config.set('isCustomDomain', true); + + expect(config.isCustomDomain()).to.equal(true); + expect(config.fqdn()).to.equal('example.com'); + expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '.example.com'); + expect(config.appFqdn('app')).to.equal('app.example.com'); + expect(config.zoneName()).to.equal('example.com'); + }); + + it('uses hyphen locations with non-custom domain', function () { + config.set('fqdn', 'test.example.com'); + config.set('isCustomDomain', false); + + expect(config.isCustomDomain()).to.equal(false); + expect(config.fqdn()).to.equal('test.example.com'); + expect(config.adminOrigin()).to.equal('https://' + constants.ADMIN_LOCATION + '-test.example.com'); + expect(config.appFqdn('app')).to.equal('app-test.example.com'); + expect(config.zoneName()).to.equal('example.com'); + }); + + it('can set arbitrary values', function (done) { + config.set('random', 'value'); + expect(config.get('random')).to.equal('value'); + + config.set('this.is.madness', 42); + expect(config.get('this.is.madness')).to.equal(42); + + done(); + }); + +}); + diff --git a/src/test/database-test.js b/src/test/database-test.js new file mode 100644 index 000000000..d5ab09dce --- /dev/null +++ b/src/test/database-test.js @@ -0,0 +1,933 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var appdb = require('../appdb.js'), + authcodedb = require('../authcodedb.js'), + clientdb = require('../clientdb.js'), + hat = require('hat'), + database = require('../database'), + DatabaseError = require('../databaseerror.js'), + expect = require('expect.js'), + async = require('async'), + settingsdb = require('../settingsdb.js'), + tokendb = require('../tokendb.js'), + userdb = require('../userdb.js'); + +describe('database', function () { + before(function (done) { + async.series([ + database.initialize, + database._clear + ], done); + }); + + after(function (done) { + database._clear(done); + }); + + describe('userdb', function () { + var USER_0 = { + id: 'uuid213', + username: 'uuid213', + password: 'secret', + email: 'safe@me.com', + admin: false, + salt: 'morton', + createdAt: 'sometime back', + modifiedAt: 'now', + resetToken: hat(256) + }; + + var ADMIN_0 = { + id: 'uuid456', + username: 'uuid456', + password: 'secret', + email: 'safe2@me.com', + admin: true, + salt: 'tata', + createdAt: 'sometime back', + modifiedAt: 'now', + resetToken: '' + }; + + it('can add user', function (done) { + userdb.add(USER_0.id, USER_0, function (error) { + expect(!error).to.be.ok(); + done(); + }); + }); + + it('can add admin user', function (done) { + userdb.add(ADMIN_0.id, ADMIN_0, function (error) { + expect(!error).to.be.ok(); + done(); + }); + }); + + it('cannot add same user again', function (done) { + userdb.add(USER_0.id, USER_0, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS); + done(); + }); + }); + + it('can get by user id', function (done) { + userdb.get(USER_0.id, function (error, user) { + expect(error).to.not.be.ok(); + expect(user).to.eql(USER_0); + done(); + }); + }); + + it('can get by user name', function (done) { + userdb.getByUsername(USER_0.username, function (error, user) { + expect(error).to.not.be.ok(); + expect(user).to.eql(USER_0); + done(); + }); + }); + + it('can get by email', function (done) { + userdb.getByEmail(USER_0.email, function (error, user) { + expect(error).to.not.be.ok(); + expect(user).to.eql(USER_0); + done(); + }); + }); + + it('can get by resetToken fails for empty resetToken', function (done) { + userdb.getByResetToken('', function (error, user) { + expect(error).to.be.ok(); + expect(error.reason).to.be(DatabaseError.INTERNAL_ERROR); + expect(user).to.not.be.ok(); + done(); + }); + }); + + it('can get by resetToken', function (done) { + userdb.getByResetToken(USER_0.resetToken, function (error, user) { + expect(error).to.not.be.ok(); + expect(user).to.eql(USER_0); + done(); + }); + }); + + it('can get all', function (done) { + userdb.getAll(function (error, all) { + expect(error).to.not.be.ok(); + expect(all.length).to.equal(2); + expect(all[0]).to.eql(USER_0); + expect(all[1]).to.eql(ADMIN_0); + done(); + }); + }); + + it('can get all admins', function (done) { + userdb.getAllAdmins(function (error, all) { + expect(error).to.not.be.ok(); + expect(all.length).to.equal(1); + expect(all[0]).to.eql(ADMIN_0); + done(); + }); + }); + + it('counts the users', function (done) { + userdb.count(function (error, count) { + expect(error).to.not.be.ok(); + expect(count).to.equal(2); + done(); + }); + }); + + it('counts the admin users', function (done) { + userdb.adminCount(function (error, count) { + expect(error).to.not.be.ok(); + expect(count).to.equal(1); + done(); + }); + }); + + it('can update the user', function (done) { + userdb.update(USER_0.id, { email: 'some@thing.com' }, function (error) { + expect(error).to.not.be.ok(); + userdb.get(USER_0.id, function (error, user) { + expect(user.email).to.equal('some@thing.com'); + done(); + }); + }); + }); + + it('cannot update with null field', function (done) { + userdb.update(USER_0.id, { email: null }, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('cannot del non-existing user', function (done) { + userdb.del(USER_0.id + USER_0.id, function (error) { + expect(error).to.be.ok(); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + done(); + }); + }); + + it('can del existing user', function (done) { + userdb.del(USER_0.id, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('did remove the user', function (done) { + userdb.count(function (error, count) { + expect(count).to.equal(1); + done(); + }); + }); + + it('can clear table', function (done) { + userdb._clear(function (error) { + expect(error).to.not.be.ok(); + userdb.count(function (error, count) { + expect(error).to.not.be.ok(); + expect(count).to.equal(0); + done(); + }); + }); + }); + }); + + describe('authcode', function () { + var AUTHCODE_0 = { + authCode: 'authcode-0', + clientId: 'clientid-0', + userId: 'userid-0', + expiresAt: Date.now() + 5000 + }; + var AUTHCODE_1 = { + authCode: 'authcode-1', + clientId: 'clientid-1', + userId: 'userid-1', + expiresAt: Date.now() + 5000 + }; + var AUTHCODE_2 = { + authCode: 'authcode-2', + clientId: 'clientid-2', + userId: 'userid-2', + expiresAt: Date.now() + }; + + it('add fails due to missing arguments', function () { + expect(function () { authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId); }).to.throwError(); + expect(function () { authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, function () {}); }).to.throwError(); + expect(function () { authcodedb.add(AUTHCODE_0.authCode, function () {}); }).to.throwError(); + }); + + it('add succeeds', function (done) { + authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId, AUTHCODE_0.expiresAt, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('add of same authcode fails', function (done) { + authcodedb.add(AUTHCODE_0.authCode, AUTHCODE_0.clientId, AUTHCODE_0.userId, AUTHCODE_0.expiresAt, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS); + done(); + }); + }); + + it('get succeeds', function (done) { + authcodedb.get(AUTHCODE_0.authCode, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an('object'); + expect(result).to.be.eql(AUTHCODE_0); + done(); + }); + }); + + it('get of nonexisting code fails', function (done) { + authcodedb.get(AUTHCODE_1.authCode, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + + it('get of expired code fails', function (done) { + authcodedb.add(AUTHCODE_2.authCode, AUTHCODE_2.clientId, AUTHCODE_2.userId, AUTHCODE_2.expiresAt, function (error) { + expect(error).to.be(null); + + authcodedb.get(AUTHCODE_2.authCode, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + }); + + it('delExpired succeeds', function (done) { + authcodedb.delExpired(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.eql(1); + + authcodedb.get(AUTHCODE_2.authCode, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + }); + + it('delete succeeds', function (done) { + authcodedb.del(AUTHCODE_0.authCode, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('cannot delete previously delete record', function (done) { + authcodedb.del(AUTHCODE_0.authCode, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + done(); + }); + }); + }); + + describe('token', function () { + var TOKEN_0 = { + accessToken: tokendb.generateToken(), + identifier: tokendb.PREFIX_USER + '0', + clientId: 'clientid-0', + expires: Date.now() + 60 * 60000, + scope: '*' + }; + var TOKEN_1 = { + accessToken: tokendb.generateToken(), + identifier: tokendb.PREFIX_USER + '1', + clientId: 'clientid-1', + expires: Number.MAX_SAFE_INTEGER, + scope: '*' + }; + var TOKEN_2 = { + accessToken: tokendb.generateToken(), + identifier: tokendb.PREFIX_USER + '2', + clientId: 'clientid-2', + expires: Date.now(), + scope: '*' + }; + + it('add fails due to missing arguments', function () { + expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.scope); }).to.throwError(); + expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, function () {}); }).to.throwError(); + expect(function () { tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, function () {}); }).to.throwError(); + expect(function () { tokendb.add(TOKEN_0.accessToken, function () {}); }).to.throwError(); + }); + + it('add succeeds', function (done) { + tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('add of same token fails', function (done) { + tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS); + done(); + }); + }); + + it('get succeeds', function (done) { + tokendb.get(TOKEN_0.accessToken, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an('object'); + expect(result).to.be.eql(TOKEN_0); + done(); + }); + }); + + it('get of nonexisting token fails', function (done) { + tokendb.get(TOKEN_1.accessToken, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + + it('getByIdentifier succeeds', function (done) { + tokendb.getByIdentifier(TOKEN_0.identifier, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result.length).to.equal(1); + expect(result[0]).to.be.an('object'); + expect(result[0]).to.be.eql(TOKEN_0); + done(); + }); + }); + + it('delete succeeds', function (done) { + tokendb.del(TOKEN_0.accessToken, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('getByIdentifier succeeds after token deletion', function (done) { + tokendb.getByIdentifier(TOKEN_0.identifier, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result.length).to.equal(0); + done(); + }); + }); + + it('delByIdentifier succeeds', function (done) { + tokendb.add(TOKEN_1.accessToken, TOKEN_1.identifier, TOKEN_1.clientId, TOKEN_1.expires, TOKEN_1.scope, function (error) { + expect(error).to.be(null); + + tokendb.delByIdentifier(TOKEN_1.identifier, function (error) { + expect(error).to.be(null); + done(); + }); + }); + }); + + it('cannot delete previously delete record', function (done) { + tokendb.del(TOKEN_0.accessToken, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + done(); + }); + }); + + it('getByIdentifierAndClientId succeeds', function (done) { + tokendb.add(TOKEN_0.accessToken, TOKEN_0.identifier, TOKEN_0.clientId, TOKEN_0.expires, TOKEN_0.scope, function (error) { + expect(error).to.be(null); + + tokendb.getByIdentifierAndClientId(TOKEN_0.identifier, TOKEN_0.clientId, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result.length).to.equal(1); + expect(result[0]).to.eql(TOKEN_0); + done(); + }); + }); + }); + + it('delExpired succeeds', function (done) { + tokendb.add(TOKEN_2.accessToken, TOKEN_2.identifier, TOKEN_2.clientId, TOKEN_2.expires, TOKEN_2.scope, function (error) { + expect(error).to.be(null); + + tokendb.delExpired(function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.eql(1); + + tokendb.get(TOKEN_2.accessToken, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + }); + }); + + it('delByIdentifierAndClientId succeeds', function (done) { + tokendb.delByIdentifierAndClientId(TOKEN_0.identifier, TOKEN_0.clientId, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('get of previously deleted token fails', function (done) { + tokendb.get(TOKEN_0.accessToken, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + }); + + describe('app', function () { + var APP_0 = { + id: 'appid-0', + appStoreId: 'appStoreId-0', + dnsRecordId: null, + installationState: appdb.ISTATE_PENDING_INSTALL, + installationProgress: null, + runState: null, + location: 'some-location-0', + manifest: { version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0' }, + httpPort: null, + containerId: null, + portBindings: { port: 5678 }, + health: null, + accessRestriction: '', + lastBackupId: null, + lastBackupConfig: null, + oldConfig: null + }; + var APP_1 = { + id: 'appid-1', + appStoreId: 'appStoreId-1', + dnsRecordId: null, + installationState: appdb.ISTATE_PENDING_INSTALL, // app health tests rely on this initial state + installationProgress: null, + runState: null, + location: 'some-location-1', + manifest: { version: '0.2', dockerImage: 'docker/app1', healthCheckPath: '/', httpPort: 80, title: 'app1' }, + httpPort: null, + containerId: null, + portBindings: { }, + health: null, + accessRestriction: 'roleAdmin', + lastBackupId: null, + lastBackupConfig: null, + oldConfig: null + }; + + it('add fails due to missing arguments', function () { + expect(function () { appdb.add(APP_0.id, APP_0.manifest, APP_0.installationState, function () {}); }).to.throwError(); + expect(function () { appdb.add(APP_0.id, function () {}); }).to.throwError(); + }); + + it('exists returns false', function (done) { + appdb.exists(APP_0.id, function (error, exists) { + expect(error).to.be(null); + expect(exists).to.be(false); + done(); + }); + }); + + it('add succeeds', function (done) { + appdb.add(APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('exists succeeds', function (done) { + appdb.exists(APP_0.id, function (error, exists) { + expect(error).to.be(null); + expect(exists).to.be(true); + done(); + }); + }); + + it('getPortBindings succeeds', function (done) { + appdb.getPortBindings(APP_0.id, function (error, bindings) { + expect(error).to.be(null); + expect(bindings).to.be.an(Object); + expect(bindings).to.be.eql({ port: '5678' }); + done(); + }); + }); + + it('add of same app fails', function (done) { + appdb.add(APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, [ ], APP_0.accessRestriction, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.ALREADY_EXISTS); + done(); + }); + }); + + it('get succeeds', function (done) { + appdb.get(APP_0.id, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an('object'); + expect(result).to.be.eql(APP_0); + done(); + }); + }); + + it('get of nonexisting code fails', function (done) { + appdb.get(APP_1.id, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + + it('update succeeds', function (done) { + APP_0.installationState = 'some-other-status'; + APP_0.location = 'some-other-location'; + APP_0.manifest.version = '0.2'; + APP_0.accessRestriction = true; + APP_0.httpPort = 1337; + + appdb.update(APP_0.id, { installationState: APP_0.installationState, location: APP_0.location, manifest: APP_0.manifest, accessRestriction: APP_0.accessRestriction, httpPort: APP_0.httpPort }, function (error) { + expect(error).to.be(null); + + appdb.get(APP_0.id, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an('object'); + expect(result).to.be.eql(APP_0); + done(); + }); + }); + }); + + it('getByHttpPort succeeds', function (done) { + appdb.getByHttpPort(APP_0.httpPort, function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an('object'); + expect(result).to.be.eql(APP_0); + done(); + }); + }); + + it('update of nonexisting app fails', function (done) { + appdb.update(APP_1.id, { installationState: APP_1.installationState, location: APP_1.location }, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + done(); + }); + }); + + it('add second app succeeds', function (done) { + appdb.add(APP_1.id, APP_1.appStoreId, APP_1.manifest, APP_1.location, [ ], APP_1.accessRestriction, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('getAll succeeds', function (done) { + appdb.getAll(function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result.length).to.be(2); + expect(result[0]).to.be.eql(APP_0); + expect(result[1]).to.be.eql(APP_1); + done(); + }); + }); + + it('getAppStoreIds succeeds', function (done) { + appdb.getAppStoreIds(function (error, results) { + expect(error).to.be(null); + expect(results).to.be.an(Array); + expect(results.length).to.be(2); + expect(results[0].appStoreId).to.equal(APP_0.appStoreId); + expect(results[1].appStoreId).to.equal(APP_1.appStoreId); + done(); + }); + }); + + it('delete succeeds', function (done) { + appdb.del(APP_0.id, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('getPortBindings should be empty', function (done) { + appdb.getPortBindings(APP_0.id, function (error, bindings) { + expect(error).to.be(null); + expect(bindings).to.be.an(Object); + expect(bindings).to.be.eql({ }); + done(); + }); + }); + + it('cannot delete previously delete record', function (done) { + appdb.del(APP_0.id, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.be(DatabaseError.NOT_FOUND); + done(); + }); + }); + + it('cannot set app as healthy because app is not installed', function (done) { + appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('cannot set app as healthy because app has pending run state', function (done) { + appdb.update(APP_1.id, { runState: appdb.RSTATE_PENDING_STOP, installationState: appdb.ISTATE_INSTALLED }, function (error) { + expect(error).to.be(null); + + appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + }); + + it('cannot set app as healthy because app has null run state', function (done) { + appdb.update(APP_1.id, { runState: null, installationState: appdb.ISTATE_INSTALLED }, function (error) { + expect(error).to.be(null); + + appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + }); + + it('can set app as healthy when installed and no pending runState', function (done) { + appdb.update(APP_1.id, { runState: appdb.RSTATE_RUNNING, installationState: appdb.ISTATE_INSTALLED }, function (error) { + expect(error).to.be(null); + + appdb.setHealth(APP_1.id, appdb.HEALTH_HEALTHY, function (error) { + expect(error).to.be(null); + appdb.get(APP_1.id, function (error, app) { + expect(error).to.be(null); + expect(app.health).to.be(appdb.HEALTH_HEALTHY); + done(); + }); + }); + }); + }); + + it('cannot set health of unknown app', function (done) { + appdb.setHealth('randomId', appdb.HEALTH_HEALTHY, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('return empty addon config array for invalid app', function (done) { + appdb.getAddonConfigByAppId('randomid', function (error, results) { + expect(error).to.be(null); + expect(results).to.eql([ ]); + done(); + }); + }); + + it('setAddonConfig succeeds', function (done) { + appdb.setAddonConfig(APP_1.id, 'addonid1', [ 'ENV1=env', 'ENV2=env' ], function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('setAddonConfig succeeds', function (done) { + appdb.setAddonConfig(APP_1.id, 'addonid2', [ 'ENV3=env' ], function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('getAddonConfig succeeds', function (done) { + appdb.getAddonConfig(APP_1.id, 'addonid1', function (error, results) { + expect(error).to.be(null); + expect(results).to.eql([ 'ENV1=env', 'ENV2=env' ]); + done(); + }); + }); + + it('getAddonConfigByAppId succeeds', function (done) { + appdb.getAddonConfigByAppId(APP_1.id, function (error, results) { + expect(error).to.be(null); + expect(results).to.eql([ 'ENV1=env', 'ENV2=env', 'ENV3=env' ]); + done(); + }); + }); + + it('unsetAddonConfig succeeds', function (done) { + appdb.unsetAddonConfig(APP_1.id, 'addonid1', function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('unsetAddonConfig did remove configs', function (done) { + appdb.getAddonConfigByAppId(APP_1.id, function (error, results) { + expect(error).to.be(null); + expect(results).to.eql([ 'ENV3=env' ]); + done(); + }); + }); + + it('unsetAddonConfigByAppId succeeds', function (done) { + appdb.unsetAddonConfigByAppId(APP_1.id, function (error) { + expect(error).to.be(null); + done(); + }); + }); + + it('unsetAddonConfigByAppId did remove configs', function (done) { + appdb.getAddonConfigByAppId(APP_1.id, function (error, results) { + expect(error).to.be(null); + expect(results).to.eql([ ]); + done(); + }); + }); + }); + + describe('client', function () { + var CLIENT_0 = { + id: 'cid-0', + appId: 'someappid_0', + clientSecret: 'secret-0', + redirectURI: 'http://foo.bar', + scope: '*' + + }; + var CLIENT_1 = { + id: 'cid-1', + appId: 'someappid_1', + clientSecret: 'secret-', + redirectURI: 'http://foo.bar', + scope: '*' + }; + var CLIENT_2 = { + id: 'cid-2', + appId: 'someappid_2', + clientSecret: 'secret-2', + redirectURI: 'http://foo.bar.baz', + scope: 'profile,roleUser' + }; + + it('add succeeds', function (done) { + clientdb.add(CLIENT_0.id, CLIENT_0.appId, CLIENT_0.clientSecret, CLIENT_0.redirectURI, CLIENT_0.scope, function (error) { + expect(error).to.be(null); + + clientdb.add(CLIENT_1.id, CLIENT_1.appId, CLIENT_1.clientSecret, CLIENT_1.redirectURI, CLIENT_1.scope, function (error) { + expect(error).to.be(null); + done(); + }); + }); + }); + + it('add same client id fails', function (done) { + clientdb.add(CLIENT_0.id, CLIENT_0.appId, CLIENT_0.clientSecret, CLIENT_0.redirectURI, CLIENT_0.scope, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.equal(DatabaseError.ALREADY_EXISTS); + done(); + }); + }); + + it('get succeeds', function (done) { + clientdb.get(CLIENT_0.id, function (error, result) { + expect(error).to.be(null); + expect(result).to.eql(CLIENT_0); + done(); + }); + }); + + it('getByAppId succeeds', function (done) { + clientdb.getByAppId(CLIENT_0.appId, function (error, result) { + expect(error).to.be(null); + expect(result).to.eql(CLIENT_0); + done(); + }); + }); + + it('getByAppId fails for unknown client id', function (done) { + clientdb.getByAppId(CLIENT_0.appId + CLIENT_0.appId, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.equal(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + + it('getAll succeeds', function (done) { + clientdb.getAll(function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result.length).to.equal(3); // one of them is webadmin + expect(result[0]).to.eql(CLIENT_0); + expect(result[1]).to.eql(CLIENT_1); + done(); + }); + }); + + it('update client fails due to unknown client id', function (done) { + clientdb.update(CLIENT_2.id, CLIENT_2.appId, CLIENT_2.clientSecret, CLIENT_2.redirectURI, CLIENT_2.scope, function (error) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.equal(DatabaseError.NOT_FOUND); + done(); + }); + }); + + it('update client succeeds', function (done) { + clientdb.update(CLIENT_1.id, CLIENT_2.appId, CLIENT_2.clientSecret, CLIENT_2.redirectURI, CLIENT_2.scope, function (error) { + expect(error).to.be(null); + + clientdb.get(CLIENT_1.id, function (error, result) { + expect(error).to.be(null); + expect(result.appId).to.eql(CLIENT_2.appId); + expect(result.clientSecret).to.eql(CLIENT_2.clientSecret); + expect(result.redirectURI).to.eql(CLIENT_2.redirectURI); + expect(result.scope).to.eql(CLIENT_2.scope); + done(); + }); + }); + }); + + it('delByAppId succeeds', function (done) { + clientdb.delByAppId(CLIENT_0.appId, function (error) { + expect(error).to.be(null); + + clientdb.getByAppId(CLIENT_0.appId, function (error, result) { + expect(error).to.be.a(DatabaseError); + expect(error.reason).to.equal(DatabaseError.NOT_FOUND); + expect(result).to.not.be.ok(); + done(); + }); + }); + }); + }); + + describe('settings', function () { + it('can set value', function (done) { + settingsdb.set('somekey', 'somevalue', function (error) { + expect(error).to.be(null); + done(); + }); + }); + it('can get the set value', function (done) { + settingsdb.get('somekey', function (error, value) { + expect(error).to.be(null); + expect(value).to.be('somevalue'); + done(); + }); + }); + it('can get all values', function (done) { + settingsdb.getAll(function (error, result) { + expect(error).to.be(null); + expect(result).to.be.an(Array); + expect(result[0].name).to.be('somekey'); + expect(result[0].value).to.be('somevalue'); + expect(result.length).to.be(1); // the value set above + done(); + }); + }); + it('can update a value', function (done) { + settingsdb.set('somekey', 'someothervalue', function (error) { + expect(error).to.be(null); + done(); + }); + }); + it('can get updated value', function (done) { + settingsdb.get('somekey', function (error, value) { + expect(error).to.be(null); + expect(value).to.be('someothervalue'); + done(); + }); + }); + + }); +}); + diff --git a/src/test/server-test.js b/src/test/server-test.js new file mode 100644 index 000000000..629296d3c --- /dev/null +++ b/src/test/server-test.js @@ -0,0 +1,273 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var progress = require('../progress.js'), + config = require('../config.js'), + database = require('../database.js'), + expect = require('expect.js'), + nock = require('nock'), + request = require('superagent'), + server = require('../server.js'); + +var SERVER_URL = 'http://localhost:' + config.get('port'); + +function cleanup(done) { + done(); +} + +describe('Server', function () { + this.timeout(5000); + + before(function () { + config.set('version', '0.5.0'); + }); + + after(cleanup); + + describe('startup', function () { + it('start fails due to wrong arguments', function (done) { + expect(function () { server.start(); }).to.throwException(); + expect(function () { server.start('foobar', function () {}); }).to.throwException(); + expect(function () { server.start(1337, function () {}); }).to.throwException(); + + done(); + }); + + it('succeeds', function (done) { + server.start(function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('is reachable', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/status', function (err, res) { + expect(res.statusCode).to.equal(200); + done(err); + }); + }); + + it('should fail because already running', function (done) { + expect(server.start).to.throwException(function () { + done(); + }); + }); + + after(function (done) { + server.stop(function () { + done(); + }); + }); + }); + + describe('runtime', function () { + before(function (done) { + server.start(done); + }); + + after(function (done) { + database._clear(function (error) { + expect(!error).to.be.ok(); + server.stop(function () { + done(); + }); + }); + }); + + it('random bad requests', function (done) { + request.get(SERVER_URL + '/random', function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(404); + done(err); + }); + }); + + it('version', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/status', function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(200); + expect(res.body.version).to.equal('0.5.0'); + done(err); + }); + }); + + it('status route is GET', function (done) { + request.post(SERVER_URL + '/api/v1/cloudron/status') + .end(function (err, res) { + expect(res.statusCode).to.equal(404); + + request.get(SERVER_URL + '/api/v1/cloudron/status') + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(err); + }); + }); + }); + }); + + describe('config', function () { + before(function (done) { + server.start(done); + }); + + after(function (done) { + server.stop(function () { + done(); + }); + }); + + it('config fails due missing token', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/config', function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + + it('config fails due wrong token', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/config').query({ access_token: 'somewrongtoken' }).end(function (err, res) { + expect(err).to.not.be.ok(); + expect(res.statusCode).to.equal(401); + done(err); + }); + }); + }); + + describe('progress', function () { + before(function (done) { + server.start(done); + }); + + after(function (done) { + server.stop(function () { + done(); + }); + }); + + it('succeeds with no progress', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.update).to.be(null); + expect(result.body.backup).to.be(null); + done(); + }); + }); + + it('succeeds with update progress', function (done) { + progress.set(progress.UPDATE, 13, 'This is some status string'); + + request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.update).to.be.an('object'); + expect(result.body.update.percent).to.be.a('number'); + expect(result.body.update.percent).to.equal(13); + expect(result.body.update.message).to.be.a('string'); + expect(result.body.update.message).to.equal('This is some status string'); + + expect(result.body.backup).to.be(null); + done(); + }); + }); + + it('succeeds with no progress after clearing the update', function (done) { + progress.clear(progress.UPDATE); + + request.get(SERVER_URL + '/api/v1/cloudron/progress', function (error, result) { + expect(error).to.not.be.ok(); + expect(result.statusCode).to.equal(200); + expect(result.body.update).to.be(null); + expect(result.body.backup).to.be(null); + done(); + }); + }); + }); + + describe('shutdown', function () { + before(function (done) { + server.start(done); + }); + + it('fails due to wrong arguments', function (done) { + expect(function () { server.stop(); }).to.throwException(); + expect(function () { server.stop('foobar'); }).to.throwException(); + expect(function () { server.stop(1337); }).to.throwException(); + expect(function () { server.stop({}); }).to.throwException(); + expect(function () { server.stop({ httpServer: {} }); }).to.throwException(); + + done(); + }); + + it('succeeds', function (done) { + server.stop(function () { + done(); + }); + }); + + it('is not reachable anymore', function (done) { + request.get(SERVER_URL + '/api/v1/cloudron/status', function (error, result) { + expect(error).to.not.be(null); + done(); + }); + }); + }); + + describe('cors', function () { + before(function (done) { + server.start(function (error) { + done(error); + }); + }); + + it('responds to OPTIONS', function (done) { + request('OPTIONS', SERVER_URL + '/api/v1/cloudron/status') + .set('Access-Control-Request-Method', 'GET') + .set('Access-Control-Request-Headers', 'accept, origin, x-requested-with') + .set('Origin', 'http://localhost') + .end(function (res) { + expect(res.headers['access-control-allow-methods']).to.be('GET, PUT, DELETE, POST, OPTIONS'); + expect(res.headers['access-control-allow-credentials']).to.be('true'); + expect(res.headers['access-control-allow-headers']).to.be('accept, origin, x-requested-with'); // mirrored from request + expect(res.headers['access-control-allow-origin']).to.be('http://localhost'); // mirrors from request + done(); + }); + }); + + after(function (done) { + server.stop(function () { + done(); + }); + }); + }); + + describe('heartbeat', function () { + var successfulHeartbeatGet; + + before(function (done) { + server.start(done); + + var scope = nock(config.apiServerOrigin()); + successfulHeartbeatGet = scope.get('/api/v1/boxes/' + config.fqdn() + '/heartbeat'); + successfulHeartbeatGet.reply(200); + }); + + after(function (done) { + server.stop(done); + nock.cleanAll(); + }); + + it('sends heartbeat', function (done) { + setTimeout(function () { + expect(successfulHeartbeatGet.counter).to.equal(1); + done(); + }, 100); + }); + }); +}); + diff --git a/src/test/settings-test.js b/src/test/settings-test.js new file mode 100644 index 000000000..0a9859aee --- /dev/null +++ b/src/test/settings-test.js @@ -0,0 +1,66 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var database = require('../database.js'), + expect = require('expect.js'), + settings = require('../settings.js'); + +function setup(done) { + // ensure data/config/mount paths + database.initialize(function (error) { + expect(error).to.be(null); + done(); + }); +} + +function cleanup(done) { + database._clear(done); +} + +describe('Settings', function () { + before(setup); + after(cleanup); + + it('can get default timezone', function (done) { + settings.getTimeZone(function (error, tz) { + expect(error).to.be(null); + expect(tz.length).to.not.be(0); + done(); + }); + }); + it('can get default autoupdate_pattern', function (done) { + settings.getAutoupdatePattern(function (error, pattern) { + expect(error).to.be(null); + expect(pattern).to.be('00 00 1,3,5,23 * * *'); + done(); + }); + }); + it ('can get default cloudron name', function (done) { + settings.getCloudronName(function (error, name) { + expect(error).to.be(null); + expect(name).to.be('Cloudron'); + done(); + }); + }); + it('can get default cloudron avatar', function (done) { + settings.getCloudronAvatar(function (error, gravatar) { + expect(error).to.be(null); + expect(gravatar).to.be.a(Buffer); + done(); + }); + }); + it('can get all values', function (done) { + settings.getAll(function (error, allSettings) { + expect(error).to.be(null); + expect(allSettings[settings.TIME_ZONE_KEY]).to.be.a('string'); + expect(allSettings[settings.AUTOUPDATE_PATTERN_KEY]).to.be.a('string'); + expect(allSettings[settings.CLOUDRON_NAME_KEY]).to.be.a('string'); + done(); + }); + }); +}); diff --git a/src/test/setupTest b/src/test/setupTest new file mode 100755 index 000000000..9525aa066 --- /dev/null +++ b/src/test/setupTest @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eu + +readonly ADMIN_LOCATION=admin +readonly source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../.. && pwd)" + +! "${source_dir}/src/test/checkInstall" && exit 1 + +# create dir structure +rm -rf $HOME/.cloudron_test +mkdir -p $HOME/.cloudron_test +cd $HOME/.cloudron_test +mkdir -p data/appdata data/box/appicons data/mail data/nginx/cert data/nginx/applications data/collectd/collectd.conf.d data/addons configs + +webadmin_scopes="root,profile,users,apps,settings,roleAdmin" +webadmin_origin="https://${ADMIN_LOCATION}-localhost" +mysql --user=root --password="" \ + -e "REPLACE INTO clients (id, appId, clientSecret, redirectURI, scope) VALUES (\"cid-webadmin\", \"webadmin\", \"secret-webadmin\", \"${webadmin_origin}\", \"${webadmin_scopes}\")" boxtest + diff --git a/src/test/shell-test.js b/src/test/shell-test.js new file mode 100644 index 000000000..1f09632f7 --- /dev/null +++ b/src/test/shell-test.js @@ -0,0 +1,51 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global after:false */ +/* global before:false */ + +'use strict'; + +var expect = require('expect.js'), + path = require('path'), + shell = require('../shell.js'); + +describe('shell', function () { + it('can run valid program', function (done) { + var cp = shell.exec('test', 'ls', [ '-l' ], function (error) { + expect(cp).to.be.ok(); + expect(error).to.be(null); + done(); + }); + }); + + it('fails on invalid program', function (done) { + var cp = shell.exec('test', 'randomprogram', [ ], function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('fails on failing program', function (done) { + var cp = shell.exec('test', '/usr/bin/false', [ ], function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('cannot sudo invalid program', function (done) { + var cp = shell.sudo('test', [ 'randomprogram' ], function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('can sudo valid program', function (done) { + var RELOAD_NGINX_CMD = path.join(__dirname, '../src/scripts/reloadnginx.sh'); + var cp = shell.sudo('test', [ RELOAD_NGINX_CMD ], function (error) { + expect(error).to.be.ok(); + done(); + }); + }); +}); + diff --git a/src/test/user-test.js b/src/test/user-test.js new file mode 100644 index 000000000..1600b09c7 --- /dev/null +++ b/src/test/user-test.js @@ -0,0 +1,401 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +var database = require('../database.js'), + expect = require('expect.js'), + user = require('../user.js'), + userdb = require('../userdb.js'), + UserError = user.UserError; + +var USERNAME = 'nobody'; +var USERNAME_NEW = 'nobodynew'; +var EMAIL = 'nobody@no.body'; +var EMAIL_NEW = 'nobodynew@no.body'; +var PASSWORD = 'foobar'; +var NEW_PASSWORD = 'somenewpassword'; +var IS_ADMIN = true; + +function cleanupUsers(done) { + userdb._clear(function () { + done(); + }); +} + +function createUser(done) { + user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + done(); + }); +} + +function setup(done) { + // ensure data/config/mount paths + database.initialize(function (error) { + expect(error).to.be(null); + done(); + }); +} + +function cleanup(done) { + database._clear(done); +} + +describe('User', function () { + before(setup); + after(cleanup); + + describe('create', function() { + before(cleanupUsers); + after(cleanupUsers); + + it('succeeds', function (done) { + user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) { + expect(error).not.to.be.ok(); + expect(result).to.be.ok(); + expect(result.username).to.equal(USERNAME); + expect(result.email).to.equal(EMAIL); + + done(); + }); + }); + + it('fails because of invalid BAD_FIELD', function (done) { + expect(function () { + user.create(EMAIL, {}, function () {}); + }).to.throwException(); + expect(function () { + user.create(12345, PASSWORD, EMAIL, function () {}); + }).to.throwException(); + expect(function () { + user.create(USERNAME, PASSWORD, EMAIL, {}); + }).to.throwException(); + expect(function () { + user.create(USERNAME, PASSWORD, EMAIL, {}, function () {}); + }).to.throwException(); + expect(function () { + user.create(USERNAME, PASSWORD, EMAIL, {}); + }).to.throwException(); + + done(); + }); + + it('fails because user exists', function (done) { + user.create(USERNAME, PASSWORD, EMAIL, IS_ADMIN, null /* invitor */, function (error, result) { + expect(error).to.be.ok(); + expect(result).not.to.be.ok(); + expect(error.reason).to.equal(UserError.ALREADY_EXISTS); + + done(); + }); + }); + + it('fails because password is empty', function (done) { + user.create(USERNAME, '', EMAIL, IS_ADMIN, null /* invitor */, function (error, result) { + expect(error).to.be.ok(); + expect(result).not.to.be.ok(); + expect(error.reason).to.equal(UserError.BAD_PASSWORD); + + done(); + }); + }); + }); + + describe('verify', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to non existing username', function (done) { + user.verify(USERNAME+USERNAME, PASSWORD, function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.NOT_FOUND); + + done(); + }); + }); + + it('fails due to empty password', function (done) { + user.verify(USERNAME, '', function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.WRONG_PASSWORD); + + done(); + }); + }); + + it('fails due to wrong password', function (done) { + user.verify(USERNAME, PASSWORD+PASSWORD, function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.WRONG_PASSWORD); + + done(); + }); + }); + + it('succeeds', function (done) { + user.verify(USERNAME, PASSWORD, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + + done(); + }); + }); + }); + + describe('verifyWithEmail', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to non existing user', function (done) { + user.verifyWithEmail(EMAIL+EMAIL, PASSWORD, function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.NOT_FOUND); + + done(); + }); + }); + + it('fails due to empty password', function (done) { + user.verifyWithEmail(EMAIL, '', function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.WRONG_PASSWORD); + + done(); + }); + }); + + it('fails due to wrong password', function (done) { + user.verifyWithEmail(EMAIL, PASSWORD+PASSWORD, function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.WRONG_PASSWORD); + + done(); + }); + }); + + it('succeeds', function (done) { + user.verifyWithEmail(EMAIL, PASSWORD, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + + done(); + }); + }); + }); + + describe('retrieving', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to non existing user', function (done) { + user.get('some non existing username', function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + + done(); + }); + }); + + it('succeeds', function (done) { + user.get(USERNAME, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + + done(); + }); + }); + }); + + describe('update', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to unknown userid', function (done) { + user.update(USERNAME+USERNAME, USERNAME_NEW, EMAIL_NEW, function (error) { + expect(error).to.be.a(UserError); + expect(error.reason).to.equal(UserError.NOT_FOUND); + + done(); + }); + }); + + it('fails due to invalid username', function (done) { + user.update(USERNAME, '', EMAIL_NEW, function (error) { + expect(error).to.be.a(UserError); + expect(error.reason).to.equal(UserError.BAD_USERNAME); + + done(); + }); + }); + + it('fails due to invalid email', function (done) { + user.update(USERNAME, USERNAME_NEW, 'brokenemailaddress', function (error) { + expect(error).to.be.a(UserError); + expect(error.reason).to.equal(UserError.BAD_EMAIL); + + done(); + }); + }); + + it('succeeds', function (done) { + user.update(USERNAME, USERNAME_NEW, EMAIL_NEW, function (error) { + expect(error).to.not.be.ok(); + + user.get(USERNAME, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + expect(result.email).to.equal(EMAIL_NEW); + expect(result.username).to.equal(USERNAME_NEW); + + done(); + }); + }); + }); + }); + + describe('admin change', function () { + before(createUser); + after(cleanupUsers); + + it('fails to remove admin flag of only admin', function (done) { + user.changeAdmin(USERNAME, false, function (error) { + expect(error).to.be.an('object'); + done(); + }); + }); + + it('make second user admin succeeds', function (done) { + var user1 = { + username: 'seconduser', + password: 'foobar', + email: 'some@thi.ng' + }; + + user.create(user1.username, user1.password, user1.email, false, { username: USERNAME, email: EMAIL } /* invitor */, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + + user.changeAdmin(user1.username, true, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + }); + + it('succeeds to remove admin flag of first user', function (done) { + user.changeAdmin(USERNAME, false, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + }); + + describe('password change', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to wrong arumgent count', function () { + expect(function () { user.changePassword(); }).to.throwError(); + expect(function () { user.changePassword(USERNAME); }).to.throwError(); + expect(function () { user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD); }).to.throwError(); + }); + + it('fails due to wrong arumgents', function () { + expect(function () { user.changePassword(USERNAME, {}, NEW_PASSWORD, function () {}); }).to.throwError(); + expect(function () { user.changePassword(1337, PASSWORD, NEW_PASSWORD, function () {}); }).to.throwError(); + expect(function () { user.changePassword(USERNAME, PASSWORD, 1337, function () {}); }).to.throwError(); + expect(function () { user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD, 'some string'); }).to.throwError(); + }); + + it('fails due to wrong password', function (done) { + user.changePassword(USERNAME, 'wrongpassword', NEW_PASSWORD, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('fails due to empty new password', function (done) { + user.changePassword(USERNAME, PASSWORD, '', function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('fails due to unknown user', function (done) { + user.changePassword('somerandomuser', PASSWORD, NEW_PASSWORD, function (error) { + expect(error).to.be.ok(); + done(); + }); + }); + + it('succeeds', function (done) { + user.changePassword(USERNAME, PASSWORD, NEW_PASSWORD, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('actually changed the password (unable to login with old pasword)', function (done) { + user.verify(USERNAME, PASSWORD, function (error, result) { + expect(error).to.be.ok(); + expect(result).to.not.be.ok(); + expect(error.reason).to.equal(UserError.WRONG_PASSWORD); + done(); + }); + }); + + it('actually changed the password (login with new password)', function (done) { + user.verify(USERNAME, NEW_PASSWORD, function (error, result) { + expect(error).to.not.be.ok(); + expect(result).to.be.ok(); + done(); + }); + }); + }); + + describe('resetPasswordByIdentifier', function () { + before(createUser); + after(cleanupUsers); + + it('fails due to unkown email', function (done) { + user.resetPasswordByIdentifier('unknown@mail.com', function (error) { + expect(error).to.be.an(UserError); + expect(error.reason).to.eql(UserError.NOT_FOUND); + done(); + }); + }); + + it('fails due to unkown username', function (done) { + user.resetPasswordByIdentifier('unknown', function (error) { + expect(error).to.be.an(UserError); + expect(error.reason).to.eql(UserError.NOT_FOUND); + done(); + }); + }); + + it('succeeds with email', function (done) { + user.resetPasswordByIdentifier(EMAIL, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + + it('succeeds with username', function (done) { + user.resetPasswordByIdentifier(USERNAME, function (error) { + expect(error).to.not.be.ok(); + done(); + }); + }); + }); +}); diff --git a/src/tokendb.js b/src/tokendb.js new file mode 100644 index 000000000..cb2b11b17 --- /dev/null +++ b/src/tokendb.js @@ -0,0 +1,147 @@ +/* jslint node: true */ + +'use strict'; + +exports = module.exports = { + generateToken: generateToken, + get: get, + add: add, + del: del, + getByIdentifier: getByIdentifier, + delByIdentifier: delByIdentifier, + getByIdentifierAndClientId: getByIdentifierAndClientId, + delByIdentifierAndClientId: delByIdentifierAndClientId, + delExpired: delExpired, + + TYPE_USER: 'user', + TYPE_DEV: 'developer', + TYPE_APP: 'appliation', + + PREFIX_USER: 'user-', + PREFIX_DEV: 'dev-', + PREFIX_APP: 'app-', + + _clear: clear +}; + +var assert = require('assert'), + database = require('./database.js'), + DatabaseError = require('./databaseerror'), + hat = require('hat'); + + +var TOKENS_FIELDS = [ 'accessToken', 'identifier', 'clientId', 'scope', 'expires' ].join(','); + +function generateToken() { + return hat(256); +} + +function get(accessToken, callback) { + assert.strictEqual(typeof accessToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE accessToken = ? AND expires > ?', [ accessToken, Date.now() ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function add(accessToken, identifier, clientId, expires, scope, callback) { + assert.strictEqual(typeof accessToken, 'string'); + assert.strictEqual(typeof identifier, 'string'); + assert(typeof clientId === 'string' || clientId === null); + assert.strictEqual(typeof expires, 'number'); + assert.strictEqual(typeof scope, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('INSERT INTO tokens (accessToken, identifier, clientId, expires, scope) VALUES (?, ?, ?, ?, ?)', + [ accessToken, identifier, clientId, expires, scope ], function (error, result) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS)); + if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function del(accessToken, callback) { + assert.strictEqual(typeof accessToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM tokens WHERE accessToken = ?', [ accessToken ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(error); + }); +} + +function getByIdentifier(identifier, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE identifier = ?', [ identifier ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function delByIdentifier(identifier, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM tokens WHERE identifier = ?', [ identifier ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null); + }); +} + +function getByIdentifierAndClientId(identifier, clientId, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof clientId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE identifier=? AND clientId=?', [ identifier, clientId ], function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (results.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, results); + }); +} + +function delByIdentifierAndClientId(identifier, clientId, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof clientId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM tokens WHERE identifier = ? AND clientId = ?', [ identifier, clientId ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null); + }); +} + +function delExpired(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM tokens WHERE expires <= ?', [ Date.now() ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + return callback(null, result.affectedRows); + }); +} + +function clear(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM tokens', function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null); + }); +} + diff --git a/src/updatechecker.js b/src/updatechecker.js new file mode 100644 index 000000000..37c30b534 --- /dev/null +++ b/src/updatechecker.js @@ -0,0 +1,160 @@ +/* jslint node:true */ + +'use strict'; + +exports = module.exports = { + checkAppUpdates: checkAppUpdates, + checkBoxUpdates: checkBoxUpdates, + + getUpdateInfo: getUpdateInfo +}; + +var apps = require('./apps.js'), + assert = require('assert'), + async = require('async'), + config = require('./config.js'), + debug = require('debug')('box:updatechecker'), + fs = require('fs'), + mailer = require('./mailer.js'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + semver = require('semver'), + superagent = require('superagent'), + util = require('util'); + +var NOOP_CALLBACK = function (error) { console.error(error); }; + +var gAppUpdateInfo = { }, // id -> update info { creationDate, manifest } + gBoxUpdateInfo = null, + gMailedUser = { }; + +function getUpdateInfo() { + return { + apps: gAppUpdateInfo, + box: gBoxUpdateInfo + }; +} + +function getAppUpdates(callback) { + apps.getAll(function (error, apps) { + if (error) return callback(error); + + var appUpdateInfo = { }; + // appStoreId can be '' for dev apps + var appStoreIds = apps.map(function (app) { return app.appStoreId; }).filter(function (id) { return id !== ''; }); + + superagent + .post(config.apiServerOrigin() + '/api/v1/appupdates') + .send({ appIds: appStoreIds, boxVersion: config.version() }) + .timeout(10 * 1000) + .end(function (error, result) { + + if (error) return callback(error); + + if (result.statusCode !== 200 || !result.body.appVersions) { + return callback(new Error(util.format('Error checking app update: %s %s', result.statusCode, result.text))); + } + + var latestAppVersions = result.body.appVersions; + for (var i = 0; i < apps.length; i++) { + if (!(apps[i].appStoreId in latestAppVersions)) continue; + + var oldVersion = apps[i].manifest.version; + + var newManifest = latestAppVersions[apps[i].appStoreId].manifest; + var newVersion = newManifest.version; + if (newVersion !== oldVersion) { + appUpdateInfo[apps[i].id] = latestAppVersions[apps[i].appStoreId]; + debug('Update available for %s (%s) from %s to %s', apps[i].location, apps[i].id, oldVersion, newVersion); + } + } + + callback(null, appUpdateInfo); + }); + }); +} + +function getBoxUpdates(callback) { + var currentVersion = config.version(); + + superagent + .get(config.get('boxVersionsUrl')) + .timeout(10 * 1000) + .end(function (error, result) { + if (error) return callback(error); + if (result.status !== 200) return callback(new Error(util.format('Bad status: %s %s', result.status, result.text))); + + var versions = safe.JSON.parse(result.text); + + if (!versions || typeof versions !== 'object') return callback(new Error('versions is not in valid format:' + safe.error)); + + var latestVersion = Object.keys(versions).sort(semver.compare).pop(); + debug('checkBoxUpdates: Latest version is %s etag:%s', latestVersion, result.header['etag']); + + if (!latestVersion) return callback(new Error('No version available')); + + var nextVersion = null, nextVersionInfo = null; + var currentVersionInfo = versions[currentVersion]; + if (!currentVersionInfo) { + debug('Cloudron runs on unknown version %s. Offering to update to latest version', currentVersion); + nextVersion = latestVersion; + nextVersionInfo = versions[latestVersion]; + } else { + nextVersion = currentVersionInfo.next; + nextVersionInfo = nextVersion ? versions[nextVersion] : null; + } + + if (nextVersionInfo && typeof nextVersionInfo === 'object') { + debug('new version %s available. imageId: %d code: %s', nextVersion, nextVersionInfo.imageId, nextVersionInfo.sourceTarballUrl); + callback(null, { + version: nextVersion, + changelog: nextVersionInfo.changelog, + upgrade: nextVersionInfo.upgrade + }); + } else { + debug('no new version available.'); + callback(null, null); + } + }); +} + +function checkAppUpdates() { + debug('Checking App Updates'); + + getAppUpdates(function (error, result) { + if (error) debug('Error checking app updates: ', error); + + gAppUpdateInfo = error ? {} : result; + + async.eachSeries(Object.keys(gAppUpdateInfo), function iterator(id, iteratorDone) { + if (gMailedUser[id]) return iteratorDone(); + + apps.get(id, function (error, app) { + if (error) { + debug('Error getting app %s %s', id, error); + return iteratorDone(); + } + + mailer.appUpdateAvailable(app, gAppUpdateInfo[id]); + gMailedUser[id] = true; + }); + }); + }); +} + +function checkBoxUpdates() { + debug('Checking Box Updates'); + + getBoxUpdates(function (error, result) { + if (error) debug('Error checking box updates: ', error); + + gBoxUpdateInfo = error ? null : result; + + if (gBoxUpdateInfo && !gMailedUser['box']) { + mailer.boxUpdateAvailable(gBoxUpdateInfo.version, gBoxUpdateInfo.changelog); + gMailedUser['box'] = true; + } + }); +} + diff --git a/src/user.js b/src/user.js new file mode 100644 index 000000000..44e720fbf --- /dev/null +++ b/src/user.js @@ -0,0 +1,384 @@ +/* jshint node:true */ + +'use strict'; + +exports = module.exports = { + UserError: UserError, + + list: listUsers, + create: createUser, + verify: verify, + verifyWithEmail: verifyWithEmail, + remove: removeUser, + get: getUser, + getByResetToken: getByResetToken, + changeAdmin: changeAdmin, + resetPasswordByIdentifier: resetPasswordByIdentifier, + setPassword: setPassword, + changePassword: changePassword, + update: updateUser, + createOwner: createOwner +}; + +var assert = require('assert'), + crypto = require('crypto'), + DatabaseError = require('./databaseerror.js'), + mailer = require('./mailer.js'), + hat = require('hat'), + userdb = require('./userdb.js'), + tokendb = require('./tokendb.js'), + clientdb = require('./clientdb.js'), + util = require('util'), + validator = require('validator'), + _ = require('underscore'); + +var CRYPTO_SALT_SIZE = 64; // 512-bit salt +var CRYPTO_ITERATIONS = 10000; // iterations +var CRYPTO_KEY_LENGTH = 512; // bits + +// http://dustinsenos.com/articles/customErrorsInNode +// http://code.google.com/p/v8/wiki/JavaScriptStackTraceApi +function UserError(reason, errorOrMessage) { + assert.strictEqual(typeof reason, 'string'); + assert(errorOrMessage instanceof Error || typeof errorOrMessage === 'string' || typeof errorOrMessage === 'undefined'); + + Error.call(this); + Error.captureStackTrace(this, this.constructor); + + this.name = this.constructor.name; + this.reason = reason; + if (typeof errorOrMessage === 'undefined') { + this.message = reason; + } else if (typeof errorOrMessage === 'string') { + this.message = errorOrMessage; + } else { + this.message = 'Internal error'; + this.nestedError = errorOrMessage; + } +} +util.inherits(UserError, Error); +UserError.INTERNAL_ERROR = 'Internal Error'; +UserError.ALREADY_EXISTS = 'Already Exists'; +UserError.NOT_FOUND = 'Not Found'; +UserError.WRONG_PASSWORD = 'Wrong User or Password'; +UserError.BAD_FIELD = 'Bad field'; +UserError.BAD_USERNAME = 'Bad username'; +UserError.BAD_EMAIL = 'Bad email'; +UserError.BAD_PASSWORD = 'Bad password'; +UserError.BAD_TOKEN = 'Bad token'; +UserError.NOT_ALLOWED = 'Not Allowed'; + +function listUsers(callback) { + assert.strictEqual(typeof callback, 'function'); + + userdb.getAll(function (error, result) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + return callback(null, result.map(function (obj) { return _.pick(obj, 'id', 'username', 'email', 'admin'); })); + }); +} + +function validateUsername(username) { + assert.strictEqual(typeof username, 'string'); + + if (username.length <= 2) return new UserError(UserError.BAD_USERNAME, 'Username must be atleast 3 chars'); + if (username.length > 256) return new UserError(UserError.BAD_USERNAME, 'Username too long'); + + return null; +} + +function validatePassword(password) { + assert.strictEqual(typeof password, 'string'); + + if (password.length < 5) return new UserError(UserError.BAD_PASSWORD, 'Password must be atleast 5 chars'); + + return null; +} + +function validateEmail(email) { + assert.strictEqual(typeof email, 'string'); + + if (!validator.isEmail(email)) return new UserError(UserError.BAD_EMAIL, 'Invalid email'); + + return null; +} + +function validateToken(token) { + assert.strictEqual(typeof token, 'string'); + + if (token.length !== 64) return new UserError(UserError.BAD_TOKEN, 'Invalid token'); // 256-bit hex coded token + + return null; +} + +function createUser(username, password, email, admin, invitor, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof admin, 'boolean'); + assert(invitor || admin); + assert.strictEqual(typeof callback, 'function'); + + var error = validateUsername(username); + if (error) return callback(error); + + error = validatePassword(password); + if (error) return callback(error); + + error = validateEmail(email); + if (error) return callback(error); + + crypto.randomBytes(CRYPTO_SALT_SIZE, function (error, salt) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + crypto.pbkdf2(password, salt, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var now = (new Date()).toUTCString(); + var user = { + id: username, + username: username, + email: email, + password: new Buffer(derivedKey, 'binary').toString('hex'), + admin: admin, + salt: salt.toString('hex'), + createdAt: now, + modifiedAt: now, + resetToken: hat(256) + }; + + userdb.add(user.id, user, function (error) { + if (error && error.reason === DatabaseError.ALREADY_EXISTS) return callback(new UserError(UserError.ALREADY_EXISTS)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + callback(null, user); + + // only send welcome mail if user is not an admin. This is only the case for the first user! + // The welcome email contains a link to create a new password + if (!user.admin) mailer.userAdded(user, invitor); + }); + }); + }); +} + +function verify(username, password, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.get(username, function (error, user) { + if (error && error.reason == DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var saltBinary = new Buffer(user.salt, 'hex'); + crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var derivedKeyHex = new Buffer(derivedKey, 'binary').toString('hex'); + if (derivedKeyHex !== user.password) return callback(new UserError(UserError.WRONG_PASSWORD)); + + callback(null, user); + }); + }); +} + +function verifyWithEmail(email, password, callback) { + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof password, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.getByEmail(email, function (error, user) { + if (error && error.reason == DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var saltBinary = new Buffer(user.salt, 'hex'); + crypto.pbkdf2(password, saltBinary, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var derivedKeyHex = new Buffer(derivedKey, 'binary').toString('hex'); + if (derivedKeyHex !== user.password) return callback(new UserError(UserError.WRONG_PASSWORD)); + + callback(null, user); + }); + }); +} + +function removeUser(username, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.del(username, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + callback(null); + + mailer.userRemoved(username); + }); +} + +function getUser(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + userdb.get(userId, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + return callback(null, result); + }); +} + +function getByResetToken(resetToken, callback) { + assert.strictEqual(typeof resetToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validateToken(resetToken); + if (error) return callback(error); + + userdb.getByResetToken(resetToken, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + callback(null, result); + }); +} + +function updateUser(userId, username, email, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validateUsername(username); + if (error) return callback(error); + + error = validateEmail(email); + if (error) return callback(error); + + userdb.update(userId, { username: username, email: email }, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND, error)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + callback(null); + }); +} + +function changeAdmin(username, admin, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof admin, 'boolean'); + assert.strictEqual(typeof callback, 'function'); + + getUser(username, function (error, user) { + if (error) return callback(error); + + userdb.getAllAdmins(function (error, result) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + // protect from a system where there is no admin left + if (result.length <= 1 && !admin) return callback(new UserError(UserError.NOT_ALLOWED, 'Only admin')); + + user.admin = admin; + + userdb.update(username, user, function (error) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + callback(null); + + mailer.adminChanged(user); + }); + }); + }); +} + +function resetPasswordByIdentifier(identifier, callback) { + assert.strictEqual(typeof identifier, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var getter; + if (identifier.indexOf('@') === -1) getter = userdb.getByUsername; + else getter = userdb.getByEmail; + + getter(identifier, function (error, result) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + result.resetToken = hat(256); + + userdb.update(result.id, result, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + mailer.passwordReset(result); + + callback(null); + }); + }); +} + +function setPassword(userId, newPassword, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof newPassword, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validatePassword(newPassword); + if (error) return callback(error); + + userdb.get(userId, function (error, user) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var saltBuffer = new Buffer(user.salt, 'hex'); + crypto.pbkdf2(newPassword, saltBuffer, CRYPTO_ITERATIONS, CRYPTO_KEY_LENGTH, function (error, derivedKey) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + user.modifiedAt = (new Date()).toUTCString(); + user.password = new Buffer(derivedKey, 'binary').toString('hex'); + user.resetToken = ''; + + userdb.update(userId, user, function (error) { + if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new UserError(UserError.NOT_FOUND)); + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + // Also generate a token so the new user can get logged in immediately + clientdb.getByAppId('webadmin', function (error, result) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + var token = tokendb.generateToken(); + var expiresAt = Date.now() + 24 * 60 * 60 * 1000; // 1 day + + tokendb.add(token, tokendb.PREFIX_USER + user.id, result.id, expiresAt, '*', function (error) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + + callback(null, { token: token, expiresAt: expiresAt }); + }); + }); + }); + }); + }); +} + +function changePassword(username, oldPassword, newPassword, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof oldPassword, 'string'); + assert.strictEqual(typeof newPassword, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var error = validatePassword(newPassword); + if (error) return callback(error); + + verify(username, oldPassword, function (error, user) { + if (error) return callback(error); + + setPassword(user.id, newPassword, callback); + }); +} + +function createOwner(username, password, email, callback) { + userdb.count(function (error, count) { + if (error) return callback(new UserError(UserError.INTERNAL_ERROR, error)); + if (count !== 0) return callback(new UserError(UserError.ALREADY_EXISTS)); + + createUser(username, password, email, true /* admin */, null /* invitor */, callback); + }); +} + diff --git a/src/userdb.js b/src/userdb.js new file mode 100644 index 000000000..38bf603cc --- /dev/null +++ b/src/userdb.js @@ -0,0 +1,189 @@ +'use strict'; + +exports = module.exports = { + get: get, + getByUsername: getByUsername, + getByEmail: getByEmail, + getByAccessToken: getByAccessToken, + getByResetToken: getByResetToken, + getAll: getAll, + getAllAdmins: getAllAdmins, + add: add, + del: del, + update: update, + count: count, + adminCount: adminCount, + + _clear: clear +}; + +var assert = require('assert'), + database = require('./database.js'), + debug = require('debug')('box:userdb'), + DatabaseError = require('./databaseerror'); + +var USERS_FIELDS = [ 'id', 'username', 'email', 'password', 'salt', 'createdAt', 'modifiedAt', 'admin', 'resetToken' ].join(','); + +function get(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE id = ?', [ userId ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function getByUsername(username, callback) { + assert.strictEqual(typeof username, 'string'); + assert.strictEqual(typeof callback, 'function'); + + // currently username is also our id + get(username, callback); +} + +function getByEmail(email, callback) { + assert.strictEqual(typeof email, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE email = ?', [ email ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function getByResetToken(resetToken, callback) { + assert.strictEqual(typeof resetToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + if (resetToken.length === 0) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, 'Empty resetToken not allowed')); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE resetToken=?', [ resetToken ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function getAll(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function getAllAdmins(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users WHERE admin=1', function (error, results) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null, results); + }); +} + +function add(userId, user, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof user.username, 'string'); + assert.strictEqual(typeof user.password, 'string'); + assert.strictEqual(typeof user.email, 'string'); + assert.strictEqual(typeof user.admin, 'boolean'); + assert.strictEqual(typeof user.salt, 'string'); + assert.strictEqual(typeof user.createdAt, 'string'); + assert.strictEqual(typeof user.modifiedAt, 'string'); + assert.strictEqual(typeof user.resetToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + var data = [ userId, user.username, user.password, user.email, user.admin, user.salt, user.createdAt, user.modifiedAt, user.resetToken ]; + database.query('INSERT INTO users (id, username, password, email, admin, salt, createdAt, modifiedAt, resetToken) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + data, function (error, result) { + if (error && error.code === 'ER_DUP_ENTRY') return callback(new DatabaseError(DatabaseError.ALREADY_EXISTS, error)); + if (error || result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(null); + }); +} + +function del(userId, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('DELETE FROM users WHERE id = ?', [ userId ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(error); + }); +} + +function getByAccessToken(accessToken, callback) { + assert.strictEqual(typeof accessToken, 'string'); + assert.strictEqual(typeof callback, 'function'); + + debug('getByAccessToken: ' + accessToken); + + database.query('SELECT ' + USERS_FIELDS + ' FROM users, tokens WHERE tokens.accessToken = ?', [ accessToken ], function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.length === 0) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + callback(null, result[0]); + }); +} + +function clear(callback) { + database.query('DELETE FROM users', function (error) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + callback(error); + }); +} + +function update(userId, user, callback) { + assert.strictEqual(typeof userId, 'string'); + assert.strictEqual(typeof user, 'object'); + assert.strictEqual(typeof callback, 'function'); + + var args = [ ]; + var fields = [ ]; + for (var k in user) { + fields.push(k + ' = ?'); + args.push(user[k]); + } + args.push(userId); + + database.query('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', args, function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + if (result.affectedRows !== 1) return callback(new DatabaseError(DatabaseError.NOT_FOUND)); + + return callback(null); + }); +} + +function count(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT COUNT(*) AS total FROM users', function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null, result[0].total); + }); +} + +function adminCount(callback) { + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT COUNT(*) AS total FROM users WHERE admin=1', function (error, result) { + if (error) return callback(new DatabaseError(DatabaseError.INTERNAL_ERROR, error)); + + return callback(null, result[0].total); + }); +} + diff --git a/src/vbox.js b/src/vbox.js new file mode 100644 index 000000000..f51a150e5 --- /dev/null +++ b/src/vbox.js @@ -0,0 +1,40 @@ +'use strict'; + +// we can possibly remove this entire file and make our tests +// smarter to just use the host interface provided by boot2docker +// https://github.com/boot2docker/boot2docker#container-port-redirection +// https://github.com/boot2docker/boot2docker/pull/93 +// https://github.com/docker/docker/issues/4007 + +exports = module.exports = { + forwardFromHostToVirtualBox: forwardFromHostToVirtualBox, + unforwardFromHostToVirtualBox: unforwardFromHostToVirtualBox +}; + +var assert = require('assert'), + child_process = require('child_process'), + debug = require('debug')('box:vbox'), + os = require('os'); + + +function forwardFromHostToVirtualBox(rulename, port) { + assert.strictEqual(typeof rulename, 'string'); + assert.strictEqual(typeof port, 'number'); + + if (os.platform() === 'darwin') { + debug('Setting up VirtualBox port forwarding for '+ rulename + ' at ' + port); + child_process.exec( + 'VBoxManage controlvm boot2docker-vm natpf1 delete ' + rulename + ';' + + 'VBoxManage controlvm boot2docker-vm natpf1 ' + rulename + ',tcp,127.0.0.1,' + port + ',,' + port); + } +} + +function unforwardFromHostToVirtualBox(rulename) { + assert.strictEqual(typeof rulename, 'string'); + + if (os.platform() === 'darwin') { + debug('Removing VirtualBox port forwarding for '+ rulename); + child_process.exec('VBoxManage controlvm boot2docker-vm natpf1 delete ' + rulename); + } +} + diff --git a/webadmin/deploymentConfig.json b/webadmin/deploymentConfig.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/webadmin/deploymentConfig.json @@ -0,0 +1,2 @@ +{ +} diff --git a/webadmin/src/3rdparty/angular-ui-notification.min.css b/webadmin/src/3rdparty/angular-ui-notification.min.css new file mode 100644 index 000000000..c7a13467a --- /dev/null +++ b/webadmin/src/3rdparty/angular-ui-notification.min.css @@ -0,0 +1,8 @@ +/** + * angular-ui-notification - Angular.js service providing simple notifications using Bootstrap 3 styles with css transitions for animating + * @author Alex_Crack + * @version v0.0.5 + * @link https://github.com/alexcrack/angular-ui-notification + * @license MIT + */ +.ui-notification{position:fixed;z-index:9999;top:-100px;right:10px;width:300px;cursor:pointer;-webkit-transition:all ease .5s;-o-transition:all ease .5s;transition:all ease .5s;color:#fff;background:#337ab7;box-shadow:5px 5px 10px rgba(0,0,0,.3)}.ui-notification.killed{-webkit-transition:opacity ease 1s;-o-transition:opacity ease 1s;transition:opacity ease 1s;opacity:0}.ui-notification>h3{font-size:14px;font-weight:700;display:block;margin:10px 10px 0;padding:0 0 5px;text-align:left;border-bottom:1px solid rgba(255,255,255,.3)}.ui-notification a{color:#fff}.ui-notification a:hover{text-decoration:underline}.ui-notification>.message{margin:10px}.ui-notification.warning{color:#fff;background:#f0ad4e}.ui-notification.error{color:#fff;background:#d9534f}.ui-notification.success{color:#fff;background:#5cb85c}.ui-notification.info{color:#fff;background:#5bc0de}.ui-notification:hover{opacity:.7} \ No newline at end of file diff --git a/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css new file mode 100644 index 000000000..bb663496d --- /dev/null +++ b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css @@ -0,0 +1,476 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +.btn-default, +.btn-primary, +.btn-success, +.btn-info, +.btn-warning, +.btn-danger { + text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); +} +.btn-default:active, +.btn-primary:active, +.btn-success:active, +.btn-info:active, +.btn-warning:active, +.btn-danger:active, +.btn-default.active, +.btn-primary.active, +.btn-success.active, +.btn-info.active, +.btn-warning.active, +.btn-danger.active { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn-default .badge, +.btn-primary .badge, +.btn-success .badge, +.btn-info .badge, +.btn-warning .badge, +.btn-danger .badge { + text-shadow: none; +} +.btn:active, +.btn.active { + background-image: none; +} +.btn-default { + text-shadow: 0 1px 0 #fff; + background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); + background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); + background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #dbdbdb; + border-color: #ccc; +} +.btn-default:hover, +.btn-default:focus { + background-color: #e0e0e0; + background-position: 0 -15px; +} +.btn-default:active, +.btn-default.active { + background-color: #e0e0e0; + border-color: #dbdbdb; +} +.btn-default.disabled, +.btn-default:disabled, +.btn-default[disabled] { + background-color: #e0e0e0; + background-image: none; +} +.btn-primary { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); + background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #245580; +} +.btn-primary:hover, +.btn-primary:focus { + background-color: #265a88; + background-position: 0 -15px; +} +.btn-primary:active, +.btn-primary.active { + background-color: #265a88; + border-color: #245580; +} +.btn-primary.disabled, +.btn-primary:disabled, +.btn-primary[disabled] { + background-color: #265a88; + background-image: none; +} +.btn-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #3e8f3e; +} +.btn-success:hover, +.btn-success:focus { + background-color: #419641; + background-position: 0 -15px; +} +.btn-success:active, +.btn-success.active { + background-color: #419641; + border-color: #3e8f3e; +} +.btn-success.disabled, +.btn-success:disabled, +.btn-success[disabled] { + background-color: #419641; + background-image: none; +} +.btn-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #28a4c9; +} +.btn-info:hover, +.btn-info:focus { + background-color: #2aabd2; + background-position: 0 -15px; +} +.btn-info:active, +.btn-info.active { + background-color: #2aabd2; + border-color: #28a4c9; +} +.btn-info.disabled, +.btn-info:disabled, +.btn-info[disabled] { + background-color: #2aabd2; + background-image: none; +} +.btn-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #e38d13; +} +.btn-warning:hover, +.btn-warning:focus { + background-color: #eb9316; + background-position: 0 -15px; +} +.btn-warning:active, +.btn-warning.active { + background-color: #eb9316; + border-color: #e38d13; +} +.btn-warning.disabled, +.btn-warning:disabled, +.btn-warning[disabled] { + background-color: #eb9316; + background-image: none; +} +.btn-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #b92c28; +} +.btn-danger:hover, +.btn-danger:focus { + background-color: #c12e2a; + background-position: 0 -15px; +} +.btn-danger:active, +.btn-danger.active { + background-color: #c12e2a; + border-color: #b92c28; +} +.btn-danger.disabled, +.btn-danger:disabled, +.btn-danger[disabled] { + background-color: #c12e2a; + background-image: none; +} +.thumbnail, +.img-thumbnail { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); + box-shadow: 0 1px 2px rgba(0, 0, 0, .075); +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + background-color: #e8e8e8; + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); + background-repeat: repeat-x; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + background-color: #2e6da4; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; +} +.navbar-default { + background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); + background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); + background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); + background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); + background-repeat: repeat-x; + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); +} +.navbar-brand, +.navbar-nav > li > a { + text-shadow: 0 1px 0 rgba(255, 255, 255, .25); +} +.navbar-inverse { + background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); + background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); + background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); + background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); + background-repeat: repeat-x; + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); +} +.navbar-inverse .navbar-brand, +.navbar-inverse .navbar-nav > li > a { + text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); +} +.navbar-static-top, +.navbar-fixed-top, +.navbar-fixed-bottom { + border-radius: 0; +} +@media (max-width: 767px) { + .navbar .navbar-nav .open .dropdown-menu > .active > a, + .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; + } +} +.alert { + text-shadow: 0 1px 0 rgba(255, 255, 255, .2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); +} +.alert-success { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); + background-repeat: repeat-x; + border-color: #b2dba1; +} +.alert-info { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); + background-repeat: repeat-x; + border-color: #9acfea; +} +.alert-warning { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); + background-repeat: repeat-x; + border-color: #f5e79e; +} +.alert-danger { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); + background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); + background-repeat: repeat-x; + border-color: #dca7a7; +} +.progress { + background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); + background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); + background-repeat: repeat-x; +} +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.list-group { + border-radius: 4px; + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); + box-shadow: 0 1px 2px rgba(0, 0, 0, .075); +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + text-shadow: 0 -1px 0 #286090; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); + background-repeat: repeat-x; + border-color: #2b669a; +} +.list-group-item.active .badge, +.list-group-item.active:hover .badge, +.list-group-item.active:focus .badge { + text-shadow: none; +} +.panel { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); + box-shadow: 0 1px 2px rgba(0, 0, 0, .05); +} +.panel-default > .panel-heading { + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); + background-repeat: repeat-x; +} +.panel-primary > .panel-heading { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-repeat: repeat-x; +} +.panel-success > .panel-heading { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); + background-repeat: repeat-x; +} +.panel-info > .panel-heading { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); + background-repeat: repeat-x; +} +.panel-warning > .panel-heading { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); + background-repeat: repeat-x; +} +.panel-danger > .panel-heading { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); + background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); + background-repeat: repeat-x; +} +.well { + background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); + background-repeat: repeat-x; + border-color: #dcdcdc; + -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); +} +/*# sourceMappingURL=bootstrap-theme.css.map */ diff --git a/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css.map b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css.map new file mode 100644 index 000000000..5a12d6317 --- /dev/null +++ b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","bootstrap-theme.css","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"AAcA;;;;;;EAME,0CAAA;ECgDA,6FAAA;EACQ,qFAAA;EC5DT;AFgBC;;;;;;;;;;;;EC2CA,0DAAA;EACQ,kDAAA;EC7CT;AFVD;;;;;;EAiBI,mBAAA;EECH;AFiCC;;EAEE,wBAAA;EE/BH;AFoCD;EGnDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EAgC2C,2BAAA;EAA2B,oBAAA;EEzBvE;AFLC;;EAEE,2BAAA;EACA,8BAAA;EEOH;AFJC;;EAEE,2BAAA;EACA,uBAAA;EEMH;AFHC;;;EAGE,2BAAA;EACA,wBAAA;EEKH;AFUD;EGpDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEgCD;AF9BC;;EAEE,2BAAA;EACA,8BAAA;EEgCH;AF7BC;;EAEE,2BAAA;EACA,uBAAA;EE+BH;AF5BC;;;EAGE,2BAAA;EACA,wBAAA;EE8BH;AFdD;EGrDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEyDD;AFvDC;;EAEE,2BAAA;EACA,8BAAA;EEyDH;AFtDC;;EAEE,2BAAA;EACA,uBAAA;EEwDH;AFrDC;;;EAGE,2BAAA;EACA,wBAAA;EEuDH;AFtCD;EGtDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEkFD;AFhFC;;EAEE,2BAAA;EACA,8BAAA;EEkFH;AF/EC;;EAEE,2BAAA;EACA,uBAAA;EEiFH;AF9EC;;;EAGE,2BAAA;EACA,wBAAA;EEgFH;AF9DD;EGvDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EE2GD;AFzGC;;EAEE,2BAAA;EACA,8BAAA;EE2GH;AFxGC;;EAEE,2BAAA;EACA,uBAAA;EE0GH;AFvGC;;;EAGE,2BAAA;EACA,wBAAA;EEyGH;AFtFD;EGxDI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EAEA,wHAAA;ECnBF,qEAAA;EJiCA,6BAAA;EACA,uBAAA;EEoID;AFlIC;;EAEE,2BAAA;EACA,8BAAA;EEoIH;AFjIC;;EAEE,2BAAA;EACA,uBAAA;EEmIH;AFhIC;;;EAGE,2BAAA;EACA,wBAAA;EEkIH;AFxGD;;EChBE,oDAAA;EACQ,4CAAA;EC4HT;AFnGD;;EGzEI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHwEF,2BAAA;EEyGD;AFvGD;;;EG9EI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH8EF,2BAAA;EE6GD;AFpGD;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EJ6GA,oBAAA;EC/CA,6FAAA;EACQ,qFAAA;EC0JT;AF/GD;;EG3FI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,0DAAA;EACQ,kDAAA;ECoKT;AF5GD;;EAEE,gDAAA;EE8GD;AF1GD;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ECnBF,qEAAA;EF+OD;AFlHD;;EG9GI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EF2CF,yDAAA;EACQ,iDAAA;EC0LT;AF5HD;;EAYI,2CAAA;EEoHH;AF/GD;;;EAGE,kBAAA;EEiHD;AF5FD;EAfI;;;IAGE,aAAA;IG3IF,0EAAA;IACA,qEAAA;IACA,+FAAA;IAAA,wEAAA;IACA,6BAAA;IACA,wHAAA;ID0PD;EACF;AFxGD;EACE,+CAAA;ECzGA,4FAAA;EACQ,oFAAA;ECoNT;AFhGD;EGpKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4GD;AFvGD;EGrKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoHD;AF9GD;EGtKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EE4HD;AFrHD;EGvKI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH4JF,uBAAA;EEoID;AFrHD;EG/KI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDuSH;AFlHD;EGzLI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED8SH;AFxHD;EG1LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDqTH;AF9HD;EG3LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED4TH;AFpID;EG5LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDmUH;AF1ID;EG7LI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED0UH;AF7ID;EGhKI,+MAAA;EACA,0MAAA;EACA,uMAAA;EDgTH;AFzID;EACE,oBAAA;EC5JA,oDAAA;EACQ,4CAAA;ECwST;AF1ID;;;EAGE,+BAAA;EGjNE,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EH+MF,uBAAA;EEgJD;AFrJD;;;EAQI,mBAAA;EEkJH;AFxID;ECjLE,mDAAA;EACQ,2CAAA;EC4TT;AFlID;EG1OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED+WH;AFxID;EG3OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDsXH;AF9ID;EG5OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED6XH;AFpJD;EG7OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDoYH;AF1JD;EG9OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;ED2YH;AFhKD;EG/OI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EDkZH;AFhKD;EGtPI,0EAAA;EACA,qEAAA;EACA,+FAAA;EAAA,wEAAA;EACA,6BAAA;EACA,wHAAA;EHoPF,uBAAA;ECzMA,2FAAA;EACQ,mFAAA;ECgXT","file":"bootstrap-theme.css","sourcesContent":["\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &:disabled,\n &[disabled] {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They will be removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n",".btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.btn-default:active,\n.btn-primary:active,\n.btn-success:active,\n.btn-info:active,\n.btn-warning:active,\n.btn-danger:active,\n.btn-default.active,\n.btn-primary.active,\n.btn-success.active,\n.btn-info.active,\n.btn-warning.active,\n.btn-danger.active {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-default .badge,\n.btn-primary .badge,\n.btn-success .badge,\n.btn-info .badge,\n.btn-warning .badge,\n.btn-danger .badge {\n text-shadow: none;\n}\n.btn:active,\n.btn.active {\n background-image: none;\n}\n.btn-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #dbdbdb;\n text-shadow: 0 1px 0 #fff;\n border-color: #ccc;\n}\n.btn-default:hover,\n.btn-default:focus {\n background-color: #e0e0e0;\n background-position: 0 -15px;\n}\n.btn-default:active,\n.btn-default.active {\n background-color: #e0e0e0;\n border-color: #dbdbdb;\n}\n.btn-default.disabled,\n.btn-default:disabled,\n.btn-default[disabled] {\n background-color: #e0e0e0;\n background-image: none;\n}\n.btn-primary {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #245580;\n}\n.btn-primary:hover,\n.btn-primary:focus {\n background-color: #265a88;\n background-position: 0 -15px;\n}\n.btn-primary:active,\n.btn-primary.active {\n background-color: #265a88;\n border-color: #245580;\n}\n.btn-primary.disabled,\n.btn-primary:disabled,\n.btn-primary[disabled] {\n background-color: #265a88;\n background-image: none;\n}\n.btn-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #3e8f3e;\n}\n.btn-success:hover,\n.btn-success:focus {\n background-color: #419641;\n background-position: 0 -15px;\n}\n.btn-success:active,\n.btn-success.active {\n background-color: #419641;\n border-color: #3e8f3e;\n}\n.btn-success.disabled,\n.btn-success:disabled,\n.btn-success[disabled] {\n background-color: #419641;\n background-image: none;\n}\n.btn-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #28a4c9;\n}\n.btn-info:hover,\n.btn-info:focus {\n background-color: #2aabd2;\n background-position: 0 -15px;\n}\n.btn-info:active,\n.btn-info.active {\n background-color: #2aabd2;\n border-color: #28a4c9;\n}\n.btn-info.disabled,\n.btn-info:disabled,\n.btn-info[disabled] {\n background-color: #2aabd2;\n background-image: none;\n}\n.btn-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #e38d13;\n}\n.btn-warning:hover,\n.btn-warning:focus {\n background-color: #eb9316;\n background-position: 0 -15px;\n}\n.btn-warning:active,\n.btn-warning.active {\n background-color: #eb9316;\n border-color: #e38d13;\n}\n.btn-warning.disabled,\n.btn-warning:disabled,\n.btn-warning[disabled] {\n background-color: #eb9316;\n background-image: none;\n}\n.btn-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%);\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n background-repeat: repeat-x;\n border-color: #b92c28;\n}\n.btn-danger:hover,\n.btn-danger:focus {\n background-color: #c12e2a;\n background-position: 0 -15px;\n}\n.btn-danger:active,\n.btn-danger.active {\n background-color: #c12e2a;\n border-color: #b92c28;\n}\n.btn-danger.disabled,\n.btn-danger:disabled,\n.btn-danger[disabled] {\n background-color: #c12e2a;\n background-image: none;\n}\n.thumbnail,\n.img-thumbnail {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n background-color: #e8e8e8;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n background-color: #2e6da4;\n}\n.navbar-default {\n background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%);\n background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075);\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%);\n background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075);\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);\n}\n.navbar-inverse {\n background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%);\n background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .active > a {\n background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%);\n background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);\n -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25);\n}\n.navbar-inverse .navbar-brand,\n.navbar-inverse .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n@media (max-width: 767px) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n }\n}\n.alert {\n text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2);\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.alert-success {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);\n border-color: #b2dba1;\n}\n.alert-info {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);\n border-color: #9acfea;\n}\n.alert-warning {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);\n border-color: #f5e79e;\n}\n.alert-danger {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);\n border-color: #dca7a7;\n}\n.progress {\n background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);\n}\n.progress-bar {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);\n}\n.progress-bar-success {\n background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%);\n background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);\n}\n.progress-bar-info {\n background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%);\n background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);\n}\n.progress-bar-warning {\n background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%);\n background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);\n}\n.progress-bar-danger {\n background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%);\n background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);\n}\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.list-group {\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075);\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 #286090;\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);\n border-color: #2b669a;\n}\n.list-group-item.active .badge,\n.list-group-item.active:hover .badge,\n.list-group-item.active:focus .badge {\n text-shadow: none;\n}\n.panel {\n -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n.panel-default > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%);\n background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);\n}\n.panel-primary > .panel-heading {\n background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%);\n background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);\n}\n.panel-success > .panel-heading {\n background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%);\n background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);\n}\n.panel-info > .panel-heading {\n background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%);\n background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);\n}\n.panel-warning > .panel-heading {\n background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%);\n background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);\n}\n.panel-danger > .panel-heading {\n background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%);\n background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);\n}\n.well {\n background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%);\n background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);\n border-color: #dcdcdc;\n -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1);\n}\n/*# sourceMappingURL=bootstrap-theme.css.map */","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} \ No newline at end of file diff --git a/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.min.css b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.min.css new file mode 100644 index 000000000..ac8dd5505 --- /dev/null +++ b/webadmin/src/3rdparty/bootstrap/css/bootstrap-theme.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default:disabled,.btn-default[disabled]{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary:disabled,.btn-primary[disabled]{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success:disabled,.btn-success[disabled]{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info:disabled,.btn-info[disabled]{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning:disabled,.btn-warning[disabled]{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger:disabled,.btn-danger[disabled]{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/webadmin/src/3rdparty/bootstrap/css/bootstrap.css b/webadmin/src/3rdparty/bootstrap/css/bootstrap.css new file mode 100644 index 000000000..c46af7dfb --- /dev/null +++ b/webadmin/src/3rdparty/bootstrap/css/bootstrap.css @@ -0,0 +1,6566 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ +html { + font-family: sans-serif; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active, +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: bold; +} +dfn { + font-style: italic; +} +h1 { + margin: .67em 0; + font-size: 2em; +} +mark { + color: #000; + background: #ff0; +} +small { + font-size: 80%; +} +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} +sup { + top: -.5em; +} +sub { + bottom: -.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + height: 0; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + margin: 0; + font: inherit; + color: inherit; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + padding: 0; + border: 0; +} +input { + line-height: normal; +} +input[type="checkbox"], +input[type="radio"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} +input[type="search"] { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + -webkit-appearance: textfield; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + padding: .35em .625em .75em; + margin: 0 2px; + border: 1px solid #c0c0c0; +} +legend { + padding: 0; + border: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: bold; +} +table { + border-spacing: 0; + border-collapse: collapse; +} +td, +th { + padding: 0; +} +/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ +@media print { + *, + *:before, + *:after { + color: #000 !important; + text-shadow: none !important; + background: transparent !important; + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + a, + a:visited { + text-decoration: underline; + } + a[href]:after { + content: " (" attr(href) ")"; + } + abbr[title]:after { + content: " (" attr(title) ")"; + } + a[href^="#"]:after, + a[href^="javascript:"]:after { + content: ""; + } + pre, + blockquote { + border: 1px solid #999; + + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + img { + max-width: 100% !important; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + select { + background: #fff !important; + } + .navbar { + display: none; + } + .btn > .caret, + .dropup > .btn > .caret { + border-top-color: #000 !important; + } + .label { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #ddd !important; + } +} +@font-face { + font-family: 'Glyphicons Halflings'; + + src: url('../fonts/glyphicons-halflings-regular.eot'); + src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); +} +.glyphicon { + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; + font-style: normal; + font-weight: normal; + line-height: 1; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.glyphicon-asterisk:before { + content: "\2a"; +} +.glyphicon-plus:before { + content: "\2b"; +} +.glyphicon-euro:before, +.glyphicon-eur:before { + content: "\20ac"; +} +.glyphicon-minus:before { + content: "\2212"; +} +.glyphicon-cloud:before { + content: "\2601"; +} +.glyphicon-envelope:before { + content: "\2709"; +} +.glyphicon-pencil:before { + content: "\270f"; +} +.glyphicon-glass:before { + content: "\e001"; +} +.glyphicon-music:before { + content: "\e002"; +} +.glyphicon-search:before { + content: "\e003"; +} +.glyphicon-heart:before { + content: "\e005"; +} +.glyphicon-star:before { + content: "\e006"; +} +.glyphicon-star-empty:before { + content: "\e007"; +} +.glyphicon-user:before { + content: "\e008"; +} +.glyphicon-film:before { + content: "\e009"; +} +.glyphicon-th-large:before { + content: "\e010"; +} +.glyphicon-th:before { + content: "\e011"; +} +.glyphicon-th-list:before { + content: "\e012"; +} +.glyphicon-ok:before { + content: "\e013"; +} +.glyphicon-remove:before { + content: "\e014"; +} +.glyphicon-zoom-in:before { + content: "\e015"; +} +.glyphicon-zoom-out:before { + content: "\e016"; +} +.glyphicon-off:before { + content: "\e017"; +} +.glyphicon-signal:before { + content: "\e018"; +} +.glyphicon-cog:before { + content: "\e019"; +} +.glyphicon-trash:before { + content: "\e020"; +} +.glyphicon-home:before { + content: "\e021"; +} +.glyphicon-file:before { + content: "\e022"; +} +.glyphicon-time:before { + content: "\e023"; +} +.glyphicon-road:before { + content: "\e024"; +} +.glyphicon-download-alt:before { + content: "\e025"; +} +.glyphicon-download:before { + content: "\e026"; +} +.glyphicon-upload:before { + content: "\e027"; +} +.glyphicon-inbox:before { + content: "\e028"; +} +.glyphicon-play-circle:before { + content: "\e029"; +} +.glyphicon-repeat:before { + content: "\e030"; +} +.glyphicon-refresh:before { + content: "\e031"; +} +.glyphicon-list-alt:before { + content: "\e032"; +} +.glyphicon-lock:before { + content: "\e033"; +} +.glyphicon-flag:before { + content: "\e034"; +} +.glyphicon-headphones:before { + content: "\e035"; +} +.glyphicon-volume-off:before { + content: "\e036"; +} +.glyphicon-volume-down:before { + content: "\e037"; +} +.glyphicon-volume-up:before { + content: "\e038"; +} +.glyphicon-qrcode:before { + content: "\e039"; +} +.glyphicon-barcode:before { + content: "\e040"; +} +.glyphicon-tag:before { + content: "\e041"; +} +.glyphicon-tags:before { + content: "\e042"; +} +.glyphicon-book:before { + content: "\e043"; +} +.glyphicon-bookmark:before { + content: "\e044"; +} +.glyphicon-print:before { + content: "\e045"; +} +.glyphicon-camera:before { + content: "\e046"; +} +.glyphicon-font:before { + content: "\e047"; +} +.glyphicon-bold:before { + content: "\e048"; +} +.glyphicon-italic:before { + content: "\e049"; +} +.glyphicon-text-height:before { + content: "\e050"; +} +.glyphicon-text-width:before { + content: "\e051"; +} +.glyphicon-align-left:before { + content: "\e052"; +} +.glyphicon-align-center:before { + content: "\e053"; +} +.glyphicon-align-right:before { + content: "\e054"; +} +.glyphicon-align-justify:before { + content: "\e055"; +} +.glyphicon-list:before { + content: "\e056"; +} +.glyphicon-indent-left:before { + content: "\e057"; +} +.glyphicon-indent-right:before { + content: "\e058"; +} +.glyphicon-facetime-video:before { + content: "\e059"; +} +.glyphicon-picture:before { + content: "\e060"; +} +.glyphicon-map-marker:before { + content: "\e062"; +} +.glyphicon-adjust:before { + content: "\e063"; +} +.glyphicon-tint:before { + content: "\e064"; +} +.glyphicon-edit:before { + content: "\e065"; +} +.glyphicon-share:before { + content: "\e066"; +} +.glyphicon-check:before { + content: "\e067"; +} +.glyphicon-move:before { + content: "\e068"; +} +.glyphicon-step-backward:before { + content: "\e069"; +} +.glyphicon-fast-backward:before { + content: "\e070"; +} +.glyphicon-backward:before { + content: "\e071"; +} +.glyphicon-play:before { + content: "\e072"; +} +.glyphicon-pause:before { + content: "\e073"; +} +.glyphicon-stop:before { + content: "\e074"; +} +.glyphicon-forward:before { + content: "\e075"; +} +.glyphicon-fast-forward:before { + content: "\e076"; +} +.glyphicon-step-forward:before { + content: "\e077"; +} +.glyphicon-eject:before { + content: "\e078"; +} +.glyphicon-chevron-left:before { + content: "\e079"; +} +.glyphicon-chevron-right:before { + content: "\e080"; +} +.glyphicon-plus-sign:before { + content: "\e081"; +} +.glyphicon-minus-sign:before { + content: "\e082"; +} +.glyphicon-remove-sign:before { + content: "\e083"; +} +.glyphicon-ok-sign:before { + content: "\e084"; +} +.glyphicon-question-sign:before { + content: "\e085"; +} +.glyphicon-info-sign:before { + content: "\e086"; +} +.glyphicon-screenshot:before { + content: "\e087"; +} +.glyphicon-remove-circle:before { + content: "\e088"; +} +.glyphicon-ok-circle:before { + content: "\e089"; +} +.glyphicon-ban-circle:before { + content: "\e090"; +} +.glyphicon-arrow-left:before { + content: "\e091"; +} +.glyphicon-arrow-right:before { + content: "\e092"; +} +.glyphicon-arrow-up:before { + content: "\e093"; +} +.glyphicon-arrow-down:before { + content: "\e094"; +} +.glyphicon-share-alt:before { + content: "\e095"; +} +.glyphicon-resize-full:before { + content: "\e096"; +} +.glyphicon-resize-small:before { + content: "\e097"; +} +.glyphicon-exclamation-sign:before { + content: "\e101"; +} +.glyphicon-gift:before { + content: "\e102"; +} +.glyphicon-leaf:before { + content: "\e103"; +} +.glyphicon-fire:before { + content: "\e104"; +} +.glyphicon-eye-open:before { + content: "\e105"; +} +.glyphicon-eye-close:before { + content: "\e106"; +} +.glyphicon-warning-sign:before { + content: "\e107"; +} +.glyphicon-plane:before { + content: "\e108"; +} +.glyphicon-calendar:before { + content: "\e109"; +} +.glyphicon-random:before { + content: "\e110"; +} +.glyphicon-comment:before { + content: "\e111"; +} +.glyphicon-magnet:before { + content: "\e112"; +} +.glyphicon-chevron-up:before { + content: "\e113"; +} +.glyphicon-chevron-down:before { + content: "\e114"; +} +.glyphicon-retweet:before { + content: "\e115"; +} +.glyphicon-shopping-cart:before { + content: "\e116"; +} +.glyphicon-folder-close:before { + content: "\e117"; +} +.glyphicon-folder-open:before { + content: "\e118"; +} +.glyphicon-resize-vertical:before { + content: "\e119"; +} +.glyphicon-resize-horizontal:before { + content: "\e120"; +} +.glyphicon-hdd:before { + content: "\e121"; +} +.glyphicon-bullhorn:before { + content: "\e122"; +} +.glyphicon-bell:before { + content: "\e123"; +} +.glyphicon-certificate:before { + content: "\e124"; +} +.glyphicon-thumbs-up:before { + content: "\e125"; +} +.glyphicon-thumbs-down:before { + content: "\e126"; +} +.glyphicon-hand-right:before { + content: "\e127"; +} +.glyphicon-hand-left:before { + content: "\e128"; +} +.glyphicon-hand-up:before { + content: "\e129"; +} +.glyphicon-hand-down:before { + content: "\e130"; +} +.glyphicon-circle-arrow-right:before { + content: "\e131"; +} +.glyphicon-circle-arrow-left:before { + content: "\e132"; +} +.glyphicon-circle-arrow-up:before { + content: "\e133"; +} +.glyphicon-circle-arrow-down:before { + content: "\e134"; +} +.glyphicon-globe:before { + content: "\e135"; +} +.glyphicon-wrench:before { + content: "\e136"; +} +.glyphicon-tasks:before { + content: "\e137"; +} +.glyphicon-filter:before { + content: "\e138"; +} +.glyphicon-briefcase:before { + content: "\e139"; +} +.glyphicon-fullscreen:before { + content: "\e140"; +} +.glyphicon-dashboard:before { + content: "\e141"; +} +.glyphicon-paperclip:before { + content: "\e142"; +} +.glyphicon-heart-empty:before { + content: "\e143"; +} +.glyphicon-link:before { + content: "\e144"; +} +.glyphicon-phone:before { + content: "\e145"; +} +.glyphicon-pushpin:before { + content: "\e146"; +} +.glyphicon-usd:before { + content: "\e148"; +} +.glyphicon-gbp:before { + content: "\e149"; +} +.glyphicon-sort:before { + content: "\e150"; +} +.glyphicon-sort-by-alphabet:before { + content: "\e151"; +} +.glyphicon-sort-by-alphabet-alt:before { + content: "\e152"; +} +.glyphicon-sort-by-order:before { + content: "\e153"; +} +.glyphicon-sort-by-order-alt:before { + content: "\e154"; +} +.glyphicon-sort-by-attributes:before { + content: "\e155"; +} +.glyphicon-sort-by-attributes-alt:before { + content: "\e156"; +} +.glyphicon-unchecked:before { + content: "\e157"; +} +.glyphicon-expand:before { + content: "\e158"; +} +.glyphicon-collapse-down:before { + content: "\e159"; +} +.glyphicon-collapse-up:before { + content: "\e160"; +} +.glyphicon-log-in:before { + content: "\e161"; +} +.glyphicon-flash:before { + content: "\e162"; +} +.glyphicon-log-out:before { + content: "\e163"; +} +.glyphicon-new-window:before { + content: "\e164"; +} +.glyphicon-record:before { + content: "\e165"; +} +.glyphicon-save:before { + content: "\e166"; +} +.glyphicon-open:before { + content: "\e167"; +} +.glyphicon-saved:before { + content: "\e168"; +} +.glyphicon-import:before { + content: "\e169"; +} +.glyphicon-export:before { + content: "\e170"; +} +.glyphicon-send:before { + content: "\e171"; +} +.glyphicon-floppy-disk:before { + content: "\e172"; +} +.glyphicon-floppy-saved:before { + content: "\e173"; +} +.glyphicon-floppy-remove:before { + content: "\e174"; +} +.glyphicon-floppy-save:before { + content: "\e175"; +} +.glyphicon-floppy-open:before { + content: "\e176"; +} +.glyphicon-credit-card:before { + content: "\e177"; +} +.glyphicon-transfer:before { + content: "\e178"; +} +.glyphicon-cutlery:before { + content: "\e179"; +} +.glyphicon-header:before { + content: "\e180"; +} +.glyphicon-compressed:before { + content: "\e181"; +} +.glyphicon-earphone:before { + content: "\e182"; +} +.glyphicon-phone-alt:before { + content: "\e183"; +} +.glyphicon-tower:before { + content: "\e184"; +} +.glyphicon-stats:before { + content: "\e185"; +} +.glyphicon-sd-video:before { + content: "\e186"; +} +.glyphicon-hd-video:before { + content: "\e187"; +} +.glyphicon-subtitles:before { + content: "\e188"; +} +.glyphicon-sound-stereo:before { + content: "\e189"; +} +.glyphicon-sound-dolby:before { + content: "\e190"; +} +.glyphicon-sound-5-1:before { + content: "\e191"; +} +.glyphicon-sound-6-1:before { + content: "\e192"; +} +.glyphicon-sound-7-1:before { + content: "\e193"; +} +.glyphicon-copyright-mark:before { + content: "\e194"; +} +.glyphicon-registration-mark:before { + content: "\e195"; +} +.glyphicon-cloud-download:before { + content: "\e197"; +} +.glyphicon-cloud-upload:before { + content: "\e198"; +} +.glyphicon-tree-conifer:before { + content: "\e199"; +} +.glyphicon-tree-deciduous:before { + content: "\e200"; +} +.glyphicon-cd:before { + content: "\e201"; +} +.glyphicon-save-file:before { + content: "\e202"; +} +.glyphicon-open-file:before { + content: "\e203"; +} +.glyphicon-level-up:before { + content: "\e204"; +} +.glyphicon-copy:before { + content: "\e205"; +} +.glyphicon-paste:before { + content: "\e206"; +} +.glyphicon-alert:before { + content: "\e209"; +} +.glyphicon-equalizer:before { + content: "\e210"; +} +.glyphicon-king:before { + content: "\e211"; +} +.glyphicon-queen:before { + content: "\e212"; +} +.glyphicon-pawn:before { + content: "\e213"; +} +.glyphicon-bishop:before { + content: "\e214"; +} +.glyphicon-knight:before { + content: "\e215"; +} +.glyphicon-baby-formula:before { + content: "\e216"; +} +.glyphicon-tent:before { + content: "\26fa"; +} +.glyphicon-blackboard:before { + content: "\e218"; +} +.glyphicon-bed:before { + content: "\e219"; +} +.glyphicon-apple:before { + content: "\f8ff"; +} +.glyphicon-erase:before { + content: "\e221"; +} +.glyphicon-hourglass:before { + content: "\231b"; +} +.glyphicon-lamp:before { + content: "\e223"; +} +.glyphicon-duplicate:before { + content: "\e224"; +} +.glyphicon-piggy-bank:before { + content: "\e225"; +} +.glyphicon-scissors:before { + content: "\e226"; +} +.glyphicon-bitcoin:before { + content: "\e227"; +} +.glyphicon-yen:before { + content: "\00a5"; +} +.glyphicon-ruble:before { + content: "\20bd"; +} +.glyphicon-scale:before { + content: "\e230"; +} +.glyphicon-ice-lolly:before { + content: "\e231"; +} +.glyphicon-ice-lolly-tasted:before { + content: "\e232"; +} +.glyphicon-education:before { + content: "\e233"; +} +.glyphicon-option-horizontal:before { + content: "\e234"; +} +.glyphicon-option-vertical:before { + content: "\e235"; +} +.glyphicon-menu-hamburger:before { + content: "\e236"; +} +.glyphicon-modal-window:before { + content: "\e237"; +} +.glyphicon-oil:before { + content: "\e238"; +} +.glyphicon-grain:before { + content: "\e239"; +} +.glyphicon-sunglasses:before { + content: "\e240"; +} +.glyphicon-text-size:before { + content: "\e241"; +} +.glyphicon-text-color:before { + content: "\e242"; +} +.glyphicon-text-background:before { + content: "\e243"; +} +.glyphicon-object-align-top:before { + content: "\e244"; +} +.glyphicon-object-align-bottom:before { + content: "\e245"; +} +.glyphicon-object-align-horizontal:before { + content: "\e246"; +} +.glyphicon-object-align-left:before { + content: "\e247"; +} +.glyphicon-object-align-vertical:before { + content: "\e248"; +} +.glyphicon-object-align-right:before { + content: "\e249"; +} +.glyphicon-triangle-right:before { + content: "\e250"; +} +.glyphicon-triangle-left:before { + content: "\e251"; +} +.glyphicon-triangle-bottom:before { + content: "\e252"; +} +.glyphicon-triangle-top:before { + content: "\e253"; +} +.glyphicon-console:before { + content: "\e254"; +} +.glyphicon-superscript:before { + content: "\e255"; +} +.glyphicon-subscript:before { + content: "\e256"; +} +.glyphicon-menu-left:before { + content: "\e257"; +} +.glyphicon-menu-right:before { + content: "\e258"; +} +.glyphicon-menu-down:before { + content: "\e259"; +} +.glyphicon-menu-up:before { + content: "\e260"; +} +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +*:before, +*:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +html { + font-size: 10px; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.42857143; + color: #333; + background-color: #fff; +} +input, +button, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +a { + color: #337ab7; + text-decoration: none; +} +a:hover, +a:focus { + color: #23527c; + text-decoration: underline; +} +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +figure { + margin: 0; +} +img { + vertical-align: middle; +} +.img-responsive, +.thumbnail > img, +.thumbnail a > img, +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 6px; +} +.img-thumbnail { + display: inline-block; + max-width: 100%; + height: auto; + padding: 4px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: all .2s ease-in-out; + -o-transition: all .2s ease-in-out; + transition: all .2s ease-in-out; +} +.img-circle { + border-radius: 50%; +} +hr { + margin-top: 20px; + margin-bottom: 20px; + border: 0; + border-top: 1px solid #eee; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +h1 small, +h2 small, +h3 small, +h4 small, +h5 small, +h6 small, +.h1 small, +.h2 small, +.h3 small, +.h4 small, +.h5 small, +.h6 small, +h1 .small, +h2 .small, +h3 .small, +h4 .small, +h5 .small, +h6 .small, +.h1 .small, +.h2 .small, +.h3 .small, +.h4 .small, +.h5 .small, +.h6 .small { + font-weight: normal; + line-height: 1; + color: #777; +} +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} +h1 small, +.h1 small, +h2 small, +.h2 small, +h3 small, +.h3 small, +h1 .small, +.h1 .small, +h2 .small, +.h2 .small, +h3 .small, +.h3 .small { + font-size: 65%; +} +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} +h4 small, +.h4 small, +h5 small, +.h5 small, +h6 small, +.h6 small, +h4 .small, +.h4 .small, +h5 .small, +.h5 .small, +h6 .small, +.h6 .small { + font-size: 75%; +} +h1, +.h1 { + font-size: 36px; +} +h2, +.h2 { + font-size: 30px; +} +h3, +.h3 { + font-size: 24px; +} +h4, +.h4 { + font-size: 18px; +} +h5, +.h5 { + font-size: 14px; +} +h6, +.h6 { + font-size: 12px; +} +p { + margin: 0 0 10px; +} +.lead { + margin-bottom: 20px; + font-size: 16px; + font-weight: 300; + line-height: 1.4; +} +@media (min-width: 768px) { + .lead { + font-size: 21px; + } +} +small, +.small { + font-size: 85%; +} +mark, +.mark { + padding: .2em; + background-color: #fcf8e3; +} +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-center { + text-align: center; +} +.text-justify { + text-align: justify; +} +.text-nowrap { + white-space: nowrap; +} +.text-lowercase { + text-transform: lowercase; +} +.text-uppercase { + text-transform: uppercase; +} +.text-capitalize { + text-transform: capitalize; +} +.text-muted { + color: #777; +} +.text-primary { + color: #337ab7; +} +a.text-primary:hover { + color: #286090; +} +.text-success { + color: #3c763d; +} +a.text-success:hover { + color: #2b542c; +} +.text-info { + color: #31708f; +} +a.text-info:hover { + color: #245269; +} +.text-warning { + color: #8a6d3b; +} +a.text-warning:hover { + color: #66512c; +} +.text-danger { + color: #a94442; +} +a.text-danger:hover { + color: #843534; +} +.bg-primary { + color: #fff; + background-color: #337ab7; +} +a.bg-primary:hover { + background-color: #286090; +} +.bg-success { + background-color: #dff0d8; +} +a.bg-success:hover { + background-color: #c1e2b3; +} +.bg-info { + background-color: #d9edf7; +} +a.bg-info:hover { + background-color: #afd9ee; +} +.bg-warning { + background-color: #fcf8e3; +} +a.bg-warning:hover { + background-color: #f7ecb5; +} +.bg-danger { + background-color: #f2dede; +} +a.bg-danger:hover { + background-color: #e4b9b9; +} +.page-header { + padding-bottom: 9px; + margin: 40px 0 20px; + border-bottom: 1px solid #eee; +} +ul, +ol { + margin-top: 0; + margin-bottom: 10px; +} +ul ul, +ol ul, +ul ol, +ol ol { + margin-bottom: 0; +} +.list-unstyled { + padding-left: 0; + list-style: none; +} +.list-inline { + padding-left: 0; + margin-left: -5px; + list-style: none; +} +.list-inline > li { + display: inline-block; + padding-right: 5px; + padding-left: 5px; +} +dl { + margin-top: 0; + margin-bottom: 20px; +} +dt, +dd { + line-height: 1.42857143; +} +dt { + font-weight: bold; +} +dd { + margin-left: 0; +} +@media (min-width: 768px) { + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + .dl-horizontal dd { + margin-left: 180px; + } +} +abbr[title], +abbr[data-original-title] { + cursor: help; + border-bottom: 1px dotted #777; +} +.initialism { + font-size: 90%; + text-transform: uppercase; +} +blockquote { + padding: 10px 20px; + margin: 0 0 20px; + font-size: 17.5px; + border-left: 5px solid #eee; +} +blockquote p:last-child, +blockquote ul:last-child, +blockquote ol:last-child { + margin-bottom: 0; +} +blockquote footer, +blockquote small, +blockquote .small { + display: block; + font-size: 80%; + line-height: 1.42857143; + color: #777; +} +blockquote footer:before, +blockquote small:before, +blockquote .small:before { + content: '\2014 \00A0'; +} +.blockquote-reverse, +blockquote.pull-right { + padding-right: 15px; + padding-left: 0; + text-align: right; + border-right: 5px solid #eee; + border-left: 0; +} +.blockquote-reverse footer:before, +blockquote.pull-right footer:before, +.blockquote-reverse small:before, +blockquote.pull-right small:before, +.blockquote-reverse .small:before, +blockquote.pull-right .small:before { + content: ''; +} +.blockquote-reverse footer:after, +blockquote.pull-right footer:after, +.blockquote-reverse small:after, +blockquote.pull-right small:after, +.blockquote-reverse .small:after, +blockquote.pull-right .small:after { + content: '\00A0 \2014'; +} +address { + margin-bottom: 20px; + font-style: normal; + line-height: 1.42857143; +} +code, +kbd, +pre, +samp { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} +kbd { + padding: 2px 4px; + font-size: 90%; + color: #fff; + background-color: #333; + border-radius: 3px; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: bold; + -webkit-box-shadow: none; + box-shadow: none; +} +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.42857143; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; +} +pre code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; +} +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} +.container { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +@media (min-width: 768px) { + .container { + width: 750px; + } +} +@media (min-width: 992px) { + .container { + width: 970px; + } +} +@media (min-width: 1200px) { + .container { + width: 1170px; + } +} +.container-fluid { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +.row { + margin-right: -15px; + margin-left: -15px; +} +.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} +.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { + float: left; +} +.col-xs-12 { + width: 100%; +} +.col-xs-11 { + width: 91.66666667%; +} +.col-xs-10 { + width: 83.33333333%; +} +.col-xs-9 { + width: 75%; +} +.col-xs-8 { + width: 66.66666667%; +} +.col-xs-7 { + width: 58.33333333%; +} +.col-xs-6 { + width: 50%; +} +.col-xs-5 { + width: 41.66666667%; +} +.col-xs-4 { + width: 33.33333333%; +} +.col-xs-3 { + width: 25%; +} +.col-xs-2 { + width: 16.66666667%; +} +.col-xs-1 { + width: 8.33333333%; +} +.col-xs-pull-12 { + right: 100%; +} +.col-xs-pull-11 { + right: 91.66666667%; +} +.col-xs-pull-10 { + right: 83.33333333%; +} +.col-xs-pull-9 { + right: 75%; +} +.col-xs-pull-8 { + right: 66.66666667%; +} +.col-xs-pull-7 { + right: 58.33333333%; +} +.col-xs-pull-6 { + right: 50%; +} +.col-xs-pull-5 { + right: 41.66666667%; +} +.col-xs-pull-4 { + right: 33.33333333%; +} +.col-xs-pull-3 { + right: 25%; +} +.col-xs-pull-2 { + right: 16.66666667%; +} +.col-xs-pull-1 { + right: 8.33333333%; +} +.col-xs-pull-0 { + right: auto; +} +.col-xs-push-12 { + left: 100%; +} +.col-xs-push-11 { + left: 91.66666667%; +} +.col-xs-push-10 { + left: 83.33333333%; +} +.col-xs-push-9 { + left: 75%; +} +.col-xs-push-8 { + left: 66.66666667%; +} +.col-xs-push-7 { + left: 58.33333333%; +} +.col-xs-push-6 { + left: 50%; +} +.col-xs-push-5 { + left: 41.66666667%; +} +.col-xs-push-4 { + left: 33.33333333%; +} +.col-xs-push-3 { + left: 25%; +} +.col-xs-push-2 { + left: 16.66666667%; +} +.col-xs-push-1 { + left: 8.33333333%; +} +.col-xs-push-0 { + left: auto; +} +.col-xs-offset-12 { + margin-left: 100%; +} +.col-xs-offset-11 { + margin-left: 91.66666667%; +} +.col-xs-offset-10 { + margin-left: 83.33333333%; +} +.col-xs-offset-9 { + margin-left: 75%; +} +.col-xs-offset-8 { + margin-left: 66.66666667%; +} +.col-xs-offset-7 { + margin-left: 58.33333333%; +} +.col-xs-offset-6 { + margin-left: 50%; +} +.col-xs-offset-5 { + margin-left: 41.66666667%; +} +.col-xs-offset-4 { + margin-left: 33.33333333%; +} +.col-xs-offset-3 { + margin-left: 25%; +} +.col-xs-offset-2 { + margin-left: 16.66666667%; +} +.col-xs-offset-1 { + margin-left: 8.33333333%; +} +.col-xs-offset-0 { + margin-left: 0; +} +@media (min-width: 768px) { + .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { + float: left; + } + .col-sm-12 { + width: 100%; + } + .col-sm-11 { + width: 91.66666667%; + } + .col-sm-10 { + width: 83.33333333%; + } + .col-sm-9 { + width: 75%; + } + .col-sm-8 { + width: 66.66666667%; + } + .col-sm-7 { + width: 58.33333333%; + } + .col-sm-6 { + width: 50%; + } + .col-sm-5 { + width: 41.66666667%; + } + .col-sm-4 { + width: 33.33333333%; + } + .col-sm-3 { + width: 25%; + } + .col-sm-2 { + width: 16.66666667%; + } + .col-sm-1 { + width: 8.33333333%; + } + .col-sm-pull-12 { + right: 100%; + } + .col-sm-pull-11 { + right: 91.66666667%; + } + .col-sm-pull-10 { + right: 83.33333333%; + } + .col-sm-pull-9 { + right: 75%; + } + .col-sm-pull-8 { + right: 66.66666667%; + } + .col-sm-pull-7 { + right: 58.33333333%; + } + .col-sm-pull-6 { + right: 50%; + } + .col-sm-pull-5 { + right: 41.66666667%; + } + .col-sm-pull-4 { + right: 33.33333333%; + } + .col-sm-pull-3 { + right: 25%; + } + .col-sm-pull-2 { + right: 16.66666667%; + } + .col-sm-pull-1 { + right: 8.33333333%; + } + .col-sm-pull-0 { + right: auto; + } + .col-sm-push-12 { + left: 100%; + } + .col-sm-push-11 { + left: 91.66666667%; + } + .col-sm-push-10 { + left: 83.33333333%; + } + .col-sm-push-9 { + left: 75%; + } + .col-sm-push-8 { + left: 66.66666667%; + } + .col-sm-push-7 { + left: 58.33333333%; + } + .col-sm-push-6 { + left: 50%; + } + .col-sm-push-5 { + left: 41.66666667%; + } + .col-sm-push-4 { + left: 33.33333333%; + } + .col-sm-push-3 { + left: 25%; + } + .col-sm-push-2 { + left: 16.66666667%; + } + .col-sm-push-1 { + left: 8.33333333%; + } + .col-sm-push-0 { + left: auto; + } + .col-sm-offset-12 { + margin-left: 100%; + } + .col-sm-offset-11 { + margin-left: 91.66666667%; + } + .col-sm-offset-10 { + margin-left: 83.33333333%; + } + .col-sm-offset-9 { + margin-left: 75%; + } + .col-sm-offset-8 { + margin-left: 66.66666667%; + } + .col-sm-offset-7 { + margin-left: 58.33333333%; + } + .col-sm-offset-6 { + margin-left: 50%; + } + .col-sm-offset-5 { + margin-left: 41.66666667%; + } + .col-sm-offset-4 { + margin-left: 33.33333333%; + } + .col-sm-offset-3 { + margin-left: 25%; + } + .col-sm-offset-2 { + margin-left: 16.66666667%; + } + .col-sm-offset-1 { + margin-left: 8.33333333%; + } + .col-sm-offset-0 { + margin-left: 0; + } +} +@media (min-width: 992px) { + .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { + float: left; + } + .col-md-12 { + width: 100%; + } + .col-md-11 { + width: 91.66666667%; + } + .col-md-10 { + width: 83.33333333%; + } + .col-md-9 { + width: 75%; + } + .col-md-8 { + width: 66.66666667%; + } + .col-md-7 { + width: 58.33333333%; + } + .col-md-6 { + width: 50%; + } + .col-md-5 { + width: 41.66666667%; + } + .col-md-4 { + width: 33.33333333%; + } + .col-md-3 { + width: 25%; + } + .col-md-2 { + width: 16.66666667%; + } + .col-md-1 { + width: 8.33333333%; + } + .col-md-pull-12 { + right: 100%; + } + .col-md-pull-11 { + right: 91.66666667%; + } + .col-md-pull-10 { + right: 83.33333333%; + } + .col-md-pull-9 { + right: 75%; + } + .col-md-pull-8 { + right: 66.66666667%; + } + .col-md-pull-7 { + right: 58.33333333%; + } + .col-md-pull-6 { + right: 50%; + } + .col-md-pull-5 { + right: 41.66666667%; + } + .col-md-pull-4 { + right: 33.33333333%; + } + .col-md-pull-3 { + right: 25%; + } + .col-md-pull-2 { + right: 16.66666667%; + } + .col-md-pull-1 { + right: 8.33333333%; + } + .col-md-pull-0 { + right: auto; + } + .col-md-push-12 { + left: 100%; + } + .col-md-push-11 { + left: 91.66666667%; + } + .col-md-push-10 { + left: 83.33333333%; + } + .col-md-push-9 { + left: 75%; + } + .col-md-push-8 { + left: 66.66666667%; + } + .col-md-push-7 { + left: 58.33333333%; + } + .col-md-push-6 { + left: 50%; + } + .col-md-push-5 { + left: 41.66666667%; + } + .col-md-push-4 { + left: 33.33333333%; + } + .col-md-push-3 { + left: 25%; + } + .col-md-push-2 { + left: 16.66666667%; + } + .col-md-push-1 { + left: 8.33333333%; + } + .col-md-push-0 { + left: auto; + } + .col-md-offset-12 { + margin-left: 100%; + } + .col-md-offset-11 { + margin-left: 91.66666667%; + } + .col-md-offset-10 { + margin-left: 83.33333333%; + } + .col-md-offset-9 { + margin-left: 75%; + } + .col-md-offset-8 { + margin-left: 66.66666667%; + } + .col-md-offset-7 { + margin-left: 58.33333333%; + } + .col-md-offset-6 { + margin-left: 50%; + } + .col-md-offset-5 { + margin-left: 41.66666667%; + } + .col-md-offset-4 { + margin-left: 33.33333333%; + } + .col-md-offset-3 { + margin-left: 25%; + } + .col-md-offset-2 { + margin-left: 16.66666667%; + } + .col-md-offset-1 { + margin-left: 8.33333333%; + } + .col-md-offset-0 { + margin-left: 0; + } +} +@media (min-width: 1200px) { + .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { + float: left; + } + .col-lg-12 { + width: 100%; + } + .col-lg-11 { + width: 91.66666667%; + } + .col-lg-10 { + width: 83.33333333%; + } + .col-lg-9 { + width: 75%; + } + .col-lg-8 { + width: 66.66666667%; + } + .col-lg-7 { + width: 58.33333333%; + } + .col-lg-6 { + width: 50%; + } + .col-lg-5 { + width: 41.66666667%; + } + .col-lg-4 { + width: 33.33333333%; + } + .col-lg-3 { + width: 25%; + } + .col-lg-2 { + width: 16.66666667%; + } + .col-lg-1 { + width: 8.33333333%; + } + .col-lg-pull-12 { + right: 100%; + } + .col-lg-pull-11 { + right: 91.66666667%; + } + .col-lg-pull-10 { + right: 83.33333333%; + } + .col-lg-pull-9 { + right: 75%; + } + .col-lg-pull-8 { + right: 66.66666667%; + } + .col-lg-pull-7 { + right: 58.33333333%; + } + .col-lg-pull-6 { + right: 50%; + } + .col-lg-pull-5 { + right: 41.66666667%; + } + .col-lg-pull-4 { + right: 33.33333333%; + } + .col-lg-pull-3 { + right: 25%; + } + .col-lg-pull-2 { + right: 16.66666667%; + } + .col-lg-pull-1 { + right: 8.33333333%; + } + .col-lg-pull-0 { + right: auto; + } + .col-lg-push-12 { + left: 100%; + } + .col-lg-push-11 { + left: 91.66666667%; + } + .col-lg-push-10 { + left: 83.33333333%; + } + .col-lg-push-9 { + left: 75%; + } + .col-lg-push-8 { + left: 66.66666667%; + } + .col-lg-push-7 { + left: 58.33333333%; + } + .col-lg-push-6 { + left: 50%; + } + .col-lg-push-5 { + left: 41.66666667%; + } + .col-lg-push-4 { + left: 33.33333333%; + } + .col-lg-push-3 { + left: 25%; + } + .col-lg-push-2 { + left: 16.66666667%; + } + .col-lg-push-1 { + left: 8.33333333%; + } + .col-lg-push-0 { + left: auto; + } + .col-lg-offset-12 { + margin-left: 100%; + } + .col-lg-offset-11 { + margin-left: 91.66666667%; + } + .col-lg-offset-10 { + margin-left: 83.33333333%; + } + .col-lg-offset-9 { + margin-left: 75%; + } + .col-lg-offset-8 { + margin-left: 66.66666667%; + } + .col-lg-offset-7 { + margin-left: 58.33333333%; + } + .col-lg-offset-6 { + margin-left: 50%; + } + .col-lg-offset-5 { + margin-left: 41.66666667%; + } + .col-lg-offset-4 { + margin-left: 33.33333333%; + } + .col-lg-offset-3 { + margin-left: 25%; + } + .col-lg-offset-2 { + margin-left: 16.66666667%; + } + .col-lg-offset-1 { + margin-left: 8.33333333%; + } + .col-lg-offset-0 { + margin-left: 0; + } +} +table { + background-color: transparent; +} +caption { + padding-top: 8px; + padding-bottom: 8px; + color: #777; + text-align: left; +} +th { + text-align: left; +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #ddd; +} +.table > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; +} +.table > caption + thead > tr:first-child > th, +.table > colgroup + thead > tr:first-child > th, +.table > thead:first-child > tr:first-child > th, +.table > caption + thead > tr:first-child > td, +.table > colgroup + thead > tr:first-child > td, +.table > thead:first-child > tr:first-child > td { + border-top: 0; +} +.table > tbody + tbody { + border-top: 2px solid #ddd; +} +.table .table { + background-color: #fff; +} +.table-condensed > thead > tr > th, +.table-condensed > tbody > tr > th, +.table-condensed > tfoot > tr > th, +.table-condensed > thead > tr > td, +.table-condensed > tbody > tr > td, +.table-condensed > tfoot > tr > td { + padding: 5px; +} +.table-bordered { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > thead > tr > td { + border-bottom-width: 2px; +} +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: #f9f9f9; +} +.table-hover > tbody > tr:hover { + background-color: #f5f5f5; +} +table col[class*="col-"] { + position: static; + display: table-column; + float: none; +} +table td[class*="col-"], +table th[class*="col-"] { + position: static; + display: table-cell; + float: none; +} +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: #f5f5f5; +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: #e8e8e8; +} +.table > thead > tr > td.success, +.table > tbody > tr > td.success, +.table > tfoot > tr > td.success, +.table > thead > tr > th.success, +.table > tbody > tr > th.success, +.table > tfoot > tr > th.success, +.table > thead > tr.success > td, +.table > tbody > tr.success > td, +.table > tfoot > tr.success > td, +.table > thead > tr.success > th, +.table > tbody > tr.success > th, +.table > tfoot > tr.success > th { + background-color: #dff0d8; +} +.table-hover > tbody > tr > td.success:hover, +.table-hover > tbody > tr > th.success:hover, +.table-hover > tbody > tr.success:hover > td, +.table-hover > tbody > tr:hover > .success, +.table-hover > tbody > tr.success:hover > th { + background-color: #d0e9c6; +} +.table > thead > tr > td.info, +.table > tbody > tr > td.info, +.table > tfoot > tr > td.info, +.table > thead > tr > th.info, +.table > tbody > tr > th.info, +.table > tfoot > tr > th.info, +.table > thead > tr.info > td, +.table > tbody > tr.info > td, +.table > tfoot > tr.info > td, +.table > thead > tr.info > th, +.table > tbody > tr.info > th, +.table > tfoot > tr.info > th { + background-color: #d9edf7; +} +.table-hover > tbody > tr > td.info:hover, +.table-hover > tbody > tr > th.info:hover, +.table-hover > tbody > tr.info:hover > td, +.table-hover > tbody > tr:hover > .info, +.table-hover > tbody > tr.info:hover > th { + background-color: #c4e3f3; +} +.table > thead > tr > td.warning, +.table > tbody > tr > td.warning, +.table > tfoot > tr > td.warning, +.table > thead > tr > th.warning, +.table > tbody > tr > th.warning, +.table > tfoot > tr > th.warning, +.table > thead > tr.warning > td, +.table > tbody > tr.warning > td, +.table > tfoot > tr.warning > td, +.table > thead > tr.warning > th, +.table > tbody > tr.warning > th, +.table > tfoot > tr.warning > th { + background-color: #fcf8e3; +} +.table-hover > tbody > tr > td.warning:hover, +.table-hover > tbody > tr > th.warning:hover, +.table-hover > tbody > tr.warning:hover > td, +.table-hover > tbody > tr:hover > .warning, +.table-hover > tbody > tr.warning:hover > th { + background-color: #faf2cc; +} +.table > thead > tr > td.danger, +.table > tbody > tr > td.danger, +.table > tfoot > tr > td.danger, +.table > thead > tr > th.danger, +.table > tbody > tr > th.danger, +.table > tfoot > tr > th.danger, +.table > thead > tr.danger > td, +.table > tbody > tr.danger > td, +.table > tfoot > tr.danger > td, +.table > thead > tr.danger > th, +.table > tbody > tr.danger > th, +.table > tfoot > tr.danger > th { + background-color: #f2dede; +} +.table-hover > tbody > tr > td.danger:hover, +.table-hover > tbody > tr > th.danger:hover, +.table-hover > tbody > tr.danger:hover > td, +.table-hover > tbody > tr:hover > .danger, +.table-hover > tbody > tr.danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + min-height: .01%; + overflow-x: auto; +} +@media screen and (max-width: 767px) { + .table-responsive { + width: 100%; + margin-bottom: 15px; + overflow-y: hidden; + -ms-overflow-style: -ms-autohiding-scrollbar; + border: 1px solid #ddd; + } + .table-responsive > .table { + margin-bottom: 0; + } + .table-responsive > .table > thead > tr > th, + .table-responsive > .table > tbody > tr > th, + .table-responsive > .table > tfoot > tr > th, + .table-responsive > .table > thead > tr > td, + .table-responsive > .table > tbody > tr > td, + .table-responsive > .table > tfoot > tr > td { + white-space: nowrap; + } + .table-responsive > .table-bordered { + border: 0; + } + .table-responsive > .table-bordered > thead > tr > th:first-child, + .table-responsive > .table-bordered > tbody > tr > th:first-child, + .table-responsive > .table-bordered > tfoot > tr > th:first-child, + .table-responsive > .table-bordered > thead > tr > td:first-child, + .table-responsive > .table-bordered > tbody > tr > td:first-child, + .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; + } + .table-responsive > .table-bordered > thead > tr > th:last-child, + .table-responsive > .table-bordered > tbody > tr > th:last-child, + .table-responsive > .table-bordered > tfoot > tr > th:last-child, + .table-responsive > .table-bordered > thead > tr > td:last-child, + .table-responsive > .table-bordered > tbody > tr > td:last-child, + .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; + } + .table-responsive > .table-bordered > tbody > tr:last-child > th, + .table-responsive > .table-bordered > tfoot > tr:last-child > th, + .table-responsive > .table-bordered > tbody > tr:last-child > td, + .table-responsive > .table-bordered > tfoot > tr:last-child > td { + border-bottom: 0; + } +} +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} +legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 20px; + font-size: 21px; + line-height: inherit; + color: #333; + border: 0; + border-bottom: 1px solid #e5e5e5; +} +label { + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + font-weight: bold; +} +input[type="search"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +input[type="radio"], +input[type="checkbox"] { + margin: 4px 0 0; + margin-top: 1px \9; + line-height: normal; +} +input[type="file"] { + display: block; +} +input[type="range"] { + display: block; + width: 100%; +} +select[multiple], +select[size] { + height: auto; +} +input[type="file"]:focus, +input[type="radio"]:focus, +input[type="checkbox"]:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +output { + display: block; + padding-top: 7px; + font-size: 14px; + line-height: 1.42857143; + color: #555; +} +.form-control { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; +} +.form-control:focus { + border-color: #66afe9; + outline: 0; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); +} +.form-control::-moz-placeholder { + color: #999; + opacity: 1; +} +.form-control:-ms-input-placeholder { + color: #999; +} +.form-control::-webkit-input-placeholder { + color: #999; +} +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + cursor: not-allowed; + background-color: #eee; + opacity: 1; +} +textarea.form-control { + height: auto; +} +input[type="search"] { + -webkit-appearance: none; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + input[type="date"], + input[type="time"], + input[type="datetime-local"], + input[type="month"] { + line-height: 34px; + } + input[type="date"].input-sm, + input[type="time"].input-sm, + input[type="datetime-local"].input-sm, + input[type="month"].input-sm, + .input-group-sm input[type="date"], + .input-group-sm input[type="time"], + .input-group-sm input[type="datetime-local"], + .input-group-sm input[type="month"] { + line-height: 30px; + } + input[type="date"].input-lg, + input[type="time"].input-lg, + input[type="datetime-local"].input-lg, + input[type="month"].input-lg, + .input-group-lg input[type="date"], + .input-group-lg input[type="time"], + .input-group-lg input[type="datetime-local"], + .input-group-lg input[type="month"] { + line-height: 46px; + } +} +.form-group { + margin-bottom: 15px; +} +.radio, +.checkbox { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +.radio label, +.checkbox label { + min-height: 20px; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + cursor: pointer; +} +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + position: absolute; + margin-top: 4px \9; + margin-left: -20px; +} +.radio + .radio, +.checkbox + .checkbox { + margin-top: -5px; +} +.radio-inline, +.checkbox-inline { + display: inline-block; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + vertical-align: middle; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 10px; +} +input[type="radio"][disabled], +input[type="checkbox"][disabled], +input[type="radio"].disabled, +input[type="checkbox"].disabled, +fieldset[disabled] input[type="radio"], +fieldset[disabled] input[type="checkbox"] { + cursor: not-allowed; +} +.radio-inline.disabled, +.checkbox-inline.disabled, +fieldset[disabled] .radio-inline, +fieldset[disabled] .checkbox-inline { + cursor: not-allowed; +} +.radio.disabled label, +.checkbox.disabled label, +fieldset[disabled] .radio label, +fieldset[disabled] .checkbox label { + cursor: not-allowed; +} +.form-control-static { + padding-top: 7px; + padding-bottom: 7px; + margin-bottom: 0; +} +.form-control-static.input-lg, +.form-control-static.input-sm { + padding-right: 0; + padding-left: 0; +} +.input-sm { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-sm { + height: 30px; + line-height: 30px; +} +textarea.input-sm, +select[multiple].input-sm { + height: auto; +} +.form-group-sm .form-control { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.form-group-sm .form-control { + height: 30px; + line-height: 30px; +} +textarea.form-group-sm .form-control, +select[multiple].form-group-sm .form-control { + height: auto; +} +.form-group-sm .form-control-static { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; +} +.input-lg { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-lg { + height: 46px; + line-height: 46px; +} +textarea.input-lg, +select[multiple].input-lg { + height: auto; +} +.form-group-lg .form-control { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.form-group-lg .form-control { + height: 46px; + line-height: 46px; +} +textarea.form-group-lg .form-control, +select[multiple].form-group-lg .form-control { + height: auto; +} +.form-group-lg .form-control-static { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; +} +.has-feedback { + position: relative; +} +.has-feedback .form-control { + padding-right: 42.5px; +} +.form-control-feedback { + position: absolute; + top: 0; + right: 0; + z-index: 2; + display: block; + width: 34px; + height: 34px; + line-height: 34px; + text-align: center; + pointer-events: none; +} +.input-lg + .form-control-feedback { + width: 46px; + height: 46px; + line-height: 46px; +} +.input-sm + .form-control-feedback { + width: 30px; + height: 30px; + line-height: 30px; +} +.has-success .help-block, +.has-success .control-label, +.has-success .radio, +.has-success .checkbox, +.has-success .radio-inline, +.has-success .checkbox-inline, +.has-success.radio label, +.has-success.checkbox label, +.has-success.radio-inline label, +.has-success.checkbox-inline label { + color: #3c763d; +} +.has-success .form-control { + border-color: #3c763d; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-success .form-control:focus { + border-color: #2b542c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; +} +.has-success .input-group-addon { + color: #3c763d; + background-color: #dff0d8; + border-color: #3c763d; +} +.has-success .form-control-feedback { + color: #3c763d; +} +.has-warning .help-block, +.has-warning .control-label, +.has-warning .radio, +.has-warning .checkbox, +.has-warning .radio-inline, +.has-warning .checkbox-inline, +.has-warning.radio label, +.has-warning.checkbox label, +.has-warning.radio-inline label, +.has-warning.checkbox-inline label { + color: #8a6d3b; +} +.has-warning .form-control { + border-color: #8a6d3b; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-warning .form-control:focus { + border-color: #66512c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; +} +.has-warning .input-group-addon { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #8a6d3b; +} +.has-warning .form-control-feedback { + color: #8a6d3b; +} +.has-error .help-block, +.has-error .control-label, +.has-error .radio, +.has-error .checkbox, +.has-error .radio-inline, +.has-error .checkbox-inline, +.has-error.radio label, +.has-error.checkbox label, +.has-error.radio-inline label, +.has-error.checkbox-inline label { + color: #a94442; +} +.has-error .form-control { + border-color: #a94442; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-error .form-control:focus { + border-color: #843534; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; +} +.has-error .input-group-addon { + color: #a94442; + background-color: #f2dede; + border-color: #a94442; +} +.has-error .form-control-feedback { + color: #a94442; +} +.has-feedback label ~ .form-control-feedback { + top: 25px; +} +.has-feedback label.sr-only ~ .form-control-feedback { + top: 0; +} +.help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: #737373; +} +@media (min-width: 768px) { + .form-inline .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-static { + display: inline-block; + } + .form-inline .input-group { + display: inline-table; + vertical-align: middle; + } + .form-inline .input-group .input-group-addon, + .form-inline .input-group .input-group-btn, + .form-inline .input-group .form-control { + width: auto; + } + .form-inline .input-group > .form-control { + width: 100%; + } + .form-inline .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio, + .form-inline .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio label, + .form-inline .checkbox label { + padding-left: 0; + } + .form-inline .radio input[type="radio"], + .form-inline .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .form-inline .has-feedback .form-control-feedback { + top: 0; + } +} +.form-horizontal .radio, +.form-horizontal .checkbox, +.form-horizontal .radio-inline, +.form-horizontal .checkbox-inline { + padding-top: 7px; + margin-top: 0; + margin-bottom: 0; +} +.form-horizontal .radio, +.form-horizontal .checkbox { + min-height: 27px; +} +.form-horizontal .form-group { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .form-horizontal .control-label { + padding-top: 7px; + margin-bottom: 0; + text-align: right; + } +} +.form-horizontal .has-feedback .form-control-feedback { + right: 15px; +} +@media (min-width: 768px) { + .form-horizontal .form-group-lg .control-label { + padding-top: 14.333333px; + } +} +@media (min-width: 768px) { + .form-horizontal .form-group-sm .control-label { + padding-top: 6px; + } +} +.btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.btn:focus, +.btn:active:focus, +.btn.active:focus, +.btn.focus, +.btn:active.focus, +.btn.active.focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +.btn:hover, +.btn:focus, +.btn.focus { + color: #333; + text-decoration: none; +} +.btn:active, +.btn.active { + background-image: none; + outline: 0; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + pointer-events: none; + cursor: not-allowed; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + box-shadow: none; + opacity: .65; +} +.btn-default { + color: #333; + background-color: #fff; + border-color: #ccc; +} +.btn-default:hover, +.btn-default:focus, +.btn-default.focus, +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + color: #333; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + background-image: none; +} +.btn-default.disabled, +.btn-default[disabled], +fieldset[disabled] .btn-default, +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus, +.btn-default.disabled:active, +.btn-default[disabled]:active, +fieldset[disabled] .btn-default:active, +.btn-default.disabled.active, +.btn-default[disabled].active, +fieldset[disabled] .btn-default.active { + background-color: #fff; + border-color: #ccc; +} +.btn-default .badge { + color: #fff; + background-color: #333; +} +.btn-primary { + color: #fff; + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary:hover, +.btn-primary:focus, +.btn-primary.focus, +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + color: #fff; + background-color: #286090; + border-color: #204d74; +} +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + background-image: none; +} +.btn-primary.disabled, +.btn-primary[disabled], +fieldset[disabled] .btn-primary, +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus, +.btn-primary.disabled:active, +.btn-primary[disabled]:active, +fieldset[disabled] .btn-primary:active, +.btn-primary.disabled.active, +.btn-primary[disabled].active, +fieldset[disabled] .btn-primary.active { + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary .badge { + color: #337ab7; + background-color: #fff; +} +.btn-success { + color: #fff; + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success:hover, +.btn-success:focus, +.btn-success.focus, +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + color: #fff; + background-color: #449d44; + border-color: #398439; +} +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + background-image: none; +} +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success .badge { + color: #5cb85c; + background-color: #fff; +} +.btn-info { + color: #fff; + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info:hover, +.btn-info:focus, +.btn-info.focus, +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + color: #fff; + background-color: #31b0d5; + border-color: #269abc; +} +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + background-image: none; +} +.btn-info.disabled, +.btn-info[disabled], +fieldset[disabled] .btn-info, +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus, +.btn-info.disabled:active, +.btn-info[disabled]:active, +fieldset[disabled] .btn-info:active, +.btn-info.disabled.active, +.btn-info[disabled].active, +fieldset[disabled] .btn-info.active { + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info .badge { + color: #5bc0de; + background-color: #fff; +} +.btn-warning { + color: #fff; + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning:hover, +.btn-warning:focus, +.btn-warning.focus, +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + color: #fff; + background-color: #ec971f; + border-color: #d58512; +} +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + background-image: none; +} +.btn-warning.disabled, +.btn-warning[disabled], +fieldset[disabled] .btn-warning, +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus, +.btn-warning.disabled:active, +.btn-warning[disabled]:active, +fieldset[disabled] .btn-warning:active, +.btn-warning.disabled.active, +.btn-warning[disabled].active, +fieldset[disabled] .btn-warning.active { + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning .badge { + color: #f0ad4e; + background-color: #fff; +} +.btn-danger { + color: #fff; + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger:hover, +.btn-danger:focus, +.btn-danger.focus, +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + color: #fff; + background-color: #c9302c; + border-color: #ac2925; +} +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + background-image: none; +} +.btn-danger.disabled, +.btn-danger[disabled], +fieldset[disabled] .btn-danger, +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus, +.btn-danger.disabled:active, +.btn-danger[disabled]:active, +fieldset[disabled] .btn-danger:active, +.btn-danger.disabled.active, +.btn-danger[disabled].active, +fieldset[disabled] .btn-danger.active { + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger .badge { + color: #d9534f; + background-color: #fff; +} +.btn-link { + font-weight: normal; + color: #337ab7; + border-radius: 0; +} +.btn-link, +.btn-link:active, +.btn-link.active, +.btn-link[disabled], +fieldset[disabled] .btn-link { + background-color: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-link, +.btn-link:hover, +.btn-link:focus, +.btn-link:active { + border-color: transparent; +} +.btn-link:hover, +.btn-link:focus { + color: #23527c; + text-decoration: underline; + background-color: transparent; +} +.btn-link[disabled]:hover, +fieldset[disabled] .btn-link:hover, +.btn-link[disabled]:focus, +fieldset[disabled] .btn-link:focus { + color: #777; + text-decoration: none; +} +.btn-lg, +.btn-group-lg > .btn { + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +.btn-sm, +.btn-group-sm > .btn { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-xs, +.btn-group-xs > .btn { + padding: 1px 5px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 5px; +} +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} +.fade { + opacity: 0; + -webkit-transition: opacity .15s linear; + -o-transition: opacity .15s linear; + transition: opacity .15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + display: none; + visibility: hidden; +} +.collapse.in { + display: block; + visibility: visible; +} +tr.collapse.in { + display: table-row; +} +tbody.collapse.in { + display: table-row-group; +} +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition-timing-function: ease; + -o-transition-timing-function: ease; + transition-timing-function: ease; + -webkit-transition-duration: .35s; + -o-transition-duration: .35s; + transition-duration: .35s; + -webkit-transition-property: height, visibility; + -o-transition-property: height, visibility; + transition-property: height, visibility; +} +.caret { + display: inline-block; + width: 0; + height: 0; + margin-left: 2px; + vertical-align: middle; + border-top: 4px solid; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} +.dropup, +.dropdown { + position: relative; +} +.dropdown-toggle:focus { + outline: 0; +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); +} +.dropdown-menu.pull-right { + right: 0; + left: auto; +} +.dropdown-menu .divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.dropdown-menu > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.42857143; + color: #333; + white-space: nowrap; +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + color: #262626; + text-decoration: none; + background-color: #f5f5f5; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + color: #fff; + text-decoration: none; + background-color: #337ab7; + outline: 0; +} +.dropdown-menu > .disabled > a, +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + color: #777; +} +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + text-decoration: none; + cursor: not-allowed; + background-color: transparent; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); +} +.open > .dropdown-menu { + display: block; +} +.open > a { + outline: 0; +} +.dropdown-menu-right { + right: 0; + left: auto; +} +.dropdown-menu-left { + right: auto; + left: 0; +} +.dropdown-header { + display: block; + padding: 3px 20px; + font-size: 12px; + line-height: 1.42857143; + color: #777; + white-space: nowrap; +} +.dropdown-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 990; +} +.pull-right > .dropdown-menu { + right: 0; + left: auto; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + content: ""; + border-top: 0; + border-bottom: 4px solid; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 2px; +} +@media (min-width: 768px) { + .navbar-right .dropdown-menu { + right: 0; + left: auto; + } + .navbar-right .dropdown-menu-left { + right: auto; + left: 0; + } +} +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover, +.btn-group > .btn:focus, +.btn-group-vertical > .btn:focus, +.btn-group > .btn:active, +.btn-group-vertical > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-right: 8px; + padding-left: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-right: 12px; + padding-left: 12px; +} +.btn-group.open .dropdown-toggle { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn-group.open .dropdown-toggle.btn-link { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret { + border-width: 5px 5px 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret { + border-width: 0 5px 5px; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 4px; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; +} +.btn-group-justified > .btn, +.btn-group-justified > .btn-group { + display: table-cell; + float: none; + width: 1%; +} +.btn-group-justified > .btn-group .btn { + width: 100%; +} +.btn-group-justified > .btn-group .dropdown-menu { + left: auto; +} +[data-toggle="buttons"] > .btn input[type="radio"], +[data-toggle="buttons"] > .btn-group > .btn input[type="radio"], +[data-toggle="buttons"] > .btn input[type="checkbox"], +[data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.input-group { + position: relative; + display: table; + border-collapse: separate; +} +.input-group[class*="col-"] { + float: none; + padding-right: 0; + padding-left: 0; +} +.input-group .form-control { + position: relative; + z-index: 2; + float: left; + width: 100%; + margin-bottom: 0; +} +.input-group-lg > .form-control, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .btn { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-group-lg > .form-control, +select.input-group-lg > .input-group-addon, +select.input-group-lg > .input-group-btn > .btn { + height: 46px; + line-height: 46px; +} +textarea.input-group-lg > .form-control, +textarea.input-group-lg > .input-group-addon, +textarea.input-group-lg > .input-group-btn > .btn, +select[multiple].input-group-lg > .form-control, +select[multiple].input-group-lg > .input-group-addon, +select[multiple].input-group-lg > .input-group-btn > .btn { + height: auto; +} +.input-group-sm > .form-control, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .btn { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-group-sm > .form-control, +select.input-group-sm > .input-group-addon, +select.input-group-sm > .input-group-btn > .btn { + height: 30px; + line-height: 30px; +} +textarea.input-group-sm > .form-control, +textarea.input-group-sm > .input-group-addon, +textarea.input-group-sm > .input-group-btn > .btn, +select[multiple].input-group-sm > .form-control, +select[multiple].input-group-sm > .input-group-addon, +select[multiple].input-group-sm > .input-group-btn > .btn { + height: auto; +} +.input-group-addon, +.input-group-btn, +.input-group .form-control { + display: table-cell; +} +.input-group-addon:not(:first-child):not(:last-child), +.input-group-btn:not(:first-child):not(:last-child), +.input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; +} +.input-group-addon, +.input-group-btn { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} +.input-group-addon { + padding: 6px 12px; + font-size: 14px; + font-weight: normal; + line-height: 1; + color: #555; + text-align: center; + background-color: #eee; + border: 1px solid #ccc; + border-radius: 4px; +} +.input-group-addon.input-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: 3px; +} +.input-group-addon.input-lg { + padding: 10px 16px; + font-size: 18px; + border-radius: 6px; +} +.input-group-addon input[type="radio"], +.input-group-addon input[type="checkbox"] { + margin-top: 0; +} +.input-group .form-control:first-child, +.input-group-addon:first-child, +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group > .btn, +.input-group-btn:first-child > .dropdown-toggle, +.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.input-group-addon:first-child { + border-right: 0; +} +.input-group .form-control:last-child, +.input-group-addon:last-child, +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group > .btn, +.input-group-btn:last-child > .dropdown-toggle, +.input-group-btn:first-child > .btn:not(:first-child), +.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.input-group-addon:last-child { + border-left: 0; +} +.input-group-btn { + position: relative; + font-size: 0; + white-space: nowrap; +} +.input-group-btn > .btn { + position: relative; +} +.input-group-btn > .btn + .btn { + margin-left: -1px; +} +.input-group-btn > .btn:hover, +.input-group-btn > .btn:focus, +.input-group-btn > .btn:active { + z-index: 2; +} +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group { + margin-right: -1px; +} +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group { + margin-left: -1px; +} +.nav { + padding-left: 0; + margin-bottom: 0; + list-style: none; +} +.nav > li { + position: relative; + display: block; +} +.nav > li > a { + position: relative; + display: block; + padding: 10px 15px; +} +.nav > li > a:hover, +.nav > li > a:focus { + text-decoration: none; + background-color: #eee; +} +.nav > li.disabled > a { + color: #777; +} +.nav > li.disabled > a:hover, +.nav > li.disabled > a:focus { + color: #777; + text-decoration: none; + cursor: not-allowed; + background-color: transparent; +} +.nav .open > a, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #eee; + border-color: #337ab7; +} +.nav .nav-divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.nav > li > a > img { + max-width: none; +} +.nav-tabs { + border-bottom: 1px solid #ddd; +} +.nav-tabs > li { + float: left; + margin-bottom: -1px; +} +.nav-tabs > li > a { + margin-right: 2px; + line-height: 1.42857143; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; +} +.nav-tabs > li > a:hover { + border-color: #eee #eee #ddd; +} +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: #555; + cursor: default; + background-color: #fff; + border: 1px solid #ddd; + border-bottom-color: transparent; +} +.nav-tabs.nav-justified { + width: 100%; + border-bottom: 0; +} +.nav-tabs.nav-justified > li { + float: none; +} +.nav-tabs.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-tabs.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-tabs.nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs.nav-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs.nav-justified > .active > a, +.nav-tabs.nav-justified > .active > a:hover, +.nav-tabs.nav-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs.nav-justified > .active > a, + .nav-tabs.nav-justified > .active > a:hover, + .nav-tabs.nav-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.nav-pills > li { + float: left; +} +.nav-pills > li > a { + border-radius: 4px; +} +.nav-pills > li + li { + margin-left: 2px; +} +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus { + color: #fff; + background-color: #337ab7; +} +.nav-stacked > li { + float: none; +} +.nav-stacked > li + li { + margin-top: 2px; + margin-left: 0; +} +.nav-justified { + width: 100%; +} +.nav-justified > li { + float: none; +} +.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs-justified { + border-bottom: 0; +} +.nav-tabs-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs-justified > .active > a, +.nav-tabs-justified > .active > a:hover, +.nav-tabs-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs-justified > .active > a, + .nav-tabs-justified > .active > a:hover, + .nav-tabs-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.tab-content > .tab-pane { + display: none; + visibility: hidden; +} +.tab-content > .active { + display: block; + visibility: visible; +} +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar { + position: relative; + min-height: 50px; + margin-bottom: 20px; + border: 1px solid transparent; +} +@media (min-width: 768px) { + .navbar { + border-radius: 4px; + } +} +@media (min-width: 768px) { + .navbar-header { + float: left; + } +} +.navbar-collapse { + padding-right: 15px; + padding-left: 15px; + overflow-x: visible; + -webkit-overflow-scrolling: touch; + border-top: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); +} +.navbar-collapse.in { + overflow-y: auto; +} +@media (min-width: 768px) { + .navbar-collapse { + width: auto; + border-top: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-collapse.collapse { + display: block !important; + height: auto !important; + padding-bottom: 0; + overflow: visible !important; + visibility: visible !important; + } + .navbar-collapse.in { + overflow-y: visible; + } + .navbar-fixed-top .navbar-collapse, + .navbar-static-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + padding-right: 0; + padding-left: 0; + } +} +.navbar-fixed-top .navbar-collapse, +.navbar-fixed-bottom .navbar-collapse { + max-height: 340px; +} +@media (max-device-width: 480px) and (orientation: landscape) { + .navbar-fixed-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + max-height: 200px; + } +} +.container > .navbar-header, +.container-fluid > .navbar-header, +.container > .navbar-collapse, +.container-fluid > .navbar-collapse { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .container > .navbar-header, + .container-fluid > .navbar-header, + .container > .navbar-collapse, + .container-fluid > .navbar-collapse { + margin-right: 0; + margin-left: 0; + } +} +.navbar-static-top { + z-index: 1000; + border-width: 0 0 1px; +} +@media (min-width: 768px) { + .navbar-static-top { + border-radius: 0; + } +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} +@media (min-width: 768px) { + .navbar-fixed-top, + .navbar-fixed-bottom { + border-radius: 0; + } +} +.navbar-fixed-top { + top: 0; + border-width: 0 0 1px; +} +.navbar-fixed-bottom { + bottom: 0; + margin-bottom: 0; + border-width: 1px 0 0; +} +.navbar-brand { + float: left; + height: 50px; + padding: 15px 15px; + font-size: 18px; + line-height: 20px; +} +.navbar-brand:hover, +.navbar-brand:focus { + text-decoration: none; +} +.navbar-brand > img { + display: block; +} +@media (min-width: 768px) { + .navbar > .container .navbar-brand, + .navbar > .container-fluid .navbar-brand { + margin-left: -15px; + } +} +.navbar-toggle { + position: relative; + float: right; + padding: 9px 10px; + margin-top: 8px; + margin-right: 15px; + margin-bottom: 8px; + background-color: transparent; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.navbar-toggle:focus { + outline: 0; +} +.navbar-toggle .icon-bar { + display: block; + width: 22px; + height: 2px; + border-radius: 1px; +} +.navbar-toggle .icon-bar + .icon-bar { + margin-top: 4px; +} +@media (min-width: 768px) { + .navbar-toggle { + display: none; + } +} +.navbar-nav { + margin: 7.5px -15px; +} +.navbar-nav > li > a { + padding-top: 10px; + padding-bottom: 10px; + line-height: 20px; +} +@media (max-width: 767px) { + .navbar-nav .open .dropdown-menu { + position: static; + float: none; + width: auto; + margin-top: 0; + background-color: transparent; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-nav .open .dropdown-menu > li > a, + .navbar-nav .open .dropdown-menu .dropdown-header { + padding: 5px 15px 5px 25px; + } + .navbar-nav .open .dropdown-menu > li > a { + line-height: 20px; + } + .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-nav .open .dropdown-menu > li > a:focus { + background-image: none; + } +} +@media (min-width: 768px) { + .navbar-nav { + float: left; + margin: 0; + } + .navbar-nav > li { + float: left; + } + .navbar-nav > li > a { + padding-top: 15px; + padding-bottom: 15px; + } +} +.navbar-form { + padding: 10px 15px; + margin-top: 8px; + margin-right: -15px; + margin-bottom: 8px; + margin-left: -15px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); +} +@media (min-width: 768px) { + .navbar-form .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .navbar-form .form-control-static { + display: inline-block; + } + .navbar-form .input-group { + display: inline-table; + vertical-align: middle; + } + .navbar-form .input-group .input-group-addon, + .navbar-form .input-group .input-group-btn, + .navbar-form .input-group .form-control { + width: auto; + } + .navbar-form .input-group > .form-control { + width: 100%; + } + .navbar-form .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio, + .navbar-form .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio label, + .navbar-form .checkbox label { + padding-left: 0; + } + .navbar-form .radio input[type="radio"], + .navbar-form .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .navbar-form .has-feedback .form-control-feedback { + top: 0; + } +} +@media (max-width: 767px) { + .navbar-form .form-group { + margin-bottom: 5px; + } + .navbar-form .form-group:last-child { + margin-bottom: 0; + } +} +@media (min-width: 768px) { + .navbar-form { + width: auto; + padding-top: 0; + padding-bottom: 0; + margin-right: 0; + margin-left: 0; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } +} +.navbar-nav > li > .dropdown-menu { + margin-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { + margin-bottom: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.navbar-btn { + margin-top: 8px; + margin-bottom: 8px; +} +.navbar-btn.btn-sm { + margin-top: 10px; + margin-bottom: 10px; +} +.navbar-btn.btn-xs { + margin-top: 14px; + margin-bottom: 14px; +} +.navbar-text { + margin-top: 15px; + margin-bottom: 15px; +} +@media (min-width: 768px) { + .navbar-text { + float: left; + margin-right: 15px; + margin-left: 15px; + } +} +@media (min-width: 768px) { + .navbar-left { + float: left !important; + } + .navbar-right { + float: right !important; + margin-right: -15px; + } + .navbar-right ~ .navbar-right { + margin-right: 0; + } +} +.navbar-default { + background-color: #f8f8f8; + border-color: #e7e7e7; +} +.navbar-default .navbar-brand { + color: #777; +} +.navbar-default .navbar-brand:hover, +.navbar-default .navbar-brand:focus { + color: #5e5e5e; + background-color: transparent; +} +.navbar-default .navbar-text { + color: #777; +} +.navbar-default .navbar-nav > li > a { + color: #777; +} +.navbar-default .navbar-nav > li > a:hover, +.navbar-default .navbar-nav > li > a:focus { + color: #333; + background-color: transparent; +} +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a:focus { + color: #555; + background-color: #e7e7e7; +} +.navbar-default .navbar-nav > .disabled > a, +.navbar-default .navbar-nav > .disabled > a:hover, +.navbar-default .navbar-nav > .disabled > a:focus { + color: #ccc; + background-color: transparent; +} +.navbar-default .navbar-toggle { + border-color: #ddd; +} +.navbar-default .navbar-toggle:hover, +.navbar-default .navbar-toggle:focus { + background-color: #ddd; +} +.navbar-default .navbar-toggle .icon-bar { + background-color: #888; +} +.navbar-default .navbar-collapse, +.navbar-default .navbar-form { + border-color: #e7e7e7; +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .open > a:hover, +.navbar-default .navbar-nav > .open > a:focus { + color: #555; + background-color: #e7e7e7; +} +@media (max-width: 767px) { + .navbar-default .navbar-nav .open .dropdown-menu > li > a { + color: #777; + } + .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { + color: #333; + background-color: transparent; + } + .navbar-default .navbar-nav .open .dropdown-menu > .active > a, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #555; + background-color: #e7e7e7; + } + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #ccc; + background-color: transparent; + } +} +.navbar-default .navbar-link { + color: #777; +} +.navbar-default .navbar-link:hover { + color: #333; +} +.navbar-default .btn-link { + color: #777; +} +.navbar-default .btn-link:hover, +.navbar-default .btn-link:focus { + color: #333; +} +.navbar-default .btn-link[disabled]:hover, +fieldset[disabled] .navbar-default .btn-link:hover, +.navbar-default .btn-link[disabled]:focus, +fieldset[disabled] .navbar-default .btn-link:focus { + color: #ccc; +} +.navbar-inverse { + background-color: #222; + border-color: #080808; +} +.navbar-inverse .navbar-brand { + color: #9d9d9d; +} +.navbar-inverse .navbar-brand:hover, +.navbar-inverse .navbar-brand:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-text { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a:hover, +.navbar-inverse .navbar-nav > li > a:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-nav > .active > a, +.navbar-inverse .navbar-nav > .active > a:hover, +.navbar-inverse .navbar-nav > .active > a:focus { + color: #fff; + background-color: #080808; +} +.navbar-inverse .navbar-nav > .disabled > a, +.navbar-inverse .navbar-nav > .disabled > a:hover, +.navbar-inverse .navbar-nav > .disabled > a:focus { + color: #444; + background-color: transparent; +} +.navbar-inverse .navbar-toggle { + border-color: #333; +} +.navbar-inverse .navbar-toggle:hover, +.navbar-inverse .navbar-toggle:focus { + background-color: #333; +} +.navbar-inverse .navbar-toggle .icon-bar { + background-color: #fff; +} +.navbar-inverse .navbar-collapse, +.navbar-inverse .navbar-form { + border-color: #101010; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .open > a:hover, +.navbar-inverse .navbar-nav > .open > a:focus { + color: #fff; + background-color: #080808; +} +@media (max-width: 767px) { + .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header { + border-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu .divider { + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a { + color: #9d9d9d; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus { + color: #fff; + background-color: transparent; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #444; + background-color: transparent; + } +} +.navbar-inverse .navbar-link { + color: #9d9d9d; +} +.navbar-inverse .navbar-link:hover { + color: #fff; +} +.navbar-inverse .btn-link { + color: #9d9d9d; +} +.navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link:focus { + color: #fff; +} +.navbar-inverse .btn-link[disabled]:hover, +fieldset[disabled] .navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link[disabled]:focus, +fieldset[disabled] .navbar-inverse .btn-link:focus { + color: #444; +} +.breadcrumb { + padding: 8px 15px; + margin-bottom: 20px; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; +} +.breadcrumb > li { + display: inline-block; +} +.breadcrumb > li + li:before { + padding: 0 5px; + color: #ccc; + content: "/\00a0"; +} +.breadcrumb > .active { + color: #777; +} +.pagination { + display: inline-block; + padding-left: 0; + margin: 20px 0; + border-radius: 4px; +} +.pagination > li { + display: inline; +} +.pagination > li > a, +.pagination > li > span { + position: relative; + float: left; + padding: 6px 12px; + margin-left: -1px; + line-height: 1.42857143; + color: #337ab7; + text-decoration: none; + background-color: #fff; + border: 1px solid #ddd; +} +.pagination > li:first-child > a, +.pagination > li:first-child > span { + margin-left: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} +.pagination > li:last-child > a, +.pagination > li:last-child > span { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + color: #23527c; + background-color: #eee; + border-color: #ddd; +} +.pagination > .active > a, +.pagination > .active > span, +.pagination > .active > a:hover, +.pagination > .active > span:hover, +.pagination > .active > a:focus, +.pagination > .active > span:focus { + z-index: 2; + color: #fff; + cursor: default; + background-color: #337ab7; + border-color: #337ab7; +} +.pagination > .disabled > span, +.pagination > .disabled > span:hover, +.pagination > .disabled > span:focus, +.pagination > .disabled > a, +.pagination > .disabled > a:hover, +.pagination > .disabled > a:focus { + color: #777; + cursor: not-allowed; + background-color: #fff; + border-color: #ddd; +} +.pagination-lg > li > a, +.pagination-lg > li > span { + padding: 10px 16px; + font-size: 18px; +} +.pagination-lg > li:first-child > a, +.pagination-lg > li:first-child > span { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} +.pagination-lg > li:last-child > a, +.pagination-lg > li:last-child > span { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} +.pagination-sm > li > a, +.pagination-sm > li > span { + padding: 5px 10px; + font-size: 12px; +} +.pagination-sm > li:first-child > a, +.pagination-sm > li:first-child > span { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.pagination-sm > li:last-child > a, +.pagination-sm > li:last-child > span { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.pager { + padding-left: 0; + margin: 20px 0; + text-align: center; + list-style: none; +} +.pager li { + display: inline; +} +.pager li > a, +.pager li > span { + display: inline-block; + padding: 5px 14px; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 15px; +} +.pager li > a:hover, +.pager li > a:focus { + text-decoration: none; + background-color: #eee; +} +.pager .next > a, +.pager .next > span { + float: right; +} +.pager .previous > a, +.pager .previous > span { + float: left; +} +.pager .disabled > a, +.pager .disabled > a:hover, +.pager .disabled > a:focus, +.pager .disabled > span { + color: #777; + cursor: not-allowed; + background-color: #fff; +} +.label { + display: inline; + padding: .2em .6em .3em; + font-size: 75%; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; +} +a.label:hover, +a.label:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.label:empty { + display: none; +} +.btn .label { + position: relative; + top: -1px; +} +.label-default { + background-color: #777; +} +.label-default[href]:hover, +.label-default[href]:focus { + background-color: #5e5e5e; +} +.label-primary { + background-color: #337ab7; +} +.label-primary[href]:hover, +.label-primary[href]:focus { + background-color: #286090; +} +.label-success { + background-color: #5cb85c; +} +.label-success[href]:hover, +.label-success[href]:focus { + background-color: #449d44; +} +.label-info { + background-color: #5bc0de; +} +.label-info[href]:hover, +.label-info[href]:focus { + background-color: #31b0d5; +} +.label-warning { + background-color: #f0ad4e; +} +.label-warning[href]:hover, +.label-warning[href]:focus { + background-color: #ec971f; +} +.label-danger { + background-color: #d9534f; +} +.label-danger[href]:hover, +.label-danger[href]:focus { + background-color: #c9302c; +} +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + background-color: #777; + border-radius: 10px; +} +.badge:empty { + display: none; +} +.btn .badge { + position: relative; + top: -1px; +} +.btn-xs .badge { + top: 0; + padding: 1px 5px; +} +a.badge:hover, +a.badge:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.list-group-item.active > .badge, +.nav-pills > .active > a > .badge { + color: #337ab7; + background-color: #fff; +} +.list-group-item > .badge { + float: right; +} +.list-group-item > .badge + .badge { + margin-right: 5px; +} +.nav-pills > li > a > .badge { + margin-left: 3px; +} +.jumbotron { + padding: 30px 15px; + margin-bottom: 30px; + color: inherit; + background-color: #eee; +} +.jumbotron h1, +.jumbotron .h1 { + color: inherit; +} +.jumbotron p { + margin-bottom: 15px; + font-size: 21px; + font-weight: 200; +} +.jumbotron > hr { + border-top-color: #d5d5d5; +} +.container .jumbotron, +.container-fluid .jumbotron { + border-radius: 6px; +} +.jumbotron .container { + max-width: 100%; +} +@media screen and (min-width: 768px) { + .jumbotron { + padding: 48px 0; + } + .container .jumbotron, + .container-fluid .jumbotron { + padding-right: 60px; + padding-left: 60px; + } + .jumbotron h1, + .jumbotron .h1 { + font-size: 63px; + } +} +.thumbnail { + display: block; + padding: 4px; + margin-bottom: 20px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: border .2s ease-in-out; + -o-transition: border .2s ease-in-out; + transition: border .2s ease-in-out; +} +.thumbnail > img, +.thumbnail a > img { + margin-right: auto; + margin-left: auto; +} +a.thumbnail:hover, +a.thumbnail:focus, +a.thumbnail.active { + border-color: #337ab7; +} +.thumbnail .caption { + padding: 9px; + color: #333; +} +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert h4 { + margin-top: 0; + color: inherit; +} +.alert .alert-link { + font-weight: bold; +} +.alert > p, +.alert > ul { + margin-bottom: 0; +} +.alert > p + p { + margin-top: 5px; +} +.alert-dismissable, +.alert-dismissible { + padding-right: 35px; +} +.alert-dismissable .close, +.alert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} +.alert-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.alert-success hr { + border-top-color: #c9e2b3; +} +.alert-success .alert-link { + color: #2b542c; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-info hr { + border-top-color: #a6e1ec; +} +.alert-info .alert-link { + color: #245269; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-warning hr { + border-top-color: #f7e1b5; +} +.alert-warning .alert-link { + color: #66512c; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert-danger hr { + border-top-color: #e4b9c0; +} +.alert-danger .alert-link { + color: #843534; +} +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@-o-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +.progress { + height: 20px; + margin-bottom: 20px; + overflow: hidden; + background-color: #f5f5f5; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); +} +.progress-bar { + float: left; + width: 0; + height: 100%; + font-size: 12px; + line-height: 20px; + color: #fff; + text-align: center; + background-color: #337ab7; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + -webkit-transition: width .6s ease; + -o-transition: width .6s ease; + transition: width .6s ease; +} +.progress-striped .progress-bar, +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + -webkit-background-size: 40px 40px; + background-size: 40px 40px; +} +.progress.active .progress-bar, +.progress-bar.active { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +.progress-bar-success { + background-color: #5cb85c; +} +.progress-striped .progress-bar-success { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-info { + background-color: #5bc0de; +} +.progress-striped .progress-bar-info { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-warning { + background-color: #f0ad4e; +} +.progress-striped .progress-bar-warning { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-danger { + background-color: #d9534f; +} +.progress-striped .progress-bar-danger { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.media { + margin-top: 15px; +} +.media:first-child { + margin-top: 0; +} +.media, +.media-body { + overflow: hidden; + zoom: 1; +} +.media-body { + width: 10000px; +} +.media-object { + display: block; +} +.media-right, +.media > .pull-right { + padding-left: 10px; +} +.media-left, +.media > .pull-left { + padding-right: 10px; +} +.media-left, +.media-right, +.media-body { + display: table-cell; + vertical-align: top; +} +.media-middle { + vertical-align: middle; +} +.media-bottom { + vertical-align: bottom; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.list-group { + padding-left: 0; + margin-bottom: 20px; +} +.list-group-item { + position: relative; + display: block; + padding: 10px 15px; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid #ddd; +} +.list-group-item:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +a.list-group-item { + color: #555; +} +a.list-group-item .list-group-item-heading { + color: #333; +} +a.list-group-item:hover, +a.list-group-item:focus { + color: #555; + text-decoration: none; + background-color: #f5f5f5; +} +.list-group-item.disabled, +.list-group-item.disabled:hover, +.list-group-item.disabled:focus { + color: #777; + cursor: not-allowed; + background-color: #eee; +} +.list-group-item.disabled .list-group-item-heading, +.list-group-item.disabled:hover .list-group-item-heading, +.list-group-item.disabled:focus .list-group-item-heading { + color: inherit; +} +.list-group-item.disabled .list-group-item-text, +.list-group-item.disabled:hover .list-group-item-text, +.list-group-item.disabled:focus .list-group-item-text { + color: #777; +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + z-index: 2; + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.list-group-item.active .list-group-item-heading, +.list-group-item.active:hover .list-group-item-heading, +.list-group-item.active:focus .list-group-item-heading, +.list-group-item.active .list-group-item-heading > small, +.list-group-item.active:hover .list-group-item-heading > small, +.list-group-item.active:focus .list-group-item-heading > small, +.list-group-item.active .list-group-item-heading > .small, +.list-group-item.active:hover .list-group-item-heading > .small, +.list-group-item.active:focus .list-group-item-heading > .small { + color: inherit; +} +.list-group-item.active .list-group-item-text, +.list-group-item.active:hover .list-group-item-text, +.list-group-item.active:focus .list-group-item-text { + color: #c7ddef; +} +.list-group-item-success { + color: #3c763d; + background-color: #dff0d8; +} +a.list-group-item-success { + color: #3c763d; +} +a.list-group-item-success .list-group-item-heading { + color: inherit; +} +a.list-group-item-success:hover, +a.list-group-item-success:focus { + color: #3c763d; + background-color: #d0e9c6; +} +a.list-group-item-success.active, +a.list-group-item-success.active:hover, +a.list-group-item-success.active:focus { + color: #fff; + background-color: #3c763d; + border-color: #3c763d; +} +.list-group-item-info { + color: #31708f; + background-color: #d9edf7; +} +a.list-group-item-info { + color: #31708f; +} +a.list-group-item-info .list-group-item-heading { + color: inherit; +} +a.list-group-item-info:hover, +a.list-group-item-info:focus { + color: #31708f; + background-color: #c4e3f3; +} +a.list-group-item-info.active, +a.list-group-item-info.active:hover, +a.list-group-item-info.active:focus { + color: #fff; + background-color: #31708f; + border-color: #31708f; +} +.list-group-item-warning { + color: #8a6d3b; + background-color: #fcf8e3; +} +a.list-group-item-warning { + color: #8a6d3b; +} +a.list-group-item-warning .list-group-item-heading { + color: inherit; +} +a.list-group-item-warning:hover, +a.list-group-item-warning:focus { + color: #8a6d3b; + background-color: #faf2cc; +} +a.list-group-item-warning.active, +a.list-group-item-warning.active:hover, +a.list-group-item-warning.active:focus { + color: #fff; + background-color: #8a6d3b; + border-color: #8a6d3b; +} +.list-group-item-danger { + color: #a94442; + background-color: #f2dede; +} +a.list-group-item-danger { + color: #a94442; +} +a.list-group-item-danger .list-group-item-heading { + color: inherit; +} +a.list-group-item-danger:hover, +a.list-group-item-danger:focus { + color: #a94442; + background-color: #ebcccc; +} +a.list-group-item-danger.active, +a.list-group-item-danger.active:hover, +a.list-group-item-danger.active:focus { + color: #fff; + background-color: #a94442; + border-color: #a94442; +} +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} +.panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: 0 1px 1px rgba(0, 0, 0, .05); +} +.panel-body { + padding: 15px; +} +.panel-heading { + padding: 10px 15px; + border-bottom: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel-heading > .dropdown .dropdown-toggle { + color: inherit; +} +.panel-title { + margin-top: 0; + margin-bottom: 0; + font-size: 16px; + color: inherit; +} +.panel-title > a, +.panel-title > small, +.panel-title > .small, +.panel-title > small > a, +.panel-title > .small > a { + color: inherit; +} +.panel-footer { + padding: 10px 15px; + background-color: #f5f5f5; + border-top: 1px solid #ddd; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .list-group, +.panel > .panel-collapse > .list-group { + margin-bottom: 0; +} +.panel > .list-group .list-group-item, +.panel > .panel-collapse > .list-group .list-group-item { + border-width: 1px 0; + border-radius: 0; +} +.panel > .list-group:first-child .list-group-item:first-child, +.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child { + border-top: 0; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .list-group:last-child .list-group-item:last-child, +.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child { + border-bottom: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel-heading + .list-group .list-group-item:first-child { + border-top-width: 0; +} +.list-group + .panel-footer { + border-top-width: 0; +} +.panel > .table, +.panel > .table-responsive > .table, +.panel > .panel-collapse > .table { + margin-bottom: 0; +} +.panel > .table caption, +.panel > .table-responsive > .table caption, +.panel > .panel-collapse > .table caption { + padding-right: 15px; + padding-left: 15px; +} +.panel > .table:first-child, +.panel > .table-responsive:first-child > .table:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child { + border-top-left-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child { + border-top-right-radius: 3px; +} +.panel > .table:last-child, +.panel > .table-responsive:last-child > .table:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child { + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child { + border-bottom-right-radius: 3px; +} +.panel > .panel-body + .table, +.panel > .panel-body + .table-responsive, +.panel > .table + .panel-body, +.panel > .table-responsive + .panel-body { + border-top: 1px solid #ddd; +} +.panel > .table > tbody:first-child > tr:first-child th, +.panel > .table > tbody:first-child > tr:first-child td { + border-top: 0; +} +.panel > .table-bordered, +.panel > .table-responsive > .table-bordered { + border: 0; +} +.panel > .table-bordered > thead > tr > th:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:first-child, +.panel > .table-bordered > tbody > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child, +.panel > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-bordered > thead > tr > td:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:first-child, +.panel > .table-bordered > tbody > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child, +.panel > .table-bordered > tfoot > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; +} +.panel > .table-bordered > thead > tr > th:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:last-child, +.panel > .table-bordered > tbody > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child, +.panel > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-bordered > thead > tr > td:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:last-child, +.panel > .table-bordered > tbody > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child, +.panel > .table-bordered > tfoot > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; +} +.panel > .table-bordered > thead > tr:first-child > td, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > td, +.panel > .table-bordered > tbody > tr:first-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td, +.panel > .table-bordered > thead > tr:first-child > th, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > th, +.panel > .table-bordered > tbody > tr:first-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th { + border-bottom: 0; +} +.panel > .table-bordered > tbody > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td, +.panel > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-bordered > tbody > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th, +.panel > .table-bordered > tfoot > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th { + border-bottom: 0; +} +.panel > .table-responsive { + margin-bottom: 0; + border: 0; +} +.panel-group { + margin-bottom: 20px; +} +.panel-group .panel { + margin-bottom: 0; + border-radius: 4px; +} +.panel-group .panel + .panel { + margin-top: 5px; +} +.panel-group .panel-heading { + border-bottom: 0; +} +.panel-group .panel-heading + .panel-collapse > .panel-body, +.panel-group .panel-heading + .panel-collapse > .list-group { + border-top: 1px solid #ddd; +} +.panel-group .panel-footer { + border-top: 0; +} +.panel-group .panel-footer + .panel-collapse .panel-body { + border-bottom: 1px solid #ddd; +} +.panel-default { + border-color: #ddd; +} +.panel-default > .panel-heading { + color: #333; + background-color: #f5f5f5; + border-color: #ddd; +} +.panel-default > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ddd; +} +.panel-default > .panel-heading .badge { + color: #f5f5f5; + background-color: #333; +} +.panel-default > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ddd; +} +.panel-primary { + border-color: #337ab7; +} +.panel-primary > .panel-heading { + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.panel-primary > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #337ab7; +} +.panel-primary > .panel-heading .badge { + color: #337ab7; + background-color: #fff; +} +.panel-primary > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #337ab7; +} +.panel-success { + border-color: #d6e9c6; +} +.panel-success > .panel-heading { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.panel-success > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #d6e9c6; +} +.panel-success > .panel-heading .badge { + color: #dff0d8; + background-color: #3c763d; +} +.panel-success > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #d6e9c6; +} +.panel-info { + border-color: #bce8f1; +} +.panel-info > .panel-heading { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.panel-info > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #bce8f1; +} +.panel-info > .panel-heading .badge { + color: #d9edf7; + background-color: #31708f; +} +.panel-info > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #bce8f1; +} +.panel-warning { + border-color: #faebcc; +} +.panel-warning > .panel-heading { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.panel-warning > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #faebcc; +} +.panel-warning > .panel-heading .badge { + color: #fcf8e3; + background-color: #8a6d3b; +} +.panel-warning > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #faebcc; +} +.panel-danger { + border-color: #ebccd1; +} +.panel-danger > .panel-heading { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.panel-danger > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ebccd1; +} +.panel-danger > .panel-heading .badge { + color: #f2dede; + background-color: #a94442; +} +.panel-danger > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ebccd1; +} +.embed-responsive { + position: relative; + display: block; + height: 0; + padding: 0; + overflow: hidden; +} +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} +.embed-responsive.embed-responsive-16by9 { + padding-bottom: 56.25%; +} +.embed-responsive.embed-responsive-4by3 { + padding-bottom: 75%; +} +.well { + min-height: 20px; + padding: 19px; + margin-bottom: 20px; + background-color: #f5f5f5; + border: 1px solid #e3e3e3; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); +} +.well blockquote { + border-color: #ddd; + border-color: rgba(0, 0, 0, .15); +} +.well-lg { + padding: 24px; + border-radius: 6px; +} +.well-sm { + padding: 9px; + border-radius: 3px; +} +.close { + float: right; + font-size: 21px; + font-weight: bold; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + filter: alpha(opacity=20); + opacity: .2; +} +.close:hover, +.close:focus { + color: #000; + text-decoration: none; + cursor: pointer; + filter: alpha(opacity=50); + opacity: .5; +} +button.close { + -webkit-appearance: none; + padding: 0; + cursor: pointer; + background: transparent; + border: 0; +} +.modal-open { + overflow: hidden; +} +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + display: none; + overflow: hidden; + -webkit-overflow-scrolling: touch; + outline: 0; +} +.modal.fade .modal-dialog { + -webkit-transition: -webkit-transform .3s ease-out; + -o-transition: -o-transform .3s ease-out; + transition: transform .3s ease-out; + -webkit-transform: translate(0, -25%); + -ms-transform: translate(0, -25%); + -o-transform: translate(0, -25%); + transform: translate(0, -25%); +} +.modal.in .modal-dialog { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} +.modal-dialog { + position: relative; + width: auto; + margin: 10px; +} +.modal-content { + position: relative; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #999; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + outline: 0; + -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5); + box-shadow: 0 3px 9px rgba(0, 0, 0, .5); +} +.modal-backdrop { + position: absolute; + top: 0; + right: 0; + left: 0; + background-color: #000; +} +.modal-backdrop.fade { + filter: alpha(opacity=0); + opacity: 0; +} +.modal-backdrop.in { + filter: alpha(opacity=50); + opacity: .5; +} +.modal-header { + min-height: 16.42857143px; + padding: 15px; + border-bottom: 1px solid #e5e5e5; +} +.modal-header .close { + margin-top: -2px; +} +.modal-title { + margin: 0; + line-height: 1.42857143; +} +.modal-body { + position: relative; + padding: 15px; +} +.modal-footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; +} +.modal-footer .btn + .btn { + margin-bottom: 0; + margin-left: 5px; +} +.modal-footer .btn-group .btn + .btn { + margin-left: -1px; +} +.modal-footer .btn-block + .btn-block { + margin-left: 0; +} +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} +@media (min-width: 768px) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + .modal-content { + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + } + .modal-sm { + width: 300px; + } +} +@media (min-width: 992px) { + .modal-lg { + width: 900px; + } +} +.tooltip { + position: absolute; + z-index: 1070; + display: block; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: normal; + line-height: 1.4; + visibility: visible; + filter: alpha(opacity=0); + opacity: 0; +} +.tooltip.in { + filter: alpha(opacity=90); + opacity: .9; +} +.tooltip.top { + padding: 5px 0; + margin-top: -3px; +} +.tooltip.right { + padding: 0 5px; + margin-left: 3px; +} +.tooltip.bottom { + padding: 5px 0; + margin-top: 3px; +} +.tooltip.left { + padding: 0 5px; + margin-left: -3px; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #fff; + text-align: center; + text-decoration: none; + background-color: #000; + border-radius: 4px; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.tooltip.top .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-left .tooltip-arrow { + right: 5px; + bottom: 0; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-right .tooltip-arrow { + bottom: 0; + left: 5px; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.right .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: #000; +} +.tooltip.left .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: #000; +} +.tooltip.bottom .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-left .tooltip-arrow { + top: 0; + right: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-right .tooltip-arrow { + top: 0; + left: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: none; + max-width: 276px; + padding: 1px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: left; + white-space: normal; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + box-shadow: 0 5px 10px rgba(0, 0, 0, .2); +} +.popover.top { + margin-top: -10px; +} +.popover.right { + margin-left: 10px; +} +.popover.bottom { + margin-top: 10px; +} +.popover.left { + margin-left: -10px; +} +.popover-title { + padding: 8px 14px; + margin: 0; + font-size: 14px; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} +.popover-content { + padding: 9px 14px; +} +.popover > .arrow, +.popover > .arrow:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover > .arrow { + border-width: 11px; +} +.popover > .arrow:after { + content: ""; + border-width: 10px; +} +.popover.top > .arrow { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: #999; + border-top-color: rgba(0, 0, 0, .25); + border-bottom-width: 0; +} +.popover.top > .arrow:after { + bottom: 1px; + margin-left: -10px; + content: " "; + border-top-color: #fff; + border-bottom-width: 0; +} +.popover.right > .arrow { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: #999; + border-right-color: rgba(0, 0, 0, .25); + border-left-width: 0; +} +.popover.right > .arrow:after { + bottom: -10px; + left: 1px; + content: " "; + border-right-color: #fff; + border-left-width: 0; +} +.popover.bottom > .arrow { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: #999; + border-bottom-color: rgba(0, 0, 0, .25); +} +.popover.bottom > .arrow:after { + top: 1px; + margin-left: -10px; + content: " "; + border-top-width: 0; + border-bottom-color: #fff; +} +.popover.left > .arrow { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: #999; + border-left-color: rgba(0, 0, 0, .25); +} +.popover.left > .arrow:after { + right: 1px; + bottom: -10px; + content: " "; + border-right-width: 0; + border-left-color: #fff; +} +.carousel { + position: relative; +} +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} +.carousel-inner > .item { + position: relative; + display: none; + -webkit-transition: .6s ease-in-out left; + -o-transition: .6s ease-in-out left; + transition: .6s ease-in-out left; +} +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + line-height: 1; +} +@media all and (transform-3d), (-webkit-transform-3d) { + .carousel-inner > .item { + -webkit-transition: -webkit-transform .6s ease-in-out; + -o-transition: -o-transform .6s ease-in-out; + transition: transform .6s ease-in-out; + + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + -webkit-perspective: 1000; + perspective: 1000; + } + .carousel-inner > .item.next, + .carousel-inner > .item.active.right { + left: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + .carousel-inner > .item.prev, + .carousel-inner > .item.active.left { + left: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + .carousel-inner > .item.next.left, + .carousel-inner > .item.prev.right, + .carousel-inner > .item.active { + left: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} +.carousel-inner > .active, +.carousel-inner > .next, +.carousel-inner > .prev { + display: block; +} +.carousel-inner > .active { + left: 0; +} +.carousel-inner > .next, +.carousel-inner > .prev { + position: absolute; + top: 0; + width: 100%; +} +.carousel-inner > .next { + left: 100%; +} +.carousel-inner > .prev { + left: -100%; +} +.carousel-inner > .next.left, +.carousel-inner > .prev.right { + left: 0; +} +.carousel-inner > .active.left { + left: -100%; +} +.carousel-inner > .active.right { + left: 100%; +} +.carousel-control { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 15%; + font-size: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); + filter: alpha(opacity=50); + opacity: .5; +} +.carousel-control.left { + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control.right { + right: 0; + left: auto; + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control:hover, +.carousel-control:focus { + color: #fff; + text-decoration: none; + filter: alpha(opacity=90); + outline: 0; + opacity: .9; +} +.carousel-control .icon-prev, +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-left, +.carousel-control .glyphicon-chevron-right { + position: absolute; + top: 50%; + z-index: 5; + display: inline-block; +} +.carousel-control .icon-prev, +.carousel-control .glyphicon-chevron-left { + left: 50%; + margin-left: -10px; +} +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-right { + right: 50%; + margin-right: -10px; +} +.carousel-control .icon-prev, +.carousel-control .icon-next { + width: 20px; + height: 20px; + margin-top: -10px; + font-family: serif; + line-height: 1; +} +.carousel-control .icon-prev:before { + content: '\2039'; +} +.carousel-control .icon-next:before { + content: '\203a'; +} +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 15; + width: 60%; + padding-left: 0; + margin-left: -30%; + text-align: center; + list-style: none; +} +.carousel-indicators li { + display: inline-block; + width: 10px; + height: 10px; + margin: 1px; + text-indent: -999px; + cursor: pointer; + background-color: #000 \9; + background-color: rgba(0, 0, 0, 0); + border: 1px solid #fff; + border-radius: 10px; +} +.carousel-indicators .active { + width: 12px; + height: 12px; + margin: 0; + background-color: #fff; +} +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); +} +.carousel-caption .btn { + text-shadow: none; +} +@media screen and (min-width: 768px) { + .carousel-control .glyphicon-chevron-left, + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-prev, + .carousel-control .icon-next { + width: 30px; + height: 30px; + margin-top: -15px; + font-size: 30px; + } + .carousel-control .glyphicon-chevron-left, + .carousel-control .icon-prev { + margin-left: -15px; + } + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-next { + margin-right: -15px; + } + .carousel-caption { + right: 20%; + left: 20%; + padding-bottom: 30px; + } + .carousel-indicators { + bottom: 20px; + } +} +.clearfix:before, +.clearfix:after, +.dl-horizontal dd:before, +.dl-horizontal dd:after, +.container:before, +.container:after, +.container-fluid:before, +.container-fluid:after, +.row:before, +.row:after, +.form-horizontal .form-group:before, +.form-horizontal .form-group:after, +.btn-toolbar:before, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:before, +.btn-group-vertical > .btn-group:after, +.nav:before, +.nav:after, +.navbar:before, +.navbar:after, +.navbar-header:before, +.navbar-header:after, +.navbar-collapse:before, +.navbar-collapse:after, +.pager:before, +.pager:after, +.panel-body:before, +.panel-body:after, +.modal-footer:before, +.modal-footer:after { + display: table; + content: " "; +} +.clearfix:after, +.dl-horizontal dd:after, +.container:after, +.container-fluid:after, +.row:after, +.form-horizontal .form-group:after, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:after, +.nav:after, +.navbar:after, +.navbar-header:after, +.navbar-collapse:after, +.pager:after, +.panel-body:after, +.modal-footer:after { + clear: both; +} +.center-block { + display: block; + margin-right: auto; + margin-left: auto; +} +.pull-right { + float: right !important; +} +.pull-left { + float: left !important; +} +.hide { + display: none !important; +} +.show { + display: block !important; +} +.invisible { + visibility: hidden; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.hidden { + display: none !important; + visibility: hidden !important; +} +.affix { + position: fixed; +} +@-ms-viewport { + width: device-width; +} +.visible-xs, +.visible-sm, +.visible-md, +.visible-lg { + display: none !important; +} +.visible-xs-block, +.visible-xs-inline, +.visible-xs-inline-block, +.visible-sm-block, +.visible-sm-inline, +.visible-sm-inline-block, +.visible-md-block, +.visible-md-inline, +.visible-md-inline-block, +.visible-lg-block, +.visible-lg-inline, +.visible-lg-inline-block { + display: none !important; +} +@media (max-width: 767px) { + .visible-xs { + display: block !important; + } + table.visible-xs { + display: table; + } + tr.visible-xs { + display: table-row !important; + } + th.visible-xs, + td.visible-xs { + display: table-cell !important; + } +} +@media (max-width: 767px) { + .visible-xs-block { + display: block !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline { + display: inline !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline-block { + display: inline-block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm { + display: block !important; + } + table.visible-sm { + display: table; + } + tr.visible-sm { + display: table-row !important; + } + th.visible-sm, + td.visible-sm { + display: table-cell !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-block { + display: block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline { + display: inline !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline-block { + display: inline-block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md { + display: block !important; + } + table.visible-md { + display: table; + } + tr.visible-md { + display: table-row !important; + } + th.visible-md, + td.visible-md { + display: table-cell !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-block { + display: block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline { + display: inline !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline-block { + display: inline-block !important; + } +} +@media (min-width: 1200px) { + .visible-lg { + display: block !important; + } + table.visible-lg { + display: table; + } + tr.visible-lg { + display: table-row !important; + } + th.visible-lg, + td.visible-lg { + display: table-cell !important; + } +} +@media (min-width: 1200px) { + .visible-lg-block { + display: block !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline { + display: inline !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline-block { + display: inline-block !important; + } +} +@media (max-width: 767px) { + .hidden-xs { + display: none !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .hidden-sm { + display: none !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .hidden-md { + display: none !important; + } +} +@media (min-width: 1200px) { + .hidden-lg { + display: none !important; + } +} +.visible-print { + display: none !important; +} +@media print { + .visible-print { + display: block !important; + } + table.visible-print { + display: table; + } + tr.visible-print { + display: table-row !important; + } + th.visible-print, + td.visible-print { + display: table-cell !important; + } +} +.visible-print-block { + display: none !important; +} +@media print { + .visible-print-block { + display: block !important; + } +} +.visible-print-inline { + display: none !important; +} +@media print { + .visible-print-inline { + display: inline !important; + } +} +.visible-print-inline-block { + display: none !important; +} +@media print { + .visible-print-inline-block { + display: inline-block !important; + } +} +@media print { + .hidden-print { + display: none !important; + } +} +/*# sourceMappingURL=bootstrap.css.map */ diff --git a/webadmin/src/3rdparty/bootstrap/css/bootstrap.css.map b/webadmin/src/3rdparty/bootstrap/css/bootstrap.css.map new file mode 100644 index 000000000..ff579ff56 --- /dev/null +++ b/webadmin/src/3rdparty/bootstrap/css/bootstrap.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["bootstrap.css","less/normalize.less","less/print.less","less/glyphicons.less","less/scaffolding.less","less/mixins/vendor-prefixes.less","less/mixins/tab-focus.less","less/mixins/image.less","less/type.less","less/mixins/text-emphasis.less","less/mixins/background-variant.less","less/mixins/text-overflow.less","less/code.less","less/grid.less","less/mixins/grid.less","less/mixins/grid-framework.less","less/tables.less","less/mixins/table-row.less","less/forms.less","less/mixins/forms.less","less/buttons.less","less/mixins/buttons.less","less/mixins/opacity.less","less/component-animations.less","less/dropdowns.less","less/mixins/nav-divider.less","less/mixins/reset-filter.less","less/button-groups.less","less/mixins/border-radius.less","less/input-groups.less","less/navs.less","less/navbar.less","less/mixins/nav-vertical-align.less","less/utilities.less","less/breadcrumbs.less","less/pagination.less","less/mixins/pagination.less","less/pager.less","less/labels.less","less/mixins/labels.less","less/badges.less","less/jumbotron.less","less/thumbnails.less","less/alerts.less","less/mixins/alerts.less","less/progress-bars.less","less/mixins/gradients.less","less/mixins/progress-bar.less","less/media.less","less/list-group.less","less/mixins/list-group.less","less/panels.less","less/mixins/panels.less","less/responsive-embed.less","less/wells.less","less/close.less","less/modals.less","less/tooltip.less","less/popovers.less","less/carousel.less","less/mixins/clearfix.less","less/mixins/center-block.less","less/mixins/hide-text.less","less/responsive-utilities.less","less/mixins/responsive-visibility.less"],"names":[],"mappings":"AAAA,6DAA4D;ACQ5D;EACE,yBAAA;EACA,4BAAA;EACA,gCAAA;EDND;ACaD;EACE,WAAA;EDXD;ACwBD;;;;;;;;;;;;;EAaE,gBAAA;EDtBD;AC8BD;;;;EAIE,uBAAA;EACA,0BAAA;ED5BD;ACoCD;EACE,eAAA;EACA,WAAA;EDlCD;AC0CD;;EAEE,eAAA;EDxCD;ACkDD;EACE,+BAAA;EDhDD;ACuDD;;EAEE,YAAA;EDrDD;AC+DD;EACE,2BAAA;ED7DD;ACoED;;EAEE,mBAAA;EDlED;ACyED;EACE,oBAAA;EDvED;AC+ED;EACE,gBAAA;EACA,kBAAA;ED7ED;ACoFD;EACE,kBAAA;EACA,aAAA;EDlFD;ACyFD;EACE,gBAAA;EDvFD;AC8FD;;EAEE,gBAAA;EACA,gBAAA;EACA,oBAAA;EACA,0BAAA;ED5FD;AC+FD;EACE,aAAA;ED7FD;ACgGD;EACE,iBAAA;ED9FD;ACwGD;EACE,WAAA;EDtGD;AC6GD;EACE,kBAAA;ED3GD;ACqHD;EACE,kBAAA;EDnHD;AC0HD;EACE,8BAAA;EACA,iCAAA;UAAA,yBAAA;EACA,WAAA;EDxHD;AC+HD;EACE,gBAAA;ED7HD;ACoID;;;;EAIE,mCAAA;EACA,gBAAA;EDlID;ACoJD;;;;;EAKE,gBAAA;EACA,eAAA;EACA,WAAA;EDlJD;ACyJD;EACE,mBAAA;EDvJD;ACiKD;;EAEE,sBAAA;ED/JD;AC0KD;;;;EAIE,4BAAA;EACA,iBAAA;EDxKD;AC+KD;;EAEE,iBAAA;ED7KD;ACoLD;;EAEE,WAAA;EACA,YAAA;EDlLD;AC0LD;EACE,qBAAA;EDxLD;ACmMD;;EAEE,gCAAA;KAAA,6BAAA;UAAA,wBAAA;EACA,YAAA;EDjMD;AC0MD;;EAEE,cAAA;EDxMD;ACiND;EACE,+BAAA;EACA,8BAAA;EACA,iCAAA;EACA,yBAAA;ED/MD;ACwND;;EAEE,0BAAA;EDtND;AC6ND;EACE,2BAAA;EACA,eAAA;EACA,gCAAA;ED3ND;ACmOD;EACE,WAAA;EACA,YAAA;EDjOD;ACwOD;EACE,gBAAA;EDtOD;AC8OD;EACE,mBAAA;ED5OD;ACsPD;EACE,2BAAA;EACA,mBAAA;EDpPD;ACuPD;;EAEE,YAAA;EDrPD;AACD,sFAAqF;AE1ErF;EAnGI;;;IAGI,oCAAA;IACA,wBAAA;IACA,qCAAA;YAAA,6BAAA;IACA,8BAAA;IFgLL;EE7KC;;IAEI,4BAAA;IF+KL;EE5KC;IACI,8BAAA;IF8KL;EE3KC;IACI,+BAAA;IF6KL;EExKC;;IAEI,aAAA;IF0KL;EEvKC;;IAEI,wBAAA;IACA,0BAAA;IFyKL;EEtKC;IACI,6BAAA;IFwKL;EErKC;;IAEI,0BAAA;IFuKL;EEpKC;IACI,4BAAA;IFsKL;EEnKC;;;IAGI,YAAA;IACA,WAAA;IFqKL;EElKC;;IAEI,yBAAA;IFoKL;EE7JC;IACI,6BAAA;IF+JL;EE3JC;IACI,eAAA;IF6JL;EE3JC;;IAGQ,mCAAA;IF4JT;EEzJC;IACI,wBAAA;IF2JL;EExJC;IACI,sCAAA;IF0JL;EE3JC;;IAKQ,mCAAA;IF0JT;EEvJC;;IAGQ,mCAAA;IFwJT;EACF;AGpPD;EACE,qCAAA;EACA,uDAAA;EACA,iYAAA;EHsPD;AG9OD;EACE,oBAAA;EACA,UAAA;EACA,uBAAA;EACA,qCAAA;EACA,oBAAA;EACA,qBAAA;EACA,gBAAA;EACA,qCAAA;EACA,oCAAA;EHgPD;AG5OmC;EAAW,gBAAA;EH+O9C;AG9OmC;EAAW,gBAAA;EHiP9C;AG/OmC;;EAAW,kBAAA;EHmP9C;AGlPmC;EAAW,kBAAA;EHqP9C;AGpPmC;EAAW,kBAAA;EHuP9C;AGtPmC;EAAW,kBAAA;EHyP9C;AGxPmC;EAAW,kBAAA;EH2P9C;AG1PmC;EAAW,kBAAA;EH6P9C;AG5PmC;EAAW,kBAAA;EH+P9C;AG9PmC;EAAW,kBAAA;EHiQ9C;AGhQmC;EAAW,kBAAA;EHmQ9C;AGlQmC;EAAW,kBAAA;EHqQ9C;AGpQmC;EAAW,kBAAA;EHuQ9C;AGtQmC;EAAW,kBAAA;EHyQ9C;AGxQmC;EAAW,kBAAA;EH2Q9C;AG1QmC;EAAW,kBAAA;EH6Q9C;AG5QmC;EAAW,kBAAA;EH+Q9C;AG9QmC;EAAW,kBAAA;EHiR9C;AGhRmC;EAAW,kBAAA;EHmR9C;AGlRmC;EAAW,kBAAA;EHqR9C;AGpRmC;EAAW,kBAAA;EHuR9C;AGtRmC;EAAW,kBAAA;EHyR9C;AGxRmC;EAAW,kBAAA;EH2R9C;AG1RmC;EAAW,kBAAA;EH6R9C;AG5RmC;EAAW,kBAAA;EH+R9C;AG9RmC;EAAW,kBAAA;EHiS9C;AGhSmC;EAAW,kBAAA;EHmS9C;AGlSmC;EAAW,kBAAA;EHqS9C;AGpSmC;EAAW,kBAAA;EHuS9C;AGtSmC;EAAW,kBAAA;EHyS9C;AGxSmC;EAAW,kBAAA;EH2S9C;AG1SmC;EAAW,kBAAA;EH6S9C;AG5SmC;EAAW,kBAAA;EH+S9C;AG9SmC;EAAW,kBAAA;EHiT9C;AGhTmC;EAAW,kBAAA;EHmT9C;AGlTmC;EAAW,kBAAA;EHqT9C;AGpTmC;EAAW,kBAAA;EHuT9C;AGtTmC;EAAW,kBAAA;EHyT9C;AGxTmC;EAAW,kBAAA;EH2T9C;AG1TmC;EAAW,kBAAA;EH6T9C;AG5TmC;EAAW,kBAAA;EH+T9C;AG9TmC;EAAW,kBAAA;EHiU9C;AGhUmC;EAAW,kBAAA;EHmU9C;AGlUmC;EAAW,kBAAA;EHqU9C;AGpUmC;EAAW,kBAAA;EHuU9C;AGtUmC;EAAW,kBAAA;EHyU9C;AGxUmC;EAAW,kBAAA;EH2U9C;AG1UmC;EAAW,kBAAA;EH6U9C;AG5UmC;EAAW,kBAAA;EH+U9C;AG9UmC;EAAW,kBAAA;EHiV9C;AGhVmC;EAAW,kBAAA;EHmV9C;AGlVmC;EAAW,kBAAA;EHqV9C;AGpVmC;EAAW,kBAAA;EHuV9C;AGtVmC;EAAW,kBAAA;EHyV9C;AGxVmC;EAAW,kBAAA;EH2V9C;AG1VmC;EAAW,kBAAA;EH6V9C;AG5VmC;EAAW,kBAAA;EH+V9C;AG9VmC;EAAW,kBAAA;EHiW9C;AGhWmC;EAAW,kBAAA;EHmW9C;AGlWmC;EAAW,kBAAA;EHqW9C;AGpWmC;EAAW,kBAAA;EHuW9C;AGtWmC;EAAW,kBAAA;EHyW9C;AGxWmC;EAAW,kBAAA;EH2W9C;AG1WmC;EAAW,kBAAA;EH6W9C;AG5WmC;EAAW,kBAAA;EH+W9C;AG9WmC;EAAW,kBAAA;EHiX9C;AGhXmC;EAAW,kBAAA;EHmX9C;AGlXmC;EAAW,kBAAA;EHqX9C;AGpXmC;EAAW,kBAAA;EHuX9C;AGtXmC;EAAW,kBAAA;EHyX9C;AGxXmC;EAAW,kBAAA;EH2X9C;AG1XmC;EAAW,kBAAA;EH6X9C;AG5XmC;EAAW,kBAAA;EH+X9C;AG9XmC;EAAW,kBAAA;EHiY9C;AGhYmC;EAAW,kBAAA;EHmY9C;AGlYmC;EAAW,kBAAA;EHqY9C;AGpYmC;EAAW,kBAAA;EHuY9C;AGtYmC;EAAW,kBAAA;EHyY9C;AGxYmC;EAAW,kBAAA;EH2Y9C;AG1YmC;EAAW,kBAAA;EH6Y9C;AG5YmC;EAAW,kBAAA;EH+Y9C;AG9YmC;EAAW,kBAAA;EHiZ9C;AGhZmC;EAAW,kBAAA;EHmZ9C;AGlZmC;EAAW,kBAAA;EHqZ9C;AGpZmC;EAAW,kBAAA;EHuZ9C;AGtZmC;EAAW,kBAAA;EHyZ9C;AGxZmC;EAAW,kBAAA;EH2Z9C;AG1ZmC;EAAW,kBAAA;EH6Z9C;AG5ZmC;EAAW,kBAAA;EH+Z9C;AG9ZmC;EAAW,kBAAA;EHia9C;AGhamC;EAAW,kBAAA;EHma9C;AGlamC;EAAW,kBAAA;EHqa9C;AGpamC;EAAW,kBAAA;EHua9C;AGtamC;EAAW,kBAAA;EHya9C;AGxamC;EAAW,kBAAA;EH2a9C;AG1amC;EAAW,kBAAA;EH6a9C;AG5amC;EAAW,kBAAA;EH+a9C;AG9amC;EAAW,kBAAA;EHib9C;AGhbmC;EAAW,kBAAA;EHmb9C;AGlbmC;EAAW,kBAAA;EHqb9C;AGpbmC;EAAW,kBAAA;EHub9C;AGtbmC;EAAW,kBAAA;EHyb9C;AGxbmC;EAAW,kBAAA;EH2b9C;AG1bmC;EAAW,kBAAA;EH6b9C;AG5bmC;EAAW,kBAAA;EH+b9C;AG9bmC;EAAW,kBAAA;EHic9C;AGhcmC;EAAW,kBAAA;EHmc9C;AGlcmC;EAAW,kBAAA;EHqc9C;AGpcmC;EAAW,kBAAA;EHuc9C;AGtcmC;EAAW,kBAAA;EHyc9C;AGxcmC;EAAW,kBAAA;EH2c9C;AG1cmC;EAAW,kBAAA;EH6c9C;AG5cmC;EAAW,kBAAA;EH+c9C;AG9cmC;EAAW,kBAAA;EHid9C;AGhdmC;EAAW,kBAAA;EHmd9C;AGldmC;EAAW,kBAAA;EHqd9C;AGpdmC;EAAW,kBAAA;EHud9C;AGtdmC;EAAW,kBAAA;EHyd9C;AGxdmC;EAAW,kBAAA;EH2d9C;AG1dmC;EAAW,kBAAA;EH6d9C;AG5dmC;EAAW,kBAAA;EH+d9C;AG9dmC;EAAW,kBAAA;EHie9C;AGhemC;EAAW,kBAAA;EHme9C;AGlemC;EAAW,kBAAA;EHqe9C;AGpemC;EAAW,kBAAA;EHue9C;AGtemC;EAAW,kBAAA;EHye9C;AGxemC;EAAW,kBAAA;EH2e9C;AG1emC;EAAW,kBAAA;EH6e9C;AG5emC;EAAW,kBAAA;EH+e9C;AG9emC;EAAW,kBAAA;EHif9C;AGhfmC;EAAW,kBAAA;EHmf9C;AGlfmC;EAAW,kBAAA;EHqf9C;AGpfmC;EAAW,kBAAA;EHuf9C;AGtfmC;EAAW,kBAAA;EHyf9C;AGxfmC;EAAW,kBAAA;EH2f9C;AG1fmC;EAAW,kBAAA;EH6f9C;AG5fmC;EAAW,kBAAA;EH+f9C;AG9fmC;EAAW,kBAAA;EHigB9C;AGhgBmC;EAAW,kBAAA;EHmgB9C;AGlgBmC;EAAW,kBAAA;EHqgB9C;AGpgBmC;EAAW,kBAAA;EHugB9C;AGtgBmC;EAAW,kBAAA;EHygB9C;AGxgBmC;EAAW,kBAAA;EH2gB9C;AG1gBmC;EAAW,kBAAA;EH6gB9C;AG5gBmC;EAAW,kBAAA;EH+gB9C;AG9gBmC;EAAW,kBAAA;EHihB9C;AGhhBmC;EAAW,kBAAA;EHmhB9C;AGlhBmC;EAAW,kBAAA;EHqhB9C;AGphBmC;EAAW,kBAAA;EHuhB9C;AGthBmC;EAAW,kBAAA;EHyhB9C;AGxhBmC;EAAW,kBAAA;EH2hB9C;AG1hBmC;EAAW,kBAAA;EH6hB9C;AG5hBmC;EAAW,kBAAA;EH+hB9C;AG9hBmC;EAAW,kBAAA;EHiiB9C;AGhiBmC;EAAW,kBAAA;EHmiB9C;AGliBmC;EAAW,kBAAA;EHqiB9C;AGpiBmC;EAAW,kBAAA;EHuiB9C;AGtiBmC;EAAW,kBAAA;EHyiB9C;AGxiBmC;EAAW,kBAAA;EH2iB9C;AG1iBmC;EAAW,kBAAA;EH6iB9C;AG5iBmC;EAAW,kBAAA;EH+iB9C;AG9iBmC;EAAW,kBAAA;EHijB9C;AGhjBmC;EAAW,kBAAA;EHmjB9C;AGljBmC;EAAW,kBAAA;EHqjB9C;AGpjBmC;EAAW,kBAAA;EHujB9C;AGtjBmC;EAAW,kBAAA;EHyjB9C;AGxjBmC;EAAW,kBAAA;EH2jB9C;AG1jBmC;EAAW,kBAAA;EH6jB9C;AG5jBmC;EAAW,kBAAA;EH+jB9C;AG9jBmC;EAAW,kBAAA;EHikB9C;AGhkBmC;EAAW,kBAAA;EHmkB9C;AGlkBmC;EAAW,kBAAA;EHqkB9C;AGpkBmC;EAAW,kBAAA;EHukB9C;AGtkBmC;EAAW,kBAAA;EHykB9C;AGxkBmC;EAAW,kBAAA;EH2kB9C;AG1kBmC;EAAW,kBAAA;EH6kB9C;AG5kBmC;EAAW,kBAAA;EH+kB9C;AG9kBmC;EAAW,kBAAA;EHilB9C;AGhlBmC;EAAW,kBAAA;EHmlB9C;AGllBmC;EAAW,kBAAA;EHqlB9C;AGplBmC;EAAW,kBAAA;EHulB9C;AGtlBmC;EAAW,kBAAA;EHylB9C;AGxlBmC;EAAW,kBAAA;EH2lB9C;AG1lBmC;EAAW,kBAAA;EH6lB9C;AG5lBmC;EAAW,kBAAA;EH+lB9C;AG9lBmC;EAAW,kBAAA;EHimB9C;AGhmBmC;EAAW,kBAAA;EHmmB9C;AGlmBmC;EAAW,kBAAA;EHqmB9C;AGpmBmC;EAAW,kBAAA;EHumB9C;AGtmBmC;EAAW,kBAAA;EHymB9C;AGxmBmC;EAAW,kBAAA;EH2mB9C;AG1mBmC;EAAW,kBAAA;EH6mB9C;AG5mBmC;EAAW,kBAAA;EH+mB9C;AG9mBmC;EAAW,kBAAA;EHinB9C;AGhnBmC;EAAW,kBAAA;EHmnB9C;AGlnBmC;EAAW,kBAAA;EHqnB9C;AGpnBmC;EAAW,kBAAA;EHunB9C;AGtnBmC;EAAW,kBAAA;EHynB9C;AGxnBmC;EAAW,kBAAA;EH2nB9C;AG1nBmC;EAAW,kBAAA;EH6nB9C;AG5nBmC;EAAW,kBAAA;EH+nB9C;AG9nBmC;EAAW,kBAAA;EHioB9C;AGhoBmC;EAAW,kBAAA;EHmoB9C;AGloBmC;EAAW,kBAAA;EHqoB9C;AGpoBmC;EAAW,kBAAA;EHuoB9C;AGtoBmC;EAAW,kBAAA;EHyoB9C;AGhoBmC;EAAW,kBAAA;EHmoB9C;AGloBmC;EAAW,kBAAA;EHqoB9C;AGpoBmC;EAAW,kBAAA;EHuoB9C;AGtoBmC;EAAW,kBAAA;EHyoB9C;AGxoBmC;EAAW,kBAAA;EH2oB9C;AG1oBmC;EAAW,kBAAA;EH6oB9C;AG5oBmC;EAAW,kBAAA;EH+oB9C;AG9oBmC;EAAW,kBAAA;EHipB9C;AGhpBmC;EAAW,kBAAA;EHmpB9C;AGlpBmC;EAAW,kBAAA;EHqpB9C;AGppBmC;EAAW,kBAAA;EHupB9C;AGtpBmC;EAAW,kBAAA;EHypB9C;AGxpBmC;EAAW,kBAAA;EH2pB9C;AG1pBmC;EAAW,kBAAA;EH6pB9C;AG5pBmC;EAAW,kBAAA;EH+pB9C;AG9pBmC;EAAW,kBAAA;EHiqB9C;AGhqBmC;EAAW,kBAAA;EHmqB9C;AGlqBmC;EAAW,kBAAA;EHqqB9C;AGpqBmC;EAAW,kBAAA;EHuqB9C;AGtqBmC;EAAW,kBAAA;EHyqB9C;AGxqBmC;EAAW,kBAAA;EH2qB9C;AG1qBmC;EAAW,kBAAA;EH6qB9C;AG5qBmC;EAAW,kBAAA;EH+qB9C;AG9qBmC;EAAW,kBAAA;EHirB9C;AGhrBmC;EAAW,kBAAA;EHmrB9C;AGlrBmC;EAAW,kBAAA;EHqrB9C;AGprBmC;EAAW,kBAAA;EHurB9C;AGtrBmC;EAAW,kBAAA;EHyrB9C;AGxrBmC;EAAW,kBAAA;EH2rB9C;AG1rBmC;EAAW,kBAAA;EH6rB9C;AG5rBmC;EAAW,kBAAA;EH+rB9C;AG9rBmC;EAAW,kBAAA;EHisB9C;AGhsBmC;EAAW,kBAAA;EHmsB9C;AGlsBmC;EAAW,kBAAA;EHqsB9C;AGpsBmC;EAAW,kBAAA;EHusB9C;AGtsBmC;EAAW,kBAAA;EHysB9C;AGxsBmC;EAAW,kBAAA;EH2sB9C;AG1sBmC;EAAW,kBAAA;EH6sB9C;AG5sBmC;EAAW,kBAAA;EH+sB9C;AG9sBmC;EAAW,kBAAA;EHitB9C;AGhtBmC;EAAW,kBAAA;EHmtB9C;AGltBmC;EAAW,kBAAA;EHqtB9C;AGptBmC;EAAW,kBAAA;EHutB9C;AGttBmC;EAAW,kBAAA;EHytB9C;AGxtBmC;EAAW,kBAAA;EH2tB9C;AG1tBmC;EAAW,kBAAA;EH6tB9C;AG5tBmC;EAAW,kBAAA;EH+tB9C;AG9tBmC;EAAW,kBAAA;EHiuB9C;AGhuBmC;EAAW,kBAAA;EHmuB9C;AGluBmC;EAAW,kBAAA;EHquB9C;AGpuBmC;EAAW,kBAAA;EHuuB9C;AGtuBmC;EAAW,kBAAA;EHyuB9C;AI3gCD;ECgEE,gCAAA;EACG,6BAAA;EACK,wBAAA;EL88BT;AI7gCD;;EC6DE,gCAAA;EACG,6BAAA;EACK,wBAAA;ELo9BT;AI3gCD;EACE,iBAAA;EACA,+CAAA;EJ6gCD;AI1gCD;EACE,6DAAA;EACA,iBAAA;EACA,yBAAA;EACA,gBAAA;EACA,2BAAA;EJ4gCD;AIxgCD;;;;EAIE,sBAAA;EACA,oBAAA;EACA,sBAAA;EJ0gCD;AIpgCD;EACE,gBAAA;EACA,uBAAA;EJsgCD;AIpgCC;;EAEE,gBAAA;EACA,4BAAA;EJsgCH;AIngCC;EErDA,sBAAA;EAEA,4CAAA;EACA,sBAAA;EN0jCD;AI7/BD;EACE,WAAA;EJ+/BD;AIz/BD;EACE,wBAAA;EJ2/BD;AIv/BD;;;;;EGvEE,gBAAA;EACA,iBAAA;EACA,cAAA;EPqkCD;AI3/BD;EACE,oBAAA;EJ6/BD;AIv/BD;EACE,cAAA;EACA,yBAAA;EACA,2BAAA;EACA,2BAAA;EACA,oBAAA;EC6FA,0CAAA;EACK,qCAAA;EACG,kCAAA;EEvLR,uBAAA;EACA,iBAAA;EACA,cAAA;EPqlCD;AIv/BD;EACE,oBAAA;EJy/BD;AIn/BD;EACE,kBAAA;EACA,qBAAA;EACA,WAAA;EACA,+BAAA;EJq/BD;AI7+BD;EACE,oBAAA;EACA,YAAA;EACA,aAAA;EACA,cAAA;EACA,YAAA;EACA,kBAAA;EACA,wBAAA;EACA,WAAA;EJ++BD;AIv+BC;;EAEE,kBAAA;EACA,aAAA;EACA,cAAA;EACA,WAAA;EACA,mBAAA;EACA,YAAA;EJy+BH;AQpnCD;;;;;;;;;;;;EAEE,sBAAA;EACA,kBAAA;EACA,kBAAA;EACA,gBAAA;ERgoCD;AQroCD;;;;;;;;;;;;;;;;;;;;;;;;EASI,qBAAA;EACA,gBAAA;EACA,gBAAA;ERspCH;AQlpCD;;;;;;EAGE,kBAAA;EACA,qBAAA;ERupCD;AQ3pCD;;;;;;;;;;;;EAQI,gBAAA;ERiqCH;AQ9pCD;;;;;;EAGE,kBAAA;EACA,qBAAA;ERmqCD;AQvqCD;;;;;;;;;;;;EAQI,gBAAA;ER6qCH;AQzqCD;;EAAU,iBAAA;ER6qCT;AQ5qCD;;EAAU,iBAAA;ERgrCT;AQ/qCD;;EAAU,iBAAA;ERmrCT;AQlrCD;;EAAU,iBAAA;ERsrCT;AQrrCD;;EAAU,iBAAA;ERyrCT;AQxrCD;;EAAU,iBAAA;ER4rCT;AQtrCD;EACE,kBAAA;ERwrCD;AQrrCD;EACE,qBAAA;EACA,iBAAA;EACA,kBAAA;EACA,kBAAA;ERurCD;AQlrCD;EAAA;IAFI,iBAAA;IRwrCD;EACF;AQhrCD;;EAEE,gBAAA;ERkrCD;AQ/qCD;;EAEE,2BAAA;EACA,eAAA;ERirCD;AQ7qCD;EAAuB,kBAAA;ERgrCtB;AQ/qCD;EAAuB,mBAAA;ERkrCtB;AQjrCD;EAAuB,oBAAA;ERorCtB;AQnrCD;EAAuB,qBAAA;ERsrCtB;AQrrCD;EAAuB,qBAAA;ERwrCtB;AQrrCD;EAAuB,2BAAA;ERwrCtB;AQvrCD;EAAuB,2BAAA;ER0rCtB;AQzrCD;EAAuB,4BAAA;ER4rCtB;AQzrCD;EACE,gBAAA;ER2rCD;AQzrCD;ECrGE,gBAAA;ETiyCD;AShyCC;EACE,gBAAA;ETkyCH;AQ5rCD;ECxGE,gBAAA;ETuyCD;AStyCC;EACE,gBAAA;ETwyCH;AQ/rCD;EC3GE,gBAAA;ET6yCD;AS5yCC;EACE,gBAAA;ET8yCH;AQlsCD;EC9GE,gBAAA;ETmzCD;ASlzCC;EACE,gBAAA;ETozCH;AQrsCD;ECjHE,gBAAA;ETyzCD;ASxzCC;EACE,gBAAA;ET0zCH;AQpsCD;EAGE,aAAA;EE3HA,2BAAA;EVg0CD;AU/zCC;EACE,2BAAA;EVi0CH;AQrsCD;EE9HE,2BAAA;EVs0CD;AUr0CC;EACE,2BAAA;EVu0CH;AQxsCD;EEjIE,2BAAA;EV40CD;AU30CC;EACE,2BAAA;EV60CH;AQ3sCD;EEpIE,2BAAA;EVk1CD;AUj1CC;EACE,2BAAA;EVm1CH;AQ9sCD;EEvIE,2BAAA;EVw1CD;AUv1CC;EACE,2BAAA;EVy1CH;AQ5sCD;EACE,qBAAA;EACA,qBAAA;EACA,kCAAA;ER8sCD;AQtsCD;;EAEE,eAAA;EACA,qBAAA;ERwsCD;AQ3sCD;;;;EAMI,kBAAA;ER2sCH;AQpsCD;EACE,iBAAA;EACA,kBAAA;ERssCD;AQlsCD;EALE,iBAAA;EACA,kBAAA;EAMA,mBAAA;ERqsCD;AQvsCD;EAKI,uBAAA;EACA,mBAAA;EACA,oBAAA;ERqsCH;AQhsCD;EACE,eAAA;EACA,qBAAA;ERksCD;AQhsCD;;EAEE,yBAAA;ERksCD;AQhsCD;EACE,mBAAA;ERksCD;AQhsCD;EACE,gBAAA;ERksCD;AQzqCD;EAAA;IAVM,aAAA;IACA,cAAA;IACA,aAAA;IACA,mBAAA;IGtNJ,kBAAA;IACA,yBAAA;IACA,qBAAA;IX84CC;EQnrCH;IAHM,oBAAA;IRyrCH;EACF;AQhrCD;;EAGE,cAAA;EACA,mCAAA;ERirCD;AQ/qCD;EACE,gBAAA;EACA,2BAAA;ERirCD;AQ7qCD;EACE,oBAAA;EACA,kBAAA;EACA,mBAAA;EACA,gCAAA;ER+qCD;AQ1qCG;;;EACE,kBAAA;ER8qCL;AQxrCD;;;EAmBI,gBAAA;EACA,gBAAA;EACA,yBAAA;EACA,gBAAA;ER0qCH;AQxqCG;;;EACE,wBAAA;ER4qCL;AQpqCD;;EAEE,qBAAA;EACA,iBAAA;EACA,iCAAA;EACA,gBAAA;EACA,mBAAA;ERsqCD;AQhqCG;;;;;;EAAW,aAAA;ERwqCd;AQvqCG;;;;;;EACE,wBAAA;ER8qCL;AQxqCD;EACE,qBAAA;EACA,oBAAA;EACA,yBAAA;ER0qCD;AYh9CD;;;;EAIE,gEAAA;EZk9CD;AY98CD;EACE,kBAAA;EACA,gBAAA;EACA,gBAAA;EACA,2BAAA;EACA,oBAAA;EZg9CD;AY58CD;EACE,kBAAA;EACA,gBAAA;EACA,gBAAA;EACA,2BAAA;EACA,oBAAA;EACA,wDAAA;UAAA,gDAAA;EZ88CD;AYp9CD;EASI,YAAA;EACA,iBAAA;EACA,mBAAA;EACA,0BAAA;UAAA,kBAAA;EZ88CH;AYz8CD;EACE,gBAAA;EACA,gBAAA;EACA,kBAAA;EACA,iBAAA;EACA,yBAAA;EACA,uBAAA;EACA,uBAAA;EACA,gBAAA;EACA,2BAAA;EACA,2BAAA;EACA,oBAAA;EZ28CD;AYt9CD;EAeI,YAAA;EACA,oBAAA;EACA,gBAAA;EACA,uBAAA;EACA,+BAAA;EACA,kBAAA;EZ08CH;AYr8CD;EACE,mBAAA;EACA,oBAAA;EZu8CD;AajgDD;ECHE,oBAAA;EACA,mBAAA;EACA,oBAAA;EACA,qBAAA;EdugDD;AajgDC;EAAA;IAFE,cAAA;IbugDD;EACF;AangDC;EAAA;IAFE,cAAA;IbygDD;EACF;AargDD;EAAA;IAFI,eAAA;Ib2gDD;EACF;AalgDD;ECvBE,oBAAA;EACA,mBAAA;EACA,oBAAA;EACA,qBAAA;Ed4hDD;Aa//CD;ECvBE,oBAAA;EACA,qBAAA;EdyhDD;AezhDG;EACE,oBAAA;EAEA,iBAAA;EAEA,oBAAA;EACA,qBAAA;EfyhDL;AezgDG;EACE,aAAA;Ef2gDL;AepgDC;EACE,aAAA;EfsgDH;AevgDC;EACE,qBAAA;EfygDH;Ae1gDC;EACE,qBAAA;Ef4gDH;Ae7gDC;EACE,YAAA;Ef+gDH;AehhDC;EACE,qBAAA;EfkhDH;AenhDC;EACE,qBAAA;EfqhDH;AethDC;EACE,YAAA;EfwhDH;AezhDC;EACE,qBAAA;Ef2hDH;Ae5hDC;EACE,qBAAA;Ef8hDH;Ae/hDC;EACE,YAAA;EfiiDH;AeliDC;EACE,qBAAA;EfoiDH;AeriDC;EACE,oBAAA;EfuiDH;AezhDC;EACE,aAAA;Ef2hDH;Ae5hDC;EACE,qBAAA;Ef8hDH;Ae/hDC;EACE,qBAAA;EfiiDH;AeliDC;EACE,YAAA;EfoiDH;AeriDC;EACE,qBAAA;EfuiDH;AexiDC;EACE,qBAAA;Ef0iDH;Ae3iDC;EACE,YAAA;Ef6iDH;Ae9iDC;EACE,qBAAA;EfgjDH;AejjDC;EACE,qBAAA;EfmjDH;AepjDC;EACE,YAAA;EfsjDH;AevjDC;EACE,qBAAA;EfyjDH;Ae1jDC;EACE,oBAAA;Ef4jDH;AexjDC;EACE,aAAA;Ef0jDH;Ae1kDC;EACE,YAAA;Ef4kDH;Ae7kDC;EACE,oBAAA;Ef+kDH;AehlDC;EACE,oBAAA;EfklDH;AenlDC;EACE,WAAA;EfqlDH;AetlDC;EACE,oBAAA;EfwlDH;AezlDC;EACE,oBAAA;Ef2lDH;Ae5lDC;EACE,WAAA;Ef8lDH;Ae/lDC;EACE,oBAAA;EfimDH;AelmDC;EACE,oBAAA;EfomDH;AermDC;EACE,WAAA;EfumDH;AexmDC;EACE,oBAAA;Ef0mDH;Ae3mDC;EACE,mBAAA;Ef6mDH;AezmDC;EACE,YAAA;Ef2mDH;Ae7lDC;EACE,mBAAA;Ef+lDH;AehmDC;EACE,2BAAA;EfkmDH;AenmDC;EACE,2BAAA;EfqmDH;AetmDC;EACE,kBAAA;EfwmDH;AezmDC;EACE,2BAAA;Ef2mDH;Ae5mDC;EACE,2BAAA;Ef8mDH;Ae/mDC;EACE,kBAAA;EfinDH;AelnDC;EACE,2BAAA;EfonDH;AernDC;EACE,2BAAA;EfunDH;AexnDC;EACE,kBAAA;Ef0nDH;Ae3nDC;EACE,2BAAA;Ef6nDH;Ae9nDC;EACE,0BAAA;EfgoDH;AejoDC;EACE,iBAAA;EfmoDH;AanoDD;EElCI;IACE,aAAA;IfwqDH;EejqDD;IACE,aAAA;IfmqDD;EepqDD;IACE,qBAAA;IfsqDD;EevqDD;IACE,qBAAA;IfyqDD;Ee1qDD;IACE,YAAA;If4qDD;Ee7qDD;IACE,qBAAA;If+qDD;EehrDD;IACE,qBAAA;IfkrDD;EenrDD;IACE,YAAA;IfqrDD;EetrDD;IACE,qBAAA;IfwrDD;EezrDD;IACE,qBAAA;If2rDD;Ee5rDD;IACE,YAAA;If8rDD;Ee/rDD;IACE,qBAAA;IfisDD;EelsDD;IACE,oBAAA;IfosDD;EetrDD;IACE,aAAA;IfwrDD;EezrDD;IACE,qBAAA;If2rDD;Ee5rDD;IACE,qBAAA;If8rDD;Ee/rDD;IACE,YAAA;IfisDD;EelsDD;IACE,qBAAA;IfosDD;EersDD;IACE,qBAAA;IfusDD;EexsDD;IACE,YAAA;If0sDD;Ee3sDD;IACE,qBAAA;If6sDD;Ee9sDD;IACE,qBAAA;IfgtDD;EejtDD;IACE,YAAA;IfmtDD;EeptDD;IACE,qBAAA;IfstDD;EevtDD;IACE,oBAAA;IfytDD;EertDD;IACE,aAAA;IfutDD;EevuDD;IACE,YAAA;IfyuDD;Ee1uDD;IACE,oBAAA;If4uDD;Ee7uDD;IACE,oBAAA;If+uDD;EehvDD;IACE,WAAA;IfkvDD;EenvDD;IACE,oBAAA;IfqvDD;EetvDD;IACE,oBAAA;IfwvDD;EezvDD;IACE,WAAA;If2vDD;Ee5vDD;IACE,oBAAA;If8vDD;Ee/vDD;IACE,oBAAA;IfiwDD;EelwDD;IACE,WAAA;IfowDD;EerwDD;IACE,oBAAA;IfuwDD;EexwDD;IACE,mBAAA;If0wDD;EetwDD;IACE,YAAA;IfwwDD;Ee1vDD;IACE,mBAAA;If4vDD;Ee7vDD;IACE,2BAAA;If+vDD;EehwDD;IACE,2BAAA;IfkwDD;EenwDD;IACE,kBAAA;IfqwDD;EetwDD;IACE,2BAAA;IfwwDD;EezwDD;IACE,2BAAA;If2wDD;Ee5wDD;IACE,kBAAA;If8wDD;Ee/wDD;IACE,2BAAA;IfixDD;EelxDD;IACE,2BAAA;IfoxDD;EerxDD;IACE,kBAAA;IfuxDD;EexxDD;IACE,2BAAA;If0xDD;Ee3xDD;IACE,0BAAA;If6xDD;Ee9xDD;IACE,iBAAA;IfgyDD;EACF;AaxxDD;EE3CI;IACE,aAAA;Ifs0DH;Ee/zDD;IACE,aAAA;Ifi0DD;Eel0DD;IACE,qBAAA;Ifo0DD;Eer0DD;IACE,qBAAA;Ifu0DD;Eex0DD;IACE,YAAA;If00DD;Ee30DD;IACE,qBAAA;If60DD;Ee90DD;IACE,qBAAA;Ifg1DD;Eej1DD;IACE,YAAA;Ifm1DD;Eep1DD;IACE,qBAAA;Ifs1DD;Eev1DD;IACE,qBAAA;Ify1DD;Ee11DD;IACE,YAAA;If41DD;Ee71DD;IACE,qBAAA;If+1DD;Eeh2DD;IACE,oBAAA;Ifk2DD;Eep1DD;IACE,aAAA;Ifs1DD;Eev1DD;IACE,qBAAA;Ify1DD;Ee11DD;IACE,qBAAA;If41DD;Ee71DD;IACE,YAAA;If+1DD;Eeh2DD;IACE,qBAAA;Ifk2DD;Een2DD;IACE,qBAAA;Ifq2DD;Eet2DD;IACE,YAAA;Ifw2DD;Eez2DD;IACE,qBAAA;If22DD;Ee52DD;IACE,qBAAA;If82DD;Ee/2DD;IACE,YAAA;Ifi3DD;Eel3DD;IACE,qBAAA;Ifo3DD;Eer3DD;IACE,oBAAA;Ifu3DD;Een3DD;IACE,aAAA;Ifq3DD;Eer4DD;IACE,YAAA;Ifu4DD;Eex4DD;IACE,oBAAA;If04DD;Ee34DD;IACE,oBAAA;If64DD;Ee94DD;IACE,WAAA;Ifg5DD;Eej5DD;IACE,oBAAA;Ifm5DD;Eep5DD;IACE,oBAAA;Ifs5DD;Eev5DD;IACE,WAAA;Ify5DD;Ee15DD;IACE,oBAAA;If45DD;Ee75DD;IACE,oBAAA;If+5DD;Eeh6DD;IACE,WAAA;Ifk6DD;Een6DD;IACE,oBAAA;Ifq6DD;Eet6DD;IACE,mBAAA;Ifw6DD;Eep6DD;IACE,YAAA;Ifs6DD;Eex5DD;IACE,mBAAA;If05DD;Ee35DD;IACE,2BAAA;If65DD;Ee95DD;IACE,2BAAA;Ifg6DD;Eej6DD;IACE,kBAAA;Ifm6DD;Eep6DD;IACE,2BAAA;Ifs6DD;Eev6DD;IACE,2BAAA;Ify6DD;Ee16DD;IACE,kBAAA;If46DD;Ee76DD;IACE,2BAAA;If+6DD;Eeh7DD;IACE,2BAAA;Ifk7DD;Een7DD;IACE,kBAAA;Ifq7DD;Eet7DD;IACE,2BAAA;Ifw7DD;Eez7DD;IACE,0BAAA;If27DD;Ee57DD;IACE,iBAAA;If87DD;EACF;Aan7DD;EE9CI;IACE,aAAA;Ifo+DH;Ee79DD;IACE,aAAA;If+9DD;Eeh+DD;IACE,qBAAA;Ifk+DD;Een+DD;IACE,qBAAA;Ifq+DD;Eet+DD;IACE,YAAA;Ifw+DD;Eez+DD;IACE,qBAAA;If2+DD;Ee5+DD;IACE,qBAAA;If8+DD;Ee/+DD;IACE,YAAA;Ifi/DD;Eel/DD;IACE,qBAAA;Ifo/DD;Eer/DD;IACE,qBAAA;Ifu/DD;Eex/DD;IACE,YAAA;If0/DD;Ee3/DD;IACE,qBAAA;If6/DD;Ee9/DD;IACE,oBAAA;IfggED;Eel/DD;IACE,aAAA;Ifo/DD;Eer/DD;IACE,qBAAA;Ifu/DD;Eex/DD;IACE,qBAAA;If0/DD;Ee3/DD;IACE,YAAA;If6/DD;Ee9/DD;IACE,qBAAA;IfggED;EejgED;IACE,qBAAA;IfmgED;EepgED;IACE,YAAA;IfsgED;EevgED;IACE,qBAAA;IfygED;Ee1gED;IACE,qBAAA;If4gED;Ee7gED;IACE,YAAA;If+gED;EehhED;IACE,qBAAA;IfkhED;EenhED;IACE,oBAAA;IfqhED;EejhED;IACE,aAAA;IfmhED;EeniED;IACE,YAAA;IfqiED;EetiED;IACE,oBAAA;IfwiED;EeziED;IACE,oBAAA;If2iED;Ee5iED;IACE,WAAA;If8iED;Ee/iED;IACE,oBAAA;IfijED;EeljED;IACE,oBAAA;IfojED;EerjED;IACE,WAAA;IfujED;EexjED;IACE,oBAAA;If0jED;Ee3jED;IACE,oBAAA;If6jED;Ee9jED;IACE,WAAA;IfgkED;EejkED;IACE,oBAAA;IfmkED;EepkED;IACE,mBAAA;IfskED;EelkED;IACE,YAAA;IfokED;EetjED;IACE,mBAAA;IfwjED;EezjED;IACE,2BAAA;If2jED;Ee5jED;IACE,2BAAA;If8jED;Ee/jED;IACE,kBAAA;IfikED;EelkED;IACE,2BAAA;IfokED;EerkED;IACE,2BAAA;IfukED;EexkED;IACE,kBAAA;If0kED;Ee3kED;IACE,2BAAA;If6kED;Ee9kED;IACE,2BAAA;IfglED;EejlED;IACE,kBAAA;IfmlED;EeplED;IACE,2BAAA;IfslED;EevlED;IACE,0BAAA;IfylED;Ee1lED;IACE,iBAAA;If4lED;EACF;AgBhqED;EACE,+BAAA;EhBkqED;AgBhqED;EACE,kBAAA;EACA,qBAAA;EACA,gBAAA;EACA,kBAAA;EhBkqED;AgBhqED;EACE,kBAAA;EhBkqED;AgB5pED;EACE,aAAA;EACA,iBAAA;EACA,qBAAA;EhB8pED;AgBjqED;;;;;;EAWQ,cAAA;EACA,yBAAA;EACA,qBAAA;EACA,+BAAA;EhB8pEP;AgB5qED;EAoBI,wBAAA;EACA,kCAAA;EhB2pEH;AgBhrED;;;;;;EA8BQ,eAAA;EhB0pEP;AgBxrED;EAoCI,+BAAA;EhBupEH;AgB3rED;EAyCI,2BAAA;EhBqpEH;AgB9oED;;;;;;EAOQ,cAAA;EhB+oEP;AgBpoED;EACE,2BAAA;EhBsoED;AgBvoED;;;;;;EAQQ,2BAAA;EhBuoEP;AgB/oED;;EAeM,0BAAA;EhBooEL;AgB1nED;EAEI,2BAAA;EhB2nEH;AgBlnED;EAEI,2BAAA;EhBmnEH;AgB1mED;EACE,kBAAA;EACA,aAAA;EACA,uBAAA;EhB4mED;AgBvmEG;;EACE,kBAAA;EACA,aAAA;EACA,qBAAA;EhB0mEL;AiBtvEC;;;;;;;;;;;;EAOI,2BAAA;EjB6vEL;AiBvvEC;;;;;EAMI,2BAAA;EjBwvEL;AiB3wEC;;;;;;;;;;;;EAOI,2BAAA;EjBkxEL;AiB5wEC;;;;;EAMI,2BAAA;EjB6wEL;AiBhyEC;;;;;;;;;;;;EAOI,2BAAA;EjBuyEL;AiBjyEC;;;;;EAMI,2BAAA;EjBkyEL;AiBrzEC;;;;;;;;;;;;EAOI,2BAAA;EjB4zEL;AiBtzEC;;;;;EAMI,2BAAA;EjBuzEL;AiB10EC;;;;;;;;;;;;EAOI,2BAAA;EjBi1EL;AiB30EC;;;;;EAMI,2BAAA;EjB40EL;AgB1rED;EACE,kBAAA;EACA,mBAAA;EhB4rED;AgB/nED;EAAA;IA1DI,aAAA;IACA,qBAAA;IACA,oBAAA;IACA,8CAAA;IACA,2BAAA;IhB6rED;EgBvoEH;IAlDM,kBAAA;IhB4rEH;EgB1oEH;;;;;;IAzCY,qBAAA;IhB2rET;EgBlpEH;IAjCM,WAAA;IhBsrEH;EgBrpEH;;;;;;IAxBY,gBAAA;IhBqrET;EgB7pEH;;;;;;IApBY,iBAAA;IhByrET;EgBrqEH;;;;IAPY,kBAAA;IhBkrET;EACF;AkB54ED;EACE,YAAA;EACA,WAAA;EACA,WAAA;EAIA,cAAA;ElB24ED;AkBx4ED;EACE,gBAAA;EACA,aAAA;EACA,YAAA;EACA,qBAAA;EACA,iBAAA;EACA,sBAAA;EACA,gBAAA;EACA,WAAA;EACA,kCAAA;ElB04ED;AkBv4ED;EACE,uBAAA;EACA,iBAAA;EACA,oBAAA;EACA,mBAAA;ElBy4ED;AkB93ED;Eb4BE,gCAAA;EACG,6BAAA;EACK,wBAAA;ELq2ET;AkB93ED;;EAEE,iBAAA;EACA,oBAAA;EACA,qBAAA;ElBg4ED;AkB53ED;EACE,gBAAA;ElB83ED;AkB13ED;EACE,gBAAA;EACA,aAAA;ElB43ED;AkBx3ED;;EAEE,cAAA;ElB03ED;AkBt3ED;;;EZxEE,sBAAA;EAEA,4CAAA;EACA,sBAAA;ENk8ED;AkBt3ED;EACE,gBAAA;EACA,kBAAA;EACA,iBAAA;EACA,yBAAA;EACA,gBAAA;ElBw3ED;AkB91ED;EACE,gBAAA;EACA,aAAA;EACA,cAAA;EACA,mBAAA;EACA,iBAAA;EACA,yBAAA;EACA,gBAAA;EACA,2BAAA;EACA,wBAAA;EACA,2BAAA;EACA,oBAAA;EbzDA,0DAAA;EACQ,kDAAA;EAyHR,wFAAA;EACK,2EAAA;EACG,wEAAA;ELkyET;AmB16EC;EACE,uBAAA;EACA,YAAA;EdUF,wFAAA;EACQ,gFAAA;ELm6ET;AKl4EC;EACE,gBAAA;EACA,YAAA;ELo4EH;AKl4EC;EAA0B,gBAAA;ELq4E3B;AKp4EC;EAAgC,gBAAA;ELu4EjC;AkBt2EC;;;EAGE,qBAAA;EACA,2BAAA;EACA,YAAA;ElBw2EH;AkBp2EC;EACE,cAAA;ElBs2EH;AkB11ED;EACE,0BAAA;ElB41ED;AkBxzED;EAxBE;;;;IAIE,mBAAA;IlBm1ED;EkBj1EC;;;;;;;;IAEE,mBAAA;IlBy1EH;EkBt1EC;;;;;;;;IAEE,mBAAA;IlB81EH;EACF;AkBp1ED;EACE,qBAAA;ElBs1ED;AkB90ED;;EAEE,oBAAA;EACA,gBAAA;EACA,kBAAA;EACA,qBAAA;ElBg1ED;AkBr1ED;;EAQI,kBAAA;EACA,oBAAA;EACA,kBAAA;EACA,qBAAA;EACA,iBAAA;ElBi1EH;AkB90ED;;;;EAIE,oBAAA;EACA,oBAAA;EACA,oBAAA;ElBg1ED;AkB70ED;;EAEE,kBAAA;ElB+0ED;AkB30ED;;EAEE,uBAAA;EACA,oBAAA;EACA,kBAAA;EACA,wBAAA;EACA,qBAAA;EACA,iBAAA;ElB60ED;AkB30ED;;EAEE,eAAA;EACA,mBAAA;ElB60ED;AkBp0EC;;;;;;EAGE,qBAAA;ElBy0EH;AkBn0EC;;;;EAEE,qBAAA;ElBu0EH;AkBj0EC;;;;EAGI,qBAAA;ElBo0EL;AkBzzED;EAEE,kBAAA;EACA,qBAAA;EAEA,kBAAA;ElByzED;AkBvzEC;;EAEE,iBAAA;EACA,kBAAA;ElByzEH;AkB5yED;ECpPE,cAAA;EACA,mBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;EnBmiFD;AmBjiFC;EACE,cAAA;EACA,mBAAA;EnBmiFH;AmBhiFC;;EAEE,cAAA;EnBkiFH;AkBxzED;ECvPE,cAAA;EACA,mBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;EnBkjFD;AmBhjFC;EACE,cAAA;EACA,mBAAA;EnBkjFH;AmB/iFC;;EAEE,cAAA;EnBijFH;AkBv0ED;EAKI,cAAA;EACA,mBAAA;EACA,iBAAA;EACA,kBAAA;ElBq0EH;AkBj0ED;ECnQE,cAAA;EACA,oBAAA;EACA,iBAAA;EACA,wBAAA;EACA,oBAAA;EnBukFD;AmBrkFC;EACE,cAAA;EACA,mBAAA;EnBukFH;AmBpkFC;;EAEE,cAAA;EnBskFH;AkB70ED;ECtQE,cAAA;EACA,oBAAA;EACA,iBAAA;EACA,wBAAA;EACA,oBAAA;EnBslFD;AmBplFC;EACE,cAAA;EACA,mBAAA;EnBslFH;AmBnlFC;;EAEE,cAAA;EnBqlFH;AkB51ED;EAKI,cAAA;EACA,oBAAA;EACA,iBAAA;EACA,wBAAA;ElB01EH;AkBj1ED;EAEE,oBAAA;ElBk1ED;AkBp1ED;EAMI,uBAAA;ElBi1EH;AkB70ED;EACE,oBAAA;EACA,QAAA;EACA,UAAA;EACA,YAAA;EACA,gBAAA;EACA,aAAA;EACA,cAAA;EACA,mBAAA;EACA,oBAAA;EACA,sBAAA;ElB+0ED;AkB70ED;EACE,aAAA;EACA,cAAA;EACA,mBAAA;ElB+0ED;AkB70ED;EACE,aAAA;EACA,cAAA;EACA,mBAAA;ElB+0ED;AkB30ED;;;;;;;;;;EC7WI,gBAAA;EnBosFH;AkBv1ED;ECzWI,uBAAA;Ed+CF,0DAAA;EACQ,kDAAA;ELqpFT;AmBnsFG;EACE,uBAAA;Ed4CJ,2EAAA;EACQ,mEAAA;EL0pFT;AkBj2ED;EC/VI,gBAAA;EACA,uBAAA;EACA,2BAAA;EnBmsFH;AkBt2ED;ECzVI,gBAAA;EnBksFH;AkBt2ED;;;;;;;;;;EChXI,gBAAA;EnBkuFH;AkBl3ED;EC5WI,uBAAA;Ed+CF,0DAAA;EACQ,kDAAA;ELmrFT;AmBjuFG;EACE,uBAAA;Ed4CJ,2EAAA;EACQ,mEAAA;ELwrFT;AkB53ED;EClWI,gBAAA;EACA,uBAAA;EACA,2BAAA;EnBiuFH;AkBj4ED;EC5VI,gBAAA;EnBguFH;AkBj4ED;;;;;;;;;;ECnXI,gBAAA;EnBgwFH;AkB74ED;EC/WI,uBAAA;Ed+CF,0DAAA;EACQ,kDAAA;ELitFT;AmB/vFG;EACE,uBAAA;Ed4CJ,2EAAA;EACQ,mEAAA;ELstFT;AkBv5ED;ECrWI,gBAAA;EACA,uBAAA;EACA,2BAAA;EnB+vFH;AkB55ED;EC/VI,gBAAA;EnB8vFH;AkBx5EC;EACG,WAAA;ElB05EJ;AkBx5EC;EACG,QAAA;ElB05EJ;AkBh5ED;EACE,gBAAA;EACA,iBAAA;EACA,qBAAA;EACA,gBAAA;ElBk5ED;AkB/zED;EAAA;IA9DM,uBAAA;IACA,kBAAA;IACA,wBAAA;IlBi4EH;EkBr0EH;IAvDM,uBAAA;IACA,aAAA;IACA,wBAAA;IlB+3EH;EkB10EH;IAhDM,uBAAA;IlB63EH;EkB70EH;IA5CM,uBAAA;IACA,wBAAA;IlB43EH;EkBj1EH;;;IAtCQ,aAAA;IlB43EL;EkBt1EH;IAhCM,aAAA;IlBy3EH;EkBz1EH;IA5BM,kBAAA;IACA,wBAAA;IlBw3EH;EkB71EH;;IApBM,uBAAA;IACA,eAAA;IACA,kBAAA;IACA,wBAAA;IlBq3EH;EkBp2EH;;IAdQ,iBAAA;IlBs3EL;EkBx2EH;;IATM,oBAAA;IACA,gBAAA;IlBq3EH;EkB72EH;IAHM,QAAA;IlBm3EH;EACF;AkBz2ED;;;;EASI,eAAA;EACA,kBAAA;EACA,kBAAA;ElBs2EH;AkBj3ED;;EAiBI,kBAAA;ElBo2EH;AkBr3ED;EJzeE,oBAAA;EACA,qBAAA;Edi2FD;AkBl1EC;EAAA;IAVI,mBAAA;IACA,kBAAA;IACA,kBAAA;IlBg2EH;EACF;AkBh4ED;EAwCI,aAAA;ElB21EH;AkB90EC;EAAA;IAHM,0BAAA;IlBq1EL;EACF;AkB50EC;EAAA;IAHM,kBAAA;IlBm1EL;EACF;AoB73FD;EACE,uBAAA;EACA,kBAAA;EACA,qBAAA;EACA,oBAAA;EACA,wBAAA;EACA,gCAAA;MAAA,4BAAA;EACA,iBAAA;EACA,wBAAA;EACA,+BAAA;EACA,qBAAA;EC6BA,mBAAA;EACA,iBAAA;EACA,yBAAA;EACA,oBAAA;EhB4KA,2BAAA;EACG,wBAAA;EACC,uBAAA;EACI,mBAAA;ELwrFT;AoBh4FG;;;;;;EdrBF,sBAAA;EAEA,4CAAA;EACA,sBAAA;EN45FD;AoBp4FC;;;EAGE,gBAAA;EACA,uBAAA;EpBs4FH;AoBn4FC;;EAEE,YAAA;EACA,wBAAA;Ef2BF,0DAAA;EACQ,kDAAA;EL22FT;AoBn4FC;;;EAGE,qBAAA;EACA,sBAAA;EE9CF,eAAA;EAGA,2BAAA;EjB8DA,0BAAA;EACQ,kBAAA;ELq3FT;AoB/3FD;ECrDE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErBu7FD;AqBr7FC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErBu7FP;AqBr7FC;;;EAGE,wBAAA;ErBu7FH;AqBl7FG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErBg8FT;AoBx6FD;ECnBI,gBAAA;EACA,2BAAA;ErB87FH;AoBz6FD;ECxDE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErBo+FD;AqBl+FC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErBo+FP;AqBl+FC;;;EAGE,wBAAA;ErBo+FH;AqB/9FG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErB6+FT;AoBl9FD;ECtBI,gBAAA;EACA,2BAAA;ErB2+FH;AoBl9FD;EC5DE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErBihGD;AqB/gGC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErBihGP;AqB/gGC;;;EAGE,wBAAA;ErBihGH;AqB5gGG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErB0hGT;AoB3/FD;EC1BI,gBAAA;EACA,2BAAA;ErBwhGH;AoB3/FD;EChEE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErB8jGD;AqB5jGC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErB8jGP;AqB5jGC;;;EAGE,wBAAA;ErB8jGH;AqBzjGG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErBukGT;AoBpiGD;EC9BI,gBAAA;EACA,2BAAA;ErBqkGH;AoBpiGD;ECpEE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErB2mGD;AqBzmGC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErB2mGP;AqBzmGC;;;EAGE,wBAAA;ErB2mGH;AqBtmGG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErBonGT;AoB7kGD;EClCI,gBAAA;EACA,2BAAA;ErBknGH;AoB7kGD;ECxEE,gBAAA;EACA,2BAAA;EACA,uBAAA;ErBwpGD;AqBtpGC;;;;;;EAME,gBAAA;EACA,2BAAA;EACI,uBAAA;ErBwpGP;AqBtpGC;;;EAGE,wBAAA;ErBwpGH;AqBnpGG;;;;;;;;;;;;;;;;;;EAME,2BAAA;EACI,uBAAA;ErBiqGT;AoBtnGD;ECtCI,gBAAA;EACA,2BAAA;ErB+pGH;AoBjnGD;EACE,gBAAA;EACA,qBAAA;EACA,kBAAA;EpBmnGD;AoBjnGC;;;;;EAKE,+BAAA;Ef7BF,0BAAA;EACQ,kBAAA;ELipGT;AoBlnGC;;;;EAIE,2BAAA;EpBonGH;AoBlnGC;;EAEE,gBAAA;EACA,4BAAA;EACA,+BAAA;EpBonGH;AoBhnGG;;;;EAEE,gBAAA;EACA,uBAAA;EpBonGL;AoB3mGD;;EC/EE,oBAAA;EACA,iBAAA;EACA,wBAAA;EACA,oBAAA;ErB8rGD;AoB9mGD;;ECnFE,mBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;ErBqsGD;AoBjnGD;;ECvFE,kBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;ErB4sGD;AoBhnGD;EACE,gBAAA;EACA,aAAA;EpBknGD;AoB9mGD;EACE,iBAAA;EpBgnGD;AoBzmGC;;;EACE,aAAA;EpB6mGH;AuBjwGD;EACE,YAAA;ElBoLA,0CAAA;EACK,qCAAA;EACG,kCAAA;ELglGT;AuBpwGC;EACE,YAAA;EvBswGH;AuBlwGD;EACE,eAAA;EACA,oBAAA;EvBowGD;AuBlwGC;EAAY,gBAAA;EAAgB,qBAAA;EvBswG7B;AuBrwGC;EAAY,oBAAA;EvBwwGb;AuBvwGC;EAAY,0BAAA;EvB0wGb;AuBvwGD;EACE,oBAAA;EACA,WAAA;EACA,kBAAA;ElBsKA,iDAAA;EACQ,4CAAA;KAAA,yCAAA;EAOR,oCAAA;EACQ,+BAAA;KAAA,4BAAA;EAGR,0CAAA;EACQ,qCAAA;KAAA,kCAAA;EL4lGT;AwBtyGD;EACE,uBAAA;EACA,UAAA;EACA,WAAA;EACA,kBAAA;EACA,wBAAA;EACA,uBAAA;EACA,qCAAA;EACA,oCAAA;ExBwyGD;AwBpyGD;;EAEE,oBAAA;ExBsyGD;AwBlyGD;EACE,YAAA;ExBoyGD;AwBhyGD;EACE,oBAAA;EACA,WAAA;EACA,SAAA;EACA,eAAA;EACA,eAAA;EACA,aAAA;EACA,kBAAA;EACA,gBAAA;EACA,iBAAA;EACA,kBAAA;EACA,iBAAA;EACA,kBAAA;EACA,2BAAA;EACA,2BAAA;EACA,uCAAA;EACA,oBAAA;EnBuBA,qDAAA;EACQ,6CAAA;EmBtBR,sCAAA;UAAA,8BAAA;ExBmyGD;AwB9xGC;EACE,UAAA;EACA,YAAA;ExBgyGH;AwBzzGD;ECxBE,aAAA;EACA,eAAA;EACA,kBAAA;EACA,2BAAA;EzBo1GD;AwB/zGD;EAmCI,gBAAA;EACA,mBAAA;EACA,aAAA;EACA,qBAAA;EACA,yBAAA;EACA,gBAAA;EACA,qBAAA;ExB+xGH;AwBzxGC;;EAEE,uBAAA;EACA,gBAAA;EACA,2BAAA;ExB2xGH;AwBrxGC;;;EAGE,gBAAA;EACA,uBAAA;EACA,YAAA;EACA,2BAAA;ExBuxGH;AwB9wGC;;;EAGE,gBAAA;ExBgxGH;AwB5wGC;;EAEE,uBAAA;EACA,+BAAA;EACA,wBAAA;EE1GF,qEAAA;EF4GE,qBAAA;ExB8wGH;AwBzwGD;EAGI,gBAAA;ExBywGH;AwB5wGD;EAQI,YAAA;ExBuwGH;AwB/vGD;EACE,YAAA;EACA,UAAA;ExBiwGD;AwBzvGD;EACE,SAAA;EACA,aAAA;ExB2vGD;AwBvvGD;EACE,gBAAA;EACA,mBAAA;EACA,iBAAA;EACA,yBAAA;EACA,gBAAA;EACA,qBAAA;ExByvGD;AwBrvGD;EACE,iBAAA;EACA,SAAA;EACA,UAAA;EACA,WAAA;EACA,QAAA;EACA,cAAA;ExBuvGD;AwBnvGD;EACE,UAAA;EACA,YAAA;ExBqvGD;AwB7uGD;;EAII,eAAA;EACA,0BAAA;EACA,aAAA;ExB6uGH;AwBnvGD;;EAUI,WAAA;EACA,cAAA;EACA,oBAAA;ExB6uGH;AwBxtGD;EAXE;IAnEA,YAAA;IACA,UAAA;IxB0yGC;EwBxuGD;IAzDA,SAAA;IACA,aAAA;IxBoyGC;EACF;A2Bn7GD;;EAEE,oBAAA;EACA,uBAAA;EACA,wBAAA;E3Bq7GD;A2Bz7GD;;EAMI,oBAAA;EACA,aAAA;E3Bu7GH;A2Br7GG;;;;;;;;EAIE,YAAA;E3B27GL;A2Br7GD;;;;EAKI,mBAAA;E3Bs7GH;A2Bj7GD;EACE,mBAAA;E3Bm7GD;A2Bp7GD;;EAMI,aAAA;E3Bk7GH;A2Bx7GD;;;EAWI,kBAAA;E3Bk7GH;A2B96GD;EACE,kBAAA;E3Bg7GD;A2B56GD;EACE,gBAAA;E3B86GD;A2B76GC;ECjDA,+BAAA;EACG,4BAAA;E5Bi+GJ;A2B56GD;;EC9CE,8BAAA;EACG,2BAAA;E5B89GJ;A2B36GD;EACE,aAAA;E3B66GD;A2B36GD;EACE,kBAAA;E3B66GD;A2B36GD;;EClEE,+BAAA;EACG,4BAAA;E5Bi/GJ;A2B16GD;EChEE,8BAAA;EACG,2BAAA;E5B6+GJ;A2Bz6GD;;EAEE,YAAA;E3B26GD;A2B15GD;EACE,mBAAA;EACA,oBAAA;E3B45GD;A2B15GD;EACE,oBAAA;EACA,qBAAA;E3B45GD;A2Bv5GD;EtB9CE,0DAAA;EACQ,kDAAA;ELw8GT;A2Bv5GC;EtBlDA,0BAAA;EACQ,kBAAA;EL48GT;A2Bp5GD;EACE,gBAAA;E3Bs5GD;A2Bn5GD;EACE,yBAAA;EACA,wBAAA;E3Bq5GD;A2Bl5GD;EACE,yBAAA;E3Bo5GD;A2B74GD;;;EAII,gBAAA;EACA,aAAA;EACA,aAAA;EACA,iBAAA;E3B84GH;A2Br5GD;EAcM,aAAA;E3B04GL;A2Bx5GD;;;;EAsBI,kBAAA;EACA,gBAAA;E3Bw4GH;A2Bn4GC;EACE,kBAAA;E3Bq4GH;A2Bn4GC;EACE,8BAAA;ECnKF,+BAAA;EACC,8BAAA;E5ByiHF;A2Bp4GC;EACE,gCAAA;EC/KF,4BAAA;EACC,2BAAA;E5BsjHF;A2Bp4GD;EACE,kBAAA;E3Bs4GD;A2Bp4GD;;EC9KE,+BAAA;EACC,8BAAA;E5BsjHF;A2Bn4GD;EC5LE,4BAAA;EACC,2BAAA;E5BkkHF;A2B/3GD;EACE,gBAAA;EACA,aAAA;EACA,qBAAA;EACA,2BAAA;E3Bi4GD;A2Br4GD;;EAOI,aAAA;EACA,qBAAA;EACA,WAAA;E3Bk4GH;A2B34GD;EAYI,aAAA;E3Bk4GH;A2B94GD;EAgBI,YAAA;E3Bi4GH;A2Bh3GD;;;;EAKM,oBAAA;EACA,wBAAA;EACA,sBAAA;E3Bi3GL;A6B1lHD;EACE,oBAAA;EACA,gBAAA;EACA,2BAAA;E7B4lHD;A6BzlHC;EACE,aAAA;EACA,iBAAA;EACA,kBAAA;E7B2lHH;A6BpmHD;EAeI,oBAAA;EACA,YAAA;EAKA,aAAA;EAEA,aAAA;EACA,kBAAA;E7BmlHH;A6B1kHD;;;EV8BE,cAAA;EACA,oBAAA;EACA,iBAAA;EACA,wBAAA;EACA,oBAAA;EnBijHD;AmB/iHC;;;EACE,cAAA;EACA,mBAAA;EnBmjHH;AmBhjHC;;;;;;EAEE,cAAA;EnBsjHH;A6B5lHD;;;EVyBE,cAAA;EACA,mBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;EnBwkHD;AmBtkHC;;;EACE,cAAA;EACA,mBAAA;EnB0kHH;AmBvkHC;;;;;;EAEE,cAAA;EnB6kHH;A6B1mHD;;;EAGE,qBAAA;E7B4mHD;A6B1mHC;;;EACE,kBAAA;E7B8mHH;A6B1mHD;;EAEE,WAAA;EACA,qBAAA;EACA,wBAAA;E7B4mHD;A6BvmHD;EACE,mBAAA;EACA,iBAAA;EACA,qBAAA;EACA,gBAAA;EACA,gBAAA;EACA,oBAAA;EACA,2BAAA;EACA,2BAAA;EACA,oBAAA;E7BymHD;A6BtmHC;EACE,mBAAA;EACA,iBAAA;EACA,oBAAA;E7BwmHH;A6BtmHC;EACE,oBAAA;EACA,iBAAA;EACA,oBAAA;E7BwmHH;A6B5nHD;;EA0BI,eAAA;E7BsmHH;A6BjmHD;;;;;;;EDhGE,+BAAA;EACG,4BAAA;E5B0sHJ;A6BlmHD;EACE,iBAAA;E7BomHD;A6BlmHD;;;;;;;EDpGE,8BAAA;EACG,2BAAA;E5B+sHJ;A6BnmHD;EACE,gBAAA;E7BqmHD;A6BhmHD;EACE,oBAAA;EAGA,cAAA;EACA,qBAAA;E7BgmHD;A6BrmHD;EAUI,oBAAA;E7B8lHH;A6BxmHD;EAYM,mBAAA;E7B+lHL;A6B5lHG;;;EAGE,YAAA;E7B8lHL;A6BzlHC;;EAGI,oBAAA;E7B0lHL;A6BvlHC;;EAGI,mBAAA;E7BwlHL;A8BlvHD;EACE,kBAAA;EACA,iBAAA;EACA,kBAAA;E9BovHD;A8BvvHD;EAOI,oBAAA;EACA,gBAAA;E9BmvHH;A8B3vHD;EAWM,oBAAA;EACA,gBAAA;EACA,oBAAA;E9BmvHL;A8BlvHK;;EAEE,uBAAA;EACA,2BAAA;E9BovHP;A8B/uHG;EACE,gBAAA;E9BivHL;A8B/uHK;;EAEE,gBAAA;EACA,uBAAA;EACA,+BAAA;EACA,qBAAA;E9BivHP;A8B1uHG;;;EAGE,2BAAA;EACA,uBAAA;E9B4uHL;A8BrxHD;ELHE,aAAA;EACA,eAAA;EACA,kBAAA;EACA,2BAAA;EzB2xHD;A8B3xHD;EA0DI,iBAAA;E9BouHH;A8B3tHD;EACE,kCAAA;E9B6tHD;A8B9tHD;EAGI,aAAA;EAEA,qBAAA;E9B6tHH;A8BluHD;EASM,mBAAA;EACA,yBAAA;EACA,+BAAA;EACA,4BAAA;E9B4tHL;A8B3tHK;EACE,uCAAA;E9B6tHP;A8BvtHK;;;EAGE,gBAAA;EACA,2BAAA;EACA,2BAAA;EACA,kCAAA;EACA,iBAAA;E9BytHP;A8BptHC;EAqDA,aAAA;EA8BA,kBAAA;E9BqoHD;A8BxtHC;EAwDE,aAAA;E9BmqHH;A8B3tHC;EA0DI,oBAAA;EACA,oBAAA;E9BoqHL;A8B/tHC;EAgEE,WAAA;EACA,YAAA;E9BkqHH;A8BtpHD;EAAA;IAPM,qBAAA;IACA,WAAA;I9BiqHH;E8B3pHH;IAJQ,kBAAA;I9BkqHL;EACF;A8B5uHC;EAuFE,iBAAA;EACA,oBAAA;E9BwpHH;A8BhvHC;;;EA8FE,2BAAA;E9BupHH;A8BzoHD;EAAA;IATM,kCAAA;IACA,4BAAA;I9BspHH;E8B9oHH;;;IAHM,8BAAA;I9BspHH;EACF;A8BvvHD;EAEI,aAAA;E9BwvHH;A8B1vHD;EAMM,oBAAA;E9BuvHL;A8B7vHD;EASM,kBAAA;E9BuvHL;A8BlvHK;;;EAGE,gBAAA;EACA,2BAAA;E9BovHP;A8B5uHD;EAEI,aAAA;E9B6uHH;A8B/uHD;EAIM,iBAAA;EACA,gBAAA;E9B8uHL;A8BluHD;EACE,aAAA;E9BouHD;A8BruHD;EAII,aAAA;E9BouHH;A8BxuHD;EAMM,oBAAA;EACA,oBAAA;E9BquHL;A8B5uHD;EAYI,WAAA;EACA,YAAA;E9BmuHH;A8BvtHD;EAAA;IAPM,qBAAA;IACA,WAAA;I9BkuHH;E8B5tHH;IAJQ,kBAAA;I9BmuHL;EACF;A8B3tHD;EACE,kBAAA;E9B6tHD;A8B9tHD;EAKI,iBAAA;EACA,oBAAA;E9B4tHH;A8BluHD;;;EAYI,2BAAA;E9B2tHH;A8B7sHD;EAAA;IATM,kCAAA;IACA,4BAAA;I9B0tHH;E8BltHH;;;IAHM,8BAAA;I9B0tHH;EACF;A8BjtHD;EAEI,eAAA;EACA,oBAAA;E9BktHH;A8BrtHD;EAMI,gBAAA;EACA,qBAAA;E9BktHH;A8BzsHD;EAEE,kBAAA;EF7OA,4BAAA;EACC,2BAAA;E5Bw7HF;A+Bl7HD;EACE,oBAAA;EACA,kBAAA;EACA,qBAAA;EACA,+BAAA;E/Bo7HD;A+B56HD;EAAA;IAFI,oBAAA;I/Bk7HD;EACF;A+Bn6HD;EAAA;IAFI,aAAA;I/By6HD;EACF;A+B35HD;EACE,qBAAA;EACA,qBAAA;EACA,oBAAA;EACA,mCAAA;EACA,4DAAA;UAAA,oDAAA;EAEA,mCAAA;E/B45HD;A+B15HC;EACE,kBAAA;E/B45HH;A+B/3HD;EAAA;IAzBI,aAAA;IACA,eAAA;IACA,0BAAA;YAAA,kBAAA;I/B45HD;E+B15HC;IACE,2BAAA;IACA,gCAAA;IACA,yBAAA;IACA,mBAAA;IACA,8BAAA;I/B45HH;E+Bz5HC;IACE,qBAAA;I/B25HH;E+Bt5HC;;;IAGE,iBAAA;IACA,kBAAA;I/Bw5HH;EACF;A+Bp5HD;;EAGI,mBAAA;E/Bq5HH;A+Bh5HC;EAAA;;IAFI,mBAAA;I/Bu5HH;EACF;A+B94HD;;;;EAII,qBAAA;EACA,oBAAA;E/Bg5HH;A+B14HC;EAAA;;;;IAHI,iBAAA;IACA,gBAAA;I/Bo5HH;EACF;A+Bx4HD;EACE,eAAA;EACA,uBAAA;E/B04HD;A+Br4HD;EAAA;IAFI,kBAAA;I/B24HD;EACF;A+Bv4HD;;EAEE,iBAAA;EACA,UAAA;EACA,SAAA;EACA,eAAA;E/By4HD;A+Bn4HD;EAAA;;IAFI,kBAAA;I/B04HD;EACF;A+Bx4HD;EACE,QAAA;EACA,uBAAA;E/B04HD;A+Bx4HD;EACE,WAAA;EACA,kBAAA;EACA,uBAAA;E/B04HD;A+Bp4HD;EACE,aAAA;EACA,oBAAA;EACA,iBAAA;EACA,mBAAA;EACA,cAAA;E/Bs4HD;A+Bp4HC;;EAEE,uBAAA;E/Bs4HH;A+B/4HD;EAaI,gBAAA;E/Bq4HH;A+B53HD;EALI;;IAEE,oBAAA;I/Bo4HH;EACF;A+B13HD;EACE,oBAAA;EACA,cAAA;EACA,oBAAA;EACA,mBAAA;EC/LA,iBAAA;EACA,oBAAA;EDgMA,+BAAA;EACA,wBAAA;EACA,+BAAA;EACA,oBAAA;E/B63HD;A+Bz3HC;EACE,YAAA;E/B23HH;A+Bz4HD;EAmBI,gBAAA;EACA,aAAA;EACA,aAAA;EACA,oBAAA;E/By3HH;A+B/4HD;EAyBI,iBAAA;E/By3HH;A+Bn3HD;EAAA;IAFI,eAAA;I/By3HD;EACF;A+Bh3HD;EACE,qBAAA;E/Bk3HD;A+Bn3HD;EAII,mBAAA;EACA,sBAAA;EACA,mBAAA;E/Bk3HH;A+Bt1HC;EAAA;IAtBI,kBAAA;IACA,aAAA;IACA,aAAA;IACA,eAAA;IACA,+BAAA;IACA,WAAA;IACA,0BAAA;YAAA,kBAAA;I/Bg3HH;E+Bh2HD;;IAbM,4BAAA;I/Bi3HL;E+Bp2HD;IAVM,mBAAA;I/Bi3HL;E+Bh3HK;;IAEE,wBAAA;I/Bk3HP;EACF;A+Bh2HD;EAAA;IAXI,aAAA;IACA,WAAA;I/B+2HD;E+Br2HH;IAPM,aAAA;I/B+2HH;E+Bx2HH;IALQ,mBAAA;IACA,sBAAA;I/Bg3HL;EACF;A+Br2HD;EACE,oBAAA;EACA,qBAAA;EACA,oBAAA;EACA,mCAAA;EACA,sCAAA;E1B/NA,8FAAA;EACQ,sFAAA;E2B/DR,iBAAA;EACA,oBAAA;EhCuoID;AkB9pHD;EAAA;IA9DM,uBAAA;IACA,kBAAA;IACA,wBAAA;IlBguHH;EkBpqHH;IAvDM,uBAAA;IACA,aAAA;IACA,wBAAA;IlB8tHH;EkBzqHH;IAhDM,uBAAA;IlB4tHH;EkB5qHH;IA5CM,uBAAA;IACA,wBAAA;IlB2tHH;EkBhrHH;;;IAtCQ,aAAA;IlB2tHL;EkBrrHH;IAhCM,aAAA;IlBwtHH;EkBxrHH;IA5BM,kBAAA;IACA,wBAAA;IlButHH;EkB5rHH;;IApBM,uBAAA;IACA,eAAA;IACA,kBAAA;IACA,wBAAA;IlBotHH;EkBnsHH;;IAdQ,iBAAA;IlBqtHL;EkBvsHH;;IATM,oBAAA;IACA,gBAAA;IlBotHH;EkB5sHH;IAHM,QAAA;IlBktHH;EACF;A+B94HC;EAAA;IANI,oBAAA;I/Bw5HH;E+Bt5HG;IACE,kBAAA;I/Bw5HL;EACF;A+Bv4HD;EAAA;IARI,aAAA;IACA,WAAA;IACA,gBAAA;IACA,iBAAA;IACA,gBAAA;IACA,mBAAA;I1B1PF,0BAAA;IACQ,kBAAA;IL8oIP;EACF;A+B74HD;EACE,eAAA;EHrUA,4BAAA;EACC,2BAAA;E5BqtIF;A+B74HD;EACE,kBAAA;EH1UA,8BAAA;EACC,6BAAA;EAOD,+BAAA;EACC,8BAAA;E5BotIF;A+Bz4HD;ECjVE,iBAAA;EACA,oBAAA;EhC6tID;A+B14HC;ECpVA,kBAAA;EACA,qBAAA;EhCiuID;A+B34HC;ECvVA,kBAAA;EACA,qBAAA;EhCquID;A+Br4HD;ECjWE,kBAAA;EACA,qBAAA;EhCyuID;A+Bj4HD;EAAA;IAJI,aAAA;IACA,mBAAA;IACA,oBAAA;I/By4HD;EACF;A+B52HD;EAhBE;IEzWA,wBAAA;IjCyuIC;E+B/3HD;IE7WA,yBAAA;IF+WE,qBAAA;I/Bi4HD;E+Bn4HD;IAKI,iBAAA;I/Bi4HH;EACF;A+Bx3HD;EACE,2BAAA;EACA,uBAAA;E/B03HD;A+B53HD;EAKI,gBAAA;E/B03HH;A+Bz3HG;;EAEE,gBAAA;EACA,+BAAA;E/B23HL;A+Bp4HD;EAcI,gBAAA;E/By3HH;A+Bv4HD;EAmBM,gBAAA;E/Bu3HL;A+Br3HK;;EAEE,gBAAA;EACA,+BAAA;E/Bu3HP;A+Bn3HK;;;EAGE,gBAAA;EACA,2BAAA;E/Bq3HP;A+Bj3HK;;;EAGE,gBAAA;EACA,+BAAA;E/Bm3HP;A+B35HD;EA8CI,uBAAA;E/Bg3HH;A+B/2HG;;EAEE,2BAAA;E/Bi3HL;A+Bl6HD;EAoDM,2BAAA;E/Bi3HL;A+Br6HD;;EA0DI,uBAAA;E/B+2HH;A+Bx2HK;;;EAGE,2BAAA;EACA,gBAAA;E/B02HP;A+Bz0HC;EAAA;IAzBQ,gBAAA;I/Bs2HP;E+Br2HO;;IAEE,gBAAA;IACA,+BAAA;I/Bu2HT;E+Bn2HO;;;IAGE,gBAAA;IACA,2BAAA;I/Bq2HT;E+Bj2HO;;;IAGE,gBAAA;IACA,+BAAA;I/Bm2HT;EACF;A+Br8HD;EA8GI,gBAAA;E/B01HH;A+Bz1HG;EACE,gBAAA;E/B21HL;A+B38HD;EAqHI,gBAAA;E/By1HH;A+Bx1HG;;EAEE,gBAAA;E/B01HL;A+Bt1HK;;;;EAEE,gBAAA;E/B01HP;A+Bl1HD;EACE,2BAAA;EACA,uBAAA;E/Bo1HD;A+Bt1HD;EAKI,gBAAA;E/Bo1HH;A+Bn1HG;;EAEE,gBAAA;EACA,+BAAA;E/Bq1HL;A+B91HD;EAcI,gBAAA;E/Bm1HH;A+Bj2HD;EAmBM,gBAAA;E/Bi1HL;A+B/0HK;;EAEE,gBAAA;EACA,+BAAA;E/Bi1HP;A+B70HK;;;EAGE,gBAAA;EACA,2BAAA;E/B+0HP;A+B30HK;;;EAGE,gBAAA;EACA,+BAAA;E/B60HP;A+Br3HD;EA+CI,uBAAA;E/By0HH;A+Bx0HG;;EAEE,2BAAA;E/B00HL;A+B53HD;EAqDM,2BAAA;E/B00HL;A+B/3HD;;EA2DI,uBAAA;E/Bw0HH;A+Bl0HK;;;EAGE,2BAAA;EACA,gBAAA;E/Bo0HP;A+B7xHC;EAAA;IA/BQ,uBAAA;I/Bg0HP;E+BjyHD;IA5BQ,2BAAA;I/Bg0HP;E+BpyHD;IAzBQ,gBAAA;I/Bg0HP;E+B/zHO;;IAEE,gBAAA;IACA,+BAAA;I/Bi0HT;E+B7zHO;;;IAGE,gBAAA;IACA,2BAAA;I/B+zHT;E+B3zHO;;;IAGE,gBAAA;IACA,+BAAA;I/B6zHT;EACF;A+Br6HD;EA+GI,gBAAA;E/ByzHH;A+BxzHG;EACE,gBAAA;E/B0zHL;A+B36HD;EAsHI,gBAAA;E/BwzHH;A+BvzHG;;EAEE,gBAAA;E/ByzHL;A+BrzHK;;;;EAEE,gBAAA;E/ByzHP;AkCp8ID;EACE,mBAAA;EACA,qBAAA;EACA,kBAAA;EACA,2BAAA;EACA,oBAAA;ElCs8ID;AkC38ID;EAQI,uBAAA;ElCs8IH;AkC98ID;EAWM,mBAAA;EACA,gBAAA;EACA,gBAAA;ElCs8IL;AkCn9ID;EAkBI,gBAAA;ElCo8IH;AmCx9ID;EACE,uBAAA;EACA,iBAAA;EACA,gBAAA;EACA,oBAAA;EnC09ID;AmC99ID;EAOI,iBAAA;EnC09IH;AmCj+ID;;EAUM,oBAAA;EACA,aAAA;EACA,mBAAA;EACA,yBAAA;EACA,uBAAA;EACA,gBAAA;EACA,2BAAA;EACA,2BAAA;EACA,mBAAA;EnC29IL;AmCz9IG;;EAGI,gBAAA;EPXN,gCAAA;EACG,6BAAA;E5Bs+IJ;AmCx9IG;;EPvBF,iCAAA;EACG,8BAAA;E5Bm/IJ;AmCn9IG;;;;EAEE,gBAAA;EACA,2BAAA;EACA,uBAAA;EnCu9IL;AmCj9IG;;;;;;EAGE,YAAA;EACA,gBAAA;EACA,2BAAA;EACA,uBAAA;EACA,iBAAA;EnCs9IL;AmC5gJD;;;;;;EAiEM,gBAAA;EACA,2BAAA;EACA,uBAAA;EACA,qBAAA;EnCm9IL;AmC18ID;;EC1EM,oBAAA;EACA,iBAAA;EpCwhJL;AoCthJG;;ERMF,gCAAA;EACG,6BAAA;E5BohJJ;AoCrhJG;;ERRF,iCAAA;EACG,8BAAA;E5BiiJJ;AmCp9ID;;EC/EM,mBAAA;EACA,iBAAA;EpCuiJL;AoCriJG;;ERMF,gCAAA;EACG,6BAAA;E5BmiJJ;AoCpiJG;;ERRF,iCAAA;EACG,8BAAA;E5BgjJJ;AqCnjJD;EACE,iBAAA;EACA,gBAAA;EACA,kBAAA;EACA,oBAAA;ErCqjJD;AqCzjJD;EAOI,iBAAA;ErCqjJH;AqC5jJD;;EAUM,uBAAA;EACA,mBAAA;EACA,2BAAA;EACA,2BAAA;EACA,qBAAA;ErCsjJL;AqCpkJD;;EAmBM,uBAAA;EACA,2BAAA;ErCqjJL;AqCzkJD;;EA2BM,cAAA;ErCkjJL;AqC7kJD;;EAkCM,aAAA;ErC+iJL;AqCjlJD;;;;EA2CM,gBAAA;EACA,2BAAA;EACA,qBAAA;ErC4iJL;AsC1lJD;EACE,iBAAA;EACA,yBAAA;EACA,gBAAA;EACA,mBAAA;EACA,gBAAA;EACA,gBAAA;EACA,oBAAA;EACA,qBAAA;EACA,0BAAA;EACA,sBAAA;EtC4lJD;AsCxlJG;;EAEE,gBAAA;EACA,uBAAA;EACA,iBAAA;EtC0lJL;AsCrlJC;EACE,eAAA;EtCulJH;AsCnlJC;EACE,oBAAA;EACA,WAAA;EtCqlJH;AsC9kJD;ECtCE,2BAAA;EvCunJD;AuCpnJG;;EAEE,2BAAA;EvCsnJL;AsCjlJD;EC1CE,2BAAA;EvC8nJD;AuC3nJG;;EAEE,2BAAA;EvC6nJL;AsCplJD;EC9CE,2BAAA;EvCqoJD;AuCloJG;;EAEE,2BAAA;EvCooJL;AsCvlJD;EClDE,2BAAA;EvC4oJD;AuCzoJG;;EAEE,2BAAA;EvC2oJL;AsC1lJD;ECtDE,2BAAA;EvCmpJD;AuChpJG;;EAEE,2BAAA;EvCkpJL;AsC7lJD;EC1DE,2BAAA;EvC0pJD;AuCvpJG;;EAEE,2BAAA;EvCypJL;AwC3pJD;EACE,uBAAA;EACA,iBAAA;EACA,kBAAA;EACA,iBAAA;EACA,mBAAA;EACA,gBAAA;EACA,gBAAA;EACA,0BAAA;EACA,qBAAA;EACA,oBAAA;EACA,2BAAA;EACA,qBAAA;ExC6pJD;AwC1pJC;EACE,eAAA;ExC4pJH;AwCxpJC;EACE,oBAAA;EACA,WAAA;ExC0pJH;AwCvpJC;EACE,QAAA;EACA,kBAAA;ExCypJH;AwCppJG;;EAEE,gBAAA;EACA,uBAAA;EACA,iBAAA;ExCspJL;AwCjpJC;;EAEE,gBAAA;EACA,2BAAA;ExCmpJH;AwChpJC;EACE,cAAA;ExCkpJH;AwC/oJC;EACE,mBAAA;ExCipJH;AwC9oJC;EACE,kBAAA;ExCgpJH;AyCzsJD;EACE,oBAAA;EACA,qBAAA;EACA,gBAAA;EACA,2BAAA;EzC2sJD;AyC/sJD;;EAQI,gBAAA;EzC2sJH;AyCntJD;EAYI,qBAAA;EACA,iBAAA;EACA,kBAAA;EzC0sJH;AyCxtJD;EAkBI,2BAAA;EzCysJH;AyCtsJC;;EAEE,oBAAA;EzCwsJH;AyC/tJD;EA2BI,iBAAA;EzCusJH;AyCtrJD;EAAA;IAbI,iBAAA;IzCusJD;EyCrsJC;;IAEE,oBAAA;IACA,qBAAA;IzCusJH;EyC/rJH;;IAHM,iBAAA;IzCssJH;EACF;A0C/uJD;EACE,gBAAA;EACA,cAAA;EACA,qBAAA;EACA,yBAAA;EACA,2BAAA;EACA,2BAAA;EACA,oBAAA;ErCiLA,6CAAA;EACK,wCAAA;EACG,qCAAA;ELikJT;A0C3vJD;;EAaI,mBAAA;EACA,oBAAA;E1CkvJH;A0C9uJC;;;EAGE,uBAAA;E1CgvJH;A0CrwJD;EA0BI,cAAA;EACA,gBAAA;E1C8uJH;A2CvwJD;EACE,eAAA;EACA,qBAAA;EACA,+BAAA;EACA,oBAAA;E3CywJD;A2C7wJD;EAQI,eAAA;EAEA,gBAAA;E3CuwJH;A2CjxJD;EAeI,mBAAA;E3CqwJH;A2CpxJD;;EAqBI,kBAAA;E3CmwJH;A2CxxJD;EAyBI,iBAAA;E3CkwJH;A2C1vJD;;EAEE,qBAAA;E3C4vJD;A2C9vJD;;EAMI,oBAAA;EACA,WAAA;EACA,cAAA;EACA,gBAAA;E3C4vJH;A2CpvJD;ECvDE,2BAAA;EACA,uBAAA;EACA,gBAAA;E5C8yJD;A2CzvJD;EClDI,2BAAA;E5C8yJH;A2C5vJD;EC/CI,gBAAA;E5C8yJH;A2C3vJD;EC3DE,2BAAA;EACA,uBAAA;EACA,gBAAA;E5CyzJD;A2ChwJD;ECtDI,2BAAA;E5CyzJH;A2CnwJD;ECnDI,gBAAA;E5CyzJH;A2ClwJD;EC/DE,2BAAA;EACA,uBAAA;EACA,gBAAA;E5Co0JD;A2CvwJD;EC1DI,2BAAA;E5Co0JH;A2C1wJD;ECvDI,gBAAA;E5Co0JH;A2CzwJD;ECnEE,2BAAA;EACA,uBAAA;EACA,gBAAA;E5C+0JD;A2C9wJD;EC9DI,2BAAA;E5C+0JH;A2CjxJD;EC3DI,gBAAA;E5C+0JH;A6Cj1JD;EACE;IAAQ,6BAAA;I7Co1JP;E6Cn1JD;IAAQ,0BAAA;I7Cs1JP;EACF;A6Cn1JD;EACE;IAAQ,6BAAA;I7Cs1JP;E6Cr1JD;IAAQ,0BAAA;I7Cw1JP;EACF;A6C31JD;EACE;IAAQ,6BAAA;I7Cs1JP;E6Cr1JD;IAAQ,0BAAA;I7Cw1JP;EACF;A6Cj1JD;EACE,kBAAA;EACA,cAAA;EACA,qBAAA;EACA,2BAAA;EACA,oBAAA;ExCsCA,wDAAA;EACQ,gDAAA;EL8yJT;A6Ch1JD;EACE,aAAA;EACA,WAAA;EACA,cAAA;EACA,iBAAA;EACA,mBAAA;EACA,gBAAA;EACA,oBAAA;EACA,2BAAA;ExCyBA,wDAAA;EACQ,gDAAA;EAyHR,qCAAA;EACK,gCAAA;EACG,6BAAA;ELksJT;A6C70JD;;ECCI,+MAAA;EACA,0MAAA;EACA,uMAAA;EDAF,oCAAA;UAAA,4BAAA;E7Ci1JD;A6C10JD;;ExC5CE,4DAAA;EACK,uDAAA;EACG,oDAAA;EL03JT;A6Cv0JD;EErEE,2BAAA;E/C+4JD;A+C54JC;EDgDE,+MAAA;EACA,0MAAA;EACA,uMAAA;E9C+1JH;A6C30JD;EEzEE,2BAAA;E/Cu5JD;A+Cp5JC;EDgDE,+MAAA;EACA,0MAAA;EACA,uMAAA;E9Cu2JH;A6C/0JD;EE7EE,2BAAA;E/C+5JD;A+C55JC;EDgDE,+MAAA;EACA,0MAAA;EACA,uMAAA;E9C+2JH;A6Cn1JD;EEjFE,2BAAA;E/Cu6JD;A+Cp6JC;EDgDE,+MAAA;EACA,0MAAA;EACA,uMAAA;E9Cu3JH;AgD/6JD;EAEE,kBAAA;EhDg7JD;AgD96JC;EACE,eAAA;EhDg7JH;AgD56JD;;EAEE,SAAA;EACA,kBAAA;EhD86JD;AgD36JD;EACE,gBAAA;EhD66JD;AgD16JD;EACE,gBAAA;EhD46JD;AgDz6JD;;EAEE,oBAAA;EhD26JD;AgDx6JD;;EAEE,qBAAA;EhD06JD;AgDv6JD;;;EAGE,qBAAA;EACA,qBAAA;EhDy6JD;AgDt6JD;EACE,wBAAA;EhDw6JD;AgDr6JD;EACE,wBAAA;EhDu6JD;AgDn6JD;EACE,eAAA;EACA,oBAAA;EhDq6JD;AgD/5JD;EACE,iBAAA;EACA,kBAAA;EhDi6JD;AiDn9JD;EAEE,qBAAA;EACA,iBAAA;EjDo9JD;AiD58JD;EACE,oBAAA;EACA,gBAAA;EACA,oBAAA;EAEA,qBAAA;EACA,2BAAA;EACA,2BAAA;EjD68JD;AiD18JC;ErB3BA,8BAAA;EACC,6BAAA;E5Bw+JF;AiD38JC;EACE,kBAAA;ErBvBF,iCAAA;EACC,gCAAA;E5Bq+JF;AiDp8JD;EACE,gBAAA;EjDs8JD;AiDv8JD;EAII,gBAAA;EjDs8JH;AiDl8JC;;EAEE,uBAAA;EACA,gBAAA;EACA,2BAAA;EjDo8JH;AiD97JC;;;EAGE,2BAAA;EACA,gBAAA;EACA,qBAAA;EjDg8JH;AiDr8JC;;;EASI,gBAAA;EjDi8JL;AiD18JC;;;EAYI,gBAAA;EjDm8JL;AiD97JC;;;EAGE,YAAA;EACA,gBAAA;EACA,2BAAA;EACA,uBAAA;EjDg8JH;AiDt8JC;;;;;;;;;EAYI,gBAAA;EjDq8JL;AiDj9JC;;;EAeI,gBAAA;EjDu8JL;AkDniKC;EACE,gBAAA;EACA,2BAAA;ElDqiKH;AkDniKG;EACE,gBAAA;ElDqiKL;AkDtiKG;EAII,gBAAA;ElDqiKP;AkDliKK;;EAEE,gBAAA;EACA,2BAAA;ElDoiKP;AkDliKK;;;EAGE,aAAA;EACA,2BAAA;EACA,uBAAA;ElDoiKP;AkDzjKC;EACE,gBAAA;EACA,2BAAA;ElD2jKH;AkDzjKG;EACE,gBAAA;ElD2jKL;AkD5jKG;EAII,gBAAA;ElD2jKP;AkDxjKK;;EAEE,gBAAA;EACA,2BAAA;ElD0jKP;AkDxjKK;;;EAGE,aAAA;EACA,2BAAA;EACA,uBAAA;ElD0jKP;AkD/kKC;EACE,gBAAA;EACA,2BAAA;ElDilKH;AkD/kKG;EACE,gBAAA;ElDilKL;AkDllKG;EAII,gBAAA;ElDilKP;AkD9kKK;;EAEE,gBAAA;EACA,2BAAA;ElDglKP;AkD9kKK;;;EAGE,aAAA;EACA,2BAAA;EACA,uBAAA;ElDglKP;AkDrmKC;EACE,gBAAA;EACA,2BAAA;ElDumKH;AkDrmKG;EACE,gBAAA;ElDumKL;AkDxmKG;EAII,gBAAA;ElDumKP;AkDpmKK;;EAEE,gBAAA;EACA,2BAAA;ElDsmKP;AkDpmKK;;;EAGE,aAAA;EACA,2BAAA;EACA,uBAAA;ElDsmKP;AiD1gKD;EACE,eAAA;EACA,oBAAA;EjD4gKD;AiD1gKD;EACE,kBAAA;EACA,kBAAA;EjD4gKD;AmDhoKD;EACE,qBAAA;EACA,2BAAA;EACA,+BAAA;EACA,oBAAA;E9C0DA,mDAAA;EACQ,2CAAA;ELykKT;AmD/nKD;EACE,eAAA;EnDioKD;AmD5nKD;EACE,oBAAA;EACA,sCAAA;EvBpBA,8BAAA;EACC,6BAAA;E5BmpKF;AmDloKD;EAMI,gBAAA;EnD+nKH;AmD1nKD;EACE,eAAA;EACA,kBAAA;EACA,iBAAA;EACA,gBAAA;EnD4nKD;AmDhoKD;;;;;EAWI,gBAAA;EnD4nKH;AmDvnKD;EACE,oBAAA;EACA,2BAAA;EACA,+BAAA;EvBxCA,iCAAA;EACC,gCAAA;E5BkqKF;AmDjnKD;;EAGI,kBAAA;EnDknKH;AmDrnKD;;EAMM,qBAAA;EACA,kBAAA;EnDmnKL;AmD/mKG;;EAEI,eAAA;EvBvEN,8BAAA;EACC,6BAAA;E5ByrKF;AmD9mKG;;EAEI,kBAAA;EvBtEN,iCAAA;EACC,gCAAA;E5BurKF;AmD3mKD;EAEI,qBAAA;EnD4mKH;AmDzmKD;EACE,qBAAA;EnD2mKD;AmDnmKD;;;EAII,kBAAA;EnDomKH;AmDxmKD;;;EAOM,oBAAA;EACA,qBAAA;EnDsmKL;AmD9mKD;;EvBnGE,8BAAA;EACC,6BAAA;E5BqtKF;AmDnnKD;;;;EAmBQ,6BAAA;EACA,8BAAA;EnDsmKP;AmD1nKD;;;;;;;;EAwBU,6BAAA;EnD4mKT;AmDpoKD;;;;;;;;EA4BU,8BAAA;EnDknKT;AmD9oKD;;EvB3FE,iCAAA;EACC,gCAAA;E5B6uKF;AmDnpKD;;;;EAyCQ,gCAAA;EACA,iCAAA;EnDgnKP;AmD1pKD;;;;;;;;EA8CU,gCAAA;EnDsnKT;AmDpqKD;;;;;;;;EAkDU,iCAAA;EnD4nKT;AmD9qKD;;;;EA2DI,+BAAA;EnDynKH;AmDprKD;;EA+DI,eAAA;EnDynKH;AmDxrKD;;EAmEI,WAAA;EnDynKH;AmD5rKD;;;;;;;;;;;;EA0EU,gBAAA;EnDgoKT;AmD1sKD;;;;;;;;;;;;EA8EU,iBAAA;EnD0oKT;AmDxtKD;;;;;;;;EAuFU,kBAAA;EnD2oKT;AmDluKD;;;;;;;;EAgGU,kBAAA;EnD4oKT;AmD5uKD;EAsGI,WAAA;EACA,kBAAA;EnDyoKH;AmD/nKD;EACE,qBAAA;EnDioKD;AmDloKD;EAKI,kBAAA;EACA,oBAAA;EnDgoKH;AmDtoKD;EASM,iBAAA;EnDgoKL;AmDzoKD;EAcI,kBAAA;EnD8nKH;AmD5oKD;;EAkBM,+BAAA;EnD8nKL;AmDhpKD;EAuBI,eAAA;EnD4nKH;AmDnpKD;EAyBM,kCAAA;EnD6nKL;AmDtnKD;ECpPE,uBAAA;EpD62KD;AoD32KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpD62KH;AoDh3KC;EAMI,2BAAA;EpD62KL;AoDn3KC;EASI,gBAAA;EACA,2BAAA;EpD62KL;AoD12KC;EAEI,8BAAA;EpD22KL;AmDroKD;ECvPE,uBAAA;EpD+3KD;AoD73KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpD+3KH;AoDl4KC;EAMI,2BAAA;EpD+3KL;AoDr4KC;EASI,gBAAA;EACA,2BAAA;EpD+3KL;AoD53KC;EAEI,8BAAA;EpD63KL;AmDppKD;EC1PE,uBAAA;EpDi5KD;AoD/4KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpDi5KH;AoDp5KC;EAMI,2BAAA;EpDi5KL;AoDv5KC;EASI,gBAAA;EACA,2BAAA;EpDi5KL;AoD94KC;EAEI,8BAAA;EpD+4KL;AmDnqKD;EC7PE,uBAAA;EpDm6KD;AoDj6KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpDm6KH;AoDt6KC;EAMI,2BAAA;EpDm6KL;AoDz6KC;EASI,gBAAA;EACA,2BAAA;EpDm6KL;AoDh6KC;EAEI,8BAAA;EpDi6KL;AmDlrKD;EChQE,uBAAA;EpDq7KD;AoDn7KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpDq7KH;AoDx7KC;EAMI,2BAAA;EpDq7KL;AoD37KC;EASI,gBAAA;EACA,2BAAA;EpDq7KL;AoDl7KC;EAEI,8BAAA;EpDm7KL;AmDjsKD;ECnQE,uBAAA;EpDu8KD;AoDr8KC;EACE,gBAAA;EACA,2BAAA;EACA,uBAAA;EpDu8KH;AoD18KC;EAMI,2BAAA;EpDu8KL;AoD78KC;EASI,gBAAA;EACA,2BAAA;EpDu8KL;AoDp8KC;EAEI,8BAAA;EpDq8KL;AqDr9KD;EACE,oBAAA;EACA,gBAAA;EACA,WAAA;EACA,YAAA;EACA,kBAAA;ErDu9KD;AqD59KD;;;;;EAYI,oBAAA;EACA,QAAA;EACA,SAAA;EACA,WAAA;EACA,cAAA;EACA,aAAA;EACA,WAAA;ErDu9KH;AqDn9KC;EACE,wBAAA;ErDq9KH;AqDj9KC;EACE,qBAAA;ErDm9KH;AsD7+KD;EACE,kBAAA;EACA,eAAA;EACA,qBAAA;EACA,2BAAA;EACA,2BAAA;EACA,oBAAA;EjDwDA,yDAAA;EACQ,iDAAA;ELw7KT;AsDv/KD;EASI,oBAAA;EACA,mCAAA;EtDi/KH;AsD5+KD;EACE,eAAA;EACA,oBAAA;EtD8+KD;AsD5+KD;EACE,cAAA;EACA,oBAAA;EtD8+KD;AuDpgLD;EACE,cAAA;EACA,iBAAA;EACA,mBAAA;EACA,gBAAA;EACA,gBAAA;EACA,8BAAA;EjCRA,cAAA;EAGA,2BAAA;EtB6gLD;AuDrgLC;;EAEE,gBAAA;EACA,uBAAA;EACA,iBAAA;EjCfF,cAAA;EAGA,2BAAA;EtBqhLD;AuDjgLC;EACE,YAAA;EACA,iBAAA;EACA,yBAAA;EACA,WAAA;EACA,0BAAA;EvDmgLH;AwDxhLD;EACE,kBAAA;ExD0hLD;AwDthLD;EACE,eAAA;EACA,kBAAA;EACA,iBAAA;EACA,QAAA;EACA,UAAA;EACA,WAAA;EACA,SAAA;EACA,eAAA;EACA,mCAAA;EAIA,YAAA;ExDqhLD;AwDlhLC;EnD+GA,uCAAA;EACI,mCAAA;EACC,kCAAA;EACG,+BAAA;EAkER,qDAAA;EAEK,2CAAA;EACG,qCAAA;ELq2KT;AwDxhLC;EnD2GA,oCAAA;EACI,gCAAA;EACC,+BAAA;EACG,4BAAA;ELg7KT;AwD5hLD;EACE,oBAAA;EACA,kBAAA;ExD8hLD;AwD1hLD;EACE,oBAAA;EACA,aAAA;EACA,cAAA;ExD4hLD;AwDxhLD;EACE,oBAAA;EACA,2BAAA;EACA,2BAAA;EACA,sCAAA;EACA,oBAAA;EnDaA,kDAAA;EACQ,0CAAA;EmDZR,sCAAA;UAAA,8BAAA;EAEA,YAAA;ExD0hLD;AwDthLD;EACE,oBAAA;EACA,QAAA;EACA,UAAA;EACA,SAAA;EACA,2BAAA;ExDwhLD;AwDthLC;ElCnEA,YAAA;EAGA,0BAAA;EtB0lLD;AwDzhLC;ElCpEA,cAAA;EAGA,2BAAA;EtB8lLD;AwDxhLD;EACE,eAAA;EACA,kCAAA;EACA,2BAAA;ExD0hLD;AwDvhLD;EACE,kBAAA;ExDyhLD;AwDrhLD;EACE,WAAA;EACA,yBAAA;ExDuhLD;AwDlhLD;EACE,oBAAA;EACA,eAAA;ExDohLD;AwDhhLD;EACE,eAAA;EACA,mBAAA;EACA,+BAAA;ExDkhLD;AwDrhLD;EAQI,kBAAA;EACA,kBAAA;ExDghLH;AwDzhLD;EAaI,mBAAA;ExD+gLH;AwD5hLD;EAiBI,gBAAA;ExD8gLH;AwDzgLD;EACE,oBAAA;EACA,cAAA;EACA,aAAA;EACA,cAAA;EACA,kBAAA;ExD2gLD;AwDz/KD;EAZE;IACE,cAAA;IACA,mBAAA;IxDwgLD;EwDtgLD;InDrEA,mDAAA;IACQ,2CAAA;IL8kLP;EwDrgLD;IAAY,cAAA;IxDwgLX;EACF;AwDngLD;EAFE;IAAY,cAAA;IxDygLX;EACF;AyDtpLD;EACE,oBAAA;EACA,eAAA;EACA,gBAAA;EACA,qBAAA;EAEA,6DAAA;EACA,iBAAA;EACA,qBAAA;EACA,kBAAA;EnCZA,YAAA;EAGA,0BAAA;EtBkqLD;AyDtpLC;EnCfA,cAAA;EAGA,2BAAA;EtBsqLD;AyDzpLC;EAAW,kBAAA;EAAmB,gBAAA;EzD6pL/B;AyD5pLC;EAAW,kBAAA;EAAmB,gBAAA;EzDgqL/B;AyD/pLC;EAAW,iBAAA;EAAmB,gBAAA;EzDmqL/B;AyDlqLC;EAAW,mBAAA;EAAmB,gBAAA;EzDsqL/B;AyDlqLD;EACE,kBAAA;EACA,kBAAA;EACA,gBAAA;EACA,oBAAA;EACA,uBAAA;EACA,2BAAA;EACA,oBAAA;EzDoqLD;AyDhqLD;EACE,oBAAA;EACA,UAAA;EACA,WAAA;EACA,2BAAA;EACA,qBAAA;EzDkqLD;AyD9pLC;EACE,WAAA;EACA,WAAA;EACA,mBAAA;EACA,yBAAA;EACA,2BAAA;EzDgqLH;AyD9pLC;EACE,WAAA;EACA,YAAA;EACA,qBAAA;EACA,yBAAA;EACA,2BAAA;EzDgqLH;AyD9pLC;EACE,WAAA;EACA,WAAA;EACA,qBAAA;EACA,yBAAA;EACA,2BAAA;EzDgqLH;AyD9pLC;EACE,UAAA;EACA,SAAA;EACA,kBAAA;EACA,6BAAA;EACA,6BAAA;EzDgqLH;AyD9pLC;EACE,UAAA;EACA,UAAA;EACA,kBAAA;EACA,6BAAA;EACA,4BAAA;EzDgqLH;AyD9pLC;EACE,QAAA;EACA,WAAA;EACA,mBAAA;EACA,yBAAA;EACA,8BAAA;EzDgqLH;AyD9pLC;EACE,QAAA;EACA,YAAA;EACA,kBAAA;EACA,yBAAA;EACA,8BAAA;EzDgqLH;AyD9pLC;EACE,QAAA;EACA,WAAA;EACA,kBAAA;EACA,yBAAA;EACA,8BAAA;EzDgqLH;A0D/vLD;EACE,oBAAA;EACA,QAAA;EACA,SAAA;EACA,eAAA;EACA,eAAA;EACA,kBAAA;EACA,cAAA;EAEA,6DAAA;EACA,iBAAA;EACA,qBAAA;EACA,yBAAA;EACA,kBAAA;EACA,2BAAA;EACA,sCAAA;UAAA,8BAAA;EACA,2BAAA;EACA,sCAAA;EACA,oBAAA;ErD6CA,mDAAA;EACQ,2CAAA;EqD1CR,qBAAA;E1D+vLD;A0D5vLC;EAAY,mBAAA;E1D+vLb;A0D9vLC;EAAY,mBAAA;E1DiwLb;A0DhwLC;EAAY,kBAAA;E1DmwLb;A0DlwLC;EAAY,oBAAA;E1DqwLb;A0DlwLD;EACE,WAAA;EACA,mBAAA;EACA,iBAAA;EACA,2BAAA;EACA,kCAAA;EACA,4BAAA;E1DowLD;A0DjwLD;EACE,mBAAA;E1DmwLD;A0D3vLC;;EAEE,oBAAA;EACA,gBAAA;EACA,UAAA;EACA,WAAA;EACA,2BAAA;EACA,qBAAA;E1D6vLH;A0D1vLD;EACE,oBAAA;E1D4vLD;A0D1vLD;EACE,oBAAA;EACA,aAAA;E1D4vLD;A0DxvLC;EACE,WAAA;EACA,oBAAA;EACA,wBAAA;EACA,2BAAA;EACA,uCAAA;EACA,eAAA;E1D0vLH;A0DzvLG;EACE,cAAA;EACA,aAAA;EACA,oBAAA;EACA,wBAAA;EACA,2BAAA;E1D2vLL;A0DxvLC;EACE,UAAA;EACA,aAAA;EACA,mBAAA;EACA,sBAAA;EACA,6BAAA;EACA,yCAAA;E1D0vLH;A0DzvLG;EACE,cAAA;EACA,WAAA;EACA,eAAA;EACA,sBAAA;EACA,6BAAA;E1D2vLL;A0DxvLC;EACE,WAAA;EACA,oBAAA;EACA,qBAAA;EACA,8BAAA;EACA,0CAAA;EACA,YAAA;E1D0vLH;A0DzvLG;EACE,cAAA;EACA,UAAA;EACA,oBAAA;EACA,qBAAA;EACA,8BAAA;E1D2vLL;A0DvvLC;EACE,UAAA;EACA,cAAA;EACA,mBAAA;EACA,uBAAA;EACA,4BAAA;EACA,wCAAA;E1DyvLH;A0DxvLG;EACE,cAAA;EACA,YAAA;EACA,uBAAA;EACA,4BAAA;EACA,eAAA;E1D0vLL;A2Dv3LD;EACE,oBAAA;E3Dy3LD;A2Dt3LD;EACE,oBAAA;EACA,kBAAA;EACA,aAAA;E3Dw3LD;A2D33LD;EAMI,eAAA;EACA,oBAAA;EtD6KF,2CAAA;EACK,sCAAA;EACG,mCAAA;EL4sLT;A2Dl4LD;;EAcM,gBAAA;E3Dw3LL;A2D91LC;EAAA;ItDiKA,wDAAA;IAEK,8CAAA;IACG,wCAAA;IA7JR,qCAAA;IAEQ,6BAAA;IA+GR,2BAAA;IAEQ,mBAAA;ILivLP;E2D53LG;;ItDmHJ,4CAAA;IACQ,oCAAA;IsDjHF,SAAA;I3D+3LL;E2D73LG;;ItD8GJ,6CAAA;IACQ,qCAAA;IsD5GF,SAAA;I3Dg4LL;E2D93LG;;;ItDyGJ,yCAAA;IACQ,iCAAA;IsDtGF,SAAA;I3Di4LL;EACF;A2Dv6LD;;;EA6CI,gBAAA;E3D+3LH;A2D56LD;EAiDI,SAAA;E3D83LH;A2D/6LD;;EAsDI,oBAAA;EACA,QAAA;EACA,aAAA;E3D63LH;A2Dr7LD;EA4DI,YAAA;E3D43LH;A2Dx7LD;EA+DI,aAAA;E3D43LH;A2D37LD;;EAmEI,SAAA;E3D43LH;A2D/7LD;EAuEI,aAAA;E3D23LH;A2Dl8LD;EA0EI,YAAA;E3D23LH;A2Dn3LD;EACE,oBAAA;EACA,QAAA;EACA,SAAA;EACA,WAAA;EACA,YAAA;ErC9FA,cAAA;EAGA,2BAAA;EqC6FA,iBAAA;EACA,gBAAA;EACA,oBAAA;EACA,2CAAA;E3Ds3LD;A2Dj3LC;EblGE,oGAAA;EACA,+FAAA;EACA,sHAAA;EAAA,gGAAA;EACA,6BAAA;EACA,wHAAA;E9Cs9LH;A2Dr3LC;EACE,YAAA;EACA,UAAA;EbvGA,oGAAA;EACA,+FAAA;EACA,sHAAA;EAAA,gGAAA;EACA,6BAAA;EACA,wHAAA;E9C+9LH;A2Dv3LC;;EAEE,YAAA;EACA,gBAAA;EACA,uBAAA;ErCtHF,cAAA;EAGA,2BAAA;EtB8+LD;A2Dx5LD;;;;EAsCI,oBAAA;EACA,UAAA;EACA,YAAA;EACA,uBAAA;E3Dw3LH;A2Dj6LD;;EA6CI,WAAA;EACA,oBAAA;E3Dw3LH;A2Dt6LD;;EAkDI,YAAA;EACA,qBAAA;E3Dw3LH;A2D36LD;;EAuDI,aAAA;EACA,cAAA;EACA,mBAAA;EACA,gBAAA;EACA,oBAAA;E3Dw3LH;A2Dn3LG;EACE,kBAAA;E3Dq3LL;A2Dj3LG;EACE,kBAAA;E3Dm3LL;A2Dz2LD;EACE,oBAAA;EACA,cAAA;EACA,WAAA;EACA,aAAA;EACA,YAAA;EACA,mBAAA;EACA,iBAAA;EACA,kBAAA;EACA,oBAAA;E3D22LD;A2Dp3LD;EAYI,uBAAA;EACA,aAAA;EACA,cAAA;EACA,aAAA;EACA,qBAAA;EACA,2BAAA;EACA,qBAAA;EACA,iBAAA;EAWA,2BAAA;EACA,oCAAA;E3Di2LH;A2Dh4LD;EAkCI,WAAA;EACA,aAAA;EACA,cAAA;EACA,2BAAA;E3Di2LH;A2D11LD;EACE,oBAAA;EACA,WAAA;EACA,YAAA;EACA,cAAA;EACA,aAAA;EACA,mBAAA;EACA,sBAAA;EACA,gBAAA;EACA,oBAAA;EACA,2CAAA;E3D41LD;A2D31LC;EACE,mBAAA;E3D61LH;A2DpzLD;EAhCE;;;;IAKI,aAAA;IACA,cAAA;IACA,mBAAA;IACA,iBAAA;I3Ds1LH;E2D91LD;;IAYI,oBAAA;I3Ds1LH;E2Dl2LD;;IAgBI,qBAAA;I3Ds1LH;E2Dj1LD;IACE,WAAA;IACA,YAAA;IACA,sBAAA;I3Dm1LD;E2D/0LD;IACE,cAAA;I3Di1LD;EACF;A4D/kMC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAEE,cAAA;EACA,gBAAA;E5D6mMH;A4D3mMC;;;;;;;;;;;;;;;EACE,aAAA;E5D2nMH;AiCnoMD;E4BRE,gBAAA;EACA,mBAAA;EACA,oBAAA;E7D8oMD;AiCroMD;EACE,yBAAA;EjCuoMD;AiCroMD;EACE,wBAAA;EjCuoMD;AiC/nMD;EACE,0BAAA;EjCioMD;AiC/nMD;EACE,2BAAA;EjCioMD;AiC/nMD;EACE,oBAAA;EjCioMD;AiC/nMD;E6BzBE,aAAA;EACA,oBAAA;EACA,mBAAA;EACA,+BAAA;EACA,WAAA;E9D2pMD;AiC7nMD;EACE,0BAAA;EACA,+BAAA;EjC+nMD;AiCxnMD;EACE,iBAAA;EjC0nMD;A+D5pMD;EACE,qBAAA;E/D8pMD;A+DxpMD;;;;ECdE,0BAAA;EhE4qMD;A+DvpMD;;;;;;;;;;;;EAYE,0BAAA;E/DypMD;A+DlpMD;EAAA;IChDE,2BAAA;IhEssMC;EgErsMD;IAAU,gBAAA;IhEwsMT;EgEvsMD;IAAU,+BAAA;IhE0sMT;EgEzsMD;;IACU,gCAAA;IhE4sMT;EACF;A+D5pMD;EAAA;IAFI,2BAAA;I/DkqMD;EACF;A+D5pMD;EAAA;IAFI,4BAAA;I/DkqMD;EACF;A+D5pMD;EAAA;IAFI,kCAAA;I/DkqMD;EACF;A+D3pMD;EAAA;ICrEE,2BAAA;IhEouMC;EgEnuMD;IAAU,gBAAA;IhEsuMT;EgEruMD;IAAU,+BAAA;IhEwuMT;EgEvuMD;;IACU,gCAAA;IhE0uMT;EACF;A+DrqMD;EAAA;IAFI,2BAAA;I/D2qMD;EACF;A+DrqMD;EAAA;IAFI,4BAAA;I/D2qMD;EACF;A+DrqMD;EAAA;IAFI,kCAAA;I/D2qMD;EACF;A+DpqMD;EAAA;IC1FE,2BAAA;IhEkwMC;EgEjwMD;IAAU,gBAAA;IhEowMT;EgEnwMD;IAAU,+BAAA;IhEswMT;EgErwMD;;IACU,gCAAA;IhEwwMT;EACF;A+D9qMD;EAAA;IAFI,2BAAA;I/DorMD;EACF;A+D9qMD;EAAA;IAFI,4BAAA;I/DorMD;EACF;A+D9qMD;EAAA;IAFI,kCAAA;I/DorMD;EACF;A+D7qMD;EAAA;IC/GE,2BAAA;IhEgyMC;EgE/xMD;IAAU,gBAAA;IhEkyMT;EgEjyMD;IAAU,+BAAA;IhEoyMT;EgEnyMD;;IACU,gCAAA;IhEsyMT;EACF;A+DvrMD;EAAA;IAFI,2BAAA;I/D6rMD;EACF;A+DvrMD;EAAA;IAFI,4BAAA;I/D6rMD;EACF;A+DvrMD;EAAA;IAFI,kCAAA;I/D6rMD;EACF;A+DtrMD;EAAA;IC5HE,0BAAA;IhEszMC;EACF;A+DtrMD;EAAA;ICjIE,0BAAA;IhE2zMC;EACF;A+DtrMD;EAAA;ICtIE,0BAAA;IhEg0MC;EACF;A+DtrMD;EAAA;IC3IE,0BAAA;IhEq0MC;EACF;A+DnrMD;ECnJE,0BAAA;EhEy0MD;A+DhrMD;EAAA;ICjKE,2BAAA;IhEq1MC;EgEp1MD;IAAU,gBAAA;IhEu1MT;EgEt1MD;IAAU,+BAAA;IhEy1MT;EgEx1MD;;IACU,gCAAA;IhE21MT;EACF;A+D9rMD;EACE,0BAAA;E/DgsMD;A+D3rMD;EAAA;IAFI,2BAAA;I/DisMD;EACF;A+D/rMD;EACE,0BAAA;E/DisMD;A+D5rMD;EAAA;IAFI,4BAAA;I/DksMD;EACF;A+DhsMD;EACE,0BAAA;E/DksMD;A+D7rMD;EAAA;IAFI,kCAAA;I/DmsMD;EACF;A+D5rMD;EAAA;ICpLE,0BAAA;IhEo3MC;EACF","file":"bootstrap.css","sourcesContent":["/*! normalize.css v3.0.2 | MIT License | git.io/normalize */\nhtml {\n font-family: sans-serif;\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\nmark {\n background: #ff0;\n color: #000;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsup {\n top: -0.5em;\n}\nsub {\n bottom: -0.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n height: 0;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit;\n font: inherit;\n margin: 0;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: textfield;\n -moz-box-sizing: content-box;\n -webkit-box-sizing: content-box;\n box-sizing: content-box;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\nlegend {\n border: 0;\n padding: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important;\n box-shadow: none !important;\n text-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n select {\n background: #fff !important;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\2a\";\n}\n.glyphicon-plus:before {\n content: \"\\2b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333333;\n background-color: #ffffff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n padding: 4px;\n line-height: 1.42857143;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n border-radius: 4px;\n -webkit-transition: all 0.2s ease-in-out;\n -o-transition: all 0.2s ease-in-out;\n transition: all 0.2s ease-in-out;\n display: inline-block;\n max-width: 100%;\n height: auto;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eeeeee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n background-color: #fcf8e3;\n padding: .2em;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eeeeee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n list-style: none;\n margin-left: -5px;\n}\n.list-inline > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n clear: left;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eeeeee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid #eeeeee;\n border-left: 0;\n text-align: right;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #ffffff;\n background-color: #333333;\n border-radius: 3px;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n word-break: break-all;\n word-wrap: break-word;\n color: #333333;\n background-color: #f5f5f5;\n border: 1px solid #cccccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n.row {\n margin-left: -15px;\n margin-right: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0%;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0%;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #dddddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #dddddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #dddddd;\n}\n.table .table {\n background-color: #ffffff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #dddddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #dddddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-column;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-cell;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #dddddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n min-width: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n background-color: #ffffff;\n background-image: none;\n border: 1px solid #cccccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n}\n.form-control::-moz-placeholder {\n color: #999999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999999;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n background-color: #eeeeee;\n opacity: 1;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"],\n input[type=\"time\"],\n input[type=\"datetime-local\"],\n input[type=\"month\"] {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-left: 0;\n padding-right: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.form-group-sm .form-control {\n height: 30px;\n line-height: 30px;\n}\ntextarea.form-group-sm .form-control,\nselect[multiple].form-group-sm .form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.form-group-lg .form-control {\n height: 46px;\n line-height: 46px;\n}\ntextarea.form-group-lg .form-control,\nselect[multiple].form-group-lg .form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n border-color: #3c763d;\n background-color: #dff0d8;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n border-color: #8a6d3b;\n background-color: #fcf8e3;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n border-color: #a94442;\n background-color: #f2dede;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: 7px;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-left: -15px;\n margin-right: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: 7px;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 14.333333px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n }\n}\n.btn {\n display: inline-block;\n margin-bottom: 0;\n font-weight: normal;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none;\n border: 1px solid transparent;\n white-space: nowrap;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n border-radius: 4px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n outline: 0;\n background-image: none;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n pointer-events: none;\n opacity: 0.65;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-default {\n color: #333333;\n background-color: #ffffff;\n border-color: #cccccc;\n}\n.btn-default:hover,\n.btn-default:focus,\n.btn-default.focus,\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled,\n.btn-default[disabled],\nfieldset[disabled] .btn-default,\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus,\n.btn-default.disabled:active,\n.btn-default[disabled]:active,\nfieldset[disabled] .btn-default:active,\n.btn-default.disabled.active,\n.btn-default[disabled].active,\nfieldset[disabled] .btn-default.active {\n background-color: #ffffff;\n border-color: #cccccc;\n}\n.btn-default .badge {\n color: #ffffff;\n background-color: #333333;\n}\n.btn-primary {\n color: #ffffff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:hover,\n.btn-primary:focus,\n.btn-primary.focus,\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #ffffff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled,\n.btn-primary[disabled],\nfieldset[disabled] .btn-primary,\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus,\n.btn-primary.disabled:active,\n.btn-primary[disabled]:active,\nfieldset[disabled] .btn-primary:active,\n.btn-primary.disabled.active,\n.btn-primary[disabled].active,\nfieldset[disabled] .btn-primary.active {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #ffffff;\n}\n.btn-success {\n color: #ffffff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:hover,\n.btn-success:focus,\n.btn-success.focus,\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #ffffff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled,\n.btn-success[disabled],\nfieldset[disabled] .btn-success,\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus,\n.btn-success.disabled:active,\n.btn-success[disabled]:active,\nfieldset[disabled] .btn-success:active,\n.btn-success.disabled.active,\n.btn-success[disabled].active,\nfieldset[disabled] .btn-success.active {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #ffffff;\n}\n.btn-info {\n color: #ffffff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:hover,\n.btn-info:focus,\n.btn-info.focus,\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #ffffff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled,\n.btn-info[disabled],\nfieldset[disabled] .btn-info,\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus,\n.btn-info.disabled:active,\n.btn-info[disabled]:active,\nfieldset[disabled] .btn-info:active,\n.btn-info.disabled.active,\n.btn-info[disabled].active,\nfieldset[disabled] .btn-info.active {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #ffffff;\n}\n.btn-warning {\n color: #ffffff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:hover,\n.btn-warning:focus,\n.btn-warning.focus,\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #ffffff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled,\n.btn-warning[disabled],\nfieldset[disabled] .btn-warning,\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus,\n.btn-warning.disabled:active,\n.btn-warning[disabled]:active,\nfieldset[disabled] .btn-warning:active,\n.btn-warning.disabled.active,\n.btn-warning[disabled].active,\nfieldset[disabled] .btn-warning.active {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #ffffff;\n}\n.btn-danger {\n color: #ffffff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:hover,\n.btn-danger:focus,\n.btn-danger.focus,\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #ffffff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled,\n.btn-danger[disabled],\nfieldset[disabled] .btn-danger,\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus,\n.btn-danger.disabled:active,\n.btn-danger[disabled]:active,\nfieldset[disabled] .btn-danger:active,\n.btn-danger.disabled.active,\n.btn-danger[disabled].active,\nfieldset[disabled] .btn-danger.active {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #ffffff;\n}\n.btn-link {\n color: #337ab7;\n font-weight: normal;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity 0.15s linear;\n -o-transition: opacity 0.15s linear;\n transition: opacity 0.15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n visibility: hidden;\n}\n.collapse.in {\n display: block;\n visibility: visible;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-property: height, visibility;\n transition-property: height, visibility;\n -webkit-transition-duration: 0.35s;\n transition-duration: 0.35s;\n -webkit-transition-timing-function: ease;\n transition-timing-function: ease;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px solid;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n list-style: none;\n font-size: 14px;\n text-align: left;\n background-color: #ffffff;\n border: 1px solid #cccccc;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n background-clip: padding-box;\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n text-decoration: none;\n color: #262626;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #ffffff;\n text-decoration: none;\n outline: 0;\n background-color: #337ab7;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n cursor: not-allowed;\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n left: auto;\n right: 0;\n}\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n border-top: 0;\n border-bottom: 4px solid;\n content: \"\";\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n left: auto;\n right: 0;\n }\n .navbar-right .dropdown-menu-left {\n left: 0;\n right: auto;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-right-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-bottom-left-radius: 4px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555555;\n text-align: center;\n background-color: #eeeeee;\n border: 1px solid #cccccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n margin-left: -1px;\n}\n.nav {\n margin-bottom: 0;\n padding-left: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.nav > li.disabled > a {\n color: #777777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777777;\n text-decoration: none;\n background-color: transparent;\n cursor: not-allowed;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eeeeee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #dddddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eeeeee #eeeeee #dddddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555555;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n border-bottom-color: transparent;\n cursor: default;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #dddddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #dddddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #ffffff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #ffffff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #dddddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #dddddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #ffffff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n visibility: hidden;\n}\n.tab-content > .active {\n display: block;\n visibility: visible;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n overflow-x: visible;\n padding-right: 15px;\n padding-left: 15px;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);\n -webkit-overflow-scrolling: touch;\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n visibility: visible !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-left: 0;\n padding-right: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n height: 50px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: 15px;\n padding: 9px 10px;\n margin-top: 8px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n margin-left: -15px;\n margin-right: -15px;\n padding: 10px 15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n margin-top: 8px;\n margin-bottom: 8px;\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-left: 15px;\n margin-right: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #cccccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #dddddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #dddddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n background-color: #e7e7e7;\n color: #555555;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #cccccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777777;\n}\n.navbar-default .navbar-link:hover {\n color: #333333;\n}\n.navbar-default .btn-link {\n color: #777777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #cccccc;\n}\n.navbar-inverse {\n background-color: #222222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #ffffff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #ffffff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #ffffff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #ffffff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n background-color: #080808;\n color: #ffffff;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #ffffff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #ffffff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #ffffff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #ffffff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n content: \"/\\00a0\";\n padding: 0 5px;\n color: #cccccc;\n}\n.breadcrumb > .active {\n color: #777777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n line-height: 1.42857143;\n text-decoration: none;\n color: #337ab7;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n margin-left: -1px;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-bottom-left-radius: 4px;\n border-top-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-bottom-right-radius: 4px;\n border-top-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n color: #23527c;\n background-color: #eeeeee;\n border-color: #dddddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 2;\n color: #ffffff;\n background-color: #337ab7;\n border-color: #337ab7;\n cursor: default;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777777;\n background-color: #ffffff;\n border-color: #dddddd;\n cursor: not-allowed;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-bottom-left-radius: 6px;\n border-top-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-bottom-right-radius: 6px;\n border-top-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-bottom-left-radius: 3px;\n border-top-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-bottom-right-radius: 3px;\n border-top-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n list-style: none;\n text-align: center;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777777;\n background-color: #ffffff;\n cursor: not-allowed;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #ffffff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #ffffff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n color: #ffffff;\n line-height: 1;\n vertical-align: baseline;\n white-space: nowrap;\n text-align: center;\n background-color: #777777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #ffffff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #ffffff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding: 30px 15px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eeeeee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n border-radius: 6px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding: 48px 0;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-left: 60px;\n padding-right: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n border-radius: 4px;\n -webkit-transition: border 0.2s ease-in-out;\n -o-transition: border 0.2s ease-in-out;\n transition: border 0.2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-left: auto;\n margin-right: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n background-color: #dff0d8;\n border-color: #d6e9c6;\n color: #3c763d;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n background-color: #d9edf7;\n border-color: #bce8f1;\n color: #31708f;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n background-color: #fcf8e3;\n border-color: #faebcc;\n color: #8a6d3b;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n background-color: #f2dede;\n border-color: #ebccd1;\n color: #a94442;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n overflow: hidden;\n height: 20px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #ffffff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n -webkit-transition: width 0.6s ease;\n -o-transition: width 0.6s ease;\n transition: width 0.6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n margin-bottom: 20px;\n padding-left: 0;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #ffffff;\n border: 1px solid #dddddd;\n}\n.list-group-item:first-child {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item {\n color: #555555;\n}\na.list-group-item .list-group-item-heading {\n color: #333333;\n}\na.list-group-item:hover,\na.list-group-item:focus {\n text-decoration: none;\n color: #555555;\n background-color: #f5f5f5;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n background-color: #eeeeee;\n color: #777777;\n cursor: not-allowed;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #ffffff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\na.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\na.list-group-item-success.active:hover,\na.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\na.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\na.list-group-item-info.active:hover,\na.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\na.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\na.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #ffffff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #dddddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-left: 15px;\n padding-right: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-left-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #dddddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n border: 0;\n margin-bottom: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #dddddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #dddddd;\n}\n.panel-default {\n border-color: #dddddd;\n}\n.panel-default > .panel-heading {\n color: #333333;\n background-color: #f5f5f5;\n border-color: #dddddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #dddddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #dddddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #ffffff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #ffffff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n}\n.embed-responsive.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000000;\n text-shadow: 0 1px 0 #ffffff;\n opacity: 0.2;\n filter: alpha(opacity=20);\n}\n.close:hover,\n.close:focus {\n color: #000000;\n text-decoration: none;\n cursor: pointer;\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\nbutton.close {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n -webkit-transition: -webkit-transform 0.3s ease-out;\n -moz-transition: -moz-transform 0.3s ease-out;\n -o-transition: -o-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #ffffff;\n border: 1px solid #999999;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n background-clip: padding-box;\n outline: 0;\n}\n.modal-backdrop {\n position: absolute;\n top: 0;\n right: 0;\n left: 0;\n background-color: #000000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.modal-backdrop.in {\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n min-height: 16.42857143px;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n visibility: visible;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n font-weight: normal;\n line-height: 1.4;\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.tooltip.in {\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.tooltip.top {\n margin-top: -3px;\n padding: 5px 0;\n}\n.tooltip.right {\n margin-left: 3px;\n padding: 0 5px;\n}\n.tooltip.bottom {\n margin-top: 3px;\n padding: 5px 0;\n}\n.tooltip.left {\n margin-left: -3px;\n padding: 0 5px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #ffffff;\n text-align: center;\n text-decoration: none;\n background-color: #000000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000000;\n}\n.tooltip.top-left .tooltip-arrow {\n bottom: 0;\n right: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n font-weight: normal;\n line-height: 1.42857143;\n text-align: left;\n background-color: #ffffff;\n background-clip: padding-box;\n border: 1px solid #cccccc;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n white-space: normal;\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n margin: 0;\n padding: 8px 14px;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n border-width: 10px;\n content: \"\";\n}\n.popover.top > .arrow {\n left: 50%;\n margin-left: -11px;\n border-bottom-width: 0;\n border-top-color: #999999;\n border-top-color: rgba(0, 0, 0, 0.25);\n bottom: -11px;\n}\n.popover.top > .arrow:after {\n content: \" \";\n bottom: 1px;\n margin-left: -10px;\n border-bottom-width: 0;\n border-top-color: #ffffff;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-left-width: 0;\n border-right-color: #999999;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.popover.right > .arrow:after {\n content: \" \";\n left: 1px;\n bottom: -10px;\n border-left-width: 0;\n border-right-color: #ffffff;\n}\n.popover.bottom > .arrow {\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999999;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n top: -11px;\n}\n.popover.bottom > .arrow:after {\n content: \" \";\n top: 1px;\n margin-left: -10px;\n border-top-width: 0;\n border-bottom-color: #ffffff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999999;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.popover.left > .arrow:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: #ffffff;\n bottom: -10px;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n}\n.carousel-inner > .item {\n display: none;\n position: relative;\n -webkit-transition: 0.6s ease-in-out left;\n -o-transition: 0.6s ease-in-out left;\n transition: 0.6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform 0.6s ease-in-out;\n -moz-transition: -moz-transform 0.6s ease-in-out;\n -o-transition: -o-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n -webkit-backface-visibility: hidden;\n -moz-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000;\n -moz-perspective: 1000;\n perspective: 1000;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n left: 0;\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 15%;\n opacity: 0.5;\n filter: alpha(opacity=50);\n font-size: 20px;\n color: #ffffff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n}\n.carousel-control.right {\n left: auto;\n right: 0;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n}\n.carousel-control:hover,\n.carousel-control:focus {\n outline: 0;\n color: #ffffff;\n text-decoration: none;\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n z-index: 5;\n display: inline-block;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n margin-top: -10px;\n line-height: 1;\n font-family: serif;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid #ffffff;\n border-radius: 10px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-indicators .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: #ffffff;\n}\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #ffffff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -15px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -15px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -15px;\n }\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-footer:before,\n.modal-footer:after {\n content: \" \";\n display: table;\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n visibility: hidden !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","/*! normalize.css v3.0.2 | MIT License | git.io/normalize */\n\n//\n// 1. Set default font family to sans-serif.\n// 2. Prevent iOS text size adjust after orientation change, without disabling\n// user zoom.\n//\n\nhtml {\n font-family: sans-serif; // 1\n -ms-text-size-adjust: 100%; // 2\n -webkit-text-size-adjust: 100%; // 2\n}\n\n//\n// Remove default margin.\n//\n\nbody {\n margin: 0;\n}\n\n// HTML5 display definitions\n// ==========================================================================\n\n//\n// Correct `block` display not defined for any HTML5 element in IE 8/9.\n// Correct `block` display not defined for `details` or `summary` in IE 10/11\n// and Firefox.\n// Correct `block` display not defined for `main` in IE 11.\n//\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\n\n//\n// 1. Correct `inline-block` display not defined in IE 8/9.\n// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n//\n\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block; // 1\n vertical-align: baseline; // 2\n}\n\n//\n// Prevent modern browsers from displaying `audio` without controls.\n// Remove excess height in iOS 5 devices.\n//\n\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n\n//\n// Address `[hidden]` styling not present in IE 8/9/10.\n// Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.\n//\n\n[hidden],\ntemplate {\n display: none;\n}\n\n// Links\n// ==========================================================================\n\n//\n// Remove the gray background color from active links in IE 10.\n//\n\na {\n background-color: transparent;\n}\n\n//\n// Improve readability when focused and also mouse hovered in all browsers.\n//\n\na:active,\na:hover {\n outline: 0;\n}\n\n// Text-level semantics\n// ==========================================================================\n\n//\n// Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n//\n\nabbr[title] {\n border-bottom: 1px dotted;\n}\n\n//\n// Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n//\n\nb,\nstrong {\n font-weight: bold;\n}\n\n//\n// Address styling not present in Safari and Chrome.\n//\n\ndfn {\n font-style: italic;\n}\n\n//\n// Address variable `h1` font-size and margin within `section` and `article`\n// contexts in Firefox 4+, Safari, and Chrome.\n//\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n//\n// Address styling not present in IE 8/9.\n//\n\nmark {\n background: #ff0;\n color: #000;\n}\n\n//\n// Address inconsistent and variable font size in all browsers.\n//\n\nsmall {\n font-size: 80%;\n}\n\n//\n// Prevent `sub` and `sup` affecting `line-height` in all browsers.\n//\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsup {\n top: -0.5em;\n}\n\nsub {\n bottom: -0.25em;\n}\n\n// Embedded content\n// ==========================================================================\n\n//\n// Remove border when inside `a` element in IE 8/9/10.\n//\n\nimg {\n border: 0;\n}\n\n//\n// Correct overflow not hidden in IE 9/10/11.\n//\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\n// Grouping content\n// ==========================================================================\n\n//\n// Address margin not present in IE 8/9 and Safari.\n//\n\nfigure {\n margin: 1em 40px;\n}\n\n//\n// Address differences between Firefox and other browsers.\n//\n\nhr {\n -moz-box-sizing: content-box;\n box-sizing: content-box;\n height: 0;\n}\n\n//\n// Contain overflow in all browsers.\n//\n\npre {\n overflow: auto;\n}\n\n//\n// Address odd `em`-unit font size rendering in all browsers.\n//\n\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\n\n// Forms\n// ==========================================================================\n\n//\n// Known limitation: by default, Chrome and Safari on OS X allow very limited\n// styling of `select`, unless a `border` property is set.\n//\n\n//\n// 1. Correct color not being inherited.\n// Known issue: affects color of disabled elements.\n// 2. Correct font properties not being inherited.\n// 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n//\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit; // 1\n font: inherit; // 2\n margin: 0; // 3\n}\n\n//\n// Address `overflow` set to `hidden` in IE 8/9/10/11.\n//\n\nbutton {\n overflow: visible;\n}\n\n//\n// Address inconsistent `text-transform` inheritance for `button` and `select`.\n// All other form control elements do not inherit `text-transform` values.\n// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n// Correct `select` style inheritance in Firefox.\n//\n\nbutton,\nselect {\n text-transform: none;\n}\n\n//\n// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n// and `video` controls.\n// 2. Correct inability to style clickable `input` types in iOS.\n// 3. Improve usability and consistency of cursor style between image-type\n// `input` and others.\n//\n\nbutton,\nhtml input[type=\"button\"], // 1\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button; // 2\n cursor: pointer; // 3\n}\n\n//\n// Re-set default cursor for disabled elements.\n//\n\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\n\n//\n// Remove inner padding and border in Firefox 4+.\n//\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\n\n//\n// Address Firefox 4+ setting `line-height` on `input` using `!important` in\n// the UA stylesheet.\n//\n\ninput {\n line-height: normal;\n}\n\n//\n// It's recommended that you don't attempt to style these elements.\n// Firefox's implementation doesn't respect box-sizing, padding, or width.\n//\n// 1. Address box sizing set to `content-box` in IE 8/9/10.\n// 2. Remove excess padding in IE 8/9/10.\n//\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box; // 1\n padding: 0; // 2\n}\n\n//\n// Fix the cursor style for Chrome's increment/decrement buttons. For certain\n// `font-size` values of the `input`, it causes the cursor style of the\n// decrement button to change from `default` to `text`.\n//\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n//\n// 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n// 2. Address `box-sizing` set to `border-box` in Safari and Chrome\n// (include `-moz` to future-proof).\n//\n\ninput[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n -moz-box-sizing: content-box;\n -webkit-box-sizing: content-box; // 2\n box-sizing: content-box;\n}\n\n//\n// Remove inner padding and search cancel button in Safari and Chrome on OS X.\n// Safari (but not Chrome) clips the cancel button when the search input has\n// padding (and `textfield` appearance).\n//\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// Define consistent border, margin, and padding.\n//\n\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\n\n//\n// 1. Correct `color` not being inherited in IE 8/9/10/11.\n// 2. Remove padding so people aren't caught out if they zero out fieldsets.\n//\n\nlegend {\n border: 0; // 1\n padding: 0; // 2\n}\n\n//\n// Remove default vertical scrollbar in IE 8/9/10/11.\n//\n\ntextarea {\n overflow: auto;\n}\n\n//\n// Don't inherit the `font-weight` (applied by a rule above).\n// NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n//\n\noptgroup {\n font-weight: bold;\n}\n\n// Tables\n// ==========================================================================\n\n//\n// Remove most spacing between table cells.\n//\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\ntd,\nth {\n padding: 0;\n}\n","/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n\n// ==========================================================================\n// Print styles.\n// Inlined to avoid the additional HTTP request: h5bp.com/r\n// ==========================================================================\n\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important; // Black prints faster: h5bp.com/s\n box-shadow: none !important;\n text-shadow: none !important;\n }\n\n a,\n a:visited {\n text-decoration: underline;\n }\n\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n\n // Don't show links that are fragment identifiers,\n // or use the `javascript:` pseudo protocol\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n\n thead {\n display: table-header-group; // h5bp.com/t\n }\n\n tr,\n img {\n page-break-inside: avoid;\n }\n\n img {\n max-width: 100% !important;\n }\n\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n\n h2,\n h3 {\n page-break-after: avoid;\n }\n\n // Bootstrap specific changes start\n //\n // Chrome (OSX) fix for https://github.com/twbs/bootstrap/issues/11245\n // Once fixed, we can just straight up remove this.\n select {\n background: #fff !important;\n }\n\n // Bootstrap components\n .navbar {\n display: none;\n }\n .btn,\n .dropup > .btn {\n > .caret {\n border-top-color: #000 !important;\n }\n }\n .label {\n border: 1px solid #000;\n }\n\n .table {\n border-collapse: collapse !important;\n\n td,\n th {\n background-color: #fff !important;\n }\n }\n .table-bordered {\n th,\n td {\n border: 1px solid #ddd !important;\n }\n }\n\n // Bootstrap specific changes end\n}\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n// Star\n\n// Import the fonts\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('@{icon-font-path}@{icon-font-name}.eot');\n src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'),\n url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'),\n url('@{icon-font-path}@{icon-font-name}.woff') format('woff'),\n url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'),\n url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg');\n}\n\n// Catchall baseclass\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk { &:before { content: \"\\2a\"; } }\n.glyphicon-plus { &:before { content: \"\\2b\"; } }\n.glyphicon-euro,\n.glyphicon-eur { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil { &:before { content: \"\\270f\"; } }\n.glyphicon-glass { &:before { content: \"\\e001\"; } }\n.glyphicon-music { &:before { content: \"\\e002\"; } }\n.glyphicon-search { &:before { content: \"\\e003\"; } }\n.glyphicon-heart { &:before { content: \"\\e005\"; } }\n.glyphicon-star { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty { &:before { content: \"\\e007\"; } }\n.glyphicon-user { &:before { content: \"\\e008\"; } }\n.glyphicon-film { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large { &:before { content: \"\\e010\"; } }\n.glyphicon-th { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list { &:before { content: \"\\e012\"; } }\n.glyphicon-ok { &:before { content: \"\\e013\"; } }\n.glyphicon-remove { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out { &:before { content: \"\\e016\"; } }\n.glyphicon-off { &:before { content: \"\\e017\"; } }\n.glyphicon-signal { &:before { content: \"\\e018\"; } }\n.glyphicon-cog { &:before { content: \"\\e019\"; } }\n.glyphicon-trash { &:before { content: \"\\e020\"; } }\n.glyphicon-home { &:before { content: \"\\e021\"; } }\n.glyphicon-file { &:before { content: \"\\e022\"; } }\n.glyphicon-time { &:before { content: \"\\e023\"; } }\n.glyphicon-road { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt { &:before { content: \"\\e025\"; } }\n.glyphicon-download { &:before { content: \"\\e026\"; } }\n.glyphicon-upload { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt { &:before { content: \"\\e032\"; } }\n.glyphicon-lock { &:before { content: \"\\e033\"; } }\n.glyphicon-flag { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode { &:before { content: \"\\e040\"; } }\n.glyphicon-tag { &:before { content: \"\\e041\"; } }\n.glyphicon-tags { &:before { content: \"\\e042\"; } }\n.glyphicon-book { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark { &:before { content: \"\\e044\"; } }\n.glyphicon-print { &:before { content: \"\\e045\"; } }\n.glyphicon-camera { &:before { content: \"\\e046\"; } }\n.glyphicon-font { &:before { content: \"\\e047\"; } }\n.glyphicon-bold { &:before { content: \"\\e048\"; } }\n.glyphicon-italic { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify { &:before { content: \"\\e055\"; } }\n.glyphicon-list { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video { &:before { content: \"\\e059\"; } }\n.glyphicon-picture { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust { &:before { content: \"\\e063\"; } }\n.glyphicon-tint { &:before { content: \"\\e064\"; } }\n.glyphicon-edit { &:before { content: \"\\e065\"; } }\n.glyphicon-share { &:before { content: \"\\e066\"; } }\n.glyphicon-check { &:before { content: \"\\e067\"; } }\n.glyphicon-move { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward { &:before { content: \"\\e070\"; } }\n.glyphicon-backward { &:before { content: \"\\e071\"; } }\n.glyphicon-play { &:before { content: \"\\e072\"; } }\n.glyphicon-pause { &:before { content: \"\\e073\"; } }\n.glyphicon-stop { &:before { content: \"\\e074\"; } }\n.glyphicon-forward { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward { &:before { content: \"\\e077\"; } }\n.glyphicon-eject { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign { &:before { content: \"\\e101\"; } }\n.glyphicon-gift { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf { &:before { content: \"\\e103\"; } }\n.glyphicon-fire { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign { &:before { content: \"\\e107\"; } }\n.glyphicon-plane { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar { &:before { content: \"\\e109\"; } }\n.glyphicon-random { &:before { content: \"\\e110\"; } }\n.glyphicon-comment { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn { &:before { content: \"\\e122\"; } }\n.glyphicon-bell { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down { &:before { content: \"\\e134\"; } }\n.glyphicon-globe { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks { &:before { content: \"\\e137\"; } }\n.glyphicon-filter { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty { &:before { content: \"\\e143\"; } }\n.glyphicon-link { &:before { content: \"\\e144\"; } }\n.glyphicon-phone { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin { &:before { content: \"\\e146\"; } }\n.glyphicon-usd { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp { &:before { content: \"\\e149\"; } }\n.glyphicon-sort { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked { &:before { content: \"\\e157\"; } }\n.glyphicon-expand { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in { &:before { content: \"\\e161\"; } }\n.glyphicon-flash { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window { &:before { content: \"\\e164\"; } }\n.glyphicon-record { &:before { content: \"\\e165\"; } }\n.glyphicon-save { &:before { content: \"\\e166\"; } }\n.glyphicon-open { &:before { content: \"\\e167\"; } }\n.glyphicon-saved { &:before { content: \"\\e168\"; } }\n.glyphicon-import { &:before { content: \"\\e169\"; } }\n.glyphicon-export { &:before { content: \"\\e170\"; } }\n.glyphicon-send { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery { &:before { content: \"\\e179\"; } }\n.glyphicon-header { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt { &:before { content: \"\\e183\"; } }\n.glyphicon-tower { &:before { content: \"\\e184\"; } }\n.glyphicon-stats { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1 { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1 { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1 { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous { &:before { content: \"\\e200\"; } }\n.glyphicon-cd { &:before { content: \"\\e201\"; } }\n.glyphicon-save-file { &:before { content: \"\\e202\"; } }\n.glyphicon-open-file { &:before { content: \"\\e203\"; } }\n.glyphicon-level-up { &:before { content: \"\\e204\"; } }\n.glyphicon-copy { &:before { content: \"\\e205\"; } }\n.glyphicon-paste { &:before { content: \"\\e206\"; } }\n// The following 2 Glyphicons are omitted for the time being because\n// they currently use Unicode codepoints that are outside the\n// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle\n// non-BMP codepoints in CSS string escapes, and thus can't display these two icons.\n// Notably, the bug affects some older versions of the Android Browser.\n// More info: https://github.com/twbs/bootstrap/issues/10106\n// .glyphicon-door { &:before { content: \"\\1f6aa\"; } }\n// .glyphicon-key { &:before { content: \"\\1f511\"; } }\n.glyphicon-alert { &:before { content: \"\\e209\"; } }\n.glyphicon-equalizer { &:before { content: \"\\e210\"; } }\n.glyphicon-king { &:before { content: \"\\e211\"; } }\n.glyphicon-queen { &:before { content: \"\\e212\"; } }\n.glyphicon-pawn { &:before { content: \"\\e213\"; } }\n.glyphicon-bishop { &:before { content: \"\\e214\"; } }\n.glyphicon-knight { &:before { content: \"\\e215\"; } }\n.glyphicon-baby-formula { &:before { content: \"\\e216\"; } }\n.glyphicon-tent { &:before { content: \"\\26fa\"; } }\n.glyphicon-blackboard { &:before { content: \"\\e218\"; } }\n.glyphicon-bed { &:before { content: \"\\e219\"; } }\n.glyphicon-apple { &:before { content: \"\\f8ff\"; } }\n.glyphicon-erase { &:before { content: \"\\e221\"; } }\n.glyphicon-hourglass { &:before { content: \"\\231b\"; } }\n.glyphicon-lamp { &:before { content: \"\\e223\"; } }\n.glyphicon-duplicate { &:before { content: \"\\e224\"; } }\n.glyphicon-piggy-bank { &:before { content: \"\\e225\"; } }\n.glyphicon-scissors { &:before { content: \"\\e226\"; } }\n.glyphicon-bitcoin { &:before { content: \"\\e227\"; } }\n.glyphicon-yen { &:before { content: \"\\00a5\"; } }\n.glyphicon-ruble { &:before { content: \"\\20bd\"; } }\n.glyphicon-scale { &:before { content: \"\\e230\"; } }\n.glyphicon-ice-lolly { &:before { content: \"\\e231\"; } }\n.glyphicon-ice-lolly-tasted { &:before { content: \"\\e232\"; } }\n.glyphicon-education { &:before { content: \"\\e233\"; } }\n.glyphicon-option-horizontal { &:before { content: \"\\e234\"; } }\n.glyphicon-option-vertical { &:before { content: \"\\e235\"; } }\n.glyphicon-menu-hamburger { &:before { content: \"\\e236\"; } }\n.glyphicon-modal-window { &:before { content: \"\\e237\"; } }\n.glyphicon-oil { &:before { content: \"\\e238\"; } }\n.glyphicon-grain { &:before { content: \"\\e239\"; } }\n.glyphicon-sunglasses { &:before { content: \"\\e240\"; } }\n.glyphicon-text-size { &:before { content: \"\\e241\"; } }\n.glyphicon-text-color { &:before { content: \"\\e242\"; } }\n.glyphicon-text-background { &:before { content: \"\\e243\"; } }\n.glyphicon-object-align-top { &:before { content: \"\\e244\"; } }\n.glyphicon-object-align-bottom { &:before { content: \"\\e245\"; } }\n.glyphicon-object-align-horizontal{ &:before { content: \"\\e246\"; } }\n.glyphicon-object-align-left { &:before { content: \"\\e247\"; } }\n.glyphicon-object-align-vertical { &:before { content: \"\\e248\"; } }\n.glyphicon-object-align-right { &:before { content: \"\\e249\"; } }\n.glyphicon-triangle-right { &:before { content: \"\\e250\"; } }\n.glyphicon-triangle-left { &:before { content: \"\\e251\"; } }\n.glyphicon-triangle-bottom { &:before { content: \"\\e252\"; } }\n.glyphicon-triangle-top { &:before { content: \"\\e253\"; } }\n.glyphicon-console { &:before { content: \"\\e254\"; } }\n.glyphicon-superscript { &:before { content: \"\\e255\"; } }\n.glyphicon-subscript { &:before { content: \"\\e256\"; } }\n.glyphicon-menu-left { &:before { content: \"\\e257\"; } }\n.glyphicon-menu-right { &:before { content: \"\\e258\"; } }\n.glyphicon-menu-down { &:before { content: \"\\e259\"; } }\n.glyphicon-menu-up { &:before { content: \"\\e260\"; } }\n","//\n// Scaffolding\n// --------------------------------------------------\n\n\n// Reset the box-sizing\n//\n// Heads up! This reset may cause conflicts with some third-party widgets.\n// For recommendations on resolving such conflicts, see\n// http://getbootstrap.com/getting-started/#third-box-sizing\n* {\n .box-sizing(border-box);\n}\n*:before,\n*:after {\n .box-sizing(border-box);\n}\n\n\n// Body reset\n\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n}\n\nbody {\n font-family: @font-family-base;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @text-color;\n background-color: @body-bg;\n}\n\n// Reset fonts for relevant elements\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\n\n// Links\n\na {\n color: @link-color;\n text-decoration: none;\n\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n }\n\n &:focus {\n .tab-focus();\n }\n}\n\n\n// Figures\n//\n// We reset this here because previously Normalize had no `figure` margins. This\n// ensures we don't break anyone's use of the element.\n\nfigure {\n margin: 0;\n}\n\n\n// Images\n\nimg {\n vertical-align: middle;\n}\n\n// Responsive images (ensure images don't scale beyond their parents)\n.img-responsive {\n .img-responsive();\n}\n\n// Rounded corners\n.img-rounded {\n border-radius: @border-radius-large;\n}\n\n// Image thumbnails\n//\n// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`.\n.img-thumbnail {\n padding: @thumbnail-padding;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(all .2s ease-in-out);\n\n // Keep them at most 100% wide\n .img-responsive(inline-block);\n}\n\n// Perfect circle\n.img-circle {\n border-radius: 50%; // set radius in percents\n}\n\n\n// Horizontal rules\n\nhr {\n margin-top: @line-height-computed;\n margin-bottom: @line-height-computed;\n border: 0;\n border-top: 1px solid @hr-border;\n}\n\n\n// Only display content to screen readers\n//\n// See: http://a11yproject.com/posts/how-to-hide-content/\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0,0,0,0);\n border: 0;\n}\n\n// Use in conjunction with .sr-only to only display content when it's focused.\n// Useful for \"Skip to main content\" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n// Credit: HTML5 Boilerplate\n\n.sr-only-focusable {\n &:active,\n &:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n }\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They will be removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility){\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// WebKit-style focus\n\n.tab-focus() {\n // Default\n outline: thin dotted;\n // WebKit\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size. Note that the\n// spelling of `min--moz-device-pixel-ratio` is intentional.\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n font-family: @headings-font-family;\n font-weight: @headings-font-weight;\n line-height: @headings-line-height;\n color: @headings-color;\n\n small,\n .small {\n font-weight: normal;\n line-height: 1;\n color: @headings-small-color;\n }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n margin-top: @line-height-computed;\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 65%;\n }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n margin-top: (@line-height-computed / 2);\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 75%;\n }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n margin-bottom: @line-height-computed;\n font-size: floor((@font-size-base * 1.15));\n font-weight: 300;\n line-height: 1.4;\n\n @media (min-width: @screen-sm-min) {\n font-size: (@font-size-base * 1.5);\n }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: (12px small font / 14px base font) * 100% = about 85%\nsmall,\n.small {\n font-size: floor((100% * @font-size-small / @font-size-base));\n}\n\nmark,\n.mark {\n background-color: @state-warning-bg;\n padding: .2em;\n}\n\n// Alignment\n.text-left { text-align: left; }\n.text-right { text-align: right; }\n.text-center { text-align: center; }\n.text-justify { text-align: justify; }\n.text-nowrap { white-space: nowrap; }\n\n// Transformation\n.text-lowercase { text-transform: lowercase; }\n.text-uppercase { text-transform: uppercase; }\n.text-capitalize { text-transform: capitalize; }\n\n// Contextual colors\n.text-muted {\n color: @text-muted;\n}\n.text-primary {\n .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n // Given the contrast here, this is the only class to have its color inverted\n // automatically.\n color: #fff;\n .bg-variant(@brand-primary);\n}\n.bg-success {\n .bg-variant(@state-success-bg);\n}\n.bg-info {\n .bg-variant(@state-info-bg);\n}\n.bg-warning {\n .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n padding-bottom: ((@line-height-computed / 2) - 1);\n margin: (@line-height-computed * 2) 0 @line-height-computed;\n border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// -------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n margin-top: 0;\n margin-bottom: (@line-height-computed / 2);\n ul,\n ol {\n margin-bottom: 0;\n }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n .list-unstyled();\n margin-left: -5px;\n\n > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n }\n}\n\n// Description Lists\ndl {\n margin-top: 0; // Remove browser default\n margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n line-height: @line-height-base;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n.dl-horizontal {\n dd {\n &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n }\n\n @media (min-width: @grid-float-breakpoint) {\n dt {\n float: left;\n width: (@dl-horizontal-offset - 20);\n clear: left;\n text-align: right;\n .text-overflow();\n }\n dd {\n margin-left: @dl-horizontal-offset;\n }\n }\n}\n\n\n// Misc\n// -------------------------\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\n\n// Blockquotes\nblockquote {\n padding: (@line-height-computed / 2) @line-height-computed;\n margin: 0 0 @line-height-computed;\n font-size: @blockquote-font-size;\n border-left: 5px solid @blockquote-border-color;\n\n p,\n ul,\n ol {\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n // Note: Deprecated small and .small as of v3.1.0\n // Context: https://github.com/twbs/bootstrap/issues/11660\n footer,\n small,\n .small {\n display: block;\n font-size: 80%; // back to default font-size\n line-height: @line-height-base;\n color: @blockquote-small-color;\n\n &:before {\n content: '\\2014 \\00A0'; // em dash, nbsp\n }\n }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid @blockquote-border-color;\n border-left: 0;\n text-align: right;\n\n // Account for citation\n footer,\n small,\n .small {\n &:before { content: ''; }\n &:after {\n content: '\\00A0 \\2014'; // nbsp, em dash\n }\n }\n}\n\n// Addresses\naddress {\n margin-bottom: @line-height-computed;\n font-style: normal;\n line-height: @line-height-base;\n}\n","// Typography\n\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover {\n color: darken(@color, 10%);\n }\n}\n","// Contextual backgrounds\n\n.bg-variant(@color) {\n background-color: @color;\n a&:hover {\n background-color: darken(@color, 10%);\n }\n}\n","// Text overflow\n// Requires inline-block or block for proper styling\n\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: @code-color;\n background-color: @code-bg;\n border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: @kbd-color;\n background-color: @kbd-bg;\n border-radius: @border-radius-small;\n box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n }\n}\n\n// Blocks of code\npre {\n display: block;\n padding: ((@line-height-computed - 1) / 2);\n margin: 0 0 (@line-height-computed / 2);\n font-size: (@font-size-base - 1); // 14px to 13px\n line-height: @line-height-base;\n word-break: break-all;\n word-wrap: break-word;\n color: @pre-color;\n background-color: @pre-bg;\n border: 1px solid @pre-border-color;\n border-radius: @border-radius-base;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: @pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n .container-fixed();\n\n @media (min-width: @screen-sm-min) {\n width: @container-sm;\n }\n @media (min-width: @screen-md-min) {\n width: @container-md;\n }\n @media (min-width: @screen-lg-min) {\n width: @container-lg;\n }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n .make-grid(lg);\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n// Centered container element\n.container-fixed(@gutter: @grid-gutter-width) {\n margin-right: auto;\n margin-left: auto;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: (@gutter / -2);\n margin-right: (@gutter / -2);\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n margin-left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-push(@columns) {\n left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-pull(@columns) {\n right: percentage((@columns / @grid-columns));\n}\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) {\n .col-@{class}-push-0 {\n left: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) {\n .col-@{class}-pull-0 {\n right: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n background-color: @table-bg;\n}\ncaption {\n padding-top: @table-cell-padding;\n padding-bottom: @table-cell-padding;\n color: @text-muted;\n text-align: left;\n}\nth {\n text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: @line-height-computed;\n // Cells\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-cell-padding;\n line-height: @line-height-base;\n vertical-align: top;\n border-top: 1px solid @table-border-color;\n }\n }\n }\n // Bottom align for column headings\n > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid @table-border-color;\n }\n // Remove top border from thead by default\n > caption + thead,\n > colgroup + thead,\n > thead:first-child {\n > tr:first-child {\n > th,\n > td {\n border-top: 0;\n }\n }\n }\n // Account for multiple tbody instances\n > tbody + tbody {\n border-top: 2px solid @table-border-color;\n }\n\n // Nesting\n .table {\n background-color: @body-bg;\n }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-condensed-cell-padding;\n }\n }\n }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n border: 1px solid @table-border-color;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n border: 1px solid @table-border-color;\n }\n }\n }\n > thead > tr {\n > th,\n > td {\n border-bottom-width: 2px;\n }\n }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(odd) {\n background-color: @table-bg-accent;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n background-color: @table-bg-hover;\n }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-column;\n}\ntable {\n td,\n th {\n &[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-cell;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837)\n\n @media screen and (max-width: @screen-xs-max) {\n width: 100%;\n margin-bottom: (@line-height-computed * 0.75);\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid @table-border-color;\n\n // Tighten up spacing\n > .table {\n margin-bottom: 0;\n\n // Ensure the content doesn't wrap\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n white-space: nowrap;\n }\n }\n }\n }\n\n // Special overrides for the bordered tables\n > .table-bordered {\n border: 0;\n\n // Nuke the appropriate borders so that the parent can handle them\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n\n // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n // chances are there will be only one `tr` in a `thead` and that would\n // remove the border altogether.\n > tbody,\n > tfoot {\n > tr:last-child {\n > th,\n > td {\n border-bottom: 0;\n }\n }\n }\n\n }\n }\n}\n","// Tables\n\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &:hover > .@{state},\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n // Chrome and Firefox set a `min-width: min-content;` on fieldsets,\n // so we reset that to ensure it behaves more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359.\n min-width: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: @line-height-computed;\n font-size: (@font-size-base * 1.5);\n line-height: inherit;\n color: @legend-color;\n border: 0;\n border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n display: inline-block;\n max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141)\n margin-bottom: 5px;\n font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9; // IE8-9\n line-height: normal;\n}\n\n// Set the height of file controls to match text inputs\ninput[type=\"file\"] {\n display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n .tab-focus();\n}\n\n// Adjust output element\noutput {\n display: block;\n padding-top: (@padding-base-vertical + 1);\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n display: block;\n width: 100%;\n height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n background-color: @input-bg;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid @input-border;\n border-radius: @input-border-radius; // Note: This has no effect on s in CSS.\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n // Customize the `:focus` state to imitate native WebKit styles.\n .form-control-focus();\n\n // Placeholder\n .placeholder();\n\n // Disabled and read-only inputs\n //\n // HTML5 says that controls under a fieldset > legend:first-child won't be\n // disabled if the fieldset is disabled. Due to implementation difficulty, we\n // don't honor that edge case; we style them as disabled anyway.\n &[disabled],\n &[readonly],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n background-color: @input-bg-disabled;\n opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655\n }\n\n // Reset height for `textarea`s\n textarea& {\n height: auto;\n }\n}\n\n\n// Search inputs in iOS\n//\n// This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n\n\n// Special styles for iOS temporal inputs\n//\n// In Mobile Safari, setting `display: block` on temporal inputs causes the\n// text within the input to become vertically misaligned. As a workaround, we\n// set a pixel line-height that matches the given height of the input, but only\n// for Safari. See https://bugs.webkit.org/show_bug.cgi?id=139848\n\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"],\n input[type=\"time\"],\n input[type=\"datetime-local\"],\n input[type=\"month\"] {\n line-height: @input-height-base;\n\n &.input-sm,\n .input-group-sm & {\n line-height: @input-height-small;\n }\n\n &.input-lg,\n .input-group-lg & {\n line-height: @input-height-large;\n }\n }\n}\n\n\n// Form groups\n//\n// Designed to help with the organization and spacing of vertical forms. For\n// horizontal forms, use the predefined grid classes.\n\n.form-group {\n margin-bottom: 15px;\n}\n\n\n// Checkboxes and radios\n//\n// Indent the labels to position radios/checkboxes as hanging controls.\n\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n\n label {\n min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n }\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing\n}\n\n// Radios and checkboxes on same line\n.radio-inline,\n.checkbox-inline {\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px; // space out consecutive inline controls\n}\n\n// Apply same disabled cursor tweak as for inputs\n// Some special care is needed because