diff --git a/Units.flitter.js b/Units.flitter.js index 7258c30..ed63168 100644 --- a/Units.flitter.js +++ b/Units.flitter.js @@ -62,6 +62,7 @@ const FlitterUnits = { * Custom units should be specified here. They will be loaded in order * after the core of Flitter has been initialized. */ + 'Socket' : require('flitter-socket/SocketUnit'), 'Ionic' : require('./app/IonicUnit'), // 'CustomUnit' : new CustomUnit(), diff --git a/app/CORSUnit.js b/app/CORSUnit.js index 3353ce9..fea06df 100644 --- a/app/CORSUnit.js +++ b/app/CORSUnit.js @@ -2,12 +2,30 @@ const Unit = require('libflitter/Unit') const cors = require('cors') class CORSUnit extends Unit { + static get services() { + return [...super.services, 'output'] + } + static get name() { return 'cors' } async go(app) { - app.express.use(cors()) + app.express.use(cors({ + origin: (origin, callback) => { + const allowed = [ + 'https://noded.garrettmills.dev', + 'https://noded-dev.garrettmills.dev', + 'http://noded.garrettmills.dev', + 'http://noded-dev.garrettmills.dev', + 'http://localhost:8000', + 'http://localhost:8100' + ] + + if ( allowed.includes(origin) || !origin ) callback(null, true) + else callback(new Error('Invalid origin.')) + } + })) } } diff --git a/app/controllers/socket/NormEditor.controller.js b/app/controllers/socket/NormEditor.controller.js new file mode 100644 index 0000000..0814966 --- /dev/null +++ b/app/controllers/socket/NormEditor.controller.js @@ -0,0 +1,264 @@ +const SocketController = require('flitter-socket/Controller') +const uuid = require('uuid').v4 + +class NormEditorGroup { + constructor(page, node) { + this.id = `${page.UUID}-${node.UUID}` + this.page = page + this.node = node + this.connections = {} + this.connection_id_x_session = {} + } + + join(connection_manager) { + this.connections[connection_manager.id] = connection_manager + this.connection_id_x_session[connection_manager.id] = {} + + connection_manager.on_close().then(() => { + delete this.connection_id_x_session[connection_manager.id] + delete this.connections[connection_manager.id] + this.blast_editor_group_users() + }) + } + + has(connection_manager) { + return !!this.connections[connection_manager.id] + } + + unique_users() { + const users = {} + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + const session = this.session(conn) + const user = conn.request.user + if ( user && !users[user.uuid] ) { + if ( !session.user_color ) { + session.user_color = '#' + Math.floor(Math.random() * 16777215).toString(16) + } + + users[user.uuid] = { user, color: session.user_color } + } + } + + return Object.values(users) + } + + session(conn) { + return this.connection_id_x_session[conn.id] + } + + blast_editor_group_users() { + const conns = Object.values(this.connections) + const unique_users = this.unique_users() + + for ( const conn of conns ) { + const users = unique_users.filter(data => data.user.uuid !== conn.request.user.uuid) + .map(data => { + const { user, color } = data + return { + uuid: user.uuid, + uid: user.uid, + display: user.uid, + color, + } + }) + + conn._request('setEditorGroupUsers', { users }) + } + } + + blast_selections() { + const conns = Object.values(this.connections) + + for ( const conn of conns ) { + const selections = conns.filter(maybeConn => conn.id !== maybeConn.id) + .map(otherConn => { + const session = this.session(otherConn) + const user_data = { + uuid: otherConn.request.user.uuid, + uid: otherConn.request.user.uid, + display: otherConn.request.user.uid, + } + + if ( session.user_color ) { + user_data.color = session.user_color + } + + return {...(this.session(otherConn).selection || {}), user_data} + }) + .filter(Boolean) + + conn._request('setEditorGroupSelections', { selections }) + } + } + + set_member_selection(conn, selection) { + const session = this.session(conn) + if ( session ) { + session.selection = selection + this.blast_selections() + } + } +} + +class NormEditorController extends SocketController { + editor_groups = {} + + static get services() { + return [...super.services, 'output', 'models'] + } + + ping(transaction, socket) { + this.output.info('Got norm editor socket ping.') + + transaction.status(200) + .message('Pong!') + .send(transaction.incoming) + } + + _get_editor_group_or_fail(transaction) { + if ( !transaction.incoming.editor_group_id ) { + transaction.status(400) + .message('Missing editor_group_id.') + .send() + + return + } + + const editor_group = this.editor_groups[transaction.incoming.editor_group_id] + if ( !editor_group || !editor_group.has(transaction.cm) ) { + transaction.status(400) + .message('Invalid editor_group_id.') + .send() + + return + } + + return editor_group + } + + set_member_selection(transaction, socket) { + const editor_group = this._get_editor_group_or_fail(transaction) + if ( !editor_group ) return; + + if ( !transaction.incoming.selection ) { + return transaction.status(400) + .message('Missing selection.') + .send() + } + + editor_group.set_member_selection(transaction.cm, transaction.incoming.selection) + transaction.status(200).send() + } + + get_selections(transaction, socket) { + const editor_group = this._get_editor_group_or_fail(transaction) + if ( !editor_group ) return; + + const selections = Object.values(editor_group.connections) + .filter(maybeConn => transaction.cm.id !== maybeConn.id) + .map(otherConn => { + const session = editor_group.session(otherConn) + const user_data = { + uuid: otherConn.request.user.uuid, + uid: otherConn.request.user.uid, + display: otherConn.request.user.uid, + } + + if ( session.user_color ) { + user_data.color = session.user_color + } + + return {...(editor_group.session(otherConn).selection || {}), user_data} + }) + .filter(Boolean) + + transaction.status(200) + .send({ selections }) + } + + get_editor_group_users(transaction, socket) { + const editor_group = this._get_editor_group_or_fail(transaction) + if ( !editor_group ) return; + + transaction.status(200) + .send( + editor_group.unique_users() + .filter(data => data.user.uuid !== transaction.cm.request.user.uuid) + .map(data => { + const { user, color } = data + return { + uuid: user.uuid, + uid: user.uid, + display: user.uid, + color, + } + }) + ) + } + + async join_editor_group(transaction, socket) { + // FIXME support versioning + const Page = this.models.get('api:Page') + const Node = this.models.get('api:Node') + + if ( !transaction.incoming.PageId ) { + return transaction.status(400) + .message('Missing pageId.') + .send() + } + + const page = await Page.findOne({ UUID: transaction.incoming.PageId, Active: true }) + if ( !page || !(await page.is_accessible_by(transaction.cm.request.user, 'update')) ) { + return transaction.status(401) + .message('Invalid PageId.') + .send() + } + + if ( !transaction.incoming.NodeId ) { + transaction.status(400) + .message('Missing NodeId.') + .send() + } + + const node = await Node.findOne({ UUID: transaction.incoming.NodeId, PageId: page.UUID }) + if ( !node ) { + transaction.status(400) + .message('Invalid NodeId.') + .send() + } + + // Try to look up an existing editor group + const editor_group_id = `${page.UUID}-${node.UUID}` + let editor_group = this.editor_groups[editor_group_id] + + if ( !editor_group ) { + // create a new editor group + editor_group = new NormEditorGroup(page, node) + this.editor_groups[editor_group_id] = editor_group + } + + if ( !editor_group.has(transaction.cm) ) + editor_group.join(transaction.cm) + + transaction.status(200) + .message('Joined editor group.') + .send({ editor_group_id }) + + editor_group.blast_editor_group_users() + } + + _client_connected(connection_manager) { + super._client_connected(connection_manager) + + this.output.debug('New client connected to norm editor socket.') + + if ( !connection_manager.request?.session?.auth?.user_id ) { + delete this.connections[connection_manager.id] + connection_manager.socket.close() + } + } +} + +module.exports = exports = NormEditorController diff --git a/app/routing/routers/api/v1/socket.routes.js b/app/routing/routers/api/v1/socket.routes.js new file mode 100644 index 0000000..46869c7 --- /dev/null +++ b/app/routing/routers/api/v1/socket.routes.js @@ -0,0 +1,14 @@ +module.exports = exports = { + prefix: '/api/v1/socket', + + middleware: [], + + socket: { + '/norm-editor': [ + 'controller::socket:NormEditor', + ], + '/norm-editor/.websocket': [ + 'controller::socket:NormEditor', + ], + } +} diff --git a/package.json b/package.json index 8b77478..5b6bfe4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "flitter-flap": "^0.5.2", "flitter-forms": "^0.8.1", "flitter-orm": "^0.4.0", + "flitter-socket": "^0.8.1", "flitter-upload": "^0.9.2", "jsonwebtoken": "^8.5.1", "libflitter": "^0.58.1", diff --git a/yarn.lock b/yarn.lock index c05c669..c0e16b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -486,6 +486,11 @@ ast-types@0.9.6: resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9" integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk= +async-limiter@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" + integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== + aws-sdk@^2.792.0: version "2.792.0" resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.792.0.tgz#d124a6074244a4675e0416887734e8f6934bdd30" @@ -1516,6 +1521,13 @@ express-session@^1.15.6: safe-buffer "5.1.2" uid-safe "~2.1.5" +express-ws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-4.0.0.tgz#dabd8dc974516418902a41fe6e30ed949b4d36c4" + integrity sha512-KEyUw8AwRET2iFjFsI1EJQrJ/fHeGiJtgpYgEWG3yDv4l/To/m3a2GaYfeGyB3lsWdvbesjF5XCMx+SVBgAAYw== + dependencies: + ws "^5.2.0" + express@^4.16.4: version "4.16.4" resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e" @@ -1705,6 +1717,14 @@ flitter-orm@^0.4.0: sinon "^9.0.0" uuid "^3.4.0" +flitter-socket@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/flitter-socket/-/flitter-socket-0.8.1.tgz#46dfc8081e98cc06418bb9876d1c16eec06e8a82" + integrity sha512-p1oLytTHGhhl9Qal1pHtlduaEuwfEXwRe9JFDboKLDjFaS2UxR/fzKpO+EM4MCnrRSN3MjtLJoBVr+bPKfVw7g== + dependencies: + express-ws "^4.0.0" + uuid "^3.3.2" + flitter-upload@^0.9.2: version "0.9.2" resolved "https://registry.yarnpkg.com/flitter-upload/-/flitter-upload-0.9.2.tgz#1cb7ab9ad5f70be83b1a97d67822480462a0bc3f" @@ -4539,6 +4559,13 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +ws@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f" + integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA== + dependencies: + async-limiter "~1.0.0" + xml2js@0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"