diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..54e5d5e5cfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git/ +.idea/ +node_modules/ +backups/ +docs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 46773455af3..04bfc585e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules -settings.json +backups !settings.json.template APIKEY.txt SESSIONKEY.txt @@ -11,6 +11,7 @@ bin/convertSettings.json *~ *.patch src/static/js/jquery.js +src/static/js/plugins.min.js npm-debug.log *.DS_Store .ep_initialized diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..a95c33c845f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Use Docker's nodejs, which is based on ubuntu +FROM node:latest + +# Get Etherpad-lite's dependencies +RUN apt-get update +RUN apt-get install -y gzip git-core curl python libssl-dev pkg-config build-essential supervisor + +# Copy codebase +COPY ./ /opt/etherpad + +WORKDIR /opt/etherpad + +# Install plugins +RUN npm install \ + ep_align \ + ep_author_neat \ + ep_comments_page \ + ep_copy_paste_images \ + ep_embedmedia \ + ep_headings2 \ + ep_spellcheck \ + ep_sticky_attributes + +# Install node dependencies +RUN /opt/etherpad/bin/installDeps.sh + +# Add conf files +ADD supervisor.conf /etc/supervisor/supervisor.conf + +EXPOSE 9001 +CMD ["supervisord", "-c", "/etc/supervisor/supervisor.conf", "-n"] \ No newline at end of file diff --git a/bin/buildPluginsHooks.js b/bin/buildPluginsHooks.js new file mode 100644 index 00000000000..853ccb28069 --- /dev/null +++ b/bin/buildPluginsHooks.js @@ -0,0 +1,48 @@ +var fs = require('fs'); +var path = require('path'); +var _ = require('ep_etherpad-lite/node_modules/underscore'); +var npm = require('ep_etherpad-lite/node_modules/npm/lib/npm.js'); +var plugins = require('ep_etherpad-lite/static/js/pluginfw/plugins'); +var settings = require("../settings.json"); +var UglifyJS = require('ep_etherpad-lite/node_modules/uglify-js'); + +if (!settings.minify) { + return; +} + +npm.load(function() { + plugins.getPackages(function(error, packages) { + const files = []; + let content = ''; + + Object.keys(packages).forEach(name => { + const definitionPath = path.resolve(packages[name].path, 'ep.json'); + const definition = fs.readFileSync(definitionPath, 'utf-8', error => console.error("Unable to load plugin definition file " + plugin_path)); + let definitionData; + + try { + definitionData = JSON.parse(definition); + } catch(e) {} + + definitionData && definitionData.parts.forEach(part => { + if (part.client_hooks) { + files.push.apply(files, _.values(part.client_hooks).map(path => path.split(':')[0] + '.js')); + } + }); + }); + + _.uniq(files).forEach(file => { + const fileContent = fs.readFileSync(path.resolve(__dirname, `../node_modules/${file}`), 'utf-8'); + + console.info(file); + + content += `require.define({ '${file}': function(require, exports, module) { ${fileContent} } });\n` + }); + + fs.writeFile( + path.resolve(__dirname, '../src/static/js/plugins.min.js'), + UglifyJS.minify(content, { fromString: true }).code, + 'utf8' + ); + }); +}); \ No newline at end of file diff --git a/bin/installDeps.sh b/bin/installDeps.sh index ea6be38f2de..20ee3951864 100755 --- a/bin/installDeps.sh +++ b/bin/installDeps.sh @@ -72,6 +72,14 @@ echo "Ensure that all dependencies are up to date... If this is the first time mkdir -p node_modules cd node_modules [ -e ep_etherpad-lite ] || ln -s ../src ep_etherpad-lite + [ -e ep_links ] || ln -s ../plugins/ep_links ep_links + if [ ! -e ep_open ]; then + ln -s ../plugins/ep_open ep_open; + cd ep_open; + npm install; + npm run build; + cd ../ + fi cd ep_etherpad-lite npm install --loglevel warn ) || { @@ -102,6 +110,9 @@ fi echo "Clearing minified cache..." rm -f var/minified* +echo "Build single file for all plugins" +node bin/buildPluginsHooks.js + echo "Ensure custom css/js files are created..." for f in "index" "pad" "timeslider" diff --git a/docker.sh b/docker.sh new file mode 100644 index 00000000000..a02f1ed7515 --- /dev/null +++ b/docker.sh @@ -0,0 +1,67 @@ +TAG="latest" + +if [ ! -z "$2" ] +then + TAG="$2" +fi + +case "$1" in + +"init") + docker create --name etherpad-db-data postgres:9.5 /bin/true +;; + +"build") + docker build --rm -t open/etherpad-server . +;; + +"run") + docker stop etherpad-server + docker rm etherpad-server + docker run --name etherpad-server -d -p 9001:9001 -e NODE_ENV=production --link etherpad-db-server:postgres open/etherpad-server:$TAG +;; + +"enter") + docker exec -i -t etherpad-server /bin/bash +;; + +"logs") + docker exec -i etherpad-server bash -c "cat /opt/etherpad/etherpad.out.log" +;; + +"db") + docker stop etherpad-db-server + docker rm etherpad-db-server + docker run --name etherpad-db-server -d --volumes-from etherpad-db-data -v /var/lib/postgresql/data postgres:9.5 +;; + +"migrate") + docker exec -i etherpad-server bash -c "cd /opt/etherpad/plugins/ep_open && npm run migrate" +;; + +"psql") + docker run -it --rm --link etherpad-db-server:postgres postgres psql -h postgres -U postgres +;; + +"backup") + docker run --rm --volumes-from etherpad-db-data -v $(pwd)/backups:/backups busybox tar cvf /backups/backup_$(date +"%Y-%m-%dT%H-%M-%S").tar /var/lib/postgresql/data +;; + +"restore") + BACKUP_FILE="$2" + + if [ -z "$2" ] + then + BACKUP_FILE="$(ls -t backups | head -n 1)" + echo "Backup file: $BACKUP_FILE" + fi + docker stop etherpad-db-server + docker run --rm --volumes-from etherpad-db-data -v $(pwd)/backups:/backups busybox tar xvf /backups/$BACKUP_FILE + docker start etherpad-db-server +;; + +"clear") + docker rmi $(docker images | grep "^" | awk '{print $3}') +;; + +esac \ No newline at end of file diff --git a/plugins/ep_links/ep.json b/plugins/ep_links/ep.json new file mode 100644 index 00000000000..0f00f2d946d --- /dev/null +++ b/plugins/ep_links/ep.json @@ -0,0 +1,19 @@ +{ + "parts": [{ + "name": "Links", + "hooks": { + "eejsBlock_editbarMenuLeft": "ep_links/hooks", + "eejsBlock_scripts": "ep_links/hooks", + "eejsBlock_styles": "ep_links/hooks", + "eejsBlock_body": "ep_links/hooks" + }, + "client_hooks": { + "aceAttribsToClasses": "ep_links/static/js/hooks", + "aceCreateDomLine": "ep_links/static/js/hooks", + "postToolbarInit": "ep_links/static/js/hooks", + "aceEditorCSS": "ep_links/static/js/hooks", + "postAceInit": "ep_links/static/js/hooks", + "aceEditEvent": "ep_links/static/js/hooks" + } + }] +} diff --git a/plugins/ep_links/hooks.js b/plugins/ep_links/hooks.js new file mode 100644 index 00000000000..7b537b2c307 --- /dev/null +++ b/plugins/ep_links/hooks.js @@ -0,0 +1,30 @@ +var path = require('path'); +var express = require('ep_etherpad-lite/node_modules/express'); +var eejs = require("ep_etherpad-lite/node/eejs"); + +exports.eejsBlock_editbarMenuLeft = function (hookName, context, cb) { + const button = eejs.require("ep_links/templates/editbarButtons.ejs", {}, module); + const regExp = /^(.*?)(.*?)<\/li>(.*?)$/m; + + context.content = context.content.replace(/\n|\r/g, '').replace(regExp, '$1$3' + button + '$4'); + + return cb(); +} + +exports.eejsBlock_body = function (hookName, context, cb) { + context.content = context.content + eejs.require("ep_links/templates/modals.ejs", {}, module); + + return cb(); +} + +exports.eejsBlock_scripts = function (hookName, context, cb) { + context.content = context.content + eejs.require("ep_links/templates/scripts.ejs", {}, module); + + return cb(); +} + +exports.eejsBlock_styles = function (hookName, context, cb) { + context.content = context.content + eejs.require("ep_links/templates/styles.ejs", {}, module); + + return cb(); +} diff --git a/plugins/ep_links/package.json b/plugins/ep_links/package.json new file mode 100644 index 00000000000..0fc3978df42 --- /dev/null +++ b/plugins/ep_links/package.json @@ -0,0 +1,11 @@ +{ + "name": "ep_links", + "description": "Links between pads", + "version": "0.0.1", + "author": "Open Companies", + "contributors": [], + "dependencies": {}, + "engines": { + "node": "*" + } +} diff --git a/plugins/ep_links/static/css/ace.css b/plugins/ep_links/static/css/ace.css new file mode 100644 index 00000000000..8f5b6446399 --- /dev/null +++ b/plugins/ep_links/static/css/ace.css @@ -0,0 +1,5 @@ +.link { + text-decoration: underline; + cursor: pointer; + color: #0645ad; +} diff --git a/plugins/ep_links/static/css/main.css b/plugins/ep_links/static/css/main.css new file mode 100644 index 00000000000..8419791f80c --- /dev/null +++ b/plugins/ep_links/static/css/main.css @@ -0,0 +1,71 @@ +#pad_link_modal { + position: absolute; + top: 55px; + right: 20px; + display: none; + z-index: 999999; +} + +#pad_link_modal h1 { + margin-bottom: 10px; +} + +#pad_link_modal input { + width: calc(100% - 50px); + float: left; +} + +#pad_link_modal button[type=submit] { + width: 40px; + margin: 10px 0 0 10px; +} + +#pad_link_insert_btn { + font-family: font-awesome; +} + +.buttonicon-link:before{ + content: "\e839"; + top: 2px!important; +} + +.pad_window { + position: absolute; + top: 0; + left: 80px; + right: 0; + bottom: 0; + z-index: 9999; +} + +.pad_window__screen { + position: absolute; + top: 0; + left: -100%; + width: 200%; + height: 100%; + background: rgba(0, 0, 0, .5); + z-index: 1; + cursor: pointer; +} + +.pad_window iframe { + position: relative; + width: 100%; + height: 100%; + z-index: 2; + border: 0; + background: #fff; +} + +.pad_window--active { + z-index: 99999; +} + +.pad_window--active .pad_window__screen { + display: block; +} + +.pad_window--hidden { + display: none; +} \ No newline at end of file diff --git a/plugins/ep_links/static/js/hooks.js b/plugins/ep_links/static/js/hooks.js new file mode 100644 index 00000000000..cfd405e6c96 --- /dev/null +++ b/plugins/ep_links/static/js/hooks.js @@ -0,0 +1,63 @@ +exports.aceEditorCSS = function(hookName, context, cb) { + return cb(['ep_links/static/css/ace.css']); +}; + +exports.aceEditEvent = function(hookName, context) { + if (context.callstack.type === 'handleClick') { + window.top.pm.send('toggleLinkModal', false); + } +}; + +exports.postAceInit = function(hookName, context) { + context.ace.callWithAce(function(ace) { + var $inner = $(ace.ace_getDocument()).find('#innerdocbody'); + + $inner.on('click', '.link', function() { + var linkPath = this.getAttribute('data-link-path'); + + if (linkPath.search(/(http|s):/) >= 0) { + window.open(linkPath, '_blank'); + } else { + window.top.pm.send('openPad', linkPath); + } + }); + }); +}; + +exports.postToolbarInit = function(hookName, context, cb) { + context.toolbar.registerDropdownCommand('padLinkModal:open', 'pad_link_modal'); + return cb(); +}; + +exports.aceAttribsToClasses = function(hookName, context, cb) { + if (context.key == 'link' && context.value != '') { + return cb(['link:' + context.value]); + } +}; + +exports.aceCreateDomLine = function(hookName, context, cb) { + if (context.cls.indexOf('link:') >= 0) { + var clss = []; + var argClss = context.cls.split(' '); + var value; + var title = 'To go to this pad click this link with pressed CTRL key'; + + for (var i = 0; i < argClss.length; i++) { + var cls = argClss[i]; + + if (cls.indexOf('link:') !== -1) { + value = cls.substr(cls.indexOf(':') + 1); + } else { + clss.push(cls); + } + } + + return cb([{ + cls: clss.join(' '), + extraOpenTags: '', + extraCloseTags: '' + }]); + } + + return cb(); +}; diff --git a/plugins/ep_links/static/js/main.js b/plugins/ep_links/static/js/main.js new file mode 100644 index 00000000000..e6d1a20ff62 --- /dev/null +++ b/plugins/ep_links/static/js/main.js @@ -0,0 +1,26 @@ +$(document).ready(function () { + $('#pad_link_insert_btn').click(function() { + var padeditor = require('ep_etherpad-lite/static/js/pad_editor').padeditor; + + padeditbar.toggleDropDown('none'); + window.top.pm.send('toggleLinkModal'); + }); + + window.top.pm.subscribe('newPadLink', function(data) { + if (data.etherpadId === pad.getPadId()) { + var padeditor = require('ep_etherpad-lite/static/js/pad_editor').padeditor; + + padeditor.ace.callWithAce(function(ace) { + var rep = ace.ace_getRep(); + + // If there is no selection, insert pad name + if (rep.selEnd[1] - rep.selStart[1] <= 1) { + ace.ace_replaceRange(rep.selEnd, rep.selEnd, data.title); + ace.ace_performSelectionChange([rep.selEnd[0], rep.selEnd[1] - data.title.length], rep.selEnd, false); + } + + ace.ace_performDocumentApplyAttributesToRange(rep.selStart, rep.selEnd, [['link', data.id]]); + }, 'link'); + } + }); +}); diff --git a/plugins/ep_links/templates/editbarButtons.ejs b/plugins/ep_links/templates/editbarButtons.ejs new file mode 100644 index 00000000000..045c4d8e9a5 --- /dev/null +++ b/plugins/ep_links/templates/editbarButtons.ejs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/ep_links/templates/modals.ejs b/plugins/ep_links/templates/modals.ejs new file mode 100644 index 00000000000..9df713ff9c5 --- /dev/null +++ b/plugins/ep_links/templates/modals.ejs @@ -0,0 +1,13 @@ + diff --git a/plugins/ep_links/templates/scripts.ejs b/plugins/ep_links/templates/scripts.ejs new file mode 100644 index 00000000000..a8001e0a7c8 --- /dev/null +++ b/plugins/ep_links/templates/scripts.ejs @@ -0,0 +1 @@ + diff --git a/plugins/ep_links/templates/styles.ejs b/plugins/ep_links/templates/styles.ejs new file mode 100644 index 00000000000..ab4a784a69a --- /dev/null +++ b/plugins/ep_links/templates/styles.ejs @@ -0,0 +1 @@ + diff --git a/plugins/ep_open/.babelrc b/plugins/ep_open/.babelrc new file mode 100644 index 00000000000..366d4e048ee --- /dev/null +++ b/plugins/ep_open/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": "es2015", + "plugins": [ + "transform-decorators-legacy", + "transform-class-properties", + "transform-react-jsx" + ] +} \ No newline at end of file diff --git a/plugins/ep_open/.eslintrc b/plugins/ep_open/.eslintrc new file mode 100644 index 00000000000..9b09a96cce9 --- /dev/null +++ b/plugins/ep_open/.eslintrc @@ -0,0 +1,20 @@ +{ + "ecmaFeatures": { + "modules": true, + "spread" : true, + "restParams" : true + }, + "env" : { + "browser" : true, + "node" : true, + "es6" : true + }, + "rules" : { + "no-unused-vars" : 2, + "no-undef" : 2 + }, + "parserOptions": { + "ecmaVersion": 7, + "sourceType": "module" + } +} diff --git a/plugins/ep_open/.gitignore b/plugins/ep_open/.gitignore new file mode 100644 index 00000000000..c594115ab7e --- /dev/null +++ b/plugins/ep_open/.gitignore @@ -0,0 +1,6 @@ +.idea +.sass-cache +node_modules +static/css +static/js/bundle.js +static/js/config/index.js \ No newline at end of file diff --git a/plugins/ep_open/.jscsrc b/plugins/ep_open/.jscsrc new file mode 100644 index 00000000000..25363b23719 --- /dev/null +++ b/plugins/ep_open/.jscsrc @@ -0,0 +1,27 @@ +{ + "preset": "google", + "validateIndentation": "\t", + "requireCamelCaseOrUpperCaseIdentifiers": "ignoreProperties", + "maximumLineLength": 200, + "requireLineFeedAtFileEnd": null, + "disallowMultipleVarDecl": null, + "disallowSpacesInsideBrackets": { + "allExcept": ["{", "}"] + }, + "disallowSpacesInsideObjectBrackets": null, + "disallowSpaceAfterObjectKeys": true, + "requireSpaceBeforeObjectValues": true, + "requirePaddingNewlinesBeforeKeywords": ["return"], + "jsDoc": { + "checkAnnotations": true, + "checkParamNames": true, + "requireParamTypes": true, + "checkRedundantParams": true, + "requireReturnTypes": true, + "checkTypes": true, + "leadingUnderscoreAccess": true, + "requireHyphenBeforeDescription": true, + "disallowNewlineAfterDescription": true, + "requireParamDescription": true + } +} diff --git a/plugins/ep_open/.jshintrc b/plugins/ep_open/.jshintrc new file mode 100644 index 00000000000..fd48a436f0d --- /dev/null +++ b/plugins/ep_open/.jshintrc @@ -0,0 +1,38 @@ +{ + "node": true, + "esnext": true, + "bitwise": true, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 4, + "newcap": true, + "noarg": true, + "quotmark": "single", + "regexp": true, + "undef": true, + "unused": true, + "strict": true, + "trailing": true, + "smarttabs": true, + "white": true, + "module": true, + "globals": { + "console": true, + "localStorage": true, + "sessionStorage": true, + "alert": true, + "Image": true, + "Math": true, + "window": true, + "document": true, + "define": true, + "module": true, + "WebSocket": true, + "XMLHttpRequest": true, + "FileReader": true + }, + "-W097": true, + "-W030": true, + "-W083": true +} diff --git a/plugins/ep_open/.sequelizerc b/plugins/ep_open/.sequelizerc new file mode 100644 index 00000000000..75366ac56fc --- /dev/null +++ b/plugins/ep_open/.sequelizerc @@ -0,0 +1,24 @@ +'use strict'; + +const _ = require('lodash'); +const path = require('path'); +const config = require('../../credentials.json'); +const envConfig = { + 'username': config.dbSettings.user, + 'password': config.dbSettings.password, + 'database': config.dbSettings.database, + 'host': config.dbSettings.host, + 'port': '5432', + "dialect": config.dbType +}; + +module.exports = _.extend({ + test: envConfig, + development: envConfig, + production: envConfig +}, { + 'config': __filename, + 'migrations-path': path.resolve('./api', 'migrations'), + 'models-path': path.resolve('./api', 'models'), + 'seeders-path': path.resolve('./api', 'seeders') +}); \ No newline at end of file diff --git a/plugins/ep_open/api/common/helpers.js b/plugins/ep_open/api/common/helpers.js new file mode 100644 index 00000000000..4ead462966f --- /dev/null +++ b/plugins/ep_open/api/common/helpers.js @@ -0,0 +1,147 @@ +'use strict'; + +const co = require('co'); +const _ = require('lodash'); + +exports.async = gen => { + const fn = co.wrap(gen); + + if (gen.length === 4) { + return function(error, request, response, next) { + return fn(error, request, response, next).catch(next); + } + } + + return function(request, response, next) { + return fn(request, response, next).then( + data => { + if (data !== response) { + data = typeof data === 'object' ? data : {}; + + if (_.isArray(data)) { + data = data.map(item => item.toPublicJSON ? item.toPublicJSON() : item) + } else { + // If data is sequelize model then convert it to plain object + if (data.toPublicJSON) { + data = data.toPublicJSON(); + } else if (data.toJSON) { + data = data.toJSON(); + } + } + + // If url contains company id then filter reputation of all users by this company id + if (request.params.companyId) { + data = filterReputation(request.params.companyId, data); + } + } + + response.send(data); + }, + error => { + let message = error.message; + + try { + switch (error.name) { + case 'SequelizeUniqueConstraintError': + message = `This ${error.errors[0].path} is already taken`; + break; + } + } catch(e) {} + + response.status(400).send({ error: message }); + } + ); + }; +}; + +exports.responseHandler = (response, errorType) => { + return (error, data) => { + if (error) { + response.status(errorType || 400).send({ error: error.message }); + } else { + response.send(data); + } + }; +}; + +exports.responseError = (response, error, code) => { + return response.status(code || 400).send({ error }); +}; + +exports.checkAuth = (request, response, next) => { + request.token && request.token.id ? next() : exports.responseError(response, 'Access allowed only for authorized users', 401); +}; + +exports.collectData = (request, config) => { + const data = {}; + + if (config.owner) { + data.ownerId = request.token && request.token.user && request.token.user.id; + } + + if (config.body) { + config.body.forEach(key => { + const value = request.body[key]; + + if (value !== undefined) { + data[key] = value; + } + }); + } + + if (config.params) { + config.params.forEach(key => { + const value = request.params[key]; + + if (value !== undefined) { + data[key] = value; + } + }); + } + + return data; +}; + +exports.randomString = length => { + const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let string = ''; + + for (var i = 0; i < length; i++) { + const rnum = Math.floor(Math.random() * chars.length); + + string += chars.substring(rnum, rnum + 1); + } + + return string; +} + +exports.promiseWrapper = (object, method, args = []) => { + return new Promise((resolve, reject) => { + object[method].apply(object, args.concat((error, data) => error ? reject(error) : resolve(data))); + }); +} + + +/** + * Filter reputation object and leave only reputation value for passed companyId + * @param companyId - Id of company + * @param data - Object with data + * @return {Object} - Filtered object + */ +function filterReputation(companyId, data) { + _.keys(data).forEach(key => { + const value = data[key]; + + if (key === 'reputation') { + data[key] = (value || {})[companyId] || 1; + } else { + if (_.isArray(value)) { + data[key] = value.map(filterReputation.bind(this, companyId)); + } else if (_.isObject(value) && !_.isDate(value)) { + data[key] = filterReputation(companyId, value); + } + } + }); + + return data; +}; \ No newline at end of file diff --git a/plugins/ep_open/api/common/socketio.js b/plugins/ep_open/api/common/socketio.js new file mode 100644 index 00000000000..5ae98ed2327 --- /dev/null +++ b/plugins/ep_open/api/common/socketio.js @@ -0,0 +1,15 @@ +const socketio = require('ep_etherpad-lite/node_modules/socket.io'); +let io; + +module.exports.init = function(server) { + io = socketio({ path: '/api_socket' }).listen(server); + module.exports = io; +}; + +module.exports.emit = function(name, data) { + if (io) { + return io.sockets.emit(name, data); + } else { + return false; + } +} \ No newline at end of file diff --git a/plugins/ep_open/api/controllers/oauth.js b/plugins/ep_open/api/controllers/oauth.js new file mode 100644 index 00000000000..b6efac1591e --- /dev/null +++ b/plugins/ep_open/api/controllers/oauth.js @@ -0,0 +1,152 @@ +'use strict'; + +const rp = require('request-promise'); +const google = require('googleapis'); +const config = require('../../config'); +const helpers = require('../common/helpers'); +const async = helpers.async; +const User = require('../models/user'); +const createToken = require('./tokens').createToken; +const googleOAuthClient = new google.auth.OAuth2( + config.google.clientId, + config.google.clientSecret, + `${config.env.apiHost}/oauth/google/callback` +); + +module.exports = api => { + api.get('/oauth/github', (request, response) => { + const authUrl = 'https://github.com/login/oauth/authorize'; + let callback = encodeURIComponent(`${config.env.apiHost}/oauth/github/callback`); + + if (request.query.authToken) { + callback += encodeURIComponent('?authToken=' + request.query.authToken); + } + + response.redirect(`${authUrl}?client_id=${config.github.clientId}&redirect_uri=${callback}`); + }); + + api.get('/oauth/google', (request, response) => { + response.redirect(googleOAuthClient.generateAuthUrl({ + scope: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile' + ] + })); + }); + + api.get('/oauth/:provider/callback', async(function*(request, response) { + const provider = request.params.provider || ''; + const providerIdKey = `${provider}UserId`; + + if (!/^github|google$/.test(provider)) { + return response.send('Invalid OAuth provider(supported values: github, google)'); + } + + if (!request.query.code) { + return response.send('Something went wrong, please try again'); + } + + const oauthData = yield getOAuthData(provider, request.query.code); + + + if (!oauthData || !oauthData.access_token) { + return response.send(oauthData.error_description || 'Something went wrong, please try again'); + } + + const userData = yield getUserData(provider, oauthData.access_token); + const userSearch = {}; + userSearch[providerIdKey] = userData[providerIdKey]; + + let user = yield User.find({ where: userSearch }); + + if (!user) { + const existedUser = yield User.find({ + $or: [ + { email: userData.email }, + { nickname: userData.nickname } + ] + }); + + if (existedUser) { + if (existedUser.email === userData.email) { + return response.send(`Email ${userData.email} is already in use, please login through regular login`); + } else { + userData.nickname += '_' + provider; + } + } + + user = yield User.create(userData); + } + + const token = yield createToken(user, request.cookies.token); + const tokenJSON = { + type: 'oauth_callback', + data: Object.assign(token.toJSON(), { user: user.toJSON() }) + }; + + response.send(` + + + + + + `); + })); +}; + +function getOAuthData(provider, code) { + if (provider === 'github') { + return rp.post({ + url: 'https://github.com/login/oauth/access_token', + json: true, + body: { + client_id: config.github.clientId, + client_secret: config.github.clientSecret, + code: code + } + }); + } else { + return new Promise((resolve, reject) => { + googleOAuthClient.getToken(code, (error, tokens) => { + error ? reject(error) : resolve(tokens); + }); + }); + } +} + +function getUserData(provider, accessToken) { + if (provider === 'github') { + return rp({ + url: 'https://api.github.com/user?access_token=' + accessToken, + json: true, + headers: { + 'User-Agent': 'Open Companies App' + } + }).then(data => ({ + email: data.email, + name: data.name, + nickname: data.login, + avatar: data.avatar_url, + github: data, + githubUserId: data.id, + githubToken: accessToken + })); + } else { + return rp({ + url: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json&access_token=' + accessToken, + json: true + }).then(data => ({ + email: data.email, + name: data.name, + nickname: data.name.toLowerCase().replace(/\s/g, '_'), + avatar: data.picture, + google: data, + googleUserId: data.id, + googleToken: accessToken + })); + } +} \ No newline at end of file diff --git a/plugins/ep_open/api/controllers/pads.js b/plugins/ep_open/api/controllers/pads.js new file mode 100644 index 00000000000..4cc2e7dcc53 --- /dev/null +++ b/plugins/ep_open/api/controllers/pads.js @@ -0,0 +1,252 @@ +'use strict'; + +const _ = require('lodash'); +const co = require('co'); +const md5 = require('md5'); +const padManager = require('ep_etherpad-lite/node/db/PadManager'); +const Changeset = require("ep_etherpad-lite/static/js/Changeset"); +const logger = require('ep_etherpad-lite/node_modules/log4js').getLogger('Pads API'); +const helpers = require('../common/helpers'); +const socketio = require('../common/socketio'); +const async = helpers.async; +const responseError = helpers.responseError; +const checkAuth = helpers.checkAuth; +const collectData = helpers.collectData; +const randomString = helpers.randomString; +const promiseWrapper = helpers.promiseWrapper; +const User = require('../models/user'); +const Pad = require('../models/pad'); +const rootHierarchy = { + object: {}, + store: {} +}; + +// Build root hierarchy on application launch +co.wrap(buildRootHierarchy)(); + +module.exports = api => { + api.get('/pads', async(function*(request, response) { + const page = (parseInt(request.query.page, 10) || 1) - 1; + const perPage = parseInt(request.query.perPage, 10) || 50; + const where = {}; + + if (request.query.query) { + where.title = { $iLike: `%${request.query.query}%` }; + } else if (request.query.ids) { + where.id = { $in: request.query.ids.split(',') }; + } + + return yield Pad.findAndCountAll({ + limit: perPage, + offset: page * perPage, + where: where, + order: [['created_at']] + }); + })); + + api.get('/pads/:id', async(function*(request, response) { + let pad = yield Pad.scope('full').findById(request.params.id); + + if (!pad) { + if (request.params.id === 'root') { + pad = yield Pad.scope('full').create({ + id: 'root', + type: 'root', + etherpadId: 'root', + title: 'Open companies' + }); + } else { + return responseError(response, 'Pad is not found'); + } + } + + const views = (pad.views || 0) + 1; + + pad.views = views; + pad.update({ views }); + + return pad; + })); + + api.post('/pads', async(function*(request, response) { + request.checkBody('title', 'Title is required').notEmpty(); + request.checkErrors(); + + const id = randomString(10); + const data = collectData(request, { + owner: true, + body: ['title', 'description', 'type'] + }); + + if (data.type && !/^company|child$/.test(data.type)) { + delete data.type; + } + + data.id = id; + data.etherpadId = md5(id); + + yield promiseWrapper(padManager, 'getPad', [data.etherpadId]); + const pad = yield Pad.scope('full').create(data); + + return yield pad.reload({ + include: [{ + model: User, + as: 'owner' + }] + }); + })); + + api.put('/pads/:id', async(function*(request, response) { + const pad = yield Pad.scope('full').findById(request.params.id); + + if (!pad) { + return responseError(response, 'Pad is not found'); + } + // + // if (!request.token.user.isActionAllowed('EDIT_PADS', pad.owner.id)) { + // return responseError(response, 'You do not have permission for this action'); + // } + + yield pad.update(collectData(request, { body: ['title', 'description'] })); + + return pad; + })); + + api.delete('/pads/:id', async(function*(request, response) { + const pad = yield Pad.scope('full').findById(request.params.id); + + if (!pad) { + return responseError(response, 'Pad is not found'); + } + // + // if (!request.token.user.isActionAllowed('DELETE_PADS', pad.owner.id)) { + // return responseError(response, 'You do not have permission for this action'); + // } + + return yield pad.destroy(); + })); + + api.get('/pads/:id/hierarchy', async(function*(request, response) { + const id = request.params.id; + + if (id === 'root') { + return rootHierarchy.object; + } else { + return yield co.wrap(buildHierarchy)(id, {}); + } + })); +}; + +module.exports.padUpdate = function(hookName, args) { + const { pad } = args; + const storedPad = rootHierarchy.store[pad.id]; + + if (storedPad) { + const children = getPadChildren(pad); + + if (!_.isEqual(storedPad.children, children)) { + logger.debug('PAD LINKS CHANGE', storedPad.children, '=>', children); + co.wrap(buildRootHierarchy)(); + } + } +} + +/** + * Return list of pad's children + * @param {Object} pad - Etherpad's pad instance + * @return {Array} - List of children ids + */ +function getPadChildren(pad) { + const children = []; + + Changeset.eachAttribNumber(pad.atext.attribs, attributeNumber => { + const attribute = pad.pool.numToAttrib[attributeNumber]; + + if (typeof attribute === 'object' && attribute[0] === 'padLink') { + const linkId = attribute[1]; + + if (linkId) { + children.push(linkId); + } + } + }); + + return _.uniq(children); +} + +/** + * Build hierarchy for passed pad + * @param {String} id - Pad id + * @param {Object} store - Store object, needed to prevent pad repeats + * @param {Number} [depth=Infinity] - Depth of hierarchy + * @return {Object} - Pad hierarchy + */ +function* buildHierarchy(id, store, depth) { + if (store[id]) { + return store[id]; + }; + + const pad = yield Pad.scope('full').findById(id); + + if (!pad) { + return {}; + } + + const padData = yield promiseWrapper(padManager, 'getPad', [pad.etherpadId]); + const result = { + id: pad.id, + title: pad.title, + type: pad.type, + }; + let children = []; + + store[id] = Object.assign({}, result); + + if (depth === undefined || (typeof depth === 'number' && --depth >= 0)) { + children = getPadChildren(padData); + + if (children.length) { + result.children = []; + + for (var i = 0; i < children.length; i++) { + const child = yield co.wrap(buildHierarchy)(children[i], store, depth); + + if (!_.isEmpty(child)) { + result.children.push(child); + } + } + } + } + + rootHierarchy.store[pad.etherpadId] = { id, children }; + + return result; +} + +/** + * Build and store root hierarchy + */ +function* buildRootHierarchy() { + const rootPad = yield co.wrap(buildHierarchy)('root', {}, 1); + + logger.debug('ROOT HIERARCHY BUILD'); + + if (!_.isEmpty(rootPad)) { + const children = rootPad.children; + + rootPad.children = []; + + if (children) { + for (var i = 0; i < children.length; i++) { + const child = children[i]; + + if (child.type === 'company') { + rootPad.children.push(yield co.wrap(buildHierarchy)(child.id, {})); + } + } + } + } + + rootHierarchy.object = rootPad; + socketio.emit('rootPadsHierarchy', rootPad); +} \ No newline at end of file diff --git a/plugins/ep_open/api/controllers/profile.js b/plugins/ep_open/api/controllers/profile.js new file mode 100644 index 00000000000..e7dc426b3ba --- /dev/null +++ b/plugins/ep_open/api/controllers/profile.js @@ -0,0 +1,86 @@ +'use strict'; + +const stream = require('stream'); +const gcloud = require('gcloud'); +const gstorage = gcloud.storage({ + projectId: 'cool-plasma-778', + keyFilename: './google_cloud_key_f738e3f5d4ca.json' +}); +const md5 = require('md5'); +const helpers = require('../common/helpers'); +const async = helpers.async; +const checkAuth = helpers.checkAuth; +const responseError = helpers.responseError; +const checkUserUniq = require('./users').checkUserUniq; +const updateAuthorName = require('./users').updateAuthorName; +const User = require('../models/user'); + +module.exports = api => { + api.get('/profile', checkAuth, async(function*(request, response) { + return request.token.user; + })); + + api.put('/profile', checkAuth, async(function*(request, response) { + const user = request.token.user; + + if (!user) { + return responseError(response, 'User is not found'); + } + + request.cookies.token && updateAuthorName(request.cookies.token, user); + + yield checkUserUniq(request.body); + + return yield user.update(request.body); + })); + + api.post('/profile/avatar', checkAuth, async(function*(request, response) { + const user = request.token.user; + + if (!user) { + return responseError(response, 'User is not found'); + } + + request.checkBody('image', 'Image is required').notEmpty(); + request.checkErrors(); + + const avatarPath = yield new Promise((resolve, reject) => { + const imageMatch = request.body.image.match(/data\:([^;]*);base64,(.*)/); + const imageType = imageMatch[1]; + const imageBase64 = imageMatch[2]; + const imageExtension = /^image\//.test(imageType) ? imageType.replace('image/', '') : 'png'; + const imagePath = `${md5(user.id)}/avatar.${imageExtension}`; + const bufferStream = new stream.PassThrough(); + const file = gstorage.bucket('open-companies').file(imagePath); + + bufferStream.end(new Buffer(imageBase64, 'base64')); + + bufferStream + .pipe(file.createWriteStream({ + metadata: { + contentType: imageType + } + })) + .on('error', reject) + .on('finish', function() { + file.makePublic(error => error ? reject(error) : resolve(imagePath + '?' + new Date().getTime())); + }); + }); + + return yield user.update({ avatar: avatarPath }); + })); + + api.put('/profile/password', checkAuth, async(function*(request, response) { + const user = request.token.user; + + request.checkBody('current', 'Current password is required').notEmpty(); + request.checkBody('new', 'New password is required').notEmpty(); + request.checkErrors(); + + if (!user.authenticate(request.body.current)) { + return responseError(response, 'Wrong current password'); + } + + return yield user.update({ password: request.body.new }); + })); +}; \ No newline at end of file diff --git a/plugins/ep_open/api/controllers/tokens.js b/plugins/ep_open/api/controllers/tokens.js new file mode 100644 index 00000000000..b8b98a4fdc3 --- /dev/null +++ b/plugins/ep_open/api/controllers/tokens.js @@ -0,0 +1,71 @@ +'use strict'; + +const http = require('http'); +const moment = require('moment'); +const helpers = require('../common/helpers'); +const async = helpers.async; +const responseError = helpers.responseError; +const updateAuthorName = require('./users').updateAuthorName; +const User = require('../models/user'); +const Token = require('../models/token'); + +module.exports = api => { + api.get('/tokens/:id', async(function*(request, response) { + return yield Token.findById(request.params.id); + })); + + api.post('/tokens', async(function*(request, response) { + request.checkBody('email', 'You should specify email').notEmpty(); + request.checkBody('password', 'You should specify password').notEmpty(); + request.checkErrors(); + + const user = yield User.scope('full').find({ where: { email: request.body.email } }); + + if (!user || !user.authenticate(request.body.password)) { + return responseError(response, 'Authentication fails'); + } + + const token = yield createToken(user, request.cookies.token); + + return Object.assign(token.toJSON(), { user }); + })); + + api.get('/tokens/:id/prolong', async(function*(request, response) { + const token = yield Token.findById(request.params.id); + + if (!token) { + return responseError(response, 'Token is not found'); + } + + token.expires = moment().add(1, 'months'); + + return yield token.save(); + })); + + + api.delete('/tokens/:id', async(function*(request) { + const token = yield Token.findById(request.params.id); + + if (!token) { + return responseError(response, 'Token is not found'); + } + + return yield token.destroy(); + })); +}; + +function createToken(user, etherpadToken) { + // Set name of authorized user to etherpad author entity + etherpadToken && updateAuthorName(etherpadToken, user); + + return Token + .destroy({ where: { userId: user.id }}) + .then(() => + Token.create({ + expires: moment().add(1, 'months'), + userId: user.id + }) + ); +} + +module.exports.createToken = createToken; diff --git a/plugins/ep_open/api/controllers/users.js b/plugins/ep_open/api/controllers/users.js new file mode 100644 index 00000000000..e24eec14b43 --- /dev/null +++ b/plugins/ep_open/api/controllers/users.js @@ -0,0 +1,129 @@ +'use strict'; + +const authorManager = require('ep_etherpad-lite/node/db/AuthorManager'); +const helpers = require('../common/helpers'); +const async = helpers.async; +const responseError = helpers.responseError; +const collectData = helpers.collectData; +const User = require('../models/user'); + +function checkUniq(data) { + return User.find({ + where: { + $or: [{ + email: data.email + }, { + nickname: data.nickname + }] + } + }).then(user => { + if (user) { + user = user.length ? user[0] : user; + + return new Error(`${user.email === data.email ? 'Email' : 'Nickname'} is already taken`) + } + }) +} + +function updateAuthorName(token, user) { + authorManager.getAuthor4Token(token, (error, author) => authorManager.setAuthorName(author, user.nickname)); +} + +module.exports = api => { + api.get('/users', async(function*(request, response) { + const page = (parseInt(request.query.page, 10) || 1) - 1; + const perPage = parseInt(request.query.perPage, 10) || 50; + let where = {}; + + if (request.query.query) { + where = { + $or: ['name', 'email', 'surname'].map(attr => ({ + [attr]: { $iLike: `%${request.query.query}%` } + })) + }; + } + + console.log(where); + + return yield User.findAndCountAll({ + limit: perPage, + offset: page * perPage, + where: where, + order: 'created_at' + }); + })); + + api.get('/users/:id', async(function*(request, response) { + return yield User.findById(request.params.id); + })); + + api.post('/users', async(function*(request, response) { + request.checkBody('email', 'Email is required').notEmpty(); + request.checkBody('nickname', 'Nickname is required').notEmpty(); + request.checkBody('password', 'Password is required').notEmpty(); + request.checkErrors(); + + const data = collectData(request, { + body: ['email', 'nickname', 'password'] + }); + + yield checkUniq(data); + + return yield User.create(data); + })); + + api.put('/users/:id', async(function*(request, response) { + if (!(request.token && request.token.user && request.token.user.id == request.params.id)) { + return responseError(response, 'You have no permission for this operation', 403); + } + + const user = yield User.findById(request.params.id); + + if (!user) { + return responseError(response, 'User is not found'); + } + + request.cookies.token && updateAuthorName(request.cookies.token, user); + + const data = collectData(request, { + body: ['email', 'nickname', 'password'] + }); + + yield checkUniq(data); + + return yield user.update(data); + })); + + api.delete('/users/:id', async(function*(request, response) { + if (!(request.token && request.token.user && request.token.user.id == request.params.id)) { + return responseError(response, 'You have no permission for this operation', 403); + } + + return yield User.destroy({ where: { id: request.params.id } }).then(() => { success: true }); + })); + + api.put('/users/:id/privileges', async(function*(request, response) { + if (!(request.token && request.token.user && request.token.user.role == 'admin')) { + return responseError(response, 'You have no permission for this operation', 403); + } + + request.checkBody('role', 'Role is incorrect').optional().isIn(['admin', 'user']); + request.checkErrors(); + + const user = yield User.findById(request.params.id); + + if (!user) { + return responseError(response, 'User is not found'); + } + + const data = collectData(request, { + body: ['permissions', 'role'] + }); + + return yield user.update(data); + })); +}; + + +module.exports.checkUserUniq = checkUniq; +module.exports.updateAuthorName = updateAuthorName; diff --git a/plugins/ep_open/api/index.js b/plugins/ep_open/api/index.js new file mode 100644 index 00000000000..7869195896a --- /dev/null +++ b/plugins/ep_open/api/index.js @@ -0,0 +1,58 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const Token = require('./models/token'); +const User = require('./models/user'); +const responseError = require('./common/helpers').responseError; +const api = express(); + +api.use((request, response, next) => { + const tokenId = request.headers['x-auth-token'] || request.query.authToken; + + // Function for triggering of validation errors; + request.checkErrors = function() { + const errors = request.validationErrors(); + + if (errors.length) { + return responseError(response, errors[0].msg); + } + }; + + if (/^POST|PUT$/.test(request.method) && typeof request.body !== 'object') { + return responseError(response, 'Request should contain body in JSON format'); + } + + if (tokenId) { + Token + .find({ + where: { id: tokenId }, + include: [ User.scope('full') ] + }) + .then(token => { + if (token && token.isActive()) { + request.token = token; + } else { + request.token = {}; + Token.destroy({ where: { id: tokenId }}); + } + + next(); + }) + .catch(next); + } else { + next(); + } +}); + +// Loading of API controllers +fs.readdir(path.resolve(__dirname, './controllers'), (error, files) => { + files.forEach(file => { + if (file.search(/\.js$/) !== -1) { + require('./controllers/' + file)(api); + } + }); +}); + +module.exports = api; \ No newline at end of file diff --git a/plugins/ep_open/api/migrations/20160201012339-create-user.js b/plugins/ep_open/api/migrations/20160201012339-create-user.js new file mode 100644 index 00000000000..06eea4478c8 --- /dev/null +++ b/plugins/ep_open/api/migrations/20160201012339-create-user.js @@ -0,0 +1,50 @@ +'use strict'; + +module.exports = { + up: function(queryInterface, Sequelize) { + return queryInterface.createTable('users', { + id: { + autoIncrement: false, + primaryKey: true, + type: Sequelize.UUID + }, + email: { + type: Sequelize.STRING, + unique: true + }, + nickname: { + type: Sequelize.STRING, + unique: true + }, + password_hash: Sequelize.STRING, + salt: Sequelize.STRING, + name: Sequelize.STRING, + surname: Sequelize.STRING, + avatar: Sequelize.STRING, + github: Sequelize.JSON, + github_user_id: Sequelize.INTEGER, + github_token: Sequelize.STRING, + google: Sequelize.JSON, + google_user_id: Sequelize.STRING, + google_token: Sequelize.STRING, + role: { + type: Sequelize.STRING, + defaultValue: 'user' + }, + reputation: { type: Sequelize.JSONB }, + permissions: { type: Sequelize.JSONB }, + created_at: { + allowNull: false, + type: Sequelize.DATE + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + down: function(queryInterface, Sequelize) { + return queryInterface.dropTable('users'); + } +}; \ No newline at end of file diff --git a/plugins/ep_open/api/migrations/20160201233836-create-pad.js b/plugins/ep_open/api/migrations/20160201233836-create-pad.js new file mode 100644 index 00000000000..14607299a71 --- /dev/null +++ b/plugins/ep_open/api/migrations/20160201233836-create-pad.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = { + up: function(queryInterface, Sequelize) { + return queryInterface.createTable('pads', { + id: { + autoIncrement: false, + primaryKey: true, + type: Sequelize.STRING + }, + etherpad_id: Sequelize.STRING, + type: Sequelize.STRING, + title: Sequelize.STRING, + description: Sequelize.TEXT, + views: Sequelize.INTEGER, + owner_id: Sequelize.UUID, + tags: Sequelize.STRING, + created_at: { + allowNull: false, + type: Sequelize.DATE + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + down: function(queryInterface, Sequelize) { + return queryInterface.dropTable('pads'); + } +}; \ No newline at end of file diff --git a/plugins/ep_open/api/migrations/20160202150730-create-token.js b/plugins/ep_open/api/migrations/20160202150730-create-token.js new file mode 100644 index 00000000000..6df9b85f5e9 --- /dev/null +++ b/plugins/ep_open/api/migrations/20160202150730-create-token.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = { + up: function(queryInterface, Sequelize) { + return queryInterface.createTable('tokens', { + id: { + autoIncrement: false, + primaryKey: true, + type: Sequelize.UUID + }, + expires: Sequelize.DATE, + user_id: Sequelize.UUID, + created_at: { + allowNull: false, + type: Sequelize.DATE + }, + updated_at: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + down: function(queryInterface, Sequelize) { + return queryInterface.dropTable('tokens'); + } +}; \ No newline at end of file diff --git a/plugins/ep_open/api/models/base.js b/plugins/ep_open/api/models/base.js new file mode 100644 index 00000000000..19d2390498a --- /dev/null +++ b/plugins/ep_open/api/models/base.js @@ -0,0 +1,46 @@ +'use strict'; + +const _ = require('lodash'); +const Sequelize = require('sequelize'); +const sequelize = require('./sequelize'); + +module.exports = (name, attributes, options) => { + const privateAttributes = []; + + attributes = attributes || {}; + options = options || {}; + + // Add dates attributes to explicitly set db field name for them + attributes.createdAt = Sequelize.DATE; + attributes.updatedAt = Sequelize.DATE; + + Object.keys(attributes).forEach(key => { + let value = attributes[key]; + + if (typeof value === 'object' && value.private) { + privateAttributes.push(key); + delete attributes[key].private; + } + + // Add snake_case field name for camelCase attributes + if (/[A-Z]/.test(key) && !value.field) { + if (typeof value !== 'object') { + value = { type: value }; + } + + attributes[key] = Object.assign({ + field: key.replace(/[A-Z]/g, str => '_' + str.toLowerCase()) + }, value); + } + }); + + // If there is option privateAttributes, then we omit these attributes from the final JSON + if (privateAttributes.length) { + options.instanceMethods = options.instanceMethods || {}; + options.instanceMethods.toPublicJSON = function() { + return _.omit(this.toJSON(), privateAttributes); + }; + } + + return sequelize.define(name, attributes, options); +}; \ No newline at end of file diff --git a/plugins/ep_open/api/models/pad.js b/plugins/ep_open/api/models/pad.js new file mode 100644 index 00000000000..ddefef9b579 --- /dev/null +++ b/plugins/ep_open/api/models/pad.js @@ -0,0 +1,46 @@ +'use strict'; + +const Sequelize = require('sequelize'); +const ModelBase = require('./base'); +const User = require('./user'); +const randomString = require('../common/helpers').randomString; + +const Pad = ModelBase('pad', { + id: { + primaryKey: true, + type: Sequelize.STRING + }, + etherpadId: Sequelize.STRING, + type: { + type: Sequelize.STRING, + defaultValue: 'child' + }, + title: Sequelize.STRING, + description: Sequelize.TEXT, + views: Sequelize.INTEGER, + ownerId: Sequelize.UUID +}, { + defaultScope: { + include: [{ + model: User, + as: 'owner' + }] + }, + scopes: { + full: { + attributes: { + exclude: ['ownerId'] + }, + include: [{ + model: User, + as: 'owner' + }], + order: [['createdAt']] + } + } +}); + +User.hasMany(Pad, { foreignKey: 'ownerId', as: 'owner' }); +Pad.belongsTo(User, { foreignKey: 'ownerId', as: 'owner' }); + +module.exports = Pad; \ No newline at end of file diff --git a/plugins/ep_open/api/models/sequelize.js b/plugins/ep_open/api/models/sequelize.js new file mode 100644 index 00000000000..2bbe18e9a44 --- /dev/null +++ b/plugins/ep_open/api/models/sequelize.js @@ -0,0 +1,15 @@ +'use strict'; + +const _ = require('lodash'); +const Sequelize = require('sequelize'); +const config = require(__dirname + '/../../../../credentials.json'); +const dbConfig = { + 'username': config.dbSettings.user, + 'password': config.dbSettings.password, + 'database': config.dbSettings.database, + 'host': config.dbSettings.host, + 'port': '5432', + "dialect": config.dbType +}; + +module.exports = new Sequelize(dbConfig.database, dbConfig.username, dbConfig.password, dbConfig); \ No newline at end of file diff --git a/plugins/ep_open/api/models/token.js b/plugins/ep_open/api/models/token.js new file mode 100644 index 00000000000..4a25b03ba95 --- /dev/null +++ b/plugins/ep_open/api/models/token.js @@ -0,0 +1,29 @@ +'use strict'; + +const Sequelize = require('sequelize'); +const ModelBase = require('./base'); +const User = require('./user'); + +const Token = ModelBase('token', { + id: { + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4 + }, + expires: Sequelize.DATE, + userId: { + type: Sequelize.UUID, + private: true + } +}, { + instanceMethods: { + isActive() { + return this.expires > new Date(); + } + } +}); + +User.hasMany(Token, { foreignKey: 'userId' }); +Token.belongsTo(User, { foreignKey: 'userId' }); + +module.exports = Token; \ No newline at end of file diff --git a/plugins/ep_open/api/models/user.js b/plugins/ep_open/api/models/user.js new file mode 100644 index 00000000000..dbed4541ff6 --- /dev/null +++ b/plugins/ep_open/api/models/user.js @@ -0,0 +1,120 @@ +'use strict'; + +const crypto = require('crypto'); +const Sequelize = require('sequelize'); +const ModelBase = require('./base'); +const constants = require('../../config/constants'); + +const User = ModelBase('user', { + id: { + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4 + }, + email: { + type: Sequelize.STRING, + unique: true + }, + nickname: { + type: Sequelize.STRING, + unique: true + }, + role: { + type: Sequelize.STRING, + defaultValue: 'user' + }, + name: Sequelize.STRING, + password: { + type: Sequelize.VIRTUAL, + set: function(value) { + this.setDataValue('password', value); + this.setDataValue('salt', Math.round((new Date().valueOf() * Math.random())) + ''); + this.setDataValue('passwordHash', this.encryptPassword(value)); + }, + private: true + }, + passwordHash: { + type: Sequelize.STRING, + private: true + }, + salt: { + type: Sequelize.STRING, + private: true + }, + surname: Sequelize.STRING, + avatar: Sequelize.STRING, + reputation: Sequelize.JSONB, + github: Sequelize.JSON, + githubUserId: { + type: Sequelize.INTEGER, + private: true + }, + githubToken: { + type: Sequelize.STRING, + private: true + }, + google: Sequelize.JSON, + googleUserId: { + type: Sequelize.STRING, + private: true + }, + googleToken: { + type: Sequelize.STRING, + private: true + } +}, { + instanceMethods: { + encryptPassword(password) { + return this.salt ? crypto.createHmac('sha1', this.salt).update(password).digest('hex') : false; + }, + authenticate(plainText) { + return this.encryptPassword(plainText) === this.passwordHash; + }, + getReputation(groupId) { + return (this.reputation || {})[groupId] || 1; + }, + setReputation(groupId, value) { + this.setDataValue('reputation', Object.assign({}, this.reputation, { [groupId]: value })); + }, + addReputation(groupId, value) { + this.setReputation(groupId, Math.max(1, this.getReputation(groupId) + value)); + }, + getPermissions(groupId) { + return (this.permissions || {})[groupId] || {}; + }, + isActionAllowed(action, companyId, ownerId) { + let isAllowed; + + if (constants.ACTIONS.indexOf(action) === -1) { + return new Error('Unknown action'); + } + + if (companyId) { + const permissions = this.getPermissions(companyId); + const reputation = this.getReputation(companyId); + + if (ownerId && constants.OWNER_ACTIONS.indexOf(action) !== -1) { + isAllowed = this.id === ownerId; + } + + if (isAllowed === undefined) { + isAllowed = permissions[action]; + } + + if (isAllowed === undefined) { + isAllowed = reputation >= constants.ACTIONS_MIN_REPUTATION[action]; + } + } + + return isAllowed; + } + }, + defaultScope: { + attributes: ['id', 'email', 'nickname', 'avatar', 'reputation', 'role'] + }, + scopes: { + full: {} + } +}); + +module.exports = User; \ No newline at end of file diff --git a/plugins/ep_open/config/constants.js b/plugins/ep_open/config/constants.js new file mode 100644 index 00000000000..0c214e376a5 --- /dev/null +++ b/plugins/ep_open/config/constants.js @@ -0,0 +1,14 @@ +exports.ACTIONS = [ + 'EDIT_PADS', + 'DELETE_PADS' +]; + +exports.OWNER_ACTIONS = [ + 'EDIT_PADS', + 'DELETE_PADS' +]; + +exports.ACTIONS_MIN_REPUTATION = { + EDIT_PADS: 2500, + DELETE_PADS: 10000 +}; \ No newline at end of file diff --git a/plugins/ep_open/config/env.json b/plugins/ep_open/config/env.json new file mode 100644 index 00000000000..bd2e1106427 --- /dev/null +++ b/plugins/ep_open/config/env.json @@ -0,0 +1,8 @@ +{ + "production": { + "apiHost": "http://pad.open.xyz/api" + }, + "development": { + "apiHost": "http://open.dev:9001/api" + } +} \ No newline at end of file diff --git a/plugins/ep_open/config/index.js b/plugins/ep_open/config/index.js new file mode 100644 index 00000000000..ed4ce88c026 --- /dev/null +++ b/plugins/ep_open/config/index.js @@ -0,0 +1,15 @@ +'use strict'; + +const argv = require('yargs').argv; +const credentials = require('../../../credentials.json'); + +try { + var env = require('./env.json'); + + if (env) { + exports.env = env[process.env.NODE_ENV || 'development']; + } +} catch(e) {} + +exports.google = credentials.users.google; +exports.github = credentials.users.github; \ No newline at end of file diff --git a/plugins/ep_open/ep.json b/plugins/ep_open/ep.json new file mode 100644 index 00000000000..7fcf25d7706 --- /dev/null +++ b/plugins/ep_open/ep.json @@ -0,0 +1,10 @@ +{ + "parts": [{ + "name": "Open Comanies extension", + "priority": 10, + "hooks": { + "expressCreateServer": "ep_open/hooks.js", + "padUpdate": "ep_open/api/controllers/pads.js" + } + }] +} diff --git a/plugins/ep_open/gulpfile.js b/plugins/ep_open/gulpfile.js new file mode 100644 index 00000000000..44a7ba0b885 --- /dev/null +++ b/plugins/ep_open/gulpfile.js @@ -0,0 +1,117 @@ +'use strict'; + +const fs = require('fs'); +const gulp = require('gulp'); +const gutil = require('gulp-util'); +const plugins = require('gulp-load-plugins')(); +const watchify = require('watchify'); +const browserify = require('browserify'); +const babelify = require('babelify'); +const source = require('vinyl-source-stream'); + +const env = gutil.env.production ? 'production' : 'development'; +const paths = { + styles: 'static/sass/style.scss', + libs: { + styles: [ + './node_modules/react-select/dist/react-select.min.css', + ] + }, + scripts: [ + 'static/js/**/*.js', + '!static/js/bundle.js' + ] +}; +const b = watchify(browserify(Object.assign({}, watchify.args, { + entries: ['static/js/app.js'], + debug: env === 'development', + ignoreMissing: true +}))); + +b.transform(babelify); + +if (env === 'production') { + b.transform({ + global: true, + ignore: [ '**/node_modules/v-sdk/*' ], + mangle: { + toplevel: true, + screw_ie8: true + }, + compress: { + screw_ie8: true, + sequences: true, + properties: true, + unsafe: true, + dead_code: true, + drop_debugger: true, + comparisons: true, + conditionals: true, + evaluate: true, + booleans: true, + loops: true, + unused: true, + hoist_funs: true, + if_return: true, + join_vars: true, + cascade: true, + negate_iife: true, + drop_console: true + } + }, 'uglifyify'); +} + +const bundle = () => { + return b.bundle() + .on('error', gutil.log.bind(gutil, 'Browserify Error')) + .pipe(source('bundle.js')) + .pipe(gulp.dest('static/js')); +}; + +gulp.task('scripts', ['config'], bundle); + +gulp.task('check', function() { + gulp + .src(paths.scripts) + .pipe(plugins.eslint()) + .pipe(plugins.jscs()) + .pipe(plugins.eslint.format()); +}); + +gulp.task('config', function() { + var config = require('./config/env.json')[env]; + + plugins + .file('index.js', 'export default ' + JSON.stringify(config) + ';') + .pipe(gulp.dest('static/js/config')); +}); + +gulp.task('styles', () => { + gulp + .src(paths.styles) + .pipe(plugins.sass().on('error', plugins.sass.logError)) + .pipe(plugins.cssImageDimensions(__dirname + '/static/images')) + .pipe(plugins.base64({ + extensions: ['png'], + maxImageSize: 16 * 1024 + })) + .pipe(plugins.autoprefixer({ + browsers: ['last 2 versions'] + })) + .pipe(plugins.addSrc.prepend(paths.libs.styles)) + .pipe(plugins.concat('style.css')) + .pipe(env === 'production' ? plugins.minifyCss({ keepSpecialComments: 0 }) : gutil.noop()) + .pipe(gulp.dest('static/css')); +}); + +gulp.task('watch', () => { + gulp.watch('static/sass/**/*', ['styles']); + gulp.watch(['gulpfile.js'], ['build']); + b.on('update', bundle); + b.on('log', gutil.log); +}); + +gulp.task('_build', ['styles', 'check', 'config', 'scripts']); +gulp.task('build', ['_build'], () => b.close()); +gulp.task('deploy', ['_build', 'publish']); +gulp.task('default', ['_build', 'watch']); diff --git a/plugins/ep_open/hooks.js b/plugins/ep_open/hooks.js new file mode 100644 index 00000000000..a76652e70b3 --- /dev/null +++ b/plugins/ep_open/hooks.js @@ -0,0 +1,36 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const bodyParser = require('body-parser'); +const helmet = require('helmet'); +const validator = require('express-validator'); +const api = require('./api'); +const socketio = require('./api/common/socketio'); +const cookieParser = require('ep_etherpad-lite/node_modules/cookie-parser'); +const settings = require('ep_etherpad-lite/node/utils/Settings'); + +exports.expressCreateServer = function(hookName, args) { + const { app } = args; + + app.use(helmet()); + app.use(bodyParser.json({ limit: '50mb' })); + app.use(bodyParser.urlencoded({ extended: true })); + app.use(validator()); + app.use(cookieParser(settings.sessionKey, {})); + app.use(express.static(__dirname + '/static')); + + app.use('/api', api); + app.get('/', indexPageHandler); + + socketio.init(args.server); + + // Add handler for all other client-side pages with timeout, to be sure that etherpad middlewares are applied and + // none of them will be overridden + setTimeout(() => app.use(indexPageHandler)); + + function indexPageHandler(request, response) { + response.send(fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8')); + } +}; \ No newline at end of file diff --git a/plugins/ep_open/index.html b/plugins/ep_open/index.html new file mode 100755 index 00000000000..daac231c284 --- /dev/null +++ b/plugins/ep_open/index.html @@ -0,0 +1,15 @@ + + + + Open Companies + + + + + + + +
+ + + \ No newline at end of file diff --git a/plugins/ep_open/package.json b/plugins/ep_open/package.json new file mode 100644 index 00000000000..b626946629c --- /dev/null +++ b/plugins/ep_open/package.json @@ -0,0 +1,77 @@ +{ + "name": "ep_open", + "version": "0.1.0", + "description": "Open Projects extension", + "main": "hooks.js", + "scripts": { + "migrate": "sequelize db:migrate", + "build": "gulp build --production" + }, + "enginse": { + "node": ">=4.2.1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "babel-preset-react": "^6.5.0", + "baobab": "^2.3.3", + "baobab-react": "^1.0.1", + "body-parser": "^1.15.2", + "classnames": "^2.2.3", + "co": "^4.6.0", + "express": "4.13.4", + "express-validator": "^2.20.3", + "gcloud": "^0.37.0", + "global": "^4.3.1", + "googleapis": "^2.1.7", + "helmet": "^1.1.0", + "isomorphic-fetch": "^2.2.1", + "lodash": "^4.2.0", + "md5": "^2.0.0", + "moment": "^2.10.6", + "pg": "^4.4.4", + "pg-hstore": "^2.3.2", + "react": "^0.14.6", + "react-avatar-editor": "^3.2.0", + "react-document-title": "^2.0.2", + "react-dom": "^0.14.0", + "react-draggable": "^2.2.2", + "react-dropzone": "^3.3.2", + "react-router": "^2.0.0-rc5", + "react-select": "^1.0.0-beta12", + "request-promise": "^2.0.0", + "sequelize": "^3.24.4", + "sequelize-cli": "^2.4.0", + "socket.io-client": "^1.5.1", + "spin": "0.0.1", + "yargs": "^3.31.0" + }, + "devDependencies": { + "babel-plugin-transform-class-properties": "^6.5.0", + "babel-plugin-transform-decorators": "^6.4.0", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-transform-react-jsx": "^6.4.0", + "babel-preset-es2015": "^6.3.13", + "babel-runtime": "~5.8.19", + "babelify": "~7.2.0", + "browserify": "~10.2.6", + "gulp": "~3.9.1", + "gulp-add-src": "~0.2.0", + "gulp-autoprefixer": "~2.3.0", + "gulp-base64": "~0.1.3", + "gulp-concat": "~2.5.2", + "gulp-connect": "~2.2.0", + "gulp-css-image-dimensions": "~1.1.4", + "gulp-eslint": "~3.0.1", + "gulp-file": "^0.2.0", + "gulp-jscs": "^4.0.0", + "gulp-load-plugins": "~0.10.0", + "gulp-minify-css": "~1.2.1", + "gulp-sass": "~2.0.1", + "gulp-util": "~3.0.6", + "jshint-stylish": "~2.0.1", + "uglifyify": "^3.0.4", + "vinyl-source-stream": "~1.1.0", + "watchify": "^3.7.0" + } +} diff --git a/plugins/ep_open/static/fonts/FontAwesome.otf b/plugins/ep_open/static/fonts/FontAwesome.otf new file mode 100644 index 00000000000..3ed7f8b48ad Binary files /dev/null and b/plugins/ep_open/static/fonts/FontAwesome.otf differ diff --git a/plugins/ep_open/static/fonts/fontawesome-webfont.eot b/plugins/ep_open/static/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000000..9b6afaedc0f Binary files /dev/null and b/plugins/ep_open/static/fonts/fontawesome-webfont.eot differ diff --git a/plugins/ep_open/static/fonts/fontawesome-webfont.svg b/plugins/ep_open/static/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000000..d05688e9e28 --- /dev/null +++ b/plugins/ep_open/static/fonts/fontawesome-webfont.svg @@ -0,0 +1,655 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/ep_open/static/fonts/fontawesome-webfont.ttf b/plugins/ep_open/static/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000000..26dea7951a7 Binary files /dev/null and b/plugins/ep_open/static/fonts/fontawesome-webfont.ttf differ diff --git a/plugins/ep_open/static/fonts/fontawesome-webfont.woff b/plugins/ep_open/static/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000000..dc35ce3c2cf Binary files /dev/null and b/plugins/ep_open/static/fonts/fontawesome-webfont.woff differ diff --git a/plugins/ep_open/static/fonts/fontawesome-webfont.woff2 b/plugins/ep_open/static/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000000..500e5172534 Binary files /dev/null and b/plugins/ep_open/static/fonts/fontawesome-webfont.woff2 differ diff --git a/plugins/ep_open/static/images/default-avatar.png b/plugins/ep_open/static/images/default-avatar.png new file mode 100644 index 00000000000..5e706ea5c4e Binary files /dev/null and b/plugins/ep_open/static/images/default-avatar.png differ diff --git a/plugins/ep_open/static/images/favicon.ico b/plugins/ep_open/static/images/favicon.ico new file mode 100644 index 00000000000..e28224b9ada Binary files /dev/null and b/plugins/ep_open/static/images/favicon.ico differ diff --git a/plugins/ep_open/static/images/favicon.png b/plugins/ep_open/static/images/favicon.png new file mode 100644 index 00000000000..7f78225ca6d Binary files /dev/null and b/plugins/ep_open/static/images/favicon.png differ diff --git a/plugins/ep_open/static/images/github.png b/plugins/ep_open/static/images/github.png new file mode 100644 index 00000000000..57d13c97bb5 Binary files /dev/null and b/plugins/ep_open/static/images/github.png differ diff --git a/plugins/ep_open/static/images/github@2x.png b/plugins/ep_open/static/images/github@2x.png new file mode 100644 index 00000000000..1b80684e6a4 Binary files /dev/null and b/plugins/ep_open/static/images/github@2x.png differ diff --git a/plugins/ep_open/static/images/google.png b/plugins/ep_open/static/images/google.png new file mode 100644 index 00000000000..f05f2a4b79a Binary files /dev/null and b/plugins/ep_open/static/images/google.png differ diff --git a/plugins/ep_open/static/images/google@2x.png b/plugins/ep_open/static/images/google@2x.png new file mode 100644 index 00000000000..9ba683c61b4 Binary files /dev/null and b/plugins/ep_open/static/images/google@2x.png differ diff --git a/plugins/ep_open/static/images/logo-single.png b/plugins/ep_open/static/images/logo-single.png new file mode 100644 index 00000000000..35a88a3b48d Binary files /dev/null and b/plugins/ep_open/static/images/logo-single.png differ diff --git a/plugins/ep_open/static/images/logo-single@2x.png b/plugins/ep_open/static/images/logo-single@2x.png new file mode 100644 index 00000000000..e34663f6385 Binary files /dev/null and b/plugins/ep_open/static/images/logo-single@2x.png differ diff --git a/plugins/ep_open/static/images/logo.png b/plugins/ep_open/static/images/logo.png new file mode 100644 index 00000000000..913d6ef20cf Binary files /dev/null and b/plugins/ep_open/static/images/logo.png differ diff --git a/plugins/ep_open/static/images/logo@2x.png b/plugins/ep_open/static/images/logo@2x.png new file mode 100644 index 00000000000..3c0c844cb1e Binary files /dev/null and b/plugins/ep_open/static/images/logo@2x.png differ diff --git a/plugins/ep_open/static/js/actions/common.js b/plugins/ep_open/static/js/actions/common.js new file mode 100644 index 00000000000..f288d96a09a --- /dev/null +++ b/plugins/ep_open/static/js/actions/common.js @@ -0,0 +1,13 @@ +export function addLayoutMode(tree, mode) { + const layoutModes = tree.get('layoutModes'); + + if (layoutModes.indexOf(mode) === -1) { + tree.set('layoutModes', layoutModes.concat(mode)); + } +} + +export function removeLayoutMode(tree, mode) { + const layoutModes = tree.get('layoutModes').filter(layoutMode => layoutMode !== mode); + + tree.set('layoutModes', layoutModes); +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/actions/errors.js b/plugins/ep_open/static/js/actions/errors.js new file mode 100644 index 00000000000..956858a15a3 --- /dev/null +++ b/plugins/ep_open/static/js/actions/errors.js @@ -0,0 +1,18 @@ +export function addError(tree, message) { + const id = new Date().getTime(); + + tree.push('errors', { id, message }); + setTimeout(() => removeError(tree, id), 5000); +} + +export function removeError(tree, id) { + tree.set('errors', tree.select('errors').get().filter(error => error.id !== id)); +} + +export function errorHandler(tree) { + return response => { + const message = response.message || response.error; + + message && addError(tree, message); + }; +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/actions/pads.js b/plugins/ep_open/static/js/actions/pads.js new file mode 100644 index 00000000000..0f84dbdd13f --- /dev/null +++ b/plugins/ep_open/static/js/actions/pads.js @@ -0,0 +1,108 @@ +import request from '../utils/request'; +import { addError, errorHandler } from './errors'; + +export function getCurrentPadId(tree) { + const currentPadId = tree.get('currentPadId'); + + if (currentPadId) { + return currentPadId; + } else { + addError(tree, 'Pad is not selected'); + return false; + } +} + +export function fetchPads(tree, query) { + const data = {}; + + if (query) { + data.query = query; + } + + return request(`/pads`, { data }) + .then(response => { + tree.set('pads', response.rows); + tree.set('padsTotal', parseInt(response.count)); + }) + .catch(errorHandler(tree)); +} + +export function fetchPadsByIds(tree, ids) { + if (ids.length === 1) { + return request(`/pads/${ids[0]}`) + .then(pad => { + tree.set('pads', [pad]); + }) + .catch(errorHandler(tree)); + } else { + return request(`/pads`, { + data: { ids } + }) + .then(response => { + tree.set('pads', response.rows); + }) + .catch(errorHandler(tree)); + } +} + +export function setCurrentPad(tree, id = '') { + tree.set('currentPadId', id); +} + +export function getCurrentPad(tree, id = '') { + tree.set('currentPadId', id); + + if (!tree.get('currentPad').responses) { + request(`/pads/${id}`) + .then(pad => { + tree.selectedItem('currentPad').set(pad); + }) + .catch(errorHandler(tree)); + } +} + +export function createPad(tree, data = {}) { + request(`/pads`, { + method: 'POST', + data + }) + .then(pad => { + tree.set('newPad', pad); + tree.push('pads', pad); + }) + .catch(errorHandler(tree)); +} + +export function updatePad(tree, data) { + const currentPadId = getCurrentPadId(tree); + + if (currentPadId) { + request(`/pads/${currentPadId}`, { + method: 'PUT', + data + }) + .then(pad => tree.selectedItem('currentPad').set(pad)) + .catch(errorHandler(tree)); + } +} + +export function deletePad(tree, id) { + request(`/pads/${id}`, { method: 'DELETE' }) + .then(() => { + const pads = tree.select('pads'); + + pads.set(pads.get().filter(pad => pad.id !== id)); + }) + .catch(errorHandler(tree)); +} + + +export function clearNewPad(tree) { + tree.set('newPad', null); +} + +export function fetchHierarchy(tree) { + request('/pads/root/hierarchy') + .then(hierarchy => tree.set('padsHierarchy', hierarchy)) + .catch(errorHandler(tree)); +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/actions/user.js b/plugins/ep_open/static/js/actions/user.js new file mode 100644 index 00000000000..06266cf51d0 --- /dev/null +++ b/plugins/ep_open/static/js/actions/user.js @@ -0,0 +1,145 @@ +import window from 'global'; +import config from '../config'; +import request from '../utils/request'; +import messages from '../utils/messages'; +import { errorHandler } from './errors'; + +export function setCurrentUser(tree, user = null) { + tree.set('currentUser', user); +} + +export function initToken(tree) { + const tokenString = window.localStorage.token || window.sessionStorage.token; + const syncUser = (token) => { + setToken(tree, token); + tree.set('userSync', true); + }; + let token; + + try { + token = JSON.parse(tokenString); + } catch (e) {} + + if (token) { + request('/profile?authToken=' + token.id, {}, true) + .then(user => syncUser(Object.assign(token, { user }))) + .catch(() => syncUser()); + } else { + syncUser(); + } +} + +export function setToken(tree, token, remember = !!window.localStorage.token) { + let user = null; + + delete window.localStorage.token; + delete window.sessionStorage.token; + + if (token) { + (remember ? window.localStorage : window.sessionStorage).token = JSON.stringify(token); + + if (token.user) { + user = token.user; + delete token.user; + } + } + + tree.set('currentUser', user); + tree.set('token', token); + messages.send('currentUserUpdate', { + isAuthorized: !!token, + name: user && user.nickname + }); +} + +export function getProfile(tree) { + request('/profile') + .then(user => tree.set('currentUser', user)) + .catch(errorHandler(tree)); +} + +export function updateProfile(tree, data) { + request('/profile', { + method: 'PUT', + data + }) + .then(user => { + tree.set('currentUser', user) + messages.send('currentUserUpdate', { + isAuthorized: true, + name: user && user.nickname + }); + }) + .catch(errorHandler(tree)); +} + +export function uploadAvatar(tree, image) { + request('/profile/avatar', { + method: 'POST', + data: { image } + }) + .then(user => tree.set('currentUser', user)) + .catch(errorHandler(tree)); +} + +export function changePassword(tree, data) { + request('/profile/password', { + method: 'PUT', + data + }) + .then(user => tree.set('currentUser', user)) + .catch(errorHandler(tree)); +} + +export function auth(tree, data = {}) { + request('/tokens', { + method: 'POST', + data: { + email: data.email, + password: data.password + } + }) + .then(token => setToken(tree, token, data.remember)) + .catch(errorHandler(tree)); +} + +export function socialAuth(tree, provider) { + const authWindow = window.open(`${config.apiHost}/oauth/${provider}`, provider, 'width=600,height=400'); + const oauthCallbackHandler = function(event) { + let message = event.data; + + try { + message = JSON.parse(message); + } catch(e) {} + + if (typeof message === 'object' && message.type === 'oauth_callback') { + if (message.data) { + setToken(tree, message.data, true); + } + } + + window.removeEventListener('message', oauthCallbackHandler); + }; + + window.addEventListener('message', oauthCallbackHandler); + authWindow.onbeforeunload = function() { + window.removeEventListener('message', oauthCallbackHandler); + }; +} + +export function register(tree, data) { + request('/users', { + method: 'POST', + data + }) + .then(() => auth(tree, { + email: data.email, + password: data.password, + remember: true + })) + .catch(errorHandler(tree)); +} + +export function logout(tree) { + setToken(tree, null); +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/actions/users.js b/plugins/ep_open/static/js/actions/users.js new file mode 100644 index 00000000000..5d8b32da02f --- /dev/null +++ b/plugins/ep_open/static/js/actions/users.js @@ -0,0 +1,35 @@ +import request from '../utils/request'; +import { errorHandler } from './errors' + +export function fetchUsers(tree, query = '', page = 1, perPage = 20) { + request('/users', { + data: { query, page, perPage } + }) + .then(response => { + tree.set('users', response.rows); + tree.set('usersTotal', response.count); + }) + .catch(errorHandler(tree)); +} + +export function selectUser(tree, id = '') { + tree.set('selectedUserId', id); + + if (!tree.get('selectedUser').id) { + request('/users/' + id) + .then(user => tree.selectedItem('selectedUser').set(user)) + .catch(errorHandler(tree)); + } +} + +export function unselectUser(tree) { + tree.set('selectedUserId', null); +} + +export function updatePrivileges(tree, userId, data) { + request(`/users/${userId}/priveleges`, { + method: 'PUT', + data + }) + .catch(errorHandler(tree)); +} diff --git a/plugins/ep_open/static/js/app.js b/plugins/ep_open/static/js/app.js new file mode 100644 index 00000000000..2dc78f9c27d --- /dev/null +++ b/plugins/ep_open/static/js/app.js @@ -0,0 +1,39 @@ +import window from 'global'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Route, IndexRedirect, browserHistory } from 'react-router'; +import { Root } from 'baobab-react/wrappers'; +import tree from './store'; +import App from './components/App.react'; +import Pad from './components/pads/Pad.react'; +import PadsSearch from './components/pads/PadsSearch.react'; +import PadForm from './components/pads/PadForm.react'; +import SignIn from './components/user/SignIn.react'; +import SignUp from './components/user/SignUp.react'; +import Profile from './components/user/Profile.react'; +import ProfilePassword from './components/user/ProfilePassword.react'; +import { initToken } from './actions/user'; + +function init() { + initToken(tree); + + ReactDOM.render(( + + + + + + + + + + + + + + + + ), document.getElementById('app-root')); +}; + +window.onload = init; \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/App.react.js b/plugins/ep_open/static/js/components/App.react.js new file mode 100644 index 00000000000..e0b64bcc91f --- /dev/null +++ b/plugins/ep_open/static/js/components/App.react.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import { branch } from 'baobab-react/decorators'; +import classNames from 'classnames'; +import DocumentTitle from 'react-document-title'; +import Header from './Header.react'; +import Modal from './Modal.react'; +import Notifications from './Notifications.react'; +import * as errorsActions from '../actions/errors'; + +@branch({ + cursors: { + user: ['currentUser'], + errors: ['errors'], + layoutModes: ['layoutModes'] + }, + actions: { + removeError: errorsActions.removeError + } +}) +class App extends Component { + static childContextTypes = { + location: React.PropTypes.object + } + + getChildContext() { + return { location: this.props.location } + } + + componentWillReceiveProps(nextProps) { + // if we changed routes... + if (( + nextProps.location.key !== this.props.location.key && + nextProps.location.state && + nextProps.location.state.modal && + (!this.props.location.state || !this.props.location.state.modal) + )) { + // save the old children (just like animation) + this.previousChildren = this.props.children; + this.previousRoutes = this.props.routes; + } + } + + render() { + const { location, user, layoutModes } = this.props; + const isAuthorized = user && user.id; + const isModal = ( + location.state && + location.state.modal + ); + const isPad = (isModal && this.previousRoutes ? this.previousRoutes : this.props.routes).map(r => r.name).indexOf('pad') !== -1; + let layoutClassNames = 'layout'; + + if (layoutModes.length) { + layoutClassNames += ' ' + layoutModes.map(mode => `layout--${mode}`).join(' '); + } + + return ( + +
+
+
+
+ {isModal ? this.previousChildren : this.props.children} +
+
+ {isModal && ( + + {this.props.children} + + )} + +
+
+ ); + } +} + +export default App; \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/Base.react.js b/plugins/ep_open/static/js/components/Base.react.js new file mode 100644 index 00000000000..5ad67f46b9d --- /dev/null +++ b/plugins/ep_open/static/js/components/Base.react.js @@ -0,0 +1,39 @@ +import { Component } from 'react'; +import { cloneDeep } from 'lodash'; + +export default class Base extends Component { + linkState(path) { + const keys = path.split('.'); + + return { + value: keys.reduce((state, key) => state[key], this.state), + requestChange: newValue => this.setDeepState({ [path]: newValue}) + } + } + + linkRadioState(path, value) { + const link = this.linkState(path); + + return { + value: link.value === value, + requestChange: link.requestChange.bind(this, value) + }; + } + + setDeepState(newState) { + const state = cloneDeep(this.state); + + Object.keys(newState).forEach(path => { + const newValue = newState[path]; + const keys = path.split('.'); + + keys.reduce((state, key, index) => { + const currentValue = state[key]; + + return state[key] = index === keys.length - 1 ? newValue : (currentValue == null ? {} : currentValue) + }, state); + }); + + this.setState(state); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/Header.react.js b/plugins/ep_open/static/js/components/Header.react.js new file mode 100644 index 00000000000..f4c31bfba9c --- /dev/null +++ b/plugins/ep_open/static/js/components/Header.react.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import { branch } from 'baobab-react/decorators'; +import { Link } from 'react-router'; +import classNames from 'classnames'; +import { logout } from '../actions/user'; + +@branch({ + cursors: { + user: ['currentUser'], + userSync: ['userSync'] + }, + actions: { + logout + } +}) +class Header extends Component { + render() { + return ( +
+ + {this.props.userSync ? ( + !!this.props.user ? ( +
+ {this.props.user.role === 'admin' ? ( + + admin panel + + ) : ''} + + {this.props.user.nickname} + + + logout + +
+ ) : ( +
+ + Sign In + + + Sign Up + +
+ ) + ) : ''} +
+ ); + } +} + +export default Header; \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/Modal.react.js b/plugins/ep_open/static/js/components/Modal.react.js new file mode 100644 index 00000000000..b83b56b2774 --- /dev/null +++ b/plugins/ep_open/static/js/components/Modal.react.js @@ -0,0 +1,55 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router'; +import window, { document } from 'global'; +//import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; + +export default class Modal extends Component { + static contextTypes = { + router: React.PropTypes.object.isRequired + }; + + constructor() { + super(); + this.state = { modalTop: 0}; + this.closeModal = this.closeModal.bind(this); + } + + handleResize() { + this.setState({ + modalTop: Math.max((window.innerHeight - this.refs.content.offsetHeight) / 2, 0) + }); + } + + closeModal(event) { + if (event.type === 'click' || event.keyCode === 27) { + this.context.router.push(this.props.returnTo || '/'); + } + } + + componentDidMount() { + window.addEventListener('resize', this.handleResize); + document.body.addEventListener('keydown', this.closeModal); + this.handleResize(); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.handleResize); + document.body.removeEventListener('keydown', this.closeModal); + } + + render() { + const children = React.Children.map(this.props.children, child => React.cloneElement(child, { + updateModal: this.handleResize, + modalGoTo: this.props.goTo + })); + + return ( +
+
+
+ {children} +
+
+ ); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/Notifications.react.js b/plugins/ep_open/static/js/components/Notifications.react.js new file mode 100644 index 00000000000..628e15631ff --- /dev/null +++ b/plugins/ep_open/static/js/components/Notifications.react.js @@ -0,0 +1,27 @@ +import React, { Component } from 'react'; + +export default class Notifications extends Component { + handleClick(id) { + this.props.close(id); + } + + render() { + return ( +
+
+
+ {this.props.items.map(item => { + return ( +
+
+
{item.message}
+
+
+ ); + })} +
+
+
+ ); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/common/Avatar.react.js b/plugins/ep_open/static/js/components/common/Avatar.react.js new file mode 100644 index 00000000000..94253db24ed --- /dev/null +++ b/plugins/ep_open/static/js/components/common/Avatar.react.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import Base from '../Base.react'; + +export default class Avatar extends Base { + static propTypes = { + image: React.PropTypes.string, + isForceCache: React.PropTypes.bool + } + + constructor(props) { + super(props); + this.state = { timestamp: props.isForceCache ? new Date().getTime() : '' }; + } + + componentWillReceiveProps(nextProps) { + if (this.props.isForceCache && this.props.image !== nextProps.image) { + this.setState({ timestamp: new Date().getTime() }); + } + } + + render() { + const image = this.props.image; + let imageURL = '/images/default-avatar.png'; + + if (image) { + if (/^data\:|http/.test(image)) { + imageURL = image; + } else { + imageURL = `https://open-companies.storage.googleapis.com/${image}`; + + if (imageURL.indexOf('?') === -1) { + imageURL += '?' + this.state.timestamp; + } + } + } + + return ( +
+ + +
+ ); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/common/Checkbox.react.js b/plugins/ep_open/static/js/components/common/Checkbox.react.js new file mode 100644 index 00000000000..c22b79ea558 --- /dev/null +++ b/plugins/ep_open/static/js/components/common/Checkbox.react.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react'; +import classNames from 'classnames'; + +export default class LinkButton extends Component { + static propTypes = { + checkedLink: React.PropTypes.object.isRequired, + label: React.PropTypes.string, + disabled: React.PropTypes.bool + }; + + render() { + return ( + + ); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/common/LinkButton.react.js b/plugins/ep_open/static/js/components/common/LinkButton.react.js new file mode 100644 index 00000000000..511322080e2 --- /dev/null +++ b/plugins/ep_open/static/js/components/common/LinkButton.react.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router'; + +export default class LinkButton extends Component { + static contextTypes = { + router: React.PropTypes.object.isRequired + }; + + render() { + return ( + + ); + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/common/Pagination.react.js b/plugins/ep_open/static/js/components/common/Pagination.react.js new file mode 100644 index 00000000000..d6ee57db724 --- /dev/null +++ b/plugins/ep_open/static/js/components/common/Pagination.react.js @@ -0,0 +1,81 @@ +import _ from 'lodash'; +import React, { Component } from 'react'; +import Base from '../Base.react'; + +export default class Pagination extends Base { + static contextTypes = { + router: React.PropTypes.object.isRequired, + location: React.PropTypes.object.isRequired + } + + static propTypes = { + name: React.PropTypes.string.isRequired, + total: React.PropTypes.number.isRequired, + perPage: React.PropTypes.number.isRequired, + currentPage: React.PropTypes.number.isRequired + } + + changePage(page, event) { + this.context.router.push({ + pathname: this.context.location.pathname, + query: Object.assign({}, this.context.location.query, { [this.props.name]: page }) + }); + + event.preventDefault(); + } + + render() { + if (this.props.total > this.props.perPage) { + const pageCount = Math.ceil(this.props.total / this.props.perPage); + const currentPage = this.props.currentPage; + let startRange = Math.max(1, currentPage - 1); + let endRange = Math.min(pageCount, currentPage + 1); + + if (endRange - startRange < 2) { + if (startRange === 1) { + endRange = Math.min(pageCount, endRange + 1); + } else if (endRange === pageCount) { + startRange = Math.max(1, startRange - 1); + } + } + + const pages = _.range(startRange, endRange + 1); + + if (startRange > 1) { + startRange !== 2 && pages.splice(0, 0, 'divider'); + pages.splice(0, 0, 1); + } + + if (endRange < pageCount) { + endRange !== pageCount - 1 && pages.push('divider'); + pages.push(pageCount); + } + + return ( +
+
+ {pages.map((page, index) => { + if (page === 'divider') { + return ...; + } else if (page === currentPage) { + return {page}; + } else { + return ( + + {page} + + ); + } + })} +
+
+ ); + } else { + return null; + } + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/common/Spinner.react.js b/plugins/ep_open/static/js/components/common/Spinner.react.js new file mode 100644 index 00000000000..239fe0cf016 --- /dev/null +++ b/plugins/ep_open/static/js/components/common/Spinner.react.js @@ -0,0 +1,24 @@ +import React, { Component } from 'react'; +import Spinner from 'spin'; +import Base from '../Base.react'; + +export default class SpinnerComponent extends Base { + static propTypes = { + color: React.PropTypes.string + } + + componentDidMount() { + this.spinner = new Spinner({ + color: this.props.color || '#000', + lines: 11, + length: 7, + width: 3, + radius: 8 + }).spin(this.refs.avatarSpinner); + this.spinner.spin(this.refs.container); + } + + render() { + return
; + } +} \ No newline at end of file diff --git a/plugins/ep_open/static/js/components/pads/Pad.react.js b/plugins/ep_open/static/js/components/pads/Pad.react.js new file mode 100644 index 00000000000..32a5ccff55d --- /dev/null +++ b/plugins/ep_open/static/js/components/pads/Pad.react.js @@ -0,0 +1,196 @@ +import window from 'global'; +import React from 'react'; +import classNames from 'classnames'; +import { branch } from 'baobab-react/decorators'; +import DocumentTitle from 'react-document-title'; +import messages from '../../utils/messages'; +import Base from '../Base.react'; +import PadsHierarchy from './PadsHierarchy.react'; +import PadLinkModal from './PadLinkModal.react'; +import * as padsActions from '../../actions/pads'; +import * as commonActions from '../../actions/common'; + +@branch({ + cursors: { + currentPad: ['currentPad'], + pads: ['pads'] + }, + actions: Object.assign({}, padsActions, commonActions) +}) +export default class Pad extends Base { + static contextTypes = { + router: React.PropTypes.object.isRequired + }; + + constructor(props) { + super(props); + + const currentTab = props.params.padId || 'root'; + + this.state = { + isFullscreenActive: window.sessionStorage.isFullscreenActive === 'true', + isHierarchyActive: window.sessionStorage.isHierarchyActive === 'true' + }; + this.tabs = (props.location.query.tabs || currentTab).split(','); + + this.state.isHierarchyActive && props.actions.addLayoutMode('pad_hierarchy'); + this.state.isFullscreenActive && props.actions.addLayoutMode('pad_fullscreen'); + props.actions.fetchPadsByIds(this.tabs); + props.actions.setCurrentPad(currentTab); + + this.cancelOpenPadSubscription = messages.subscribe('openPad', padId => { + const currentTabIndex = this.tabs.indexOf(this.props.currentPad.id); + + if (this.tabs[currentTabIndex + 1] !== padId) { + this.tabs = this.tabs.slice(0, currentTabIndex + 1); + this.tabs.push(padId); + } + + this.goToTab(padId); + }); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.location.query.tabs !== this.props.location.query.tabs) { + this.tabs = (nextProps.location.query.tabs || nextProps.params.padId || 'root').split(','); + this.props.actions.fetchPadsByIds(this.tabs); + } + + if (nextProps.params.padId !== this.props.params.padId) { + this.props.actions.setCurrentPad(nextProps.params.padId); + } + } + + goToTab(id) { + const query = this.tabs.length > 1 ? `?tabs=${this.tabs.join(',')}` : ''; + + this.context.router.push(`/pads/${id}${query}`); + } + + toggleMode(paramName, modeName) { + const newValue = !this.state[paramName]; + + window.sessionStorage.setItem(paramName, newValue); + this.setState({ [paramName]: newValue }); + this.props.actions[newValue ? 'addLayoutMode' : 'removeLayoutMode'](modeName); + } + + onIframeClick(event) { + if (event.target.className === 'pad__iframe__screen') { + const currentTabIndex = this.tabs.indexOf(this.props.currentPad.id); + + if (currentTabIndex > 0) { + this.goToTab(this.tabs[currentTabIndex - 1]); + } + } + } + + getPads() { + const padsObject = {}; + + this.props.pads.forEach(pad => padsObject[pad.id] = pad); + + return this.tabs.map(tab => padsObject[tab]); + } + + buildTabs() { + return this.getPads().map(pad => ( + pad ? ( +
{pad.title}
+ ) : null + )); + } + + render() { + const { currentPad } = this.props; + const title = `${currentPad.title && currentPad.id !== 'root' ? (currentPad.title + ' | ') : ''}Open Companies`; + + return ( + +
+
+
+ {this.buildTabs()} +
+
+
+ + +
+ +
+
+ +
+
+ + ); + } + + componentDidUpdate() { + const etherpadId = this.props.currentPad && this.props.currentPad.etherpadId; + + if (etherpadId) { + const unloadedIframes = []; + + Array.prototype.forEach.call(this.refs.iframes.querySelectorAll('.pad__iframe'), el => el.className = 'pad__iframe'); + + this.getPads().some((pad, index) => { + if (!pad) return true; + + const isCurrent = pad.etherpadId === etherpadId; + let iframe = document.getElementById(pad.etherpadId); + + if (!iframe) { + iframe = document.createElement('div'); + iframe.id = pad.etherpadId; + iframe.className = 'pad__iframe'; + iframe.innerHTML = ` +
+